feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+117
-54
@@ -53,9 +53,23 @@ import type {
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeSceneNpcToken,
|
||||
type SceneNpcToken,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
@@ -614,6 +628,7 @@ export class ZipProjectStore {
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
@@ -621,6 +636,7 @@ export class ZipProjectStore {
|
||||
...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),
|
||||
@@ -636,9 +652,7 @@ export class ZipProjectStore {
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps
|
||||
.map((t) => normalizeSceneTrap(t))
|
||||
.filter((t): t is SceneTrap => Boolean(t)),
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.tokens !== undefined
|
||||
@@ -648,6 +662,13 @@ export class ZipProjectStore {
|
||||
.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),
|
||||
@@ -798,7 +819,10 @@ export class ZipProjectStore {
|
||||
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
|
||||
if (!node) throw new Error('Graph node not found');
|
||||
const enabling = !node.isSideStoryStart;
|
||||
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
|
||||
if (
|
||||
enabling &&
|
||||
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
|
||||
) {
|
||||
return open.project;
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
@@ -1158,16 +1182,11 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setMaterialRotation(
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
): Promise<Project> {
|
||||
async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const materials = (p.materials ?? []).map((m) =>
|
||||
m.id === materialId ? { ...m, rotationDeg } : m,
|
||||
);
|
||||
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
|
||||
return { ...p, materials };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1243,6 +1262,9 @@ export class ZipProjectStore {
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||
): Promise<Project> {
|
||||
@@ -1300,7 +1322,10 @@ export class ZipProjectStore {
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
|
||||
const resolveGroup = (
|
||||
raw: NpcGroupId | null | undefined,
|
||||
prev: NpcGroupId | null,
|
||||
): NpcGroupId | null => {
|
||||
if (raw === undefined) return prev;
|
||||
if (raw === null) return null;
|
||||
return groupIds.has(raw) ? raw : null;
|
||||
@@ -1314,9 +1339,17 @@ export class ZipProjectStore {
|
||||
...prev,
|
||||
name,
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
description: typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
...(input.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(input.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
|
||||
: {}),
|
||||
...(input.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(input.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
@@ -1329,6 +1362,9 @@ export class ZipProjectStore {
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
imageOffset: clampPlayerImageOffset(input.imageOffset),
|
||||
imageScale: clampPlayerImageScale(input.imageScale),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1346,20 +1382,18 @@ export class ZipProjectStore {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name =
|
||||
typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
if (name !== undefined) {
|
||||
if (name.length < 1) throw new Error('NPC name is required');
|
||||
const nameKey = name.toLowerCase();
|
||||
if (
|
||||
(open.project.npcs ?? []).some(
|
||||
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
|
||||
)
|
||||
) {
|
||||
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
}
|
||||
@@ -1369,14 +1403,22 @@ export class ZipProjectStore {
|
||||
if (n.id !== npcId) return n;
|
||||
let groupId = n.groupId;
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId =
|
||||
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(patch.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
|
||||
: {}),
|
||||
...(patch.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(patch.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1401,13 +1443,25 @@ export class ZipProjectStore {
|
||||
async deleteNpc(npcId: NpcId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
}));
|
||||
await this.updateProject((p) => {
|
||||
const scenes: Record<SceneId, Scene> = { ...p.scenes };
|
||||
for (const sid of Object.keys(scenes) as SceneId[]) {
|
||||
const sc = scenes[sid];
|
||||
if (!sc) continue;
|
||||
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
|
||||
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
|
||||
scenes[sid] = { ...sc, npcTokens };
|
||||
}
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
scenes,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -1462,10 +1516,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
const nameKey = name.toLowerCase();
|
||||
const siblingConflict = groups.some(
|
||||
(g) =>
|
||||
g.id !== editingId &&
|
||||
g.parentId === parentId &&
|
||||
g.name.trim().toLowerCase() === nameKey,
|
||||
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
|
||||
);
|
||||
if (siblingConflict) throw new Error('Group name already exists');
|
||||
|
||||
@@ -1498,9 +1549,7 @@ export class ZipProjectStore {
|
||||
const nextGroups = groups
|
||||
.filter((g) => g.id !== groupId)
|
||||
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
|
||||
const npcs = (p.npcs ?? []).map((n) =>
|
||||
n.groupId === groupId ? { ...n, groupId: null } : n,
|
||||
);
|
||||
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
|
||||
return { ...p, npcGroups: nextGroups, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1541,10 +1590,7 @@ export class ZipProjectStore {
|
||||
if (label.length < 1) throw new Error('Relation label is required');
|
||||
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
|
||||
const npcs = open.project.npcs ?? [];
|
||||
if (
|
||||
!npcs.some((n) => n.id === input.sourceNpcId) ||
|
||||
!npcs.some((n) => n.id === input.targetNpcId)
|
||||
) {
|
||||
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
|
||||
throw new Error('NPC not found');
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
@@ -2089,16 +2135,14 @@ export class ZipProjectStore {
|
||||
sourceForMerge = remapProjectSceneTokenIds(source, remap);
|
||||
}
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
|
||||
this.openProject.project,
|
||||
sourceForMerge,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
const {
|
||||
project: merged,
|
||||
report,
|
||||
assetCopies,
|
||||
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
|
||||
@@ -2307,6 +2351,10 @@ function normalizeScene(s: Scene): Scene {
|
||||
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
|
||||
.map((t) => normalizeSceneToken(t))
|
||||
.filter((t): t is SceneToken => Boolean(t));
|
||||
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
|
||||
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
|
||||
.map((t) => normalizeSceneNpcToken(t))
|
||||
.filter((t): t is SceneNpcToken => Boolean(t));
|
||||
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
|
||||
|
||||
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||
@@ -2338,6 +2386,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
darkenScene,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
grid,
|
||||
layout: layoutIn ?? { x: 0, y: 0 },
|
||||
media: {
|
||||
@@ -2429,6 +2478,8 @@ function normalizeProject(p: Project): Project {
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2444,6 +2495,9 @@ function normalizeProject(p: Project): Project {
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2481,6 +2535,15 @@ function normalizeProject(p: Project): Project {
|
||||
if (a && a.length > 0) return a;
|
||||
return '0.0.0';
|
||||
})();
|
||||
const scenesPruned: Record<SceneId, Scene> = {};
|
||||
for (const sid of Object.keys(scenes) as SceneId[]) {
|
||||
const sc = scenes[sid];
|
||||
if (!sc) continue;
|
||||
scenesPruned[sid] = {
|
||||
...sc,
|
||||
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
meta: {
|
||||
@@ -2491,7 +2554,7 @@ function normalizeProject(p: Project): Project {
|
||||
createdWithAppVersion,
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes,
|
||||
scenes: scenesPruned,
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
@@ -2501,7 +2564,7 @@ function normalizeProject(p: Project): Project {
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
sceneListOrder: reconcileSceneListOrder(
|
||||
scenes,
|
||||
scenesPruned,
|
||||
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user