feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user