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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user