feat: scene traps, material legends, and scene editor window

Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-24 16:29:43 +08:00
parent d3b1c4660d
commit 02d73ddf81
39 changed files with 2563 additions and 36 deletions
+37
View File
@@ -27,12 +27,15 @@ import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
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 { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -119,6 +122,7 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState();
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1818,6 +1822,17 @@ export function ControlApp() {
clearDraftFromPixi();
}}
/>
{previewContentRect ? (
<SceneTrapsOverlay
traps={currentScene?.traps ?? []}
session={sceneTraps}
viewport={previewContentRect}
mode="control"
onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
onActivate={(trapId) => void sceneTrapsApi.dispatch({ kind: 'activate', trapId })}
onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
/>
) : null}
</>
) : null}
{(() => {
@@ -1893,6 +1908,28 @@ export function ControlApp() {
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
legendMarkers={
activeMaterial.legend?.enabled
? (activeMaterial.legend.markers ?? [])
: undefined
}
/>
) : null}
{showMaterial && activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel
legend={activeMaterial.legend}
layout={
materialsOverlay?.legendLayout ?? {
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
}
}
editable
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'legendLayout.set', layout });
}}
/>
) : null}
{showNpcs ? (
+18
View File
@@ -1564,6 +1564,16 @@ export function EditorApp() {
});
});
}}
onLegendChange={async (materialId, legend) => {
try {
await actions.setMaterialLegend(materialId, legend);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
}}
/>
<MaterialEditModal
open={materialEdit !== null}
@@ -2599,6 +2609,14 @@ function SceneInspector({
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
<div className={styles.spacer6} />
<Button
onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}}
>
Редактор сцены
</Button>
</>
) : null}
<div className={styles.spacer6} />
@@ -0,0 +1,137 @@
.legendBlock {
display: grid;
gap: 10px;
min-width: 0;
}
.legendBlockLarge {
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hint {
font-size: 12px;
opacity: 0.7;
line-height: 1.35;
}
.map {
position: relative;
width: 100%;
max-height: 280px;
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--stroke, #333);
background: #0a0b0e;
}
.mapLarge {
max-height: none;
height: min(48vh, 440px);
min-height: 260px;
flex: 0 0 auto;
touch-action: none;
cursor: grab;
}
.mapIdle {
display: grid;
place-items: center;
}
.mapEmpty {
color: var(--text2);
font-size: 13px;
}
.mapImg {
display: block;
width: 100%;
height: 100%;
max-height: inherit;
object-fit: contain;
user-select: none;
pointer-events: none;
margin: 0 auto;
}
.marker {
position: absolute;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border-radius: 50%;
background: #1e3a5f;
border: 2px solid #f5c542;
color: #fff;
font-size: 11px;
font-weight: 800;
display: grid;
place-items: center;
cursor: grab;
user-select: none;
z-index: 2;
touch-action: none;
}
.mapHint {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 3;
font-size: 12px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.65);
color: rgba(255, 255, 255, 0.85);
pointer-events: none;
}
.mapHintZoom {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
font-size: 11px;
padding: 5px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.55);
color: rgba(255, 255, 255, 0.75);
pointer-events: none;
}
.itemRow {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
}
.itemRowActive {
border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent);
}
.num {
font-weight: 800;
text-align: center;
}
.itemDrag {
cursor: pointer;
opacity: 0.7;
font-size: 12px;
border: none;
background: transparent;
color: inherit;
}
@@ -0,0 +1,474 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types';
import {
asMaterialLegendItemId,
asMaterialLegendMarkerId,
EMPTY_MATERIAL_LEGEND,
hostPointToLegendImageUv,
legendImageUvToHostPoint,
nextLegendNumber,
} from '../../shared/types/materialLegend';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
type SceneViewCamera,
} from '../../shared/types/sceneView';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import styles from './MaterialLegendEditor.module.css';
type Props = {
legend: MaterialLegend | undefined;
previewUrl: string | null;
rotationDeg?: 0 | 90 | 180 | 270;
/** Крупная карта (окно «Материалы»). */
largeMap?: boolean;
onChange: (legend: MaterialLegend) => void;
};
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'marker'; id: string }
| null;
const PERSIST_DEBOUNCE_MS = 180;
function rid(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend {
const base = legend ?? EMPTY_MATERIAL_LEGEND;
return {
enabled: base.enabled,
items: base.items.map((it) => ({ ...it })),
markers: base.markers.map((m) => ({ ...m })),
};
}
export function MaterialLegendEditor({
legend,
previewUrl,
rotationDeg = 0,
largeMap = false,
onChange,
}: Props) {
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const [view, setView] = useState<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const mapRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const skipMapClickRef = useRef(false);
const spaceDownRef = useRef(false);
const draftRef = useRef(draft);
draftRef.current = draft;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const saveTimerRef = useRef(0);
const dirtyRef = useRef(false);
const flushPersist = () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = 0;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
};
const schedulePersist = () => {
dirtyRef.current = true;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = 0;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
}, PERSIST_DEBOUNCE_MS);
};
const applyDraft = (next: MaterialLegend) => {
draftRef.current = next;
setDraft(next);
schedulePersist();
};
useEffect(() => {
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
if (dirtyRef.current) onChangeRef.current(draftRef.current);
};
}, []);
useEffect(() => {
flushPersist();
setDraft(cloneLegend(legend));
dirtyRef.current = false;
setActiveItemId(null);
setView(DEFAULT_SCENE_VIEW_CAMERA);
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image
}, [previewUrl]);
useEffect(() => {
if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) {
setActiveItemId(null);
}
}, [activeItemId, draft.items]);
useEffect(() => {
if (!largeMap) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [largeMap]);
useEffect(() => {
if (!largeMap) return;
const host = mapRef.current;
if (!host) return;
const nativeWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const cr = contentRect;
if (!cr) {
setView((v) => {
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
return { ...v, scale: nextScale };
});
return;
}
const r = host.getBoundingClientRect();
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewZoomAt(v, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
});
};
host.addEventListener('wheel', nativeWheel, { passive: false });
return () => host.removeEventListener('wheel', nativeWheel);
}, [contentRect, largeMap, previewUrl]);
const activeItem = useMemo(
() => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null),
[activeItemId, draft.items],
);
const setEnabled = (enabled: boolean) => {
applyDraft({ ...draftRef.current, enabled });
};
const updateItems = (items: MaterialLegendItem[]) => {
applyDraft({ ...draftRef.current, enabled: true, items });
};
const updateMarkers = (markers: MaterialLegendMarker[]) => {
applyDraft({ ...draftRef.current, enabled: true, markers });
};
const addItem = () => {
const cur = draftRef.current;
const number = nextLegendNumber(cur.items);
const item: MaterialLegendItem = {
id: asMaterialLegendItemId(rid('li')),
number,
text: '',
};
updateItems([...cur.items, item]);
setActiveItemId(item.id);
};
const placeMarker = (nx: number, ny: number, number: number) => {
updateMarkers([
...draftRef.current.markers,
{
id: asMaterialLegendMarkerId(rid('lm')),
number,
nx,
ny,
},
]);
};
const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const el = mapRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
// UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации.
if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) {
return hostPointToLegendImageUv(
clientX - r.left,
clientY - r.top,
contentRect,
rotationDeg,
);
}
const img = el.querySelector('img');
if (!img) return null;
const ir = img.getBoundingClientRect();
if (ir.width < 1 || ir.height < 1) return null;
return {
x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)),
y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)),
};
};
const onMapPointerDown = (e: React.PointerEvent) => {
if (!largeMap) return;
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
skipMapClickRef.current = true;
}
};
const onMapPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
if (d.kind === 'pan') {
const cr = contentRect;
if (!cr) return;
const dx = e.clientX - d.lastX;
const dy = e.clientY - d.lastY;
d.lastX = e.clientX;
d.lastY = e.clientY;
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewPanBy(v, { containW, containH, dx, dy });
});
return;
}
if (d.kind === 'marker') {
skipMapClickRef.current = true;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
const nextMarkers = draftRef.current.markers.map((x) =>
x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x,
);
const next = { ...draftRef.current, enabled: true, markers: nextMarkers };
draftRef.current = next;
setDraft(next);
// persist только на pointerup
}
};
const onMapPointerUp = () => {
const d = dragRef.current;
if (d?.kind === 'marker') {
schedulePersist();
}
dragRef.current = null;
};
const onMapClick = (e: React.MouseEvent) => {
if (skipMapClickRef.current) {
skipMapClickRef.current = false;
return;
}
if (dragRef.current) return;
if (!draft.enabled || !activeItem) return;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
placeMarker(p.x, p.y, activeItem.number);
};
const renderMarkers = () => {
if (!draft.enabled) return null;
return draft.markers.map((m) => {
const style =
largeMap && contentRect
? (() => {
const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg);
return { left: p.x, top: p.y };
})()
: {
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
};
return (
<div
key={m.id}
className={styles.marker}
style={style}
onPointerDown={(e) => {
if (!draft.enabled) return;
e.stopPropagation();
e.preventDefault();
dragRef.current = { kind: 'marker', id: m.id };
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}}
onPointerMove={onMapPointerMove}
onPointerUp={(e) => {
e.stopPropagation();
if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true;
onMapPointerUp();
}}
onClick={(e) => {
e.stopPropagation();
skipMapClickRef.current = true;
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id));
}}
title="Перетащите, чтобы переместить. ПКМ — удалить"
>
{m.number}
</div>
);
});
};
const mapBlock = previewUrl ? (
<div
ref={mapRef}
className={[
styles.map,
largeMap ? styles.mapLarge : '',
!draft.enabled ? styles.mapIdle : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!draft.enabled) return;
e.preventDefault();
}}
onDrop={(e) => {
if (!draft.enabled) return;
e.preventDefault();
const num = Number(e.dataTransfer.getData('application/x-legend-number'));
const p = toNorm(e.clientX, e.clientY);
if (!p || !Number.isFinite(num) || num < 1) return;
placeMarker(p.x, p.y, Math.round(num));
}}
onClick={onMapClick}
onPointerDown={onMapPointerDown}
onPointerMove={onMapPointerMove}
onPointerUp={onMapPointerUp}
onPointerCancel={onMapPointerUp}
>
{largeMap ? (
<RotatedImage
url={previewUrl}
rotationDeg={rotationDeg}
mode="contain"
viewCamera={view}
onContentRectChange={setContentRect}
/>
) : (
<img className={styles.mapImg} src={previewUrl} alt="" draggable={false} />
)}
{renderMarkers()}
{draft.enabled && !activeItem ? (
<div className={styles.mapHint}>Выберите строку легенды, чтобы ставить метки</div>
) : null}
{largeMap ? (
<div className={styles.mapHintZoom}>Колесо зум · СКМ / Space+ЛКМ пан</div>
) : null}
</div>
) : (
<div className={[styles.map, largeMap ? styles.mapLarge : '', styles.mapIdle].filter(Boolean).join(' ')}>
<div className={styles.mapEmpty}>Нет изображения</div>
</div>
);
return (
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
{largeMap ? mapBlock : null}
<label className={styles.row}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>Легенда</span>
</label>
{draft.enabled ? (
<>
<div className={styles.row}>
<Button onClick={addItem}>Добавить пункт</Button>
<span className={styles.hint}>
Активная строка подсвечена клик по картинке ставит метку; клик по метке перемещение
</span>
</div>
{draft.items.map((item) => {
const active = item.id === activeItemId;
return (
<div
key={item.id}
className={[styles.itemRow, active ? styles.itemRowActive : ''].filter(Boolean).join(' ')}
draggable
onClick={() => setActiveItemId(item.id)}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-legend-number', String(item.number));
setActiveItemId(item.id);
}}
>
<div className={styles.num}>{item.number}</div>
<Input
value={item.text}
onChange={(text) => {
updateItems(
draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)),
);
}}
placeholder="Описание…"
/>
<button
type="button"
className={styles.itemDrag}
title="Удалить"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const cur = draftRef.current;
applyDraft({
...cur,
enabled: true,
items: cur.items.filter((it) => it.id !== item.id),
markers: cur.markers.filter((m) => m.number !== item.number),
});
if (activeItemId === item.id) setActiveItemId(null);
}}
>
×
</button>
</div>
);
})}
{!largeMap ? mapBlock : null}
</>
) : largeMap ? null : (
mapBlock
)}
</div>
);
}
+40 -15
View File
@@ -1,13 +1,14 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { MaterialId, ProjectMaterial } from '../../shared/types';
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { MaterialLegendEditor } from './MaterialLegendEditor';
import matStyles from './MaterialsModals.module.css';
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
@@ -24,6 +25,7 @@ export type MaterialsBrowserProps = {
onDelete?: (materialId: MaterialId) => Promise<void>;
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
onTileActivate?: (materialId: MaterialId) => void;
toolbar?: React.ReactNode;
className?: string | undefined;
@@ -44,6 +46,7 @@ export function MaterialsBrowser({
onDelete,
onReorder,
onRotate,
onLegendChange,
onTileActivate,
toolbar,
className,
@@ -179,20 +182,42 @@ export function MaterialsBrowser({
</div>
{!listOnly ? (
<div className={matStyles.previewColumn}>
<div className={matStyles.previewPane}>
{selected && selectedUrl ? (
<div className={matStyles.previewLargeHost}>
<RotatedImage
url={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0}
mode="contain"
/>
</div>
) : (
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
)}
</div>
<div
className={[
matStyles.previewColumn,
onLegendChange ? matStyles.previewColumnLegend : '',
]
.filter(Boolean)
.join(' ')}
>
{selected && selectedUrl && onLegendChange ? (
<div className={matStyles.previewLegendScroll}>
<MaterialLegendEditor
key={selected.id}
largeMap
legend={selected.legend}
previewUrl={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0}
onChange={(next) => {
void onLegendChange(selected.id, next);
}}
/>
</div>
) : (
<div className={matStyles.previewPane}>
{selected && selectedUrl ? (
<div className={matStyles.previewLargeHost}>
<RotatedImage
url={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0}
mode="contain"
/>
</div>
) : (
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
)}
</div>
)}
{selected && onRotate ? (
<div className={matStyles.previewActions}>
<Button
+14 -3
View File
@@ -1,6 +1,6 @@
.managerDialog {
width: min(960px, calc(100vw - 48px));
max-width: 960px;
width: min(1100px, calc(100vw - 48px));
max-width: 1100px;
}
.browserRoot {
@@ -49,7 +49,7 @@
gap: 14px;
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
min-height: 560px;
max-height: min(78vh, 720px);
max-height: min(84vh, 820px);
}
.managerBodyFill {
@@ -180,6 +180,17 @@
min-height: 0;
}
.previewColumnLegend {
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
}
.previewLegendScroll {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.previewPane {
width: 100%;
/* высота ≈ 3 плитки списка */
+4 -2
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import type { MaterialId, ProjectMaterial } from '../../shared/types';
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -81,7 +81,6 @@ export function MaterialEditModal({
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
// Prefer File blob URL if available from the last drop via entries — handled in onDrop below.
setPreviewFromPathAndUrl(picked, '');
},
filterPaths: filterMaterialImagePaths,
@@ -226,6 +225,7 @@ type MaterialsManagerModalProps = {
onDelete: (materialId: MaterialId) => Promise<void>;
onReorder: (materialIds: MaterialId[]) => Promise<void>;
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
};
export function MaterialsManagerModal({
@@ -237,6 +237,7 @@ export function MaterialsManagerModal({
onDelete,
onReorder,
onRotate,
onLegendChange,
}: MaterialsManagerModalProps) {
const { t } = useEditorI18n();
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
@@ -278,6 +279,7 @@ export function MaterialsManagerModal({
onDelete={onDelete}
onReorder={onReorder}
onRotate={onRotate}
onLegendChange={onLegendChange}
/>
</div>
</>,
+17
View File
@@ -58,6 +58,10 @@ type Actions = {
deleteMaterial: (materialId: MaterialId) => Promise<void>;
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
setMaterialLegend: (
materialId: MaterialId,
legend: import('../../shared/types').MaterialLegend | null,
) => Promise<void>;
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
updateScene: (
sceneId: SceneId,
@@ -344,6 +348,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
traps: [],
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
@@ -531,6 +536,15 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects();
};
const setMaterialLegend = async (
materialId: MaterialId,
legend: import('../../shared/types').MaterialLegend | null,
) => {
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
setState((s) => ({ ...s, project: res.project }));
};
const pickMaterialImage = async () => {
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
if (res.canceled) return null;
@@ -548,6 +562,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
traps?: import('../../shared/types').SceneTrap[];
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
@@ -574,6 +589,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
@@ -896,6 +912,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
deleteMaterial,
setMaterialsOrder,
setMaterialRotation,
setMaterialLegend,
pickMaterialImage,
updateScene,
updateConnections,
@@ -24,6 +24,11 @@ export function PresentationApp() {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
return;
}
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
e.preventDefault();
void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {});
}
};
window.addEventListener('keydown', onKeyDown);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/sceneEditor/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,170 @@
.page {
display: grid;
grid-template-columns: 260px 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg, #12141a);
color: var(--text, #e8eaef);
}
.sidebar {
border-right: 1px solid var(--stroke, #2a2f3a);
padding: 12px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.sideTitle {
font-weight: 800;
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.85;
}
.accordion {
border: 1px solid var(--stroke, #2a2f3a);
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.accordionHead {
width: 100%;
text-align: left;
padding: 10px 12px;
font-weight: 700;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
}
.palette {
display: grid;
gap: 6px;
padding: 8px;
}
.paletteItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: rgba(0, 0, 0, 0.2);
color: inherit;
cursor: grab;
user-select: none;
}
.paletteItem:active {
cursor: grabbing;
}
.paletteLabel {
font-size: 13px;
font-weight: 600;
}
.hint {
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
}
.stage {
position: relative;
min-width: 0;
min-height: 0;
background: #0a0b0e;
}
.viewport {
position: absolute;
inset: 0;
overflow: hidden;
cursor: default;
}
.world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
will-change: transform;
}
.mapImg {
display: block;
max-width: none;
user-select: none;
pointer-events: none;
}
.trap {
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.55);
background: rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
cursor: move;
touch-action: none;
}
.trapSelected {
border-color: #f5c542;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.4),
0 0 0 3px rgba(245, 197, 66, 0.35);
}
.trapLabel {
position: absolute;
left: 50%;
top: calc(100% + 4px);
transform: translateX(-50%);
font-size: 11px;
white-space: nowrap;
background: rgba(0, 0, 0, 0.7);
padding: 2px 6px;
border-radius: 4px;
}
.handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 2px;
background: #f5c542;
border: 1px solid #000;
cursor: nwse-resize;
touch-action: none;
}
.empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0.7;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.toolbar button {
font-size: 12px;
}
+365
View File
@@ -0,0 +1,365 @@
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 {
asSceneTrapId,
DEFAULT_SCENE_TRAP_SIZE_N,
SCENE_TRAP_TYPES,
trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './SceneEditorApp.module.css';
type LocalView = { scale: number; ox: number; oy: number };
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
| { kind: 'resize'; trapId: string; startSize: number; startDist: number }
| null;
function randomTrapId(): string {
return `trap_${Math.random().toString(36).slice(2, 10)}`;
}
export function SceneEditorApp() {
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = 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>(
null,
);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const saveTimerRef = useRef(0);
const spaceDownRef = useRef(false);
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const trapsRef = useRef<SceneTrap[]>([]);
trapsRef.current = localTraps;
useEffect(() => {
setLocalTraps(scene?.traps ?? []);
setSelectedId(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]);
useEffect(() => {
// External updates (other windows) — sync when not dragging
if (dragRef.current) return;
setLocalTraps(scene?.traps ?? []);
}, [scene?.traps]);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const persistTraps = useCallback(
async (next: SceneTrap[]) => {
if (!sceneId) return;
setLocalTraps(next);
trapsRef.current = next;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
}, 120);
},
[api, sceneId],
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) {
e.preventDefault();
const next = trapsRef.current.filter((t) => t.id !== selectedId);
setSelectedId(null);
void persistTraps(next);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [persistTraps, sceneId, selectedId]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current;
const cr = contentRect;
if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
const r = host.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
};
};
const onWheel = (_e: React.WheelEvent) => {
// native non-passive listener handles zoom
};
void onWheel;
const viewCamera = useMemo(() => view, [view]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const nativeWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const cr = contentRect;
if (!cr) {
setView((v) => {
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
return { ...v, scale: nextScale };
});
return;
}
const r = host.getBoundingClientRect();
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewZoomAt(v, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
});
};
host.addEventListener('wheel', nativeWheel, { passive: false });
return () => host.removeEventListener('wheel', nativeWheel);
}, [contentRect]);
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
const trap: SceneTrap = {
id: asSceneTrapId(randomTrapId()),
type,
nx,
ny,
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
};
const next = [...trapsRef.current, trap];
setSelectedId(trap.id);
void persistTraps(next);
};
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => {
e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy';
};
const onStageDrop = (e: React.DragEvent) => {
e.preventDefault();
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return;
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
addTrapAt(type, p.x, p.y);
};
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t));
void persistTraps(next);
};
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
<div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранную ловушку.
</div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'}
</button>
{trapsOpen ? (
<div className={styles.palette}>
{SCENE_TRAP_TYPES.map((type) => (
<div
key={type}
className={styles.paletteItem}
draggable
onDragStart={onPaletteDragStart(type)}
title="Перетащите на карту"
>
<TrapGlyph type={type} size={22} />
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
</div>
))}
</div>
) : null}
</div>
{selectedId ? (
<div className={styles.toolbar}>
<Button
onClick={() => {
const next = localTraps.filter((t) => t.id !== selectedId);
setSelectedId(null);
void persistTraps(next);
}}
>
Удалить
</Button>
</div>
) : null}
</aside>
<div className={styles.stage}>
{!isImage ? (
<div className={styles.empty}>Нужно изображение сцены</div>
) : (
<div
ref={hostRef}
className={styles.viewport}
onDragOver={(e) => e.preventDefault()}
onDrop={onStageDrop}
onPointerDown={(e) => {
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
}
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d) return;
if (d.kind === 'pan') {
const cr = contentRect;
if (!cr) return;
const dx = e.clientX - d.lastX;
const dy = e.clientY - d.lastY;
d.lastX = e.clientX;
d.lastY = e.clientY;
setView((v) => {
const containW = cr.w / Math.max(1e-6, v.scale);
const containH = cr.h / Math.max(1e-6, v.scale);
return sceneViewPanBy(v, { containW, containH, dx, dy });
});
return;
}
if (d.kind === 'move') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateTrap(d.trapId, {
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
});
return;
}
if (d.kind === 'resize') {
const p = hostToNorm(e.clientX, e.clientY);
const trap = trapsRef.current.find((t) => t.id === d.trapId);
if (!p || !trap) return;
const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
updateTrap(d.trapId, {
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
});
}
}}
onPointerUp={() => {
dragRef.current = null;
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
<RotatedImage
url={url!}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={setContentRect}
/>
{contentRect
? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, trap.sizeN * minDim);
const left = contentRect.x + trap.nx * contentRect.w;
const top = contentRect.y + trap.ny * contentRect.h;
const selected = selectedId === trap.id;
return (
<div
key={trap.id}
className={[styles.trap, selected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
style={{ left, top, width: sizePx, height: sizePx }}
onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return;
e.stopPropagation();
setSelectedId(trap.id);
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'move',
trapId: trap.id,
startNx: trap.nx,
startNy: trap.ny,
pointerNx: p.x,
pointerNy: p.y,
};
}}
>
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null}
{selected ? (
<div
className={styles.handle}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'resize',
trapId: trap.id,
startSize: trap.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
};
}}
/>
) : null}
</div>
);
})
: null}
</div>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { SceneEditorApp } from './SceneEditorApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<SceneEditorApp />
</EditorI18nProvider>
</React.StrictMode>,
);
+21
View File
@@ -8,12 +8,15 @@ 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 { MaterialLegendPanel } from './materials/MaterialLegendPanel';
import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
import { useSceneTrapsState } from './traps/useSceneTrapsState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -35,6 +38,7 @@ export function PresentationView({
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [sceneTraps] = useSceneTrapsState();
const [sceneView] = useSceneViewState();
const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState();
@@ -165,6 +169,14 @@ export function PresentationView({
}
/>
) : 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}
@@ -174,6 +186,15 @@ export function PresentationView({
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
legendMarkers={
activeMaterial.legend?.enabled ? (activeMaterial.legend.markers ?? []) : undefined
}
/>
) : null}
{activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel
legend={activeMaterial.legend}
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
@@ -88,7 +88,7 @@ export function SceneDarknessOverlay({
height: viewport.h,
opacity: overlayAlpha,
pointerEvents: 'none',
zIndex: 2,
zIndex: 3,
...style,
}}
/>
@@ -0,0 +1,59 @@
.panel {
position: absolute;
z-index: 45;
min-width: 200px;
max-width: min(360px, 42vw);
max-height: min(70vh, 520px);
overflow: auto;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: linear-gradient(160deg, rgba(18, 22, 30, 0.94), rgba(12, 14, 20, 0.92));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
color: #f2f4f8;
backdrop-filter: blur(8px);
cursor: move;
touch-action: none;
user-select: none;
pointer-events: auto;
}
.title {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 10px;
}
.item {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: start;
padding: 6px 0;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.item:first-of-type {
border-top: 0;
}
.num {
width: 28px;
height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 800;
font-size: 12px;
background: #1e3a5f;
border: 2px solid #f5c542;
}
.text {
font-size: 13px;
line-height: 1.35;
padding-top: 4px;
}
@@ -0,0 +1,75 @@
import React, { useRef } from 'react';
import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import styles from './MaterialLegendPanel.module.css';
type Props = {
legend: MaterialLegend;
layout: MaterialsOverlayLayout;
editable?: boolean;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
};
export function MaterialLegendPanel({
legend,
layout,
editable = false,
onLayoutChange,
}: Props) {
const host = useSceneOverlayView();
const view = host?.view ?? { w: 1, h: 1 };
const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>(
null,
);
const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale);
const left = effective.cx * view.w - w / 2;
const top = effective.cy * view.h - 40;
if (!legend.enabled || legend.items.length === 0) return null;
return (
<div
className={styles.panel}
style={{ left, top, width: w }}
onPointerDown={(e) => {
if (!editable || !onLayoutChange) return;
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
origin: { ...effective },
};
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || !onLayoutChange) return;
const dx = (e.clientX - d.startX) / Math.max(1, view.w);
const dy = (e.clientY - d.startY) / Math.max(1, view.h);
onLayoutChange({
...d.origin,
cx: d.origin.cx + dx,
cy: d.origin.cy + dy,
});
}}
onPointerUp={() => {
dragRef.current = null;
}}
>
<div className={styles.title}>Легенда</div>
{legend.items
.slice()
.sort((a, b) => a.number - b.number)
.map((item) => (
<div key={item.id} className={styles.item}>
<div className={styles.num}>{item.number}</div>
<div className={styles.text}>{item.text || '—'}</div>
</div>
))}
</div>
);
}
@@ -50,15 +50,33 @@
.image {
position: absolute;
left: 50%;
top: 50%;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: fill;
border-radius: 6px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
user-select: none;
pointer-events: none;
transform-origin: center center;
}
.legendMarker {
position: absolute;
transform: translate(-50%, -50%);
width: 26px;
height: 26px;
border-radius: 50%;
background: #1e3a5f;
border: 2px solid #f5c542;
color: #fff;
font-size: 12px;
font-weight: 800;
display: grid;
place-items: center;
pointer-events: none;
z-index: 2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.handle {
@@ -22,6 +22,8 @@ type MaterialOverlayProps = {
onZoomAt?: (nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
/** Маркеры легенды (норм. координаты картинки). */
legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[];
};
function RotateIcon() {
@@ -99,6 +101,7 @@ export function MaterialOverlay({
onLayoutChange,
onZoomAt,
embedded = false,
legendMarkers,
}: MaterialOverlayProps) {
const url = useAssetUrl(assetId);
const host = useSceneOverlayView();
@@ -381,16 +384,23 @@ export function MaterialOverlay({
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{(legendMarkers ?? []).map((m) => (
<div
key={m.id}
className={styles.legendMarker}
style={{
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
}}
>
{m.number}
</div>
))}
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
@@ -0,0 +1,100 @@
.layer {
position: absolute;
inset: 0;
pointer-events: none;
/* Выше brushLayer (z-index: 3), иначе ПКМ/меню перехватывает кисть. */
z-index: 5;
}
.trap {
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
pointer-events: auto;
cursor: context-menu;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.trapActive {
border-color: #ff6b4a;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.35),
0 0 14px rgba(255, 90, 40, 0.45);
}
.trapDisarmed {
border-color: #9ca3af;
filter: grayscale(0.7);
opacity: 0.75;
}
.trapGmHidden {
opacity: 0.55;
border-style: dashed;
}
.label {
position: absolute;
left: 50%;
top: calc(100% + 3px);
transform: translateX(-50%);
font-size: 11px;
white-space: nowrap;
background: rgba(0, 0, 0, 0.75);
padding: 1px 6px;
border-radius: 4px;
pointer-events: none;
}
.menu {
position: fixed;
z-index: 80;
min-width: 180px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--stroke, #333);
background: #1a1d24;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
pointer-events: auto;
}
.menuItem {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #e8eaef;
font-size: 13px;
cursor: pointer;
}
.menuItem:hover {
background: rgba(255, 255, 255, 0.08);
}
.flash {
position: absolute;
inset: -30%;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 200, 80, 0.85), transparent 70%);
animation: trapFlash 0.7s ease-out forwards;
pointer-events: none;
}
@keyframes trapFlash {
from {
opacity: 1;
transform: scale(0.4);
}
to {
opacity: 0;
transform: scale(1.6);
}
}
@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { SceneTrap, SceneTrapsState } from '../../../shared/types';
import { defaultTrapRuntime } from '../../../shared/types/sceneTraps';
import { TrapGlyph } from './TrapGlyph';
import styles from './SceneTrapsOverlay.module.css';
type Props = {
traps: readonly SceneTrap[];
session: SceneTrapsState | null;
viewport: { x: number; y: number; w: number; h: number } | null;
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
mode: 'control' | 'presentation';
onReveal?: (trapId: string) => void;
onActivate?: (trapId: string) => void;
onDisarm?: (trapId: string) => void;
};
function menuPosition(clientX: number, clientY: number): { x: number; y: number } {
const menuW = 200;
const menuH = 140;
const pad = 8;
return {
x: Math.max(pad, Math.min(clientX, window.innerWidth - menuW - pad)),
y: Math.max(pad, Math.min(clientY, window.innerHeight - menuH - pad)),
};
}
export function SceneTrapsOverlay({
traps,
session,
viewport,
mode,
onReveal,
onActivate,
onDisarm,
}: Props) {
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
const [flashToken, setFlashToken] = useState<{ trapId: string; token: number } | null>(null);
useEffect(() => {
const act = session?.lastActivation;
if (!act) return;
setFlashToken(act);
const t = window.setTimeout(() => setFlashToken(null), 750);
return () => window.clearTimeout(t);
}, [session?.lastActivation?.token, session?.lastActivation?.trapId]);
useEffect(() => {
if (!menu) return;
const close = (e: PointerEvent) => {
const t = e.target;
if (t instanceof Element && t.closest('[data-trap-menu-root="1"]')) return;
setMenu(null);
};
window.addEventListener('pointerdown', close, true);
return () => window.removeEventListener('pointerdown', close, true);
}, [menu]);
if (!viewport || traps.length === 0) return null;
const minDim = Math.min(viewport.w, viewport.h);
return (
<div className={styles.layer}>
{traps.map((trap) => {
const rt = session?.byId[trap.id] ?? defaultTrapRuntime();
if (mode === 'presentation' && !rt.revealed) return null;
const sizePx = Math.max(16, trap.sizeN * minDim);
const left = viewport.x + trap.nx * viewport.w;
const top = viewport.y + trap.ny * viewport.h;
const cls = [
styles.trap,
rt.status === 'active' ? styles.trapActive : '',
rt.status === 'disarmed' ? styles.trapDisarmed : '',
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
]
.filter(Boolean)
.join(' ');
return (
<div
key={trap.id}
className={cls}
style={{ left, top, width: sizePx, height: sizePx }}
onContextMenu={
mode === 'control'
? (e) => {
e.preventDefault();
e.stopPropagation();
const pos = menuPosition(e.clientX, e.clientY);
setMenu({ trapId: trap.id, x: pos.x, y: pos.y });
}
: undefined
}
>
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
{flashToken?.trapId === trap.id ? <div className={styles.flash} /> : null}
</div>
);
})}
{menu && mode === 'control'
? createPortal(
<div
role="menu"
data-trap-menu-root="1"
className={styles.menu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
onReveal?.(menu.trapId);
setMenu(null);
}}
>
Проявить
</button>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
onActivate?.(menu.trapId);
setMenu(null);
}}
>
Активировать
</button>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
onDisarm?.(menu.trapId);
setMenu(null);
}}
>
Обезвредить
</button>
</div>,
document.body,
)
: null}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
/** Простые SVG-иконки ловушек (MVP). */
import type { SceneTrapStatus, SceneTrapType } from '../../shared/types';
const TYPE_COLOR: Record<SceneTrapType, string> = {
mimic: '#c4783a',
explosion: '#e85d3a',
poison: '#6bcb5a',
pit: '#5a5a6e',
arrow: '#d4a017',
laser: '#3ad0e8',
freeform: '#a78bfa',
};
export function trapAccentColor(type: SceneTrapType, status: SceneTrapStatus = 'inactive'): string {
if (status === 'disarmed') return '#6b7280';
if (status === 'active') return TYPE_COLOR[type];
return TYPE_COLOR[type];
}
export function TrapGlyph({
type,
status = 'inactive',
size = 28,
}: {
type: SceneTrapType;
status?: SceneTrapStatus;
size?: number;
}) {
const stroke = trapAccentColor(type, status);
const opacity = status === 'disarmed' ? 0.55 : 1;
const common = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke,
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
opacity,
'aria-hidden': true as const,
};
switch (type) {
case 'mimic':
return (
<svg {...common}>
<rect x="4" y="8" width="16" height="10" rx="1.5" />
<path d="M8 8 V6.5 a4 4 0 0 1 8 0 V8" />
<path d="M9 13h6M10 16h4" />
</svg>
);
case 'explosion':
return (
<svg {...common}>
<circle cx="12" cy="12" r="3.2" fill={stroke} stroke="none" opacity={opacity * 0.9} />
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" />
</svg>
);
case 'poison':
return (
<svg {...common}>
<path d="M12 3c2.5 3.5 5 6.2 5 10a5 5 0 1 1-10 0c0-3.8 2.5-6.5 5-10z" />
<circle cx="10" cy="14" r="0.9" fill={stroke} stroke="none" />
<circle cx="13.5" cy="15.5" r="0.7" fill={stroke} stroke="none" />
</svg>
);
case 'pit':
return (
<svg {...common}>
<path d="M4 8h16M6 8l2 10h8l2-10" />
<path d="M9 14h6" opacity={0.7} />
</svg>
);
case 'arrow':
return (
<svg {...common}>
<path d="M4 12h14" />
<path d="M14 7l5 5-5 5" />
<path d="M4 9v6" />
</svg>
);
case 'laser':
return (
<svg {...common}>
<circle cx="6" cy="12" r="2.2" />
<path d="M9 12h11" strokeWidth="2.4" />
<path d="M17 9l3 3-3 3" />
</svg>
);
case 'freeform':
return (
<svg {...common}>
<path d="M12 3l2.2 6.2H21l-5.2 3.8 2 6.5L12 16.2 6.2 19.5l2-6.5L3 9.2h6.8z" />
</svg>
);
default: {
const _x: never = type;
return <svg {...common}>{String(_x)}</svg>;
}
}
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneTrapsEvent, SceneTrapsState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSceneTrapsState(): readonly [
SceneTrapsState | null,
{ dispatch: (event: SceneTrapsEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneTrapsState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sceneTraps.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.sceneTraps.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.sceneTraps.dispatch, { event });
},
},
] as const;
}