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
+1
View File
@@ -549,6 +549,7 @@ export async function buildProjectFromFoundryDocuments(
assets: ctx.assets,
campaignAudios,
materials: [],
gridTemplates: [],
npcs,
npcGroups,
npcRelations: [],
+20
View File
@@ -1000,6 +1000,26 @@ async function main() {
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.addGridTemplate, async ({ name, type, sizeN, color }) => {
const project = await projectStore.addGridTemplate({ name, type, sizeN, color });
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.renameGridTemplate, async ({ templateId, name }) => {
const project = await projectStore.renameGridTemplate(templateId, name);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteGridTemplate, async ({ templateId }) => {
const project = await projectStore.deleteGridTemplate(templateId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.applyGridTemplate, async ({ sceneId, templateId }) => {
const scene = await projectStore.applyGridTemplate(sceneId, templateId);
emitSessionState();
return { scene };
});
registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
+70 -1
View File
@@ -67,7 +67,8 @@ import {
type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid, normalizeSceneGridTemplates, sceneGridFromTemplate, sceneGridTemplateStyleFromGrid, MAX_SCENE_GRID_TEMPLATES } from '../../shared/types/sceneGrid';
import type { SceneGridTemplate } from '../../shared/types/sceneGrid';
import {
asPreviewRotationDeg,
previewRotationStepsCw,
@@ -78,6 +79,7 @@ import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type {
AssetId,
GraphNodeId,
GridTemplateId,
MaterialId,
NpcGroupId,
NpcId,
@@ -86,6 +88,7 @@ import type {
import {
asAssetId,
asGraphNodeId,
asGridTemplateId,
asMaterialId,
asNpcGroupId,
asNpcId,
@@ -320,6 +323,7 @@ export class ZipProjectStore {
assets: {},
campaignAudios: [],
materials: [],
gridTemplates: [],
npcs: [],
npcGroups: [],
npcRelations: [],
@@ -1411,6 +1415,70 @@ export class ZipProjectStore {
return latest;
}
async addGridTemplate(input: {
name: string;
type: SceneGridTemplate['type'];
sizeN: number;
color: string;
}): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
const name = input.name.trim();
if (!name) throw new Error('Grid template name is required');
const style = sceneGridTemplateStyleFromGrid(input);
await this.updateProject((p) => {
const list = normalizeSceneGridTemplates(p.gridTemplates);
if (list.length >= MAX_SCENE_GRID_TEMPLATES) {
throw new Error('Grid template limit reached');
}
const template: SceneGridTemplate = {
id: asGridTemplateId(`gt_${this.randomId()}`),
name,
...style,
};
return { ...p, gridTemplates: [...list, template] };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async renameGridTemplate(templateId: GridTemplateId, name: string): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
const nextName = name.trim();
if (!nextName) throw new Error('Grid template name is required');
await this.updateProject((p) => {
const list = normalizeSceneGridTemplates(p.gridTemplates);
const idx = list.findIndex((t) => t.id === templateId);
if (idx < 0) throw new Error('Grid template not found');
const next = [...list];
next[idx] = { ...next[idx]!, name: nextName };
return { ...p, gridTemplates: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteGridTemplate(templateId: GridTemplateId): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
gridTemplates: normalizeSceneGridTemplates(p.gridTemplates).filter((t) => t.id !== templateId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async applyGridTemplate(sceneId: SceneId, templateId: GridTemplateId): Promise<Scene> {
if (!this.openProject) throw new Error('No open project');
const open = this.openProject;
const template = normalizeSceneGridTemplates(open.project.gridTemplates).find((t) => t.id === templateId);
if (!template) throw new Error('Grid template not found');
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
return this.updateScene(sceneId, { grid: sceneGridFromTemplate(template) });
}
/**
* Создаёт или обновляет НПС.
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
@@ -2756,6 +2824,7 @@ function normalizeProject(p: Project): Project {
scenes: scenesPruned,
campaignAudios,
materials,
gridTemplates: normalizeSceneGridTemplates((p as unknown as { gridTemplates?: unknown }).gridTemplates),
npcs,
npcGroups,
npcRelations,