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:
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
Reference in New Issue
Block a user