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:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
+1
View File
@@ -24,6 +24,7 @@ function scene(id: string): Scene {
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
+148 -1
View File
@@ -10,6 +10,7 @@ import {
asNpcRelationId,
asProjectId,
asSceneId,
asSceneNpcTokenId,
} from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types';
@@ -40,6 +41,7 @@ function scene(id: string, title: string): Scene {
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
@@ -242,6 +244,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 0,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
@@ -251,6 +256,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 10,
y: 0,
groupId: gChild,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n3,
@@ -260,6 +268,9 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
x: 20,
y: 0,
groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const relations: ProjectNpcRelation[] = [
@@ -308,6 +319,139 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other')));
});
void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', () => {
const n1 = asNpcId('npc1');
const n2 = asNpcId('npc2');
const avatar = asAssetId('a1');
const sceneId = asSceneId('s1');
const source = minimalProject({
scenes: {
[sceneId]: {
...scene('s1', 'Start'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1 },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1 },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1 },
],
},
},
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'abc',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: n1,
name: 'A',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: n2,
name: 'B',
avatarAssetId: avatar,
description: '',
x: 10,
y: 0,
groupId: null,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const partial = buildPartialExportProject(source, [{ kind: 'main' }], {
newProjectId: newExportBundleProjectId(),
exportTitle: 'Export',
labels: LABELS,
npcIds: [n1],
});
const tokens = partial.scenes[sceneId]?.npcTokens ?? [];
assert.equal(tokens.length, 1);
assert.equal(tokens[0]!.npcId, n1);
});
void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
const source = minimalProject({
scenes: {
[asSceneId('s1')]: {
...scene('s1', 'Side'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12 },
],
},
},
sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'npcsha',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: sourceNpcId,
name: 'Hero',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
ringColor: '#aabbcc',
imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1,
},
],
});
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
sceneGraphNodes: [node('tgn', 't1', { x: 0 })],
});
const { project: merged } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }],
{
graphOffsetX: 200,
npcResolutions: [{ sourceNpcId, mode: 'create' }],
},
);
assert.equal(merged.npcs.length, 1);
const importedScene = Object.values(merged.scenes).find((s) => s.title === 'Side');
assert.ok(importedScene);
assert.equal(importedScene!.npcTokens.length, 1);
assert.equal(importedScene!.npcTokens[0]!.npcId, merged.npcs[0]!.id);
assert.notEqual(importedScene!.npcTokens[0]!.npcId, sourceNpcId);
assert.equal(merged.npcs[0]!.ringColor, '#aabbcc');
});
void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
@@ -335,7 +479,10 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
x: 0,
y: 0,
groupId: null,
},
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
],
});
const target = minimalProject({
+37
View File
@@ -19,6 +19,7 @@ import {
asNpcRelationId,
asProjectId,
asSceneId,
asSceneNpcTokenId,
asTokenId,
} from '../types/ids';
@@ -291,6 +292,15 @@ export function buildPartialExportProject(
npcs: exportedNpcs.map((n) => ({ ...n })),
npcGroups: exportedGroups.map((g) => ({ ...g })),
npcRelations: exportedRelations.map((r) => ({ ...r })),
scenes: Object.fromEntries(
Object.entries(draft.scenes).map(([sid, sc]) => [
sid,
{
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => exportedNpcIds.has(t.npcId)),
},
]),
) as Project['scenes'],
};
const assetIds = collectReferencedAssetIdsForProject(draft);
@@ -686,11 +696,38 @@ export function mergeStorylinesIntoProject(
x: n.x,
y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
ringColor: n.ringColor ?? '#c9a227',
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
});
npcNameKeys.add(name.toLowerCase());
npcsCreated += 1;
}
// Remap npcTokens on newly created scenes to imported NPC ids.
for (const sid of sourceSceneIds) {
const res = resolutionBySource.get(sid);
if (res?.mode === 'use') continue;
const newSid = sceneIdMap.get(sid);
if (!newSid) continue;
const sc = scenes[newSid];
if (!sc) continue;
scenes[newSid] = {
...sc,
npcTokens: (sc.npcTokens ?? [])
.map((t) => {
const mappedNpc = npcIdMap.get(t.npcId);
if (!mappedNpc) return null;
return {
...t,
id: asSceneNpcTokenId(`snt_${generateId()}`),
npcId: asNpcId(mappedNpc),
};
})
.filter((t): t is NonNullable<typeof t> => Boolean(t)),
};
}
const npcRelations = [...(target.npcRelations ?? [])];
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
for (const r of source.npcRelations ?? []) {