diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index f9845b7..57449b0 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments( previewRotationDeg: 0, darkenScene: false, traps: [], + grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: audioRefs }, settings: { autoplayVideo: previewAssetType === 'video', diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index a222708..66908a7 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -51,6 +51,7 @@ import type { } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; +import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; import { @@ -616,11 +617,13 @@ export class ZipProjectStore { previewRotationDeg: 0, darkenScene: false, traps: [], + grid: { ...DEFAULT_SCENE_GRID }, } satisfies Scene); const next: Scene = { ...base, traps: base.traps ?? [], + grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, ...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), @@ -640,6 +643,7 @@ export class ZipProjectStore { .filter((t): t is SceneTrap => Boolean(t)), } : null), + ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), @@ -2325,6 +2329,7 @@ function normalizeScene(s: Scene): Scene { const traps = (Array.isArray(rawTraps) ? rawTraps : []) .map((t) => normalizeSceneTrap(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 audios = rawAudios @@ -2354,6 +2359,7 @@ function normalizeScene(s: Scene): Scene { previewRotationDeg, darkenScene, traps, + grid, layout: layoutIn ?? { x: 0, y: 0 }, media: { videos: raw.videos ?? [], diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index c6cf1fe..0fe9f7e 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -35,6 +35,7 @@ import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; +import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay'; import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState'; import { Button } from '../shared/ui/controls'; @@ -1710,10 +1711,11 @@ export function ControlApp() { {!isVideoPreviewScene ? ( <> + (null); const [trapsOpen, setTrapsOpen] = useState(true); + const [gridOpen, setGridOpen] = useState(true); const [selectedId, setSelectedId] = useState(null); const [view, setView] = useState({ 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(null); const dragRef = useRef(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([]); + const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); const trapsRef = useRef([]); 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() { Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку. + + setGridOpen((v) => !v)}> + Сетка {gridOpen ? '▾' : '▸'} + + {gridOpen ? ( + + + persistGrid({ ...localGrid, enabled: e.target.checked })} + /> + Наложить сетку + + + Тип + + persistGrid({ + ...localGrid, + type: e.target.value === 'hex' ? 'hex' : 'square', + }) + } + > + {sceneGridTypeLabelRu('square')} + {sceneGridTypeLabelRu('hex')} + + + + Цвет + persistGrid({ ...localGrid, color: e.target.value })} + aria-label="Цвет сетки" + /> + + + + Размер {Math.round(localGrid.sizeN * 100)} + + + persistGrid({ + ...localGrid, + sizeN: clampSceneGridSizeN(Number(e.currentTarget.value)), + }) + } + /> + + + ) : null} + setTrapsOpen((v) => !v)}> Ловушки {trapsOpen ? '▾' : '▸'} @@ -304,6 +398,7 @@ export function SceneEditorApp() { viewCamera={viewCamera} onContentRectChange={setContentRect} /> + {contentRect ? localTraps.map((trap) => { const minDim = Math.min(contentRect.w, contentRect.h); diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 624b256..bb076b9 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -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({ ) : ( )} + {scene?.previewAssetType === 'image' ? ( + + ) : null} + {scene?.previewAssetType === 'image' && contentRect ? ( + + ) : null} {showEffects && scene?.previewAssetType !== 'video' ? ( ) : null} - {scene?.previewAssetType === 'image' && contentRect ? ( - - ) : null} {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( ) : null} diff --git a/app/renderer/shared/effects/ExplosionVideoOverlay.module.css b/app/renderer/shared/effects/ExplosionVideoOverlay.module.css index 99a4df9..705e85e 100644 --- a/app/renderer/shared/effects/ExplosionVideoOverlay.module.css +++ b/app/renderer/shared/effects/ExplosionVideoOverlay.module.css @@ -2,7 +2,8 @@ position: absolute; inset: 0; pointer-events: none; - z-index: 2; + /* Выше иконок ловушек (z-index: 5), чтобы VFX взрыва не уходил под маркер. */ + z-index: 7; overflow: hidden; } diff --git a/app/renderer/shared/grid/SceneGridOverlay.module.css b/app/renderer/shared/grid/SceneGridOverlay.module.css new file mode 100644 index 0000000..3e44bce --- /dev/null +++ b/app/renderer/shared/grid/SceneGridOverlay.module.css @@ -0,0 +1,12 @@ +.layer { + position: absolute; + pointer-events: none; + /* Без z-index: порядок в DOM (сразу после картинки) держит сетку под остальными слоями. */ + overflow: hidden; +} + +.canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/app/renderer/shared/grid/SceneGridOverlay.tsx b/app/renderer/shared/grid/SceneGridOverlay.tsx new file mode 100644 index 0000000..e04d2bb --- /dev/null +++ b/app/renderer/shared/grid/SceneGridOverlay.tsx @@ -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(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 ( + + + + ); +} diff --git a/app/renderer/shared/traps/SceneTrapsOverlay.module.css b/app/renderer/shared/traps/SceneTrapsOverlay.module.css index 04dad1f..1aa11a6 100644 --- a/app/renderer/shared/traps/SceneTrapsOverlay.module.css +++ b/app/renderer/shared/traps/SceneTrapsOverlay.module.css @@ -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; } diff --git a/app/shared/graph/sceneListOrder.test.ts b/app/shared/graph/sceneListOrder.test.ts index b883a94..cd7e36a 100644 --- a/app/shared/graph/sceneListOrder.test.ts +++ b/app/shared/graph/sceneListOrder.test.ts @@ -23,6 +23,7 @@ function scene(id: string): Scene { previewRotationDeg: 0, darkenScene: false, traps: [], + grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, connections: [], diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index a394d58..16bf60c 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene { previewRotationDeg: 0, darkenScene: false, traps: [], + grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, connections: [], diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index ca9bae3..7ddcc00 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -20,6 +20,7 @@ import type { Scene, SceneDarknessEvent, SceneDarknessState, + SceneGrid, SceneId, SceneTrap, SceneTrapsEvent, @@ -703,6 +704,7 @@ export type ScenePatch = { previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; traps?: SceneTrap[]; + grid?: SceneGrid; settings?: Partial; media?: Partial; layout?: Partial; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 93ee7c8..e28904d 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -9,6 +9,7 @@ import type { SceneId, } from './ids'; import type { MaterialLegend } from './materialLegend'; +import type { SceneGrid } from './sceneGrid'; import type { SceneTrap } from './sceneTraps'; export const PROJECT_SCHEMA_VERSION = 9 as const; @@ -162,6 +163,8 @@ export type Scene = { darkenScene: boolean; /** Ловушки на карте (только для image-превью); расстановка в проекте. */ traps: SceneTrap[]; + /** Боевая сетка поверх превью (под ловушками/эффектами). */ + grid: SceneGrid; media: SceneMediaRefs; settings: SceneSettings; connections: SceneId[]; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index c9baf26..02c6891 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -5,6 +5,7 @@ export * from './materialLegend'; export * from './materials'; export * from './npcs'; export * from './sceneDarkness'; +export * from './sceneGrid'; export * from './sceneTraps'; export * from './sceneView'; export * from './videoPlayback'; diff --git a/app/shared/types/sceneGrid.ts b/app/shared/types/sceneGrid.ts new file mode 100644 index 0000000..28cb5e8 --- /dev/null +++ b/app/shared/types/sceneGrid.ts @@ -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; + 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' ? 'Гексогональная' : 'Квадратная'; +}