feat(grid): именованные шаблоны сетки кампании
Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -84,6 +84,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
gridTemplates: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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