fix: scene clear race, path editor anchors, avatar cover, team color crash

Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-29 15:30:28 +08:00
parent 028c1be059
commit 0a9168b320
10 changed files with 189 additions and 95 deletions
@@ -85,6 +85,18 @@ void test('zipStore: NPC parse defaults externalUrl', () => {
assert.match(src, /externalUrl: normalizeNpcExternalUrl/); assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
}); });
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
assert.ok(i >= 0);
const j = src.indexOf('async updateConnections(', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
});
void test('zipStore: listProjects skips unreadable archives', () => { void test('zipStore: listProjects skips unreadable archives', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match( assert.match(
+88 -81
View File
@@ -728,91 +728,98 @@ export class ZipProjectStore {
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> { async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const existing = open.project.scenes[sceneId];
const base: Scene =
existing ??
({
id: sceneId,
title: '',
description: '',
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
const next: Scene = { // Apply patch inside the mutator so concurrent updateScene calls (e.g. clear scene
...base, // firing traps/tokens/npcTokens separately) each read a fresh scene snapshot.
traps: base.traps ?? [], let result!: Scene;
tokens: base.tokens ?? [], await this.updateProject((p) => {
npcTokens: base.npcTokens ?? [], const existing = p.scenes[sceneId];
grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, const base: Scene =
...(patch.title !== undefined ? { title: patch.title } : null), existing ??
...(patch.description !== undefined ? { description: patch.description } : null), ({
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), id: sceneId,
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), title: '',
...(patch.previewThumbAssetId !== undefined description: '',
? { previewThumbAssetId: patch.previewThumbAssetId } media: { videos: [], audios: [] },
: null), settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
...(patch.previewVideoAutostart !== undefined connections: [],
? { previewVideoAutostart: patch.previewVideoAutostart } layout: { x: 0, y: 0 },
: null), previewAssetId: null,
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), previewAssetType: null,
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), previewThumbAssetId: null,
...(patch.traps !== undefined previewVideoAutostart: false,
? { previewRotationDeg: 0,
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), darkenScene: false,
} traps: [],
: null), tokens: [],
...(patch.tokens !== undefined npcTokens: [],
? { grid: { ...DEFAULT_SCENE_GRID },
tokens: patch.tokens } satisfies Scene);
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => 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),
};
// Keep map markers glued to the art when previewRotationDeg changes (image/video). const next: Scene = {
if (patch.previewRotationDeg !== undefined) { ...base,
const fromRot = asPreviewRotationDeg(base.previewRotationDeg); traps: base.traps ?? [],
const toRot = asPreviewRotationDeg(patch.previewRotationDeg); tokens: base.tokens ?? [],
const steps = previewRotationStepsCw(fromRot, toRot); npcTokens: base.npcTokens ?? [],
if (steps !== 0) { grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
if (patch.tokens === undefined) { ...(patch.title !== undefined ? { title: patch.title } : null),
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps); ...(patch.description !== undefined ? { description: patch.description } : null),
} ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
if (patch.npcTokens === undefined) { ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps); ...(patch.previewThumbAssetId !== undefined
} ? { previewThumbAssetId: patch.previewThumbAssetId }
if (patch.traps === undefined) { : null),
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps); ...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined
? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
? {
tokens: patch.tokens
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => 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),
};
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
if (patch.previewRotationDeg !== undefined) {
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
const steps = previewRotationStepsCw(fromRot, toRot);
if (steps !== 0) {
if (patch.tokens === undefined) {
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
}
if (patch.npcTokens === undefined) {
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
}
if (patch.traps === undefined) {
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
}
} }
} }
}
await this.updateProject((p) => { result = next;
const scenes = { ...p.scenes, [sceneId]: next }; const scenes = { ...p.scenes, [sceneId]: next };
const sceneListOrder = const sceneListOrder =
existing != null existing != null
@@ -820,7 +827,7 @@ export class ZipProjectStore {
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId); : prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
return { ...p, scenes, sceneListOrder }; return { ...p, scenes, sceneListOrder };
}); });
return next; return result;
} }
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> { async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
+4 -5
View File
@@ -408,11 +408,10 @@ export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose:
<input <input
type="color" type="color"
value={teamForm.color} value={teamForm.color}
onChange={(e) => onChange={(e) => {
setTeamForm((prev) => const next = e.currentTarget.value;
prev ? { ...prev, color: e.currentTarget.value } : prev, setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
) }}
}
/> />
</label> </label>
<div className={playerStyles.teamFormActions}> <div className={playerStyles.teamFormActions}>
+18 -3
View File
@@ -700,10 +700,25 @@ export function SceneEditorApp() {
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0) !sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
} }
onClick={() => { onClick={() => {
persistTraps([]); if (!sceneId) return;
persistTokens([]); if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
persistNpcTokens([]); if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
saveTrapsTimerRef.current = 0;
saveTokensTimerRef.current = 0;
saveNpcTokensTimerRef.current = 0;
setLocalTraps([]);
setLocalTokens([]);
setLocalNpcTokens([]);
trapsRef.current = [];
tokensRef.current = [];
npcTokensRef.current = [];
setSelected(null); setSelected(null);
// Single atomic patch — separate persist* calls raced and restored tokens/traps.
void api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: { traps: [], tokens: [], npcTokens: [] },
});
}} }}
> >
Очистить сцену Очистить сцену
@@ -34,13 +34,15 @@
box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28)); box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28));
} }
/* Width/height set in JS (syncAvatarCoverSize) for aspect-preserving cover.
Do not force a square img box — that cropped tall/wide photos before pan/zoom. */
.avatar { .avatar {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
width: 140%; max-width: none;
height: 140%; max-height: none;
object-fit: cover; object-fit: fill;
pointer-events: none; pointer-events: none;
} }
@@ -22,6 +22,17 @@ function writeAvatarTransform(
img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`; img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`;
} }
/** Size the <img> so it covers the circle while keeping natural aspect (no square pre-crop). */
function syncAvatarCoverSize(img: HTMLImageElement | null, circlePx: number) {
if (!img || circlePx <= 0) return;
const nw = img.naturalWidth;
const nh = img.naturalHeight;
if (nw <= 0 || nh <= 0) return;
const cover = Math.max(circlePx / nw, circlePx / nh);
img.style.width = `${String(nw * cover)}px`;
img.style.height = `${String(nh * cover)}px`;
}
export type PlayerTokenViewProps = { export type PlayerTokenViewProps = {
name: string; name: string;
imageUrl: string | null; imageUrl: string | null;
@@ -134,6 +145,7 @@ export function PlayerTokenView({
}, [scale]); }, [scale]);
useLayoutEffect(() => { useLayoutEffect(() => {
syncAvatarCoverSize(imgRef.current, sizePx);
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current); writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
}); });
@@ -259,6 +271,10 @@ export function PlayerTokenView({
alt="" alt=""
draggable={false} draggable={false}
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined} style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
onLoad={() => {
syncAvatarCoverSize(imgRef.current, sizePx);
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
}}
/> />
) : ( ) : (
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}> <div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
@@ -133,6 +133,14 @@
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
} }
/* NPC: center-anchor like SceneEditor `.sceneNpcToken`; no box chrome (ring is on PlayerTokenView). */
.tokenPreviewNpc {
position: absolute;
z-index: 3;
pointer-events: none;
overflow: visible;
}
.tokenPreview img { .tokenPreview img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -86,12 +86,22 @@ function TokenPreview({
} }
if (kind === 'npcToken' && npcToken) { if (kind === 'npcToken' && npcToken) {
return ( return (
<div className={styles.tokenPreview} style={{ left, top, width: sizePx, height: sizePx }}> <div
className={styles.tokenPreviewNpc}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
>
<PlayerTokenView <PlayerTokenView
name={npcName ?? ''} name={npcName ?? ''}
imageUrl={npcAvatarUrl ?? null} imageUrl={npcAvatarUrl ?? null}
ringColor={ringColor ?? '#888'} ringColor={ringColor ?? '#888'}
sizePx={sizePx} sizePx={sizePx}
hideName
{...(imageOffset ? { imageOffset } : {})} {...(imageOffset ? { imageOffset } : {})}
{...(typeof imageScale === 'number' ? { imageScale } : {})} {...(typeof imageScale === 'number' ? { imageScale } : {})}
/> />
+25
View File
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
createEmptyTokenPath,
findNearestAheadDistance, findNearestAheadDistance,
normalizeTokenPath, normalizeTokenPath,
normalizeTokenPathFacingMode,
reverseTokenPathPoints, reverseTokenPathPoints,
rotateTokenPathByCwSteps, rotateTokenPathByCwSteps,
sampleTokenPathAtProgress, sampleTokenPathAtProgress,
@@ -12,6 +14,29 @@ import {
TOKEN_PATH_MIN_POINT_DIST, TOKEN_PATH_MIN_POINT_DIST,
} from './tokenPath'; } from './tokenPath';
void test('createEmptyTokenPath defaults to fixed facing (no auto-rotate)', () => {
const empty = createEmptyTokenPath();
assert.equal(empty.facingMode, 'fixed');
assert.equal(empty.fixedRotationDeg, 0);
});
void test('normalizeTokenPathFacingMode: missing → fixed; explicit tangent kept', () => {
assert.equal(normalizeTokenPathFacingMode(undefined), 'fixed');
assert.equal(normalizeTokenPathFacingMode('fixed'), 'fixed');
assert.equal(normalizeTokenPathFacingMode('tangentSmooth'), 'tangentSmooth');
});
void test('normalizeTokenPath defaults facingMode to fixed when omitted', () => {
const p = normalizeTokenPath({
points: [
{ nx: 0.1, ny: 0.1 },
{ nx: 0.5, ny: 0.1 },
],
});
assert.ok(p);
assert.equal(p!.facingMode, 'fixed');
});
void test('tryAppendPathPoint rejects double-click distance', () => { void test('tryAppendPathPoint rejects double-click distance', () => {
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 }); const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
assert.ok(a); assert.ok(a);
+2 -2
View File
@@ -56,13 +56,13 @@ export function createEmptyTokenPath(): TokenPath {
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC, durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
startMode: 'onEnter', startMode: 'onEnter',
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC, delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
facingMode: 'tangentSmooth', facingMode: 'fixed',
fixedRotationDeg: 0, fixedRotationDeg: 0,
}; };
} }
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode { export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
return raw === 'fixed' ? 'fixed' : 'tangentSmooth'; return raw === 'tangentSmooth' ? 'tangentSmooth' : 'fixed';
} }
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode { export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {