feat(players): NPC disposition types and launch-with-players session tokens

Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 17:11:01 +08:00
parent 101f595bac
commit cc50e64e21
38 changed files with 1803 additions and 59 deletions
+11 -4
View File
@@ -245,6 +245,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0,
groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -257,6 +258,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0,
groupId: gChild,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -269,6 +271,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
y: 0,
groupId: asNpcGroupId('g_other'),
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -329,9 +332,9 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
[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 },
{ id: asSceneNpcTokenId('nt1'), npcId: n1, nx: 0.2, ny: 0.3, sizeN: 0.1, disposition: 'neutral' },
{ id: asSceneNpcTokenId('nt2'), npcId: n2, nx: 0.5, ny: 0.5, sizeN: 0.1, disposition: 'hostile' },
{ id: asSceneNpcTokenId('nt3'), npcId: asNpcId('missing'), nx: 0.1, ny: 0.1, sizeN: 0.1, disposition: 'neutral' },
],
},
},
@@ -358,6 +361,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -370,6 +374,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -395,7 +400,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
[asSceneId('s1')]: {
...scene('s1', 'Side'),
npcTokens: [
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12 },
{ id: asSceneNpcTokenId('nt1'), npcId: sourceNpcId, nx: 0.4, ny: 0.6, sizeN: 0.12, disposition: 'friendly' },
],
},
},
@@ -422,6 +427,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
y: 0,
groupId: null,
ringColor: '#aabbcc',
disposition: 'neutral',
imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1,
},
@@ -480,6 +486,7 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
y: 0,
groupId: null,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
@@ -697,6 +697,7 @@ export function mergeStorylinesIntoProject(
y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
ringColor: n.ringColor ?? '#c9a227',
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
});
+1
View File
@@ -13,5 +13,6 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /list:\s*'players\.list'/);
assert.match(src, /upsertProgress:\s*'players\.upsertProgress'/);
assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
});
+21 -1
View File
@@ -12,6 +12,7 @@ import type {
NpcId,
NpcRelationId,
NpcGroupId,
NpcDisposition,
NpcsOverlayEvent,
NpcsOverlayState,
Project,
@@ -31,6 +32,8 @@ import type {
SceneNpcToken,
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState,
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
SceneToken,
SceneTokensSessionEvent,
SceneTokensSessionState,
@@ -210,6 +213,11 @@ export const ipcChannels = {
dispatch: 'sceneNpcTokensSession.dispatch',
stateChanged: 'sceneNpcTokensSession.stateChanged',
},
scenePlayerTokensSession: {
getState: 'scenePlayerTokensSession.getState',
dispatch: 'scenePlayerTokensSession.dispatch',
stateChanged: 'scenePlayerTokensSession.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
@@ -285,6 +293,7 @@ export type IpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -400,6 +409,7 @@ export type IpcInvokeMap = {
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
};
@@ -412,6 +422,7 @@ export type IpcInvokeMap = {
description?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: PlayerImageOffset;
imageScale?: number;
};
@@ -610,7 +621,7 @@ export type IpcInvokeMap = {
res: { ok: true };
};
[ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>;
req: { playerIds?: string[] };
res: { ok: true };
};
[ipcChannels.windows.closeMultiWindow]: {
@@ -801,6 +812,14 @@ export type IpcInvokeMap = {
req: { event: SceneNpcTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.scenePlayerTokensSession.getState]: {
req: Record<string, never>;
res: { state: ScenePlayerTokensSessionState };
};
[ipcChannels.scenePlayerTokensSession.dispatch]: {
req: { event: ScenePlayerTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
@@ -845,6 +864,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
};
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asPlayerId, asPlayerTeamId } from '../types/ids';
import { layoutPlayerTokensBottom } from '../types/appPlayers';
import { resolveLaunchPlayerIds } from './resolveLaunchPlayerIds';
void test('layoutPlayerTokensBottom spaces tokens along bottom', () => {
const layout = layoutPlayerTokensBottom(['a', 'b', 'c'], 0.1);
assert.equal(layout.a?.ny, 0.88);
assert.equal(layout.a?.nx, 0.25);
assert.equal(layout.b?.nx, 0.5);
assert.equal(layout.c?.nx, 0.75);
});
void test('resolveLaunchPlayerIds merges players and teams with dedupe', () => {
const teamId = asPlayerTeamId('t1');
const players = [
{
id: asPlayerId('p1'),
name: 'A',
imageRelPath: 'x',
sha256: '1',
teamId,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
{
id: asPlayerId('p2'),
name: 'B',
imageRelPath: 'y',
sha256: '2',
teamId: null,
ringColor: '#fff',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
},
];
const ids = resolveLaunchPlayerIds(players, new Set(['p1', 'p2']), new Set(['t1']));
assert.deepEqual([...ids].sort(), ['p1', 'p2']);
});
@@ -0,0 +1,15 @@
import type { AppPlayer } from '../types';
/** Игроки + члены выбранных команд, без дублей. */
export function resolveLaunchPlayerIds(
players: readonly AppPlayer[],
selectedPlayerIds: ReadonlySet<string>,
selectedTeamIds: ReadonlySet<string>,
): string[] {
const out = new Set<string>();
for (const id of selectedPlayerIds) out.add(id);
for (const p of players) {
if (p.teamId && selectedTeamIds.has(String(p.teamId))) out.add(String(p.id));
}
return [...out];
}
+54 -1
View File
@@ -1,5 +1,7 @@
/** App-local библиотека живых игроков (userData, не в project zip). */
import type { NpcDisposition } from './npcDisposition';
import { normalizeNpcDisposition } from './npcDisposition';
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
import { normalizeHexColor } from '../npcs/npcGroups';
@@ -37,6 +39,8 @@ export type SceneNpcToken = {
nx: number;
ny: number;
sizeN: number;
/** Состояние экземпляра на карте (копируется из НПС при постановке). */
disposition: NpcDisposition;
};
export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
@@ -57,23 +61,71 @@ export const DEFAULT_NPC_TOKEN_SESSION_SCALE = 1;
export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4;
export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5;
export type SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
export type SceneNpcTokensSessionState = {
revision: number;
byPlacementId: Record<string, { nx: number; ny: number }>;
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number;
};
export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number }
| { kind: 'clear' };
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
export type ScenePlayerTokensSessionState = {
revision: number;
selectedPlayerIds: string[];
visible: boolean;
byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};
export type ScenePlayerTokensSessionEvent =
| { kind: 'setSelection'; playerIds: readonly string[] }
| { kind: 'setVisible'; visible: boolean }
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'clearPlacements' }
| { kind: 'clear' };
export function clampNpcTokenSessionScale(raw: unknown): number {
const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE;
return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n));
}
/** Ряд токенов внизу карты (первый показ «Показать игроков»). */
export function layoutPlayerTokensBottom(
playerIds: readonly string[],
sizeN: number,
): Record<string, ScenePlayerTokenPlacement> {
const clamped = clampSceneNpcTokenSizeN(sizeN);
const n = playerIds.length;
const out: Record<string, ScenePlayerTokenPlacement> = {};
const ny = 0.88;
for (let i = 0; i < n; i += 1) {
const id = playerIds[i];
if (!id) continue;
out[id] = { nx: (i + 1) / (n + 1), ny, sizeN: clamped };
}
return out;
}
export type PlayersUpsertProgressEvent = {
percent: number;
stage: string;
@@ -154,5 +206,6 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
nx: Math.max(0, Math.min(1, nx)),
ny: Math.max(0, Math.min(1, ny)),
sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
};
}
+6 -1
View File
@@ -11,6 +11,7 @@ import type {
import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid } from './sceneGrid';
import type { SceneTrap } from './sceneTraps';
@@ -47,8 +48,12 @@ export type ProjectNpc = {
y: number;
/** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null;
/** Цвет кольца игрового токена на сцене. */
/**
* @deprecated Цвет кольца выводится из `disposition`. Поле оставлено для совместимости старых проектов.
*/
ringColor: string;
/** Дефолтный тип токена при постановке на карту. */
disposition: NpcDisposition;
/** Сдвиг аватара внутри круга токена. */
imageOffset: PlayerImageOffset;
/** Масштаб аватара внутри круга токена. */
+1
View File
@@ -5,6 +5,7 @@ export * from './effects';
export * from './ids';
export * from './materialLegend';
export * from './materials';
export * from './npcDisposition';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
otherNpcDispositions,
} from './npcDisposition';
void test('normalizeNpcDisposition defaults to neutral', () => {
assert.equal(normalizeNpcDisposition(undefined), DEFAULT_NPC_DISPOSITION);
assert.equal(normalizeNpcDisposition('hostile'), 'hostile');
assert.equal(normalizeNpcDisposition('friendly'), 'friendly');
assert.equal(normalizeNpcDisposition('nope'), 'neutral');
});
void test('npcDispositionRingColor and inactive', () => {
assert.equal(npcDispositionRingColor('hostile'), '#e53935');
assert.equal(npcDispositionRingColor('neutral'), '#c9a227');
assert.equal(npcDispositionRingColor('friendly'), '#43a047');
assert.equal(npcDispositionRingColor('hostile', true), '#9e9e9e');
});
void test('otherNpcDispositions hides current', () => {
assert.deepEqual(otherNpcDispositions('neutral'), ['hostile', 'friendly']);
assert.deepEqual(otherNpcDispositions('hostile'), ['neutral', 'friendly']);
});
+29
View File
@@ -0,0 +1,29 @@
/** Отношение НПС: задаёт цвет кольца токена. */
export const NPC_DISPOSITIONS = ['hostile', 'neutral', 'friendly'] as const;
export type NpcDisposition = (typeof NPC_DISPOSITIONS)[number];
export const DEFAULT_NPC_DISPOSITION: NpcDisposition = 'neutral';
export const NPC_DISPOSITION_RING_COLOR: Record<NpcDisposition, string> = {
hostile: '#e53935',
neutral: '#c9a227',
friendly: '#43a047',
};
/** Серый для session-only «Неактивен». */
export const NPC_INACTIVE_RING_COLOR = '#9e9e9e';
export function normalizeNpcDisposition(raw: unknown): NpcDisposition {
if (raw === 'hostile' || raw === 'friendly' || raw === 'neutral') return raw;
return DEFAULT_NPC_DISPOSITION;
}
export function npcDispositionRingColor(disposition: NpcDisposition, inactive = false): string {
if (inactive) return NPC_INACTIVE_RING_COLOR;
return NPC_DISPOSITION_RING_COLOR[disposition];
}
export function otherNpcDispositions(current: NpcDisposition): NpcDisposition[] {
return NPC_DISPOSITIONS.filter((d) => d !== current);
}