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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/** Масштаб аватара внутри круга токена. */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user