feat(control): spawn session NPCs and resize tokens in preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-18 14:42:06 +08:00
parent 1ab6ffd593
commit 1a9f0f2557
28 changed files with 1446 additions and 89 deletions
+93
View File
@@ -68,26 +68,59 @@ export type SceneNpcTokensSessionPlacement = {
/** Есть после move; без move позиция берётся из сцены. */
nx?: number;
ny?: number;
/** Session-only override of map diameter. */
sizeN?: number;
/** Session-only override типа экземпляра. */
disposition?: NpcDisposition;
/** Session-only: серая рамка и grayscale. */
inactive?: boolean;
};
/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
export type SceneNpcSessionSpawn = {
id: SceneNpcTokenId;
sceneId: string;
npcId: NpcId;
nx: number;
ny: number;
sizeN: number;
disposition: NpcDisposition;
};
export type SceneNpcTokensSessionState = {
revision: number;
byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
/** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
scale: number;
spawned: Record<string, SceneNpcSessionSpawn>;
};
export type SceneNpcTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setSize'; placementId: string; sizeN: number }
| { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
| { kind: 'setInactive'; placementId: string; inactive: boolean }
| { kind: 'setScale'; scale: number }
| {
kind: 'spawn';
npcId: string;
sceneId: string;
nx: number;
ny: number;
sizeN?: number;
disposition?: NpcDisposition;
placementId?: string;
}
| { kind: 'despawn'; placementId: string }
| { kind: 'clear' };
export type NpcMapSpawnDragState = {
dragging: boolean;
npcId: string | null;
hoverNx: number | null;
hoverNy: number | null;
};
/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };
@@ -104,6 +137,7 @@ export type ScenePlayerTokensSessionEvent =
| { kind: 'show'; sizeN: number }
| { kind: 'seedBottom'; sizeN: number }
| { kind: 'move'; playerId: string; nx: number; ny: number }
| { kind: 'setSize'; playerId: string; sizeN: number }
| { kind: 'clearPlacements' }
| { kind: 'clear' };
@@ -156,6 +190,65 @@ export function clampSceneNpcTokenSizeN(sizeN: number): number {
return Math.max(SCENE_NPC_TOKEN_SIZE_MIN, Math.min(SCENE_NPC_TOKEN_SIZE_MAX, sizeN));
}
export function emptySceneNpcTokensSessionState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
spawned: {},
};
}
export function newSceneNpcSessionSpawnId(): SceneNpcTokenId {
return asSceneNpcTokenId(
`snpc_sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
);
}
export function nextSessionNpcSpawnPoint(
occupied: readonly { nx: number; ny: number }[],
): { nx: number; ny: number } {
const tryPoint = (nx: number, ny: number) => {
const p = {
nx: Math.max(0.05, Math.min(0.95, nx)),
ny: Math.max(0.05, Math.min(0.95, ny)),
};
return occupied.some((o) => Math.hypot(o.nx - p.nx, o.ny - p.ny) < 0.045) ? null : p;
};
const first = tryPoint(0.5, 0.5);
if (first) return first;
for (let i = 1; i <= 16; i += 1) {
const r = 0.05 * i;
for (let k = 0; k < 8; k += 1) {
const a = (k / 8) * Math.PI * 2 + i * 0.35;
const hit = tryPoint(0.5 + Math.cos(a) * r, 0.5 + Math.sin(a) * r);
if (hit) return hit;
}
}
return { nx: 0.5, ny: 0.5 };
}
export function listVisibleSceneNpcTokens(
sceneTokens: readonly SceneNpcToken[] | undefined,
spawned: Record<string, SceneNpcSessionSpawn> | undefined,
sceneId: string | null | undefined,
): SceneNpcToken[] {
const out = [...(sceneTokens ?? [])];
if (!sceneId || !spawned) return out;
for (const s of Object.values(spawned)) {
if (s.sceneId !== sceneId) continue;
out.push({
id: asSceneNpcTokenId(s.id),
npcId: asNpcId(s.npcId),
nx: s.nx,
ny: s.ny,
sizeN: s.sizeN,
disposition: s.disposition,
});
}
return out;
}
export function normalizeAppPlayerTeam(raw: unknown): AppPlayerTeam | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<AppPlayerTeam>;
+10 -2
View File
@@ -33,14 +33,22 @@ export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
export const SCENE_TOKEN_SIZE_MIN = 0.02;
export const SCENE_TOKEN_SIZE_MAX = 0.45;
export type SceneTokensSessionPlacement = {
nx?: number;
ny?: number;
/** Session-only override of map diameter. */
sizeN?: number;
};
export type SceneTokensSessionState = {
revision: number;
/** Смещение позиции на текущую сессию (только nx/ny). */
byPlacementId: Record<string, { nx: number; ny: number }>;
/** Смещение позиции/размера на текущую сессию. */
byPlacementId: Record<string, SceneTokensSessionPlacement>;
};
export type SceneTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'setSize'; placementId: string; sizeN: number }
| { kind: 'clear' };
export function clampSceneTokenSizeN(sizeN: number): number {