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:
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
traps: [],
|
traps: [],
|
||||||
|
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||||
media: { videos: [], audios: audioRefs },
|
media: { videos: [], audios: audioRefs },
|
||||||
settings: {
|
settings: {
|
||||||
autoplayVideo: previewAssetType === 'video',
|
autoplayVideo: previewAssetType === 'video',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||||
|
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||||
import {
|
import {
|
||||||
@@ -616,11 +617,13 @@ export class ZipProjectStore {
|
|||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
traps: [],
|
traps: [],
|
||||||
|
grid: { ...DEFAULT_SCENE_GRID },
|
||||||
} satisfies Scene);
|
} satisfies Scene);
|
||||||
|
|
||||||
const next: Scene = {
|
const next: Scene = {
|
||||||
...base,
|
...base,
|
||||||
traps: base.traps ?? [],
|
traps: base.traps ?? [],
|
||||||
|
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||||
@@ -640,6 +643,7 @@ export class ZipProjectStore {
|
|||||||
.filter((t): t is SceneTrap => Boolean(t)),
|
.filter((t): t is SceneTrap => Boolean(t)),
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
|
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
@@ -2325,6 +2329,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
|
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
|
||||||
.map((t) => normalizeSceneTrap(t))
|
.map((t) => normalizeSceneTrap(t))
|
||||||
.filter((t): t is SceneTrap => Boolean(t));
|
.filter((t): t is SceneTrap => Boolean(t));
|
||||||
|
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
|
||||||
|
|
||||||
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
@@ -2354,6 +2359,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
darkenScene,
|
darkenScene,
|
||||||
traps,
|
traps,
|
||||||
|
grid,
|
||||||
layout: layoutIn ?? { x: 0, y: 0 },
|
layout: layoutIn ?? { x: 0, y: 0 },
|
||||||
media: {
|
media: {
|
||||||
videos: raw.videos ?? [],
|
videos: raw.videos ?? [],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
|||||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||||
|
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||||
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
|
||||||
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
@@ -1710,10 +1711,11 @@ export function ControlApp() {
|
|||||||
</div>
|
</div>
|
||||||
{!isVideoPreviewScene ? (
|
{!isVideoPreviewScene ? (
|
||||||
<>
|
<>
|
||||||
|
<SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
|
||||||
<PixiEffectsOverlay
|
<PixiEffectsOverlay
|
||||||
ref={effectsOverlayRef}
|
ref={effectsOverlayRef}
|
||||||
state={fxState}
|
state={fxState}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 6 }}
|
||||||
viewport={
|
viewport={
|
||||||
previewContentRect
|
previewContentRect
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
traps: [],
|
traps: [],
|
||||||
|
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||||
connections: [],
|
connections: [],
|
||||||
@@ -591,6 +592,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
: null),
|
: null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||||
|
...(patch.grid !== undefined ? { grid: patch.grid } : null),
|
||||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||||
|
|||||||
@@ -49,6 +49,98 @@
|
|||||||
padding: 8px;
|
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 {
|
.paletteItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -108,6 +200,7 @@
|
|||||||
|
|
||||||
.trap {
|
.trap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
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 {
|
import {
|
||||||
asSceneTrapId,
|
asSceneTrapId,
|
||||||
DEFAULT_SCENE_TRAP_SIZE_N,
|
DEFAULT_SCENE_TRAP_SIZE_N,
|
||||||
@@ -10,6 +17,7 @@ import {
|
|||||||
} from '../../shared/types/sceneTraps';
|
} from '../../shared/types/sceneTraps';
|
||||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
@@ -33,6 +41,7 @@ export function SceneEditorApp() {
|
|||||||
const api = getDndApi();
|
const api = getDndApi();
|
||||||
const [session, setSession] = useState<SessionState | null>(null);
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
const [trapsOpen, setTrapsOpen] = useState(true);
|
const [trapsOpen, setTrapsOpen] = useState(true);
|
||||||
|
const [gridOpen, setGridOpen] = useState(true);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
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>(
|
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 hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragRef = useRef<DragMode>(null);
|
const dragRef = useRef<DragMode>(null);
|
||||||
const saveTimerRef = useRef(0);
|
const saveTrapsTimerRef = useRef(0);
|
||||||
|
const saveGridTimerRef = useRef(0);
|
||||||
const spaceDownRef = useRef(false);
|
const spaceDownRef = useRef(false);
|
||||||
|
|
||||||
const project = session?.project ?? null;
|
const project = session?.project ?? null;
|
||||||
@@ -49,11 +59,13 @@ export function SceneEditorApp() {
|
|||||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||||
const rot = scene?.previewRotationDeg ?? 0;
|
const rot = scene?.previewRotationDeg ?? 0;
|
||||||
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||||
|
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
|
||||||
const trapsRef = useRef<SceneTrap[]>([]);
|
const trapsRef = useRef<SceneTrap[]>([]);
|
||||||
trapsRef.current = localTraps;
|
trapsRef.current = localTraps;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalTraps(scene?.traps ?? []);
|
setLocalTraps(scene?.traps ?? []);
|
||||||
|
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||||
}, [sceneId, scene?.previewAssetId]);
|
}, [sceneId, scene?.previewAssetId]);
|
||||||
@@ -64,6 +76,11 @@ export function SceneEditorApp() {
|
|||||||
setLocalTraps(scene?.traps ?? []);
|
setLocalTraps(scene?.traps ?? []);
|
||||||
}, [scene?.traps]);
|
}, [scene?.traps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragRef.current) return;
|
||||||
|
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||||
|
}, [scene?.grid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||||
@@ -78,14 +95,26 @@ export function SceneEditorApp() {
|
|||||||
if (!sceneId) return;
|
if (!sceneId) return;
|
||||||
setLocalTraps(next);
|
setLocalTraps(next);
|
||||||
trapsRef.current = next;
|
trapsRef.current = next;
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
|
||||||
saveTimerRef.current = window.setTimeout(() => {
|
saveTrapsTimerRef.current = window.setTimeout(() => {
|
||||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
|
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
|
||||||
}, 120);
|
}, 120);
|
||||||
},
|
},
|
||||||
[api, sceneId],
|
[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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.code === 'Space') spaceDownRef.current = true;
|
if (e.code === 'Space') spaceDownRef.current = true;
|
||||||
@@ -201,6 +230,71 @@ export function SceneEditorApp() {
|
|||||||
<div className={styles.hint}>
|
<div className={styles.hint}>
|
||||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
||||||
</div>
|
</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}>
|
<div className={styles.accordion}>
|
||||||
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
||||||
Ловушки {trapsOpen ? '▾' : '▸'}
|
Ловушки {trapsOpen ? '▾' : '▸'}
|
||||||
@@ -304,6 +398,7 @@ export function SceneEditorApp() {
|
|||||||
viewCamera={viewCamera}
|
viewCamera={viewCamera}
|
||||||
onContentRectChange={setContentRect}
|
onContentRectChange={setContentRect}
|
||||||
/>
|
/>
|
||||||
|
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||||
{contentRect
|
{contentRect
|
||||||
? localTraps.map((trap) => {
|
? localTraps.map((trap) => {
|
||||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
|
|||||||
import { useEffectsState } from './effects/useEffectsState';
|
import { useEffectsState } from './effects/useEffectsState';
|
||||||
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||||
|
import { SceneGridOverlay } from './grid/SceneGridOverlay';
|
||||||
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
|
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
|
||||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||||
@@ -159,10 +160,22 @@ export function PresentationView({
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.placeholderBg} />
|
<div className={styles.placeholderBg} />
|
||||||
)}
|
)}
|
||||||
|
{scene?.previewAssetType === 'image' ? (
|
||||||
|
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
||||||
|
) : null}
|
||||||
<div className={styles.vignette} />
|
<div className={styles.vignette} />
|
||||||
|
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||||
|
<SceneTrapsOverlay
|
||||||
|
traps={scene.traps ?? []}
|
||||||
|
session={sceneTraps}
|
||||||
|
viewport={contentRect}
|
||||||
|
mode="presentation"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||||
<PixiEffectsOverlay
|
<PixiEffectsOverlay
|
||||||
state={fxState}
|
state={fxState}
|
||||||
|
style={{ zIndex: 6 }}
|
||||||
viewport={
|
viewport={
|
||||||
contentRect
|
contentRect
|
||||||
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
|
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
|
||||||
@@ -173,14 +186,6 @@ export function PresentationView({
|
|||||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||||
) : null}
|
) : null}
|
||||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
|
||||||
<SceneTrapsOverlay
|
|
||||||
traps={scene.traps ?? []}
|
|
||||||
session={sceneTraps}
|
|
||||||
viewport={contentRect}
|
|
||||||
mode="presentation"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 2;
|
/* Выше иконок ловушек (z-index: 5), чтобы VFX взрыва не уходил под маркер. */
|
||||||
|
z-index: 7;
|
||||||
overflow: hidden;
|
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть. */
|
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть.
|
||||||
|
* Ниже Pixi/Explosion (6/7), чтобы VFX яда/взрыва шли поверх иконки. */
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function scene(id: string): Scene {
|
|||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
traps: [],
|
traps: [],
|
||||||
|
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||||
connections: [],
|
connections: [],
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene {
|
|||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
traps: [],
|
traps: [],
|
||||||
|
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||||
media: { videos: [], audios: [] },
|
media: { videos: [], audios: [] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||||
connections: [],
|
connections: [],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
Scene,
|
Scene,
|
||||||
SceneDarknessEvent,
|
SceneDarknessEvent,
|
||||||
SceneDarknessState,
|
SceneDarknessState,
|
||||||
|
SceneGrid,
|
||||||
SceneId,
|
SceneId,
|
||||||
SceneTrap,
|
SceneTrap,
|
||||||
SceneTrapsEvent,
|
SceneTrapsEvent,
|
||||||
@@ -703,6 +704,7 @@ export type ScenePatch = {
|
|||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
darkenScene?: boolean;
|
darkenScene?: boolean;
|
||||||
traps?: SceneTrap[];
|
traps?: SceneTrap[];
|
||||||
|
grid?: SceneGrid;
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: Partial<Scene['layout']>;
|
layout?: Partial<Scene['layout']>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
SceneId,
|
SceneId,
|
||||||
} from './ids';
|
} from './ids';
|
||||||
import type { MaterialLegend } from './materialLegend';
|
import type { MaterialLegend } from './materialLegend';
|
||||||
|
import type { SceneGrid } from './sceneGrid';
|
||||||
import type { SceneTrap } from './sceneTraps';
|
import type { SceneTrap } from './sceneTraps';
|
||||||
|
|
||||||
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
||||||
@@ -162,6 +163,8 @@ export type Scene = {
|
|||||||
darkenScene: boolean;
|
darkenScene: boolean;
|
||||||
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
|
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
|
||||||
traps: SceneTrap[];
|
traps: SceneTrap[];
|
||||||
|
/** Боевая сетка поверх превью (под ловушками/эффектами). */
|
||||||
|
grid: SceneGrid;
|
||||||
media: SceneMediaRefs;
|
media: SceneMediaRefs;
|
||||||
settings: SceneSettings;
|
settings: SceneSettings;
|
||||||
connections: SceneId[];
|
connections: SceneId[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './materialLegend';
|
|||||||
export * from './materials';
|
export * from './materials';
|
||||||
export * from './npcs';
|
export * from './npcs';
|
||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
|
export * from './sceneGrid';
|
||||||
export * from './sceneTraps';
|
export * from './sceneTraps';
|
||||||
export * from './sceneView';
|
export * from './sceneView';
|
||||||
export * from './videoPlayback';
|
export * from './videoPlayback';
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/** Настройки боевой сетки на превью сцены (сохраняется в проекте). */
|
||||||
|
|
||||||
|
export const SCENE_GRID_TYPES = ['square', 'hex'] as const;
|
||||||
|
|
||||||
|
export type SceneGridType = (typeof SCENE_GRID_TYPES)[number];
|
||||||
|
|
||||||
|
export type SceneGrid = {
|
||||||
|
/** Наложить сетку на изображение сцены. */
|
||||||
|
enabled: boolean;
|
||||||
|
type: SceneGridType;
|
||||||
|
/** Размер ячейки относительно min(w, h) картинки. */
|
||||||
|
sizeN: number;
|
||||||
|
/** Hex `#rrggbb`. */
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SCENE_GRID_SIZE_N = 0.06;
|
||||||
|
export const SCENE_GRID_SIZE_MIN = 0.02;
|
||||||
|
export const SCENE_GRID_SIZE_MAX = 0.25;
|
||||||
|
export const DEFAULT_SCENE_GRID_COLOR = '#ffffff';
|
||||||
|
|
||||||
|
export const DEFAULT_SCENE_GRID: SceneGrid = {
|
||||||
|
enabled: false,
|
||||||
|
type: 'square',
|
||||||
|
sizeN: DEFAULT_SCENE_GRID_SIZE_N,
|
||||||
|
color: DEFAULT_SCENE_GRID_COLOR,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clampSceneGridSizeN(sizeN: number): number {
|
||||||
|
if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_GRID_SIZE_N;
|
||||||
|
return Math.max(SCENE_GRID_SIZE_MIN, Math.min(SCENE_GRID_SIZE_MAX, sizeN));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSceneGridColor(raw: unknown): string {
|
||||||
|
if (typeof raw !== 'string') return DEFAULT_SCENE_GRID_COLOR;
|
||||||
|
const s = raw.trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/u.test(s)) return s.toLowerCase();
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/u.test(s)) {
|
||||||
|
const r = s[1]!;
|
||||||
|
const g = s[2]!;
|
||||||
|
const b = s[3]!;
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||||
|
}
|
||||||
|
return DEFAULT_SCENE_GRID_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSceneGrid(raw: unknown): SceneGrid {
|
||||||
|
if (!raw || typeof raw !== 'object') return { ...DEFAULT_SCENE_GRID };
|
||||||
|
const obj = raw as Partial<SceneGrid>;
|
||||||
|
const type: SceneGridType = obj.type === 'hex' ? 'hex' : 'square';
|
||||||
|
return {
|
||||||
|
enabled: Boolean(obj.enabled),
|
||||||
|
type,
|
||||||
|
sizeN: clampSceneGridSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_GRID_SIZE_N),
|
||||||
|
color: normalizeSceneGridColor(obj.color),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneGridTypeLabelRu(type: SceneGridType): string {
|
||||||
|
return type === 'hex' ? 'Гексогональная' : 'Квадратная';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user