feat(grid): именованные шаблоны сетки кампании

Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-03 12:04:47 +08:00
parent 278eb78637
commit a4d7ab77e7
14 changed files with 681 additions and 6 deletions
+7 -2
View File
@@ -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 = порядок в массиве). */
+5
View File
@@ -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;
}
+41 -1
View File
@@ -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',
});
});
+63
View File
@@ -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' ? 'Гексогональная' : 'Квадратная';
}