diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index f2252d0..dcb1b26 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -549,6 +549,7 @@ export async function buildProjectFromFoundryDocuments( assets: ctx.assets, campaignAudios, materials: [], + gridTemplates: [], npcs, npcGroups, npcRelations: [], diff --git a/app/main/index.ts b/app/main/index.ts index f24f4ed..858ab65 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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 }) => { diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index e245347..8a86d81 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -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 { + 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 { + 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 { + 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 { + 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, diff --git a/app/renderer/editor/graph/sceneCardById.test.ts b/app/renderer/editor/graph/sceneCardById.test.ts index f4485cb..e35a40f 100644 --- a/app/renderer/editor/graph/sceneCardById.test.ts +++ b/app/renderer/editor/graph/sceneCardById.test.ts @@ -23,6 +23,7 @@ function minimalProject(overrides: Partial): Project { assets: {}, campaignAudios: [], materials: [], + gridTemplates: [], npcs: [], npcRelations: [], currentSceneId: null, diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 51a0969..2f83579 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -190,7 +190,17 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.grid.title': 'Генератор сетки', 'help.section.grid.body': - 'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.', + 'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nШаблоны сетки кампании:\n\n1) Настройте сетку на сцене.\n\n2) Нажмите «Сохранить как шаблон» и задайте имя — стиль (тип, цвет, размер) запишется в файл проекта. Автоматически на другие сцены он не подставляется.\n\n3) На любой сцене в списке шаблонов нажмите «Применить» — на сцену запишутся параметры шаблона и сетка включится.\n\n4) Шаблон можно переименовать или удалить. В кампании можно хранить несколько шаблонов (до 20).\n\nНастройки сетки каждой сцены и список шаблонов хранятся в проекте. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.', + + 'sceneGrid.templates': 'Шаблоны кампании', + 'sceneGrid.saveTemplate': 'Сохранить как шаблон', + 'sceneGrid.applyTemplate': 'Применить', + 'sceneGrid.renameTemplate': 'Переименовать', + 'sceneGrid.deleteTemplate': 'Удалить', + 'sceneGrid.deleteTemplateTitle': 'Удаление шаблона сетки', + 'sceneGrid.deleteTemplateConfirm': 'Удалить шаблон «{name}»?', + 'sceneGrid.emptyTemplates': 'Пока нет шаблонов. Сохраните текущие настройки сетки.', + 'sceneGrid.templatesFull': 'Достигнут лимит шаблонов ({max}).', 'help.section.traps.title': 'Ловушки', 'help.section.traps.body': @@ -869,7 +879,17 @@ export const EDITOR_MESSAGES: Record> = { 'help.section.grid.title': 'Grid generator', 'help.section.grid.body': - 'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nGrid settings are stored with the scene in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.', + 'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nCampaign grid templates:\n\n1) Configure the grid on a scene.\n\n2) Click Save as template and name it — type, color, and size are stored in the project file. Nothing is applied to other scenes automatically.\n\n3) On any scene, click Apply on a template — that scene gets the template style and the grid turns on.\n\n4) Rename or delete templates anytime. A campaign can keep several templates (up to 20).\n\nPer-scene grid settings and the template list live in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.', + + 'sceneGrid.templates': 'Campaign templates', + 'sceneGrid.saveTemplate': 'Save as template', + 'sceneGrid.applyTemplate': 'Apply', + 'sceneGrid.renameTemplate': 'Rename', + 'sceneGrid.deleteTemplate': 'Delete', + 'sceneGrid.deleteTemplateTitle': 'Delete grid template', + 'sceneGrid.deleteTemplateConfirm': 'Delete template “{name}”?', + 'sceneGrid.emptyTemplates': 'No templates yet. Save the current grid settings.', + 'sceneGrid.templatesFull': 'Template limit reached ({max}).', 'help.section.traps.title': 'Traps', 'help.section.traps.body': diff --git a/app/renderer/sceneEditor/SceneEditorApp.module.css b/app/renderer/sceneEditor/SceneEditorApp.module.css index be240be..e67fced 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.module.css +++ b/app/renderer/sceneEditor/SceneEditorApp.module.css @@ -526,3 +526,136 @@ .ctxItemDanger:hover { background: rgba(255, 255, 255, 0.06); } + +.templatesBlock { + display: grid; + gap: 8px; + padding-top: 4px; + border-top: 1px solid var(--stroke, #2a2f3a); +} + +.templatesTitle { + font-size: 12px; + font-weight: 700; + opacity: 0.8; +} + +.templateSaveRow { + display: grid; + gap: 6px; +} + +.templateSaveActions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + align-items: stretch; +} + +.templateSaveActions > * { + width: 100%; + max-width: none; + display: flex; + flex: unset; + align-items: stretch; +} + +.templateSaveActions > * > * { + width: 100%; + display: flex; + align-items: stretch; +} + +.templateSaveActions button { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.templatesListSection { + display: grid; + gap: 8px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px solid var(--stroke, #2a2f3a); +} + +.templatesEmpty { + font-size: 12px; + opacity: 0.6; + line-height: 1.35; +} + +.templatesList { + display: grid; + gap: 6px; +} + +.templateRow { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 8px 8px; + align-items: start; + padding: 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--stroke, #2a2f3a); +} + +.templateSwatch { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.25); + margin-top: 2px; +} + +.templateMeta { + display: grid; + gap: 2px; + min-width: 0; +} + +.templateName { + font-size: 12px; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.templateHint { + font-size: 11px; + opacity: 0.65; +} + +.templateActions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + align-items: stretch; +} + +.templateActions > * { + width: 100%; + max-width: none; + display: flex; + flex: unset; + align-items: stretch; +} + +.templateActions > * > * { + width: 100%; + display: flex; + align-items: stretch; +} + +.templateActions button { + width: 100%; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx index 4208fce..d1e3144 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.tsx +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -3,10 +3,12 @@ import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import type { + GridTemplateId, NpcDisposition, NpcId, ProjectNpc, SceneGrid, + SceneGridTemplate, SceneNpcToken, SceneToken, SceneTrap, @@ -32,6 +34,7 @@ import { import { clampSceneGridSizeN, DEFAULT_SCENE_GRID, + MAX_SCENE_GRID_TEMPLATES, SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MIN, sceneGridTokenFitFactor, @@ -71,6 +74,51 @@ function dispositionMakeKey(d: NpcDisposition): string { if (d === 'friendly') return 'npcs.makeFriendly'; return 'npcs.makeNeutral'; } + +function GridTemplateApplyIcon() { + return ( + + + + ); +} + +function GridTemplateRenameIcon() { + return ( + + + + + ); +} + +function GridTemplateDeleteIcon() { + return ( + + + + ); +} function isTypingTarget(el: EventTarget | null): boolean { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; @@ -262,6 +310,11 @@ export function SceneEditorApp() { const [localTokens, setLocalTokens] = useState([]); const [localNpcTokens, setLocalNpcTokens] = useState([]); const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); + const [templateNameDraft, setTemplateNameDraft] = useState(''); + const [saveTemplateOpen, setSaveTemplateOpen] = useState(false); + const [renamingTemplateId, setRenamingTemplateId] = useState(null); + const [renameDraft, setRenameDraft] = useState(''); + const [pendingDeleteTemplate, setPendingDeleteTemplate] = useState(null); const trapsRef = useRef([]); const tokensRef = useRef([]); const npcTokensRef = useRef([]); @@ -365,6 +418,55 @@ export function SceneEditorApp() { [api, sceneId], ); + const gridTemplates = project?.gridTemplates ?? []; + + const defaultTemplateName = useMemo(() => { + const sizeLabel = Math.round(localGrid.sizeN * 100); + return `${sceneGridTypeLabelRu(localGrid.type)} · ${String(sizeLabel)}`; + }, [localGrid.sizeN, localGrid.type]); + + const saveGridTemplate = async () => { + if (!sceneId) return; + const name = (templateNameDraft.trim() || defaultTemplateName).trim(); + if (!name) return; + if (gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES) return; + await api.invoke(ipcChannels.project.addGridTemplate, { + name, + type: localGrid.type, + sizeN: localGrid.sizeN, + color: localGrid.color, + }); + setSaveTemplateOpen(false); + setTemplateNameDraft(''); + }; + + const applyGridTemplate = async (templateId: GridTemplateId) => { + if (!sceneId) return; + if (saveGridTimerRef.current) window.clearTimeout(saveGridTimerRef.current); + await api.invoke(ipcChannels.project.applyGridTemplate, { sceneId, templateId }); + }; + + const commitRenameTemplate = async () => { + if (!renamingTemplateId) return; + const name = renameDraft.trim(); + if (!name) { + setRenamingTemplateId(null); + return; + } + await api.invoke(ipcChannels.project.renameGridTemplate, { + templateId: renamingTemplateId, + name, + }); + setRenamingTemplateId(null); + }; + + const confirmDeleteTemplate = async () => { + if (!pendingDeleteTemplate) return; + const id = pendingDeleteTemplate.id; + setPendingDeleteTemplate(null); + await api.invoke(ipcChannels.project.deleteGridTemplate, { templateId: id }); + }; + useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isTypingTarget(e.target)) return; @@ -607,6 +709,123 @@ export function SceneEditorApp() { } /> + +
+
{t('sceneGrid.templates')}
+ {saveTemplateOpen ? ( +
+ +
+ + +
+
+ ) : ( + + )} + {gridTemplates.length === 0 ? ( +
+
{t('sceneGrid.emptyTemplates')}
+
+ ) : ( +
+
+ {gridTemplates.map((tpl) => ( +
+ +
+ {renamingTemplateId === tpl.id ? ( + void commitRenameTemplate()} + data-testid="grid-template-rename-input" + /> + ) : ( +
{tpl.name}
+ )} +
+ {sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)} +
+
+
+ + + +
+
+ ))} +
+
+ )} +
) : null} @@ -1264,6 +1483,47 @@ export function SceneEditorApp() { document.body, ) : null} + + {pendingDeleteTemplate + ? createPortal( + <> + + +
+ {t('sceneGrid.deleteTemplateConfirm', { name: pendingDeleteTemplate.name })} +
+
+ + +
+ + , + document.body, + ) + : null} ); } diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index a54f0cb..5ceedb4 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -84,6 +84,7 @@ function minimalProject(overrides: Partial = {}): Project { assets: {}, campaignAudios: [], materials: [], + gridTemplates: [], npcs: [], npcGroups: [], npcRelations: [], diff --git a/app/shared/graph/storylineExportImport.ts b/app/shared/graph/storylineExportImport.ts index 2cd794d..f57bc87 100644 --- a/app/shared/graph/storylineExportImport.ts +++ b/app/shared/graph/storylineExportImport.ts @@ -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, diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index a39114e..c7fb290 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index aeb662d..5beaa83 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -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 = порядок в массиве). */ diff --git a/app/shared/types/ids.ts b/app/shared/types/ids.ts index 102a286..950a99c 100644 --- a/app/shared/types/ids.ts +++ b/app/shared/types/ids.ts @@ -16,6 +16,7 @@ export type SceneNpcTokenId = Brand; export type PlaylistId = Brand; export type PlaylistAudioId = Brand; export type PlaylistTrackId = Brand; +export type GridTemplateId = Brand; 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; +} diff --git a/app/shared/types/sceneGrid.test.ts b/app/shared/types/sceneGrid.test.ts index 05d4cfd..5adb60f 100644 --- a/app/shared/types/sceneGrid.test.ts +++ b/app/shared/types/sceneGrid.test.ts @@ -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', + }); +}); diff --git a/app/shared/types/sceneGrid.ts b/app/shared/types/sceneGrid.ts index 3ba7a61..4ee7829 100644 --- a/app/shared/types/sceneGrid.ts +++ b/app/shared/types/sceneGrid.ts @@ -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; + 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(); + 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, +): Pick { + return { + type: grid.type === 'hex' ? 'hex' : 'square', + sizeN: clampSceneGridSizeN(grid.sizeN), + color: normalizeSceneGridColor(grid.color), + }; +} + export function sceneGridTypeLabelRu(type: SceneGridType): string { return type === 'hex' ? 'Гексогональная' : 'Квадратная'; }