feat(grid): именованные шаблоны сетки кампании
Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,10 +12,10 @@ 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 { SceneGrid, SceneGridTemplate } from './sceneGrid';
|
||||
import type { SceneTrap } from './sceneTraps';
|
||||
|
||||
export const PROJECT_SCHEMA_VERSION = 11 as const;
|
||||
export const PROJECT_SCHEMA_VERSION = 12 as const;
|
||||
|
||||
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
|
||||
export type ProjectMaterial = {
|
||||
@@ -213,6 +213,11 @@ export type Project = {
|
||||
campaignAudios: SceneAudioRef[];
|
||||
/** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */
|
||||
materials: ProjectMaterial[];
|
||||
/**
|
||||
* Именованные шаблоны боевой сетки кампании.
|
||||
* Не подставляются автоматически: только ручное «Применить» в редакторе сцены.
|
||||
*/
|
||||
gridTemplates: SceneGridTemplate[];
|
||||
/** НПС кампании (порядок = порядок в списке редактора/пульта). */
|
||||
npcs: ProjectNpc[];
|
||||
/** Группы НПС (порядок среди siblings = порядок в массиве). */
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
|
||||
export type PlaylistId = Brand<string, 'PlaylistId'>;
|
||||
export type PlaylistAudioId = Brand<string, 'PlaylistAudioId'>;
|
||||
export type PlaylistTrackId = Brand<string, 'PlaylistTrackId'>;
|
||||
export type GridTemplateId = Brand<string, 'GridTemplateId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -80,3 +81,7 @@ export function asPlaylistAudioId(value: string): PlaylistAudioId {
|
||||
export function asPlaylistTrackId(value: string): PlaylistTrackId {
|
||||
return value as PlaylistTrackId;
|
||||
}
|
||||
|
||||
export function asGridTemplateId(value: string): GridTemplateId {
|
||||
return value as GridTemplateId;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { sceneGridTokenFitFactor } from './sceneGrid';
|
||||
import {
|
||||
normalizeSceneGridTemplates,
|
||||
sceneGridFromTemplate,
|
||||
sceneGridTokenFitFactor,
|
||||
} from './sceneGrid';
|
||||
import { asGridTemplateId } from './ids';
|
||||
|
||||
void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
|
||||
assert.equal(sceneGridTokenFitFactor(undefined), 1);
|
||||
@@ -12,3 +17,38 @@ void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
|
||||
void test('sceneGridTokenFitFactor: hex uses inscribed diameter', () => {
|
||||
assert.ok(Math.abs(sceneGridTokenFitFactor({ enabled: true, type: 'hex' }) - Math.sqrt(3) / 2) < 1e-9);
|
||||
});
|
||||
|
||||
void test('normalizeSceneGridTemplates: empty / invalid → []', () => {
|
||||
assert.deepEqual(normalizeSceneGridTemplates(undefined), []);
|
||||
assert.deepEqual(normalizeSceneGridTemplates(null), []);
|
||||
assert.deepEqual(normalizeSceneGridTemplates([{ id: 'x' }]), []);
|
||||
});
|
||||
|
||||
void test('normalizeSceneGridTemplates: keeps valid entries, drops dup ids', () => {
|
||||
const out = normalizeSceneGridTemplates([
|
||||
{ id: 'a', name: ' Grey ', type: 'hex', sizeN: 0.05, color: '#7e7e7e' },
|
||||
{ id: 'a', name: 'dup', type: 'square', sizeN: 0.1, color: '#ffffff' },
|
||||
{ id: 'b', name: ' ', type: 'square', sizeN: 0.1, color: '#fff' },
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0]!.id, 'a');
|
||||
assert.equal(out[0]!.name, 'Grey');
|
||||
assert.equal(out[0]!.type, 'hex');
|
||||
assert.equal(out[0]!.color, '#7e7e7e');
|
||||
});
|
||||
|
||||
void test('sceneGridFromTemplate: D2 enables grid', () => {
|
||||
const grid = sceneGridFromTemplate({
|
||||
id: asGridTemplateId('t1'),
|
||||
name: 'Grey 5',
|
||||
type: 'square',
|
||||
sizeN: 0.05,
|
||||
color: '#7e7e7e',
|
||||
});
|
||||
assert.deepEqual(grid, {
|
||||
enabled: true,
|
||||
type: 'square',
|
||||
sizeN: 0.05,
|
||||
color: '#7e7e7e',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { asGridTemplateId, type GridTemplateId } from './ids';
|
||||
|
||||
/** Настройки боевой сетки на превью сцены (сохраняется в проекте). */
|
||||
|
||||
export const SCENE_GRID_TYPES = ['square', 'hex'] as const;
|
||||
@@ -14,6 +16,17 @@ export type SceneGrid = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
/** Именованный шаблон сетки кампании (без `enabled`; применение включает сетку). */
|
||||
export type SceneGridTemplate = {
|
||||
id: GridTemplateId;
|
||||
name: string;
|
||||
type: SceneGridType;
|
||||
sizeN: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const MAX_SCENE_GRID_TEMPLATES = 20;
|
||||
|
||||
export const DEFAULT_SCENE_GRID_SIZE_N = 0.06;
|
||||
export const SCENE_GRID_SIZE_MIN = 0.02;
|
||||
export const SCENE_GRID_SIZE_MAX = 0.25;
|
||||
@@ -56,6 +69,56 @@ export function normalizeSceneGrid(raw: unknown): SceneGrid {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSceneGridTemplate(raw: unknown): SceneGridTemplate | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Partial<SceneGridTemplate>;
|
||||
if (!obj.id || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
if (!name) return null;
|
||||
const type: SceneGridType = obj.type === 'hex' ? 'hex' : 'square';
|
||||
return {
|
||||
id: asGridTemplateId(String(obj.id)),
|
||||
name,
|
||||
type,
|
||||
sizeN: clampSceneGridSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_GRID_SIZE_N),
|
||||
color: normalizeSceneGridColor(obj.color),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSceneGridTemplates(raw: unknown): SceneGridTemplate[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: SceneGridTemplate[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw) {
|
||||
const t = normalizeSceneGridTemplate(item);
|
||||
if (!t || seen.has(t.id)) continue;
|
||||
seen.add(t.id);
|
||||
out.push(t);
|
||||
if (out.length >= MAX_SCENE_GRID_TEMPLATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** D2: применение шаблона включает сетку на сцене. */
|
||||
export function sceneGridFromTemplate(template: SceneGridTemplate): SceneGrid {
|
||||
return {
|
||||
enabled: true,
|
||||
type: template.type,
|
||||
sizeN: template.sizeN,
|
||||
color: template.color,
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneGridTemplateStyleFromGrid(
|
||||
grid: Pick<SceneGrid, 'type' | 'sizeN' | 'color'>,
|
||||
): Pick<SceneGridTemplate, 'type' | 'sizeN' | 'color'> {
|
||||
return {
|
||||
type: grid.type === 'hex' ? 'hex' : 'square',
|
||||
sizeN: clampSceneGridSizeN(grid.sizeN),
|
||||
color: normalizeSceneGridColor(grid.color),
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneGridTypeLabelRu(type: SceneGridType): string {
|
||||
return type === 'hex' ? 'Гексогональная' : 'Квадратная';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user