feat(scene): battle grid overlay with color and trap VFX stacking

Add configurable square/hex grid in the scene editor (persisted and shown on control/presentation) and keep trap activation effects above trap icons.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 06:47:59 +08:00
parent 4aa0f257d5
commit bdeb64e356
17 changed files with 413 additions and 15 deletions
+13 -8
View File
@@ -9,6 +9,7 @@ import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { SceneGridOverlay } from './grid/SceneGridOverlay';
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
@@ -159,10 +160,22 @@ export function PresentationView({
) : (
<div className={styles.placeholderBg} />
)}
{scene?.previewAssetType === 'image' ? (
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
) : null}
<div className={styles.vignette} />
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay
traps={scene.traps ?? []}
session={sceneTraps}
viewport={contentRect}
mode="presentation"
/>
) : null}
{showEffects && scene?.previewAssetType !== 'video' ? (
<PixiEffectsOverlay
state={fxState}
style={{ zIndex: 6 }}
viewport={
contentRect
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
@@ -173,14 +186,6 @@ export function PresentationView({
{showEffects && scene?.previewAssetType !== 'video' ? (
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay
traps={scene.traps ?? []}
session={sceneTraps}
viewport={contentRect}
mode="presentation"
/>
) : null}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
@@ -2,7 +2,8 @@
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
/* Выше иконок ловушек (z-index: 5), чтобы VFX взрыва не уходил под маркер. */
z-index: 7;
overflow: hidden;
}
@@ -0,0 +1,12 @@
.layer {
position: absolute;
pointer-events: none;
/* Без z-index: порядок в DOM (сразу после картинки) держит сетку под остальными слоями. */
overflow: hidden;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
@@ -0,0 +1,111 @@
import React, { useEffect, useRef } from 'react';
import type { SceneGrid } from '../../../shared/types';
import styles from './SceneGridOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
type Props = {
grid: SceneGrid | null | undefined;
viewport: Viewport | null;
};
function drawSquareGrid(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
cell: number,
): void {
ctx.beginPath();
for (let x = 0; x <= w + 0.5; x += cell) {
ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, h);
}
for (let y = 0; y <= h + 0.5; y += cell) {
ctx.moveTo(0, y + 0.5);
ctx.lineTo(w, y + 0.5);
}
ctx.stroke();
}
/** Flat-top hex grid; `cell` ≈ ширина гекса (расстояние между параллельными сторонами по X ≈ 0.75·cell между центрами). */
function drawHexGrid(ctx: CanvasRenderingContext2D, w: number, h: number, cell: number): void {
const hexW = cell;
const vert = (Math.sqrt(3) / 2) * hexW;
const horiz = hexW * 0.75;
const r = hexW / 2;
ctx.beginPath();
const cols = Math.ceil(w / horiz) + 2;
const rows = Math.ceil(h / vert) + 2;
for (let row = -1; row < rows; row++) {
for (let col = -1; col < cols; col++) {
const cx = col * horiz;
const cy = row * vert + (col % 2 === 0 ? 0 : vert / 2);
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i;
const x = cx + r * Math.cos(a);
const y = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
}
ctx.stroke();
}
export function SceneGridOverlay({ grid, viewport }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !viewport || !grid?.enabled) return;
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const w = Math.max(1, Math.round(viewport.w));
const h = Math.max(1, Math.round(viewport.h));
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const minDim = Math.min(w, h);
const cell = Math.max(4, grid.sizeN * minDim);
ctx.strokeStyle = grid.color || '#ffffff';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.72;
if (grid.type === 'hex') {
drawHexGrid(ctx, w, h, cell);
} else {
drawSquareGrid(ctx, w, h, cell);
}
}, [
grid?.enabled,
grid?.type,
grid?.sizeN,
grid?.color,
viewport?.x,
viewport?.y,
viewport?.w,
viewport?.h,
]);
if (!viewport || !grid?.enabled) return null;
return (
<div
className={styles.layer}
style={{ left: viewport.x, top: viewport.y, width: viewport.w, height: viewport.h }}
aria-hidden
>
<canvas ref={canvasRef} className={styles.canvas} />
</div>
);
}
@@ -2,7 +2,8 @@
position: absolute;
inset: 0;
pointer-events: none;
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть. */
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть.
* Ниже Pixi/Explosion (6/7), чтобы VFX яда/взрыва шли поверх иконки. */
z-index: 5;
}