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
@@ -49,6 +49,98 @@
padding: 8px;
}
.gridPanel {
display: grid;
gap: 10px;
padding: 10px 12px 12px;
}
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.field {
display: grid;
gap: 6px;
}
.fieldDisabled {
opacity: 0.45;
pointer-events: none;
}
.fieldLabel {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-weight: 600;
opacity: 0.9;
}
.fieldValue {
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.select {
width: 100%;
box-sizing: border-box;
padding: 7px 28px 7px 10px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background-color: rgba(0, 0, 0, 0.25);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: inherit;
font: inherit;
appearance: none;
}
.range {
width: 100%;
accent-color: #f5c542;
}
.colorInput {
width: 100%;
height: 34px;
padding: 0;
border: 1px solid var(--stroke, #2a2f3a);
border-radius: 8px;
background: transparent;
cursor: pointer;
overflow: hidden;
}
.colorInput:disabled {
cursor: default;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.colorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.colorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
.paletteItem {
display: flex;
align-items: center;
@@ -108,6 +200,7 @@
.trap {
position: absolute;
z-index: 1;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.55);
+99 -4
View File
@@ -1,7 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneTrap, SceneTrapType } from '../../shared/types';
import type { SceneGrid, SceneTrap, SceneTrapType } from '../../shared/types';
import {
clampSceneGridSizeN,
DEFAULT_SCENE_GRID,
SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN,
sceneGridTypeLabelRu,
} from '../../shared/types/sceneGrid';
import {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
@@ -10,6 +17,7 @@ import {
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { getDndApi } from '../shared/dndApi';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { RotatedImage } from '../shared/RotatedImage';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button } from '../shared/ui/controls';
@@ -33,6 +41,7 @@ export function SceneEditorApp() {
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(true);
const [gridOpen, setGridOpen] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -40,7 +49,8 @@ export function SceneEditorApp() {
);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const saveTimerRef = useRef(0);
const saveTrapsTimerRef = useRef(0);
const saveGridTimerRef = useRef(0);
const spaceDownRef = useRef(false);
const project = session?.project ?? null;
@@ -49,11 +59,13 @@ export function SceneEditorApp() {
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]);
trapsRef.current = localTraps;
useEffect(() => {
setLocalTraps(scene?.traps ?? []);
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setSelectedId(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]);
@@ -64,6 +76,11 @@ export function SceneEditorApp() {
setLocalTraps(scene?.traps ?? []);
}, [scene?.traps]);
useEffect(() => {
if (dragRef.current) return;
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
}, [scene?.grid]);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
@@ -78,14 +95,26 @@ export function SceneEditorApp() {
if (!sceneId) return;
setLocalTraps(next);
trapsRef.current = next;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
saveTrapsTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
}, 120);
},
[api, sceneId],
);
const persistGrid = useCallback(
(next: SceneGrid) => {
if (!sceneId) return;
setLocalGrid(next);
if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current);
saveGridTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { grid: next } });
}, 120);
},
[api, sceneId],
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
@@ -201,6 +230,71 @@ export function SceneEditorApp() {
<div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранную ловушку.
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'}
</button>
{gridOpen ? (
<div className={styles.gridPanel}>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={localGrid.enabled}
onChange={(e) => persistGrid({ ...localGrid, enabled: e.target.checked })}
/>
<span>Наложить сетку</span>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span>
<select
className={styles.select}
disabled={!localGrid.enabled}
value={localGrid.type}
onChange={(e) =>
persistGrid({
...localGrid,
type: e.target.value === 'hex' ? 'hex' : 'square',
})
}
>
<option value="square">{sceneGridTypeLabelRu('square')}</option>
<option value="hex">{sceneGridTypeLabelRu('hex')}</option>
</select>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span>
<input
type="color"
className={styles.colorInput}
disabled={!localGrid.enabled}
value={localGrid.color}
onChange={(e) => persistGrid({ ...localGrid, color: e.target.value })}
aria-label="Цвет сетки"
/>
</label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>
Размер <span className={styles.fieldValue}>{Math.round(localGrid.sizeN * 100)}</span>
</span>
<input
type="range"
className={styles.range}
disabled={!localGrid.enabled}
min={SCENE_GRID_SIZE_MIN}
max={SCENE_GRID_SIZE_MAX}
step={0.005}
value={localGrid.sizeN}
onChange={(e) =>
persistGrid({
...localGrid,
sizeN: clampSceneGridSizeN(Number(e.currentTarget.value)),
})
}
/>
</label>
</div>
) : null}
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'}
@@ -304,6 +398,7 @@ export function SceneEditorApp() {
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
{contentRect
? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h);