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:
@@ -85,6 +85,18 @@ void test('zipStore: NPC parse defaults externalUrl', () => {
|
||||
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', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -408,11 +408,10 @@ export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose:
|
||||
<input
|
||||
type="color"
|
||||
value={teamForm.color}
|
||||
onChange={(e) =>
|
||||
setTeamForm((prev) =>
|
||||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.teamFormActions}>
|
||||
|
||||
@@ -700,10 +700,25 @@ export function SceneEditorApp() {
|
||||
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
|
||||
}
|
||||
onClick={() => {
|
||||
persistTraps([]);
|
||||
persistTokens([]);
|
||||
persistNpcTokens([]);
|
||||
if (!sceneId) return;
|
||||
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 140%;
|
||||
height: 140%;
|
||||
object-fit: cover;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: fill;
|
||||
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)})`;
|
||||
}
|
||||
|
||||
/** 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 = {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
@@ -134,6 +145,7 @@ export function PlayerTokenView({
|
||||
}, [scale]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncAvatarCoverSize(imgRef.current, sizePx);
|
||||
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
||||
});
|
||||
|
||||
@@ -259,6 +271,10 @@ export function PlayerTokenView({
|
||||
alt=""
|
||||
draggable={false}
|
||||
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(' ')}>
|
||||
|
||||
@@ -133,6 +133,14 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -86,12 +86,22 @@ function TokenPreview({
|
||||
}
|
||||
if (kind === 'npcToken' && npcToken) {
|
||||
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
|
||||
name={npcName ?? ''}
|
||||
imageUrl={npcAvatarUrl ?? null}
|
||||
ringColor={ringColor ?? '#888'}
|
||||
sizePx={sizePx}
|
||||
hideName
|
||||
{...(imageOffset ? { imageOffset } : {})}
|
||||
{...(typeof imageScale === 'number' ? { imageScale } : {})}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createEmptyTokenPath,
|
||||
findNearestAheadDistance,
|
||||
normalizeTokenPath,
|
||||
normalizeTokenPathFacingMode,
|
||||
reverseTokenPathPoints,
|
||||
rotateTokenPathByCwSteps,
|
||||
sampleTokenPathAtProgress,
|
||||
@@ -12,6 +14,29 @@ import {
|
||||
TOKEN_PATH_MIN_POINT_DIST,
|
||||
} 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', () => {
|
||||
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
|
||||
assert.ok(a);
|
||||
|
||||
@@ -56,13 +56,13 @@ export function createEmptyTokenPath(): TokenPath {
|
||||
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
|
||||
startMode: 'onEnter',
|
||||
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
|
||||
facingMode: 'tangentSmooth',
|
||||
facingMode: 'fixed',
|
||||
fixedRotationDeg: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
|
||||
return raw === 'fixed' ? 'fixed' : 'tangentSmooth';
|
||||
return raw === 'tangentSmooth' ? 'tangentSmooth' : 'fixed';
|
||||
}
|
||||
|
||||
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
|
||||
|
||||
Reference in New Issue
Block a user