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, assets: ctx.assets,
campaignAudios, campaignAudios,
materials: [], materials: [],
gridTemplates: [],
npcs, npcs,
npcGroups, npcGroups,
npcRelations: [], npcRelations: [],
+20
View File
@@ -1000,6 +1000,26 @@ async function main() {
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl }; 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( registerHandler(
ipcChannels.project.upsertNpc, ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => { async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
+70 -1
View File
@@ -67,7 +67,8 @@ import {
type NpcDisposition, type NpcDisposition,
} from '../../shared/types/npcDisposition'; } from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl'; 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 { import {
asPreviewRotationDeg, asPreviewRotationDeg,
previewRotationStepsCw, previewRotationStepsCw,
@@ -78,6 +79,7 @@ import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { import type {
AssetId, AssetId,
GraphNodeId, GraphNodeId,
GridTemplateId,
MaterialId, MaterialId,
NpcGroupId, NpcGroupId,
NpcId, NpcId,
@@ -86,6 +88,7 @@ import type {
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
asGridTemplateId,
asMaterialId, asMaterialId,
asNpcGroupId, asNpcGroupId,
asNpcId, asNpcId,
@@ -320,6 +323,7 @@ export class ZipProjectStore {
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
gridTemplates: [],
npcs: [], npcs: [],
npcGroups: [], npcGroups: [],
npcRelations: [], npcRelations: [],
@@ -1411,6 +1415,70 @@ export class ZipProjectStore {
return latest; 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` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар. * При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
@@ -2756,6 +2824,7 @@ function normalizeProject(p: Project): Project {
scenes: scenesPruned, scenes: scenesPruned,
campaignAudios, campaignAudios,
materials, materials,
gridTemplates: normalizeSceneGridTemplates((p as unknown as { gridTemplates?: unknown }).gridTemplates),
npcs, npcs,
npcGroups, npcGroups,
npcRelations, npcRelations,
@@ -23,6 +23,7 @@ function minimalProject(overrides: Partial<Project>): Project {
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
gridTemplates: [],
npcs: [], npcs: [],
npcRelations: [], npcRelations: [],
currentSceneId: null, currentSceneId: null,
+22 -2
View File
@@ -190,7 +190,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.grid.title': 'Генератор сетки', 'help.section.grid.title': 'Генератор сетки',
'help.section.grid.body': '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.title': 'Ловушки',
'help.section.traps.body': 'help.section.traps.body':
@@ -869,7 +879,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.grid.title': 'Grid generator', 'help.section.grid.title': 'Grid generator',
'help.section.grid.body': '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.title': 'Traps',
'help.section.traps.body': 'help.section.traps.body':
@@ -526,3 +526,136 @@
.ctxItemDanger:hover { .ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06); 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;
}
+260
View File
@@ -3,10 +3,12 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { import type {
GridTemplateId,
NpcDisposition, NpcDisposition,
NpcId, NpcId,
ProjectNpc, ProjectNpc,
SceneGrid, SceneGrid,
SceneGridTemplate,
SceneNpcToken, SceneNpcToken,
SceneToken, SceneToken,
SceneTrap, SceneTrap,
@@ -32,6 +34,7 @@ import {
import { import {
clampSceneGridSizeN, clampSceneGridSizeN,
DEFAULT_SCENE_GRID, DEFAULT_SCENE_GRID,
MAX_SCENE_GRID_TEMPLATES,
SCENE_GRID_SIZE_MAX, SCENE_GRID_SIZE_MAX,
SCENE_GRID_SIZE_MIN, SCENE_GRID_SIZE_MIN,
sceneGridTokenFitFactor, sceneGridTokenFitFactor,
@@ -71,6 +74,51 @@ function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'friendly') return 'npcs.makeFriendly'; if (d === 'friendly') return 'npcs.makeFriendly';
return 'npcs.makeNeutral'; return 'npcs.makeNeutral';
} }
function GridTemplateApplyIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 12.5 10 17.5 19 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GridTemplateRenameIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M4 20h4.2L19.5 8.7a1.8 1.8 0 0 0 0-2.5L17.8 4.5a1.8 1.8 0 0 0-2.5 0L4 15.8V20Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M13.5 6.5 17.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function GridTemplateDeleteIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M5 7h14M10 7V5h4v2M8 7l.8 12h6.4L16 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function isTypingTarget(el: EventTarget | null): boolean { function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false; if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName; const tag = el.tagName;
@@ -262,6 +310,11 @@ export function SceneEditorApp() {
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]); const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]); const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID }); const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const [templateNameDraft, setTemplateNameDraft] = useState('');
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [renamingTemplateId, setRenamingTemplateId] = useState<GridTemplateId | null>(null);
const [renameDraft, setRenameDraft] = useState('');
const [pendingDeleteTemplate, setPendingDeleteTemplate] = useState<SceneGridTemplate | null>(null);
const trapsRef = useRef<SceneTrap[]>([]); const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]); const tokensRef = useRef<SceneToken[]>([]);
const npcTokensRef = useRef<SceneNpcToken[]>([]); const npcTokensRef = useRef<SceneNpcToken[]>([]);
@@ -365,6 +418,55 @@ export function SceneEditorApp() {
[api, sceneId], [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(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target)) return;
@@ -607,6 +709,123 @@ export function SceneEditorApp() {
} }
/> />
</label> </label>
<div className={styles.templatesBlock}>
<div className={styles.templatesTitle}>{t('sceneGrid.templates')}</div>
{saveTemplateOpen ? (
<div className={styles.templateSaveRow}>
<Input
value={templateNameDraft}
onChange={setTemplateNameDraft}
placeholder={defaultTemplateName}
data-testid="grid-template-name"
/>
<div className={styles.templateSaveActions}>
<Button
variant="primary"
disabled={gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => void saveGridTemplate()}
data-testid="grid-template-save-confirm"
>
{t('common.save')}
</Button>
<Button
variant="ghost"
onClick={() => {
setSaveTemplateOpen(false);
setTemplateNameDraft('');
}}
>
{t('common.cancel')}
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
disabled={!sceneId || gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES}
onClick={() => {
setTemplateNameDraft(defaultTemplateName);
setSaveTemplateOpen(true);
}}
data-testid="grid-template-save"
title={
gridTemplates.length >= MAX_SCENE_GRID_TEMPLATES
? t('sceneGrid.templatesFull', { max: MAX_SCENE_GRID_TEMPLATES })
: undefined
}
>
{t('sceneGrid.saveTemplate')}
</Button>
)}
{gridTemplates.length === 0 ? (
<div className={styles.templatesListSection}>
<div className={styles.templatesEmpty}>{t('sceneGrid.emptyTemplates')}</div>
</div>
) : (
<div className={styles.templatesListSection}>
<div className={styles.templatesList}>
{gridTemplates.map((tpl) => (
<div key={tpl.id} className={styles.templateRow} data-testid={`grid-template-${tpl.id}`}>
<span
className={styles.templateSwatch}
style={{ background: tpl.color }}
aria-hidden
/>
<div className={styles.templateMeta}>
{renamingTemplateId === tpl.id ? (
<Input
value={renameDraft}
onChange={setRenameDraft}
onBlur={() => void commitRenameTemplate()}
data-testid="grid-template-rename-input"
/>
) : (
<div className={styles.templateName}>{tpl.name}</div>
)}
<div className={styles.templateHint}>
{sceneGridTypeLabelRu(tpl.type)} · {Math.round(tpl.sizeN * 100)}
</div>
</div>
<div className={styles.templateActions}>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.applyTemplate')}
ariaLabel={t('sceneGrid.applyTemplate')}
onClick={() => void applyGridTemplate(tpl.id)}
data-testid={`grid-template-apply-${tpl.id}`}
>
<GridTemplateApplyIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.renameTemplate')}
ariaLabel={t('sceneGrid.renameTemplate')}
onClick={() => {
setRenamingTemplateId(tpl.id);
setRenameDraft(tpl.name);
}}
>
<GridTemplateRenameIcon />
</Button>
<Button
variant="ghost"
iconOnly
title={t('sceneGrid.deleteTemplate')}
ariaLabel={t('sceneGrid.deleteTemplate')}
onClick={() => setPendingDeleteTemplate(tpl)}
>
<GridTemplateDeleteIcon />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1264,6 +1483,47 @@ export function SceneEditorApp() {
document.body, document.body,
) )
: null} : null}
{pendingDeleteTemplate
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteTemplate(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="grid-template-delete-confirm"
className={editorStyles.modalDialog}
>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('sceneGrid.deleteTemplateTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalClose}
onClick={() => setPendingDeleteTemplate(null)}
>
×
</button>
</div>
<div className={editorStyles.muted}>
{t('sceneGrid.deleteTemplateConfirm', { name: pendingDeleteTemplate.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteTemplate(null)}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={() => void confirmDeleteTemplate()}>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
@@ -84,6 +84,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
gridTemplates: [],
npcs: [], npcs: [],
npcGroups: [], npcGroups: [],
npcRelations: [], npcRelations: [],
+30
View File
@@ -13,6 +13,7 @@ import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
asGridTemplateId,
asMaterialId, asMaterialId,
asNpcGroupId, asNpcGroupId,
asNpcId, asNpcId,
@@ -23,6 +24,11 @@ import {
asTokenId, asTokenId,
} from '../types/ids'; } from '../types/ids';
import { normalizeNpcExternalUrl } from '../types/npcExternalUrl'; import { normalizeNpcExternalUrl } from '../types/npcExternalUrl';
import {
MAX_SCENE_GRID_TEMPLATES,
sceneGridTemplateStyleFromGrid,
type SceneGridTemplate,
} from '../types/sceneGrid';
import { import {
getConnectedComponent, getConnectedComponent,
@@ -318,6 +324,7 @@ export function buildPartialExportProject(
assets, assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })), campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })), materials: (source.materials ?? []).map((m) => ({ ...m })),
gridTemplates: (source.gridTemplates ?? []).map((t) => ({ ...t })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null, currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null, currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
}; };
@@ -626,6 +633,28 @@ export function mergeStorylinesIntoProject(
materialAssetIds.add(mapped); 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 npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate); const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
@@ -759,6 +788,7 @@ export function mergeStorylinesIntoProject(
assets, assets,
campaignAudios, campaignAudios,
materials, materials,
gridTemplates,
npcs, npcs,
npcGroups, npcGroups,
npcRelations, npcRelations,
+27
View File
@@ -38,7 +38,9 @@ import type {
SceneMarkerEvent, SceneMarkerEvent,
SceneMarkerState, SceneMarkerState,
SceneGrid, SceneGrid,
SceneGridTemplate,
SceneId, SceneId,
GridTemplateId,
SceneNpcToken, SceneNpcToken,
NpcMapSpawnDragState, NpcMapSpawnDragState,
SceneNpcTokensSessionEvent, SceneNpcTokensSessionEvent,
@@ -99,6 +101,10 @@ export const ipcChannels = {
deleteMaterial: 'project.deleteMaterial', deleteMaterial: 'project.deleteMaterial',
setMaterialsOrder: 'project.setMaterialsOrder', setMaterialsOrder: 'project.setMaterialsOrder',
pickMaterialImage: 'project.pickMaterialImage', pickMaterialImage: 'project.pickMaterialImage',
addGridTemplate: 'project.addGridTemplate',
renameGridTemplate: 'project.renameGridTemplate',
deleteGridTemplate: 'project.deleteGridTemplate',
applyGridTemplate: 'project.applyGridTemplate',
upsertNpc: 'project.upsertNpc', upsertNpc: 'project.upsertNpc',
updateNpcFields: 'project.updateNpcFields', updateNpcFields: 'project.updateNpcFields',
updateNpcPosition: 'project.updateNpcPosition', updateNpcPosition: 'project.updateNpcPosition',
@@ -477,6 +483,27 @@ export type IpcInvokeMap = {
| { canceled: true } | { canceled: true }
| { canceled: false; filePath: string; previewDataUrl: string }; | { 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]: { [ipcChannels.project.upsertNpc]: {
req: { req: {
npcId?: NpcId; npcId?: NpcId;
+7 -2
View File
@@ -12,10 +12,10 @@ import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens'; import type { SceneToken } from './appTokens';
import type { SceneNpcToken, PlayerImageOffset } from './appPlayers'; import type { SceneNpcToken, PlayerImageOffset } from './appPlayers';
import type { NpcDisposition } from './npcDisposition'; import type { NpcDisposition } from './npcDisposition';
import type { SceneGrid } from './sceneGrid'; import type { SceneGrid, SceneGridTemplate } from './sceneGrid';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 11 as const; export const PROJECT_SCHEMA_VERSION = 12 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */ /** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = { export type ProjectMaterial = {
@@ -213,6 +213,11 @@ export type Project = {
campaignAudios: SceneAudioRef[]; campaignAudios: SceneAudioRef[];
/** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */ /** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */
materials: ProjectMaterial[]; materials: ProjectMaterial[];
/**
* Именованные шаблоны боевой сетки кампании.
* Не подставляются автоматически: только ручное «Применить» в редакторе сцены.
*/
gridTemplates: SceneGridTemplate[];
/** НПС кампании (порядок = порядок в списке редактора/пульта). */ /** НПС кампании (порядок = порядок в списке редактора/пульта). */
npcs: ProjectNpc[]; npcs: ProjectNpc[];
/** Группы НПС (порядок среди siblings = порядок в массиве). */ /** Группы НПС (порядок среди siblings = порядок в массиве). */
+5
View File
@@ -16,6 +16,7 @@ export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
export type PlaylistId = Brand<string, 'PlaylistId'>; export type PlaylistId = Brand<string, 'PlaylistId'>;
export type PlaylistAudioId = Brand<string, 'PlaylistAudioId'>; export type PlaylistAudioId = Brand<string, 'PlaylistAudioId'>;
export type PlaylistTrackId = Brand<string, 'PlaylistTrackId'>; export type PlaylistTrackId = Brand<string, 'PlaylistTrackId'>;
export type GridTemplateId = Brand<string, 'GridTemplateId'>;
export function asProjectId(value: string): ProjectId { export function asProjectId(value: string): ProjectId {
return value as ProjectId; return value as ProjectId;
@@ -80,3 +81,7 @@ export function asPlaylistAudioId(value: string): PlaylistAudioId {
export function asPlaylistTrackId(value: string): PlaylistTrackId { export function asPlaylistTrackId(value: string): PlaylistTrackId {
return value as 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 assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
assert.equal(sceneGridTokenFitFactor(undefined), 1); assert.equal(sceneGridTokenFitFactor(undefined), 1);
@@ -12,3 +17,38 @@ void test('sceneGridTokenFitFactor: square and disabled = 1', () => {
void test('sceneGridTokenFitFactor: hex uses inscribed diameter', () => { void test('sceneGridTokenFitFactor: hex uses inscribed diameter', () => {
assert.ok(Math.abs(sceneGridTokenFitFactor({ enabled: true, type: 'hex' }) - Math.sqrt(3) / 2) < 1e-9); 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; export const SCENE_GRID_TYPES = ['square', 'hex'] as const;
@@ -14,6 +16,17 @@ export type SceneGrid = {
color: string; 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 DEFAULT_SCENE_GRID_SIZE_N = 0.06;
export const SCENE_GRID_SIZE_MIN = 0.02; export const SCENE_GRID_SIZE_MIN = 0.02;
export const SCENE_GRID_SIZE_MAX = 0.25; 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 { export function sceneGridTypeLabelRu(type: SceneGridType): string {
return type === 'hex' ? 'Гексогональная' : 'Квадратная'; return type === 'hex' ? 'Гексогональная' : 'Квадратная';
} }