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
+88 -81
View File
@@ -728,91 +728,98 @@ export class ZipProjectStore {
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
const open = this.openProject;
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 = {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(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),
};
// Apply patch inside the mutator so concurrent updateScene calls (e.g. clear scene
// firing traps/tokens/npcTokens separately) each read a fresh scene snapshot.
let result!: Scene;
await this.updateProject((p) => {
const existing = p.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);
// 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);
const next: Scene = {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(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 sceneListOrder =
existing != null
@@ -820,7 +827,7 @@ export class ZipProjectStore {
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
return { ...p, scenes, sceneListOrder };
});
return next;
return result;
}
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {