feat(tokens): app-local non-player tokens with session moves and UI polish

Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
@@ -0,0 +1,35 @@
.layer {
position: absolute;
inset: 0;
pointer-events: none;
/* Выше brushLayer (3) и traps (5), ниже explosion (7) — drag должен стабильно ловиться. */
z-index: 8;
}
.token {
position: absolute;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
overflow: hidden;
pointer-events: none;
touch-action: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.tokenEditable {
pointer-events: auto;
cursor: grab;
}
.tokenEditable:active {
cursor: grabbing;
}
.tokenImg {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
@@ -0,0 +1,193 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
type Props = {
placements: readonly SceneToken[];
library: readonly AppToken[];
session: SceneTokensSessionState | null;
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
};
function TokenSprite({
placement,
nx,
ny,
viewport,
editable,
onMove,
}: {
placement: SceneToken;
nx: number;
ny: number;
viewport: Viewport;
editable: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
pointerId: number;
lastNx: number;
lastNy: number;
} | null>(null);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const frameRef = useRef(0);
useEffect(() => {
if (dragRef.current) return;
setLocalPos(null);
}, [nx, ny]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w);
const h = Math.max(1e-6, viewport.h);
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + viewport.x)) / w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + viewport.y)) / h)),
};
};
const endDrag = (el: HTMLDivElement, pointerId: number) => {
const d = dragRef.current;
if (!d || d.pointerId !== pointerId) return;
dragRef.current = null;
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = 0;
}
// Финальный commit в session store — один раз на отпускание.
onMove?.(String(placement.id), d.lastNx, d.lastNy);
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
try {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
}}
onPointerDown={
editable && onMove
? (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
const p = hostToNorm(e.clientX, e.clientY, host);
dragRef.current = {
startNx: posNx,
startNy: posNy,
pointerNx: p.x,
pointerNy: p.y,
pointerId: e.pointerId,
lastNx: posNx,
lastNy: posNy,
};
setLocalPos({ nx: posNx, ny: posNy });
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
editable && onMove
? (e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
const p = hostToNorm(e.clientX, e.clientY, host);
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
d.lastNx = nextNx;
d.lastNy = nextNy;
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
const cur = dragRef.current;
if (!cur) return;
setLocalPos({ nx: cur.lastNx, ny: cur.lastNy });
});
}
: undefined
}
onPointerUp={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
onPointerCancel={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
>
{url ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
</div>
);
}
export function SceneTokensOverlay({
placements,
library,
session,
viewport,
editable = false,
onMove,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
return (
<div className={styles.layer}>
{placements
.filter((p) => known.has(p.tokenId))
.map((placement) => {
const key = String(placement.id);
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
return (
<TokenSprite
key={key}
placement={placement}
nx={nx}
ny={ny}
viewport={viewport}
editable={editable}
onMove={onMove}
/>
);
})}
</div>
);
}
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { AppToken } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useAppTokens(): AppToken[] {
const api = getDndApi();
const [tokens, setTokens] = useState<AppToken[]>([]);
useEffect(() => {
void api.invoke(ipcChannels.tokens.list, {}).then(({ tokens: list }) => setTokens(list));
return api.on(ipcChannels.tokens.stateChanged, ({ tokens: list }) => setTokens(list));
}, [api]);
return tokens;
}
@@ -0,0 +1,63 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
function applyEvent(
prev: SceneTokensSessionState | null,
event: SceneTokensSessionEvent,
): SceneTokensSessionState {
const base = prev ?? { revision: 0, byPlacementId: {} };
if (event.kind === 'clear') {
return { revision: base.revision + 1, byPlacementId: {} };
}
const placementId = String(event.placementId ?? '');
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: { nx: event.nx, ny: event.ny },
},
};
}
export function useSceneTokensSession(): [
SceneTokensSessionState | null,
{ dispatch: (event: SceneTokensSessionEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneTokensSessionState | null>(null);
const localRevisionRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneTokensSession.getState, {}).then(({ state: s }) => {
localRevisionRef.current = Math.max(localRevisionRef.current, s.revision);
setState(s);
});
return api.on(ipcChannels.sceneTokensSession.stateChanged, ({ state: s }) => {
// Не затираем более свежий optimistic-стейт устаревшим broadcast
// (например, emit при смене сцены, пока последний move ещё в полёте).
if (localRevisionRef.current > s.revision) return;
localRevisionRef.current = s.revision;
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
dispatch: async (event: SceneTokensSessionEvent) => {
setState((prev) => {
const next = applyEvent(prev, event);
localRevisionRef.current = Math.max(localRevisionRef.current, next.revision);
return next;
});
const res = await api.invoke(ipcChannels.sceneTokensSession.dispatch, { event });
void res;
},
}),
[api],
);
return [state, apiWrap];
}
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { TokenId } from '../../../shared/types';
import { getDndApi } from '../dndApi';
const cache = new Map<string, string | null>();
export function useTokenImageUrl(tokenId: TokenId | null | undefined): string | null {
const api = getDndApi();
const [url, setUrl] = useState<string | null>(() =>
tokenId ? (cache.get(tokenId) ?? null) : null,
);
useEffect(() => {
if (!tokenId) {
setUrl(null);
return;
}
const cached = cache.get(tokenId);
if (cached !== undefined) {
setUrl(cached);
return;
}
let cancelled = false;
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
cache.set(tokenId, next);
if (!cancelled) setUrl(next);
});
return () => {
cancelled = true;
};
}, [api, tokenId]);
useEffect(() => {
return api.on(ipcChannels.tokens.stateChanged, ({ tokens }) => {
for (const t of tokens) {
cache.delete(t.id);
}
if (tokenId) {
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
cache.set(tokenId, next);
setUrl(next);
});
}
});
}, [api, tokenId]);
return url;
}