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
@@ -84,6 +84,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
assets: {},
campaignAudios: [],
materials: [],
gridTemplates: [],
npcs: [],
npcGroups: [],
npcRelations: [],
+30
View File
@@ -13,6 +13,7 @@ import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
import {
asAssetId,
asGraphNodeId,
asGridTemplateId,
asMaterialId,
asNpcGroupId,
asNpcId,
@@ -23,6 +24,11 @@ import {
asTokenId,
} from '../types/ids';
import { normalizeNpcExternalUrl } from '../types/npcExternalUrl';
import {
MAX_SCENE_GRID_TEMPLATES,
sceneGridTemplateStyleFromGrid,
type SceneGridTemplate,
} from '../types/sceneGrid';
import {
getConnectedComponent,
@@ -318,6 +324,7 @@ export function buildPartialExportProject(
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
gridTemplates: (source.gridTemplates ?? []).map((t) => ({ ...t })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
@@ -626,6 +633,28 @@ export function mergeStorylinesIntoProject(
materialAssetIds.add(mapped);
}
const gridTemplates = [...(target.gridTemplates ?? [])];
const gridTemplateNameKeys = new Set(gridTemplates.map((t) => t.name.trim().toLowerCase()));
for (const t of source.gridTemplates ?? []) {
if (gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES) break;
let name = t.name.trim();
if (!name) continue;
const baseKey = name.toLowerCase();
if (gridTemplateNameKeys.has(baseKey)) {
let n = 2;
while (gridTemplateNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
const style = sceneGridTemplateStyleFromGrid(t);
const next: SceneGridTemplate = {
id: asGridTemplateId(`gt_${generateId()}`),
name,
...style,
};
gridTemplates.push(next);
gridTemplateNameKeys.add(name.toLowerCase());
}
// Группы только для создаваемых НПС выбранных линий.
const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
@@ -759,6 +788,7 @@ export function mergeStorylinesIntoProject(
assets,
campaignAudios,
materials,
gridTemplates,
npcs,
npcGroups,
npcRelations,
+27
View File
@@ -38,7 +38,9 @@ import type {
SceneMarkerEvent,
SceneMarkerState,
SceneGrid,
SceneGridTemplate,
SceneId,
GridTemplateId,
SceneNpcToken,
NpcMapSpawnDragState,
SceneNpcTokensSessionEvent,
@@ -99,6 +101,10 @@ export const ipcChannels = {
deleteMaterial: 'project.deleteMaterial',
setMaterialsOrder: 'project.setMaterialsOrder',
pickMaterialImage: 'project.pickMaterialImage',
addGridTemplate: 'project.addGridTemplate',
renameGridTemplate: 'project.renameGridTemplate',
deleteGridTemplate: 'project.deleteGridTemplate',
applyGridTemplate: 'project.applyGridTemplate',
upsertNpc: 'project.upsertNpc',
updateNpcFields: 'project.updateNpcFields',
updateNpcPosition: 'project.updateNpcPosition',
@@ -477,6 +483,27 @@ export type IpcInvokeMap = {
| { canceled: true }
| { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.project.addGridTemplate]: {
req: {
name: string;
type: SceneGridTemplate['type'];
sizeN: number;
color: string;
};
res: { project: Project };
};
[ipcChannels.project.renameGridTemplate]: {
req: { templateId: GridTemplateId; name: string };
res: { project: Project };
};
[ipcChannels.project.deleteGridTemplate]: {
req: { templateId: GridTemplateId };
res: { project: Project };
};
[ipcChannels.project.applyGridTemplate]: {
req: { sceneId: SceneId; templateId: GridTemplateId };
res: { scene: Scene };
};
[ipcChannels.project.upsertNpc]: {
req: {
npcId?: NpcId;
+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' ? 'Гексогональная' : 'Квадратная';
}