feat: save games and edit scene darkness
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -86,6 +86,39 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.darknessTools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.darknessBrushLayer {
|
||||
position: absolute;
|
||||
z-index: 29;
|
||||
cursor: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.brushCursor {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
will-change: left, top;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||||
0 0 18px rgba(255, 140, 40, 0.12),
|
||||
inset 0 0 18px rgba(255, 140, 40, 0.1);
|
||||
background: rgba(255, 160, 60, 0.03);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brushCursorSquare {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -7,14 +7,21 @@ import type {
|
||||
NpcDisposition,
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
Scene,
|
||||
SceneDarknessBrushShape,
|
||||
SceneDarknessDraft,
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneGrid,
|
||||
SceneGridTemplate,
|
||||
SceneId,
|
||||
SceneNpcToken,
|
||||
SceneToken,
|
||||
SceneTrap,
|
||||
SceneTrapType,
|
||||
TokenId,
|
||||
} from '../../shared/types';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import {
|
||||
asSceneNpcTokenId,
|
||||
clampSceneNpcTokenSizeN,
|
||||
@@ -237,6 +244,24 @@ function SceneNpcMarker({
|
||||
);
|
||||
}
|
||||
|
||||
function sameDarknessStrokes(
|
||||
a: readonly SceneDarknessRevealStroke[],
|
||||
b: readonly SceneDarknessRevealStroke[],
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
const left = a[i];
|
||||
const right = b[i];
|
||||
if (!left || !right) return false;
|
||||
if (left.id !== right.id || left.points.length !== right.points.length) return false;
|
||||
if (left.radiusN !== right.radiusN || left.mode !== right.mode || left.shape !== right.shape) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(npc.disposition);
|
||||
@@ -270,8 +295,28 @@ export function SceneEditorApp() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
const [gridOpen, setGridOpen] = useState(false);
|
||||
const [darknessOpen, setDarknessOpen] = useState(false);
|
||||
const [tokensOpen, setTokensOpen] = useState(false);
|
||||
const [npcsOpen, setNpcsOpen] = useState(false);
|
||||
const [localDarken, setLocalDarken] = useState(false);
|
||||
const [localDarknessStrokes, setLocalDarknessStrokes] = useState<SceneDarknessRevealStroke[]>([]);
|
||||
const [darknessDraft, setDarknessDraft] = useState<SceneDarknessDraft | null>(null);
|
||||
const [darknessTool, setDarknessTool] = useState<'none' | 'explore' | 'close'>('none');
|
||||
const [darknessRadiusN, setDarknessRadiusN] = useState(0.08);
|
||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||
const darknessBrushRef = useRef<{
|
||||
points: { x: number; y: number; tMs: number }[];
|
||||
createdAtMs: number;
|
||||
seed: number;
|
||||
} | null>(null);
|
||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const darknessCursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const darknessToolRef = useRef(darknessTool);
|
||||
const darknessRadiusNRef = useRef(darknessRadiusN);
|
||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||
darknessToolRef.current = darknessTool;
|
||||
darknessRadiusNRef.current = darknessRadiusN;
|
||||
darknessBrushShapeRef.current = darknessBrushShape;
|
||||
const [tokenSearch, setTokenSearch] = useState('');
|
||||
const [npcSearch, setNpcSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<
|
||||
@@ -291,16 +336,23 @@ export function SceneEditorApp() {
|
||||
const [selected, setSelected] = useState<Selection>(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 contentRectRef = useRef(contentRect);
|
||||
contentRectRef.current = contentRect;
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTrapsTimerRef = useRef(0);
|
||||
const saveTokensTimerRef = useRef(0);
|
||||
const saveNpcTokensTimerRef = useRef(0);
|
||||
const saveGridTimerRef = useRef(0);
|
||||
const saveDarknessTimerRef = useRef(0);
|
||||
const pendingDarknessStrokesRef = useRef<SceneDarknessRevealStroke[] | null>(null);
|
||||
const darknessDirtyRef = useRef(false);
|
||||
const spaceDownRef = useRef(false);
|
||||
const sceneIdRef = useRef<SceneId | null>(null);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
sceneIdRef.current = sceneId;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
@@ -316,6 +368,7 @@ export function SceneEditorApp() {
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
const tokensRef = useRef<SceneToken[]>([]);
|
||||
const npcTokensRef = useRef<SceneNpcToken[]>([]);
|
||||
const localDarknessStrokesRef = useRef<SceneDarknessRevealStroke[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
trapsRef.current = localTraps;
|
||||
@@ -324,12 +377,28 @@ export function SceneEditorApp() {
|
||||
}, [localNpcTokens, localTokens, localTraps]);
|
||||
|
||||
useEffect(() => {
|
||||
localDarknessStrokesRef.current = localDarknessStrokes;
|
||||
}, [localDarknessStrokes]);
|
||||
|
||||
useEffect(() => {
|
||||
darknessDirtyRef.current = false;
|
||||
pendingDarknessStrokesRef.current = null;
|
||||
if (saveDarknessTimerRef.current) {
|
||||
window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = 0;
|
||||
}
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
|
||||
setLocalNpcTokens(
|
||||
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
|
||||
);
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
setLocalDarken(Boolean(scene?.darkenScene));
|
||||
const strokes = scene?.darknessStrokes ?? [];
|
||||
localDarknessStrokesRef.current = strokes;
|
||||
setLocalDarknessStrokes(strokes);
|
||||
setDarknessDraft(null);
|
||||
setDarknessTool('none');
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
}, [sceneId, scene?.previewAssetId]);
|
||||
@@ -356,6 +425,16 @@ export function SceneEditorApp() {
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
}, [scene?.grid]);
|
||||
|
||||
useEffect(() => {
|
||||
// IPC session clones arrays — do not clobber unsaved local darkness edits.
|
||||
if (darknessBrushRef.current || darknessDirtyRef.current || saveDarknessTimerRef.current) return;
|
||||
setLocalDarken(Boolean(scene?.darkenScene));
|
||||
const incoming = scene?.darknessStrokes ?? [];
|
||||
if (sameDarknessStrokes(incoming, localDarknessStrokesRef.current)) return;
|
||||
localDarknessStrokesRef.current = incoming;
|
||||
setLocalDarknessStrokes(incoming);
|
||||
}, [scene?.darkenScene, scene?.darknessStrokes]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||
@@ -404,6 +483,99 @@ export function SceneEditorApp() {
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const mergeSceneIntoSession = useCallback((updated: Scene) => {
|
||||
setSession((prev) => {
|
||||
if (!prev?.project?.scenes[updated.id]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
project: {
|
||||
...prev.project,
|
||||
scenes: { ...prev.project.scenes, [updated.id]: updated },
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushDarknessStrokes = useCallback(async () => {
|
||||
const sid = sceneIdRef.current;
|
||||
const payload = pendingDarknessStrokesRef.current;
|
||||
if (saveDarknessTimerRef.current) {
|
||||
window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = 0;
|
||||
}
|
||||
if (!sid || !payload) return;
|
||||
pendingDarknessStrokesRef.current = null;
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId: sid,
|
||||
patch: { darknessStrokes: payload },
|
||||
});
|
||||
darknessDirtyRef.current = false;
|
||||
mergeSceneIntoSession(res.scene);
|
||||
} catch {
|
||||
// Keep dirty so a later flush / session sync can retry from local ref.
|
||||
pendingDarknessStrokesRef.current = payload;
|
||||
darknessDirtyRef.current = true;
|
||||
}
|
||||
}, [api, mergeSceneIntoSession]);
|
||||
|
||||
const persistDarken = useCallback(
|
||||
(next: boolean) => {
|
||||
const sid = sceneIdRef.current;
|
||||
if (!sid) return;
|
||||
setLocalDarken(next);
|
||||
if (!next) setDarknessTool('none');
|
||||
darknessDirtyRef.current = true;
|
||||
void (async () => {
|
||||
await flushDarknessStrokes();
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId: sid,
|
||||
patch: { darkenScene: next },
|
||||
});
|
||||
darknessDirtyRef.current = false;
|
||||
mergeSceneIntoSession(res.scene);
|
||||
} catch {
|
||||
darknessDirtyRef.current = true;
|
||||
}
|
||||
})();
|
||||
},
|
||||
[api, flushDarknessStrokes, mergeSceneIntoSession],
|
||||
);
|
||||
|
||||
const persistDarknessStrokes = useCallback(
|
||||
(next: SceneDarknessRevealStroke[]) => {
|
||||
if (!sceneIdRef.current) return;
|
||||
localDarknessStrokesRef.current = next;
|
||||
setLocalDarknessStrokes(next);
|
||||
pendingDarknessStrokesRef.current = next;
|
||||
darknessDirtyRef.current = true;
|
||||
if (saveDarknessTimerRef.current) window.clearTimeout(saveDarknessTimerRef.current);
|
||||
saveDarknessTimerRef.current = window.setTimeout(() => {
|
||||
void flushDarknessStrokes();
|
||||
}, 120);
|
||||
},
|
||||
[flushDarknessStrokes],
|
||||
);
|
||||
|
||||
const flushDarknessStrokesRef = useRef(flushDarknessStrokes);
|
||||
flushDarknessStrokesRef.current = flushDarknessStrokes;
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
void flushDarknessStrokesRef.current();
|
||||
};
|
||||
const onVis = () => {
|
||||
if (document.hidden) flush();
|
||||
};
|
||||
window.addEventListener('blur', flush);
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => {
|
||||
window.removeEventListener('blur', flush);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
flush();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistGrid = useCallback(
|
||||
(next: SceneGrid) => {
|
||||
if (!sceneId) return;
|
||||
@@ -504,6 +676,34 @@ export function SceneEditorApp() {
|
||||
};
|
||||
};
|
||||
|
||||
const layoutDarknessBrushCursor = () => {
|
||||
const el = brushCursorElRef.current;
|
||||
const p = darknessCursorPosRef.current;
|
||||
const cr = contentRectRef.current;
|
||||
if (!el) return;
|
||||
if (!p || !cr || darknessToolRef.current === 'none') {
|
||||
el.style.visibility = 'hidden';
|
||||
return;
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
el.className = [
|
||||
styles.brushCursor,
|
||||
darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const minDim = Math.min(cr.w, cr.h);
|
||||
const size = Math.max(1, darknessRadiusNRef.current * minDim * 2);
|
||||
el.style.left = `${String(cr.x + p.x * cr.w)}px`;
|
||||
el.style.top = `${String(cr.y + p.y * cr.h)}px`;
|
||||
el.style.width = `${String(size)}px`;
|
||||
el.style.height = `${String(size)}px`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
layoutDarknessBrushCursor();
|
||||
}, [darknessBrushShape, darknessRadiusN, darknessTool, contentRect]);
|
||||
|
||||
const viewCamera = useMemo(() => view, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -632,6 +832,17 @@ export function SceneEditorApp() {
|
||||
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
|
||||
const hasMapMedia = isImage || isVideo;
|
||||
|
||||
const darknessOverlayState = useMemo((): SceneDarknessState | null => {
|
||||
if (!localDarken) return null;
|
||||
return {
|
||||
revision: 1,
|
||||
enabled: true,
|
||||
cacheKey: sceneId,
|
||||
strokes: localDarknessStrokes,
|
||||
draft: darknessDraft,
|
||||
};
|
||||
}, [darknessDraft, localDarken, localDarknessStrokes, sceneId]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
@@ -828,6 +1039,98 @@ export function SceneEditorApp() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion} data-testid="scene-darkness-accordion">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.accordionHead}
|
||||
onClick={() => setDarknessOpen((v) => !v)}
|
||||
>
|
||||
{t('sceneEditor.darkness')} {darknessOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{darknessOpen ? (
|
||||
<div className={styles.gridPanel}>
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localDarken}
|
||||
disabled={!hasMapMedia}
|
||||
onChange={(e) => persistDarken(e.target.checked)}
|
||||
data-testid="scene-darken-toggle"
|
||||
/>
|
||||
<span>{t('scene.darkenScene')}</span>
|
||||
</label>
|
||||
{localDarken && hasMapMedia ? (
|
||||
<>
|
||||
<div className={styles.darknessTools}>
|
||||
<Button
|
||||
variant={darknessTool === 'explore' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.explorerBrush')}
|
||||
ariaLabel={t('control.explorerBrush')}
|
||||
onClick={() =>
|
||||
setDarknessTool((prev) => (prev === 'explore' ? 'none' : 'explore'))
|
||||
}
|
||||
>
|
||||
<span aria-hidden>🔦</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={darknessTool === 'close' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.closerBrush')}
|
||||
ariaLabel={t('control.closerBrush')}
|
||||
onClick={() =>
|
||||
setDarknessTool((prev) => (prev === 'close' ? 'none' : 'close'))
|
||||
}
|
||||
>
|
||||
<span aria-hidden>⬛</span>
|
||||
</Button>
|
||||
</div>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('control.brushRadius')}{' '}
|
||||
<span className={styles.fieldValue}>
|
||||
{Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.range}
|
||||
min={1}
|
||||
max={36}
|
||||
step={1}
|
||||
value={Math.max(1, Math.min(36, Math.round(darknessRadiusN / 0.005)))}
|
||||
onChange={(e) => {
|
||||
const ui = Number(e.currentTarget.value);
|
||||
setDarknessRadiusN(Math.max(1, Math.min(36, Math.round(ui))) * 0.005);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.darknessTools}>
|
||||
<Button
|
||||
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeCircle')}
|
||||
ariaLabel={t('control.brushShapeCircle')}
|
||||
onClick={() => setDarknessBrushShape('circle')}
|
||||
>
|
||||
<span aria-hidden>○</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeSquare')}
|
||||
ariaLabel={t('control.brushShapeSquare')}
|
||||
onClick={() => setDarknessBrushShape('square')}
|
||||
>
|
||||
<span aria-hidden>□</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<div className={styles.accordion} data-testid="scene-npc-accordion">
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
|
||||
@@ -1071,6 +1374,105 @@ export function SceneEditorApp() {
|
||||
/>
|
||||
)}
|
||||
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||
{localDarken && contentRect ? (
|
||||
<SceneDarknessOverlay
|
||||
state={darknessOverlayState}
|
||||
overlayAlpha={0.55}
|
||||
viewport={contentRect}
|
||||
style={{ zIndex: 28 }}
|
||||
/>
|
||||
) : null}
|
||||
{localDarken && darknessTool !== 'none' && contentRect ? (
|
||||
<>
|
||||
<div
|
||||
ref={brushCursorElRef}
|
||||
className={styles.brushCursor}
|
||||
style={{ visibility: 'hidden' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className={styles.darknessBrushLayer}
|
||||
style={{
|
||||
left: contentRect.x,
|
||||
top: contentRect.y,
|
||||
width: contentRect.w,
|
||||
height: contentRect.h,
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
darknessCursorPosRef.current = null;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
const pt = { x: p.x, y: p.y, tMs: createdAtMs };
|
||||
darknessBrushRef.current = { points: [pt], createdAtMs, seed };
|
||||
setDarknessDraft({
|
||||
points: [pt],
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
});
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (p) {
|
||||
darknessCursorPosRef.current = p;
|
||||
layoutDarknessBrushCursor();
|
||||
}
|
||||
const brush = darknessBrushRef.current;
|
||||
if (!brush || (e.buttons & 1) === 0) return;
|
||||
if (!p) return;
|
||||
const last = brush.points[brush.points.length - 1];
|
||||
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 0.002) return;
|
||||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||
brush.points.push(pt);
|
||||
setDarknessDraft({
|
||||
points: [...brush.points],
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const brush = darknessBrushRef.current;
|
||||
darknessBrushRef.current = null;
|
||||
setDarknessDraft(null);
|
||||
if (!brush || brush.points.length === 0) return;
|
||||
const stroke: SceneDarknessRevealStroke = {
|
||||
id: `sd_${String(brush.createdAtMs)}_${String(brush.seed)}`,
|
||||
seed: brush.seed,
|
||||
createdAtMs: brush.createdAtMs,
|
||||
points: brush.points,
|
||||
radiusN: darknessRadiusN,
|
||||
mode: darknessTool === 'close' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShape,
|
||||
};
|
||||
persistDarknessStrokes([...localDarknessStrokesRef.current, stroke]);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
darknessBrushRef.current = null;
|
||||
setDarknessDraft(null);
|
||||
darknessCursorPosRef.current = null;
|
||||
layoutDarknessBrushCursor();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<TokenPathsOverlay
|
||||
tokens={localTokens}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
|
||||
|
||||
Reference in New Issue
Block a user