/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/ui/controller.ts
869 строк
34 KB
docNemo
feat(units)!: метрические единицы в модели и хранении
03 авг 2026, 14:51
03 авг 2026, 14:51
f1b5073
Код
Авторство
О чём код?
import type { AssetCatalog } from '@core/asset-manifest'; import { logger } from '@core/log'; import { WorldAtlas, type SettingsHost } from '@atlas/world'; import { ATLAS_DIRECTIONS, neighbourCell, type AtlasCell } from '@atlas/coordinates'; import { detailSceneOf, linkDetailScene, planDetailScene } from '@atlas/navigation'; import { axialToOffset } from '@core/hex'; import { foundryCenter, HexLayout } from '@core/layout'; import { generateMap, type GenerationRequest, type GenerationResult } from '@generation/pipeline'; import { ARCHETYPE_IDS, type ArchetypeId } from '@generation/shape-mask'; import { t, tf } from '@foundry/i18n'; import type { MapSession } from '@foundry/session'; import { readHexcrawlSettings, readVisibilitySettings, writeVisibilitySettings, } from '@foundry/settings'; import { applySceneSetup, DEFAULT_HEX_PIXELS, isSceneEmpty, sceneSetupData, willResize, type SceneLike, } from '@foundry/scene-setup'; import { clearAllOverrides } from '@editor/regenerate'; import { revealAround } from '@hexcrawl/exploration'; import { DEFAULT_TRAVEL_OPTIONS, planRoute, type RouteEstimate, type TravelOptions, } from '@hexcrawl/travel'; import { ensureJournalEntry, hasJournal } from '@hexcrawl/journal'; import { hasWritingClient, isWritingClient } from '@foundry/authority'; import { FoundryJournalHost, openJournalSheet } from '@foundry/journal-host'; import { broadcastRefresh } from '@foundry/refresh-socket'; import { createMapScene, linkToParent, parentLinkOf } from '@foundry/scene-factory'; import { assignPartyToken, clearPartyToken, isPartyToken, partyTokenOf, tokenHexIndex, type TokenCorner, } from '@foundry/party'; import { BiomeTable, CLIMATE_PRESETS } from '@presets/biome-table'; import { isEnabled, presetSettings, toSightOptions, type VisibilitySettings, } from '@visibility/settings'; import type { SightOptions } from '@visibility/sight'; import { markSighted, Viewshed } from '@visibility/viewshed'; import { AtlasApp, type AtlasSnapshot } from './atlas-app'; import { BrushApp } from './brush-app'; import { BrushController } from './brush-controller'; import { GeneratorApp, defaultGeneratorValues } from './generator-app'; import { toRequest } from './generator-form'; import { archetypeLabelKey } from './labels'; import type { GeneratorValues } from './generator-form'; import { HexPicker } from './hex-picker'; import { clearHighlight, highlightRoute, ROUTE_LAYER } from './highlight'; import { InspectorApp } from './inspector-app'; import { setToolActive } from './scene-controls'; import { TravelApp } from './travel-app'; import { VisibilityApp } from './visibility-app'; /** * Связующее звено между окнами и состоянием модуля. * * Окна намеренно ничего не знают ни о сцене, ни о реестре мира: каждое получает * узкий интерфейс, а всё, что требует Foundry, собрано здесь. Иначе состояние * расползлось бы по четырём окнам и рассинхронизировалось при первой же смене сцены. */ export class UiController { private readonly picker = new HexPicker(); private readonly brush = new BrushController({ map: () => this.session.currentMap, table: () => this.biomeTable(), onEdited: (indices) => this.onHexesEdited(indices), onStateChanged: () => void this.brushApp?.render(), }); private generator: GeneratorApp | null = null; private inspector: InspectorApp | null = null; private atlasApp: AtlasApp | null = null; private visibilityApp: VisibilityApp | null = null; private brushApp: BrushApp | null = null; private travelApp: TravelApp | null = null; /** Концы прокладываемого маршрута. Начало по умолчанию — гекс партии. */ private routeFrom: number | null = null; private routeTo: number | null = null; /** Предрасчёт видимости. Сбрасывается при любом изменении рельефа. */ private viewshedCache: Viewshed | null = null; /** Гекс, выбранный для панели обзора. */ private observer: number | null = null; constructor( private readonly session: MapSession, private readonly settings: SettingsHost, private readonly catalog: AssetCatalog, ) {} /** Сцена сменилась: расчёты прежней карты недействительны. */ onSceneChanged(): void { this.viewshedCache = null; this.observer = null; this.picker.detach(); this.clearRoute(); // История правок привязана к карте: снимки прежней к новой неприменимы. this.brush.reset(); this.refreshWindows(); } get brushActive(): boolean { return this.brush.active; } undo(): boolean { return this.brush.undo(); } redo(): boolean { return this.brush.redo(); } // --- Генератор ----------------------------------------------------------- openGenerator(): void { // Закрытое окно пересоздаётся, а не показывается повторно: настройки в нём // снимаются с текущей сцены при создании, и переиспользованное окно показало // бы значения той сцены, на которой его открывали в прошлый раз. if (this.generator?.rendered !== true) { this.generator = new GeneratorApp({ catalog: this.catalog, initialValues: this.generatorValues(), apply: (result, request) => this.applyGenerated(result, request), }); } void this.generator.render({ force: true }); } /** * Настройки, с которыми открывается генератор. * * Приоритет у карты текущей сцены: чаще всего генератор открывают, чтобы * перекатить именно её. Затем — реестр мира, чтобы новая ячейка получилась * стыкующейся с соседями. И только для пустого мира берутся умолчания. */ private generatorValues(): GeneratorValues { const base = defaultGeneratorValues(); const atlas = this.atlas(); const meta = this.session.currentMeta; const map = this.session.currentMap; if (meta !== null && map !== null) { // Архетип в сохранённой карте — обычная строка: она могла быть записана // более новой версией модуля, знающей архетипы, которых здесь ещё нет. const archetype = meta.archetype as ArchetypeId; return { ...base, worldSeed: meta.worldSeed, atlasX: meta.atlasX, atlasY: meta.atlasY, columns: map.columns, rows: map.rows, archetype: ARCHETYPE_IDS.includes(archetype) ? archetype : base.archetype, climatePreset: meta.climatePreset, hexScaleKm: meta.hexScaleKm, biomePalette: meta.biomePalette, }; } const free = this.firstFreeCell(atlas); return { ...base, worldSeed: atlas.cellCount > 0 ? atlas.worldSeed : base.worldSeed, atlasX: free.x, atlasY: free.y, columns: atlas.cellSize.columns, rows: atlas.cellSize.rows, hexScaleKm: atlas.hexScaleKm, }; } /** Первая свободная ячейка справа от занятых — куда мир продолжается естественнее всего. */ private firstFreeCell(atlas: WorldAtlas): { x: number; y: number } { const bounds = atlas.bounds(); if (bounds === null) return { x: 0, y: 0 }; return { x: bounds.maxX + 1, y: bounds.minY }; } /** * Применяет сгенерированную карту к текущей сцене. * * Изменение размеров сцены сдвигает всё, что на ней расставлено, и одним * действием не отменяется — поэтому непустая сцена требует подтверждения. */ private async applyGenerated( result: GenerationResult, request: GenerationRequest, ): Promise<boolean> { const scene = canvas.scene; if (scene === null) { ui.notifications?.warn(t('generator.noScene')); return false; } const setup = sceneSetupData(result.map, result.meta, DEFAULT_HEX_PIXELS); if (willResize(scene as unknown as SceneLike, setup) && !isSceneEmpty(sceneContent(scene))) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: t('generator.title') }, content: `<p>${t('warning.sceneNotEmpty')}</p>`, }); if (!confirmed) return false; } await applySceneSetup(scene as unknown as SceneLike, setup); await this.session.apply(result.map, result.meta); await this.registerInAtlas(scene.id, request); // Карта заменилась целиком: снимки истории и расчёт видимости прежней // к новой неприменимы. this.brush.reset(); this.viewshedCache = null; this.refreshWindows(); ui.notifications?.info( tf('generator.applied', { columns: result.map.columns, rows: result.map.rows }), ); return true; } /** * Записывает сцену в реестр мира. * * Карта, сделанная другим мировым сидом, в атлас не попадает: она не стыкуется * с соседями, и запись о ней сделала бы бесшовность мира ложной. Такая сцена * остаётся самостоятельной картой — это законный случай, а не ошибка. */ private async registerInAtlas(sceneId: string, request: GenerationRequest): Promise<void> { const atlas = this.atlas(); if (atlas.cellCount === 0) { if (atlas.worldSeed !== request.worldSeed) await atlas.setWorldSeed(request.worldSeed); await atlas.setCellSize({ columns: request.columns, rows: request.rows }); await atlas.setHexScale(request.hexScaleKm); } else if (atlas.worldSeed !== request.worldSeed) { ui.notifications?.warn(t('generator.outsideAtlas')); return; } await atlas.register({ x: request.atlasX, y: request.atlasY }, sceneId); } // --- Hexcrawl ------------------------------------------------------------ /** * Помечает выделенный токен как токен партии. * * Пометка снимается повторным вызовом на том же токене: отдельная кнопка * «снять» понадобилась бы ровно один раз за кампанию. */ async assignParty(): Promise<void> { const scene = canvas.scene; if (scene === null) return; const selected = canvas.tokens.controlled[0]?.document; if (selected === undefined) { ui.notifications?.warn(t('party.selectToken')); return; } if (isPartyToken(selected)) { await clearPartyToken(scene); ui.notifications?.info(t('party.cleared')); return; } await assignPartyToken(scene, selected); ui.notifications?.info(tf('party.assigned', { name: selected.name })); // Гекс, на котором партия уже стоит, раскрывается сразу: иначе первое // раскрытие случилось бы только после первого шага. this.revealAroundParty(selected); } /** * Перемещение токена: если это партия — раскрыть окрестности. * * Точка назначения приходит доводом, а не читается из `token.x`: в v14 * перемещение идёт через собственный конвейер, и координаты документа * во время движения показывают ещё не то место, куда токен пришёл. */ onTokenMoved(token: FoundryTokenDocument, destination?: TokenCorner): void { if (!isPartyToken(token)) return; this.revealAroundParty(token, destination); } /** * Раскрывает карту вокруг партии. * * Замеченные издалека ориентиры обрабатываются здесь же: гекс с ориентиром * переходит в `sighted`, не раскрывая ни ландшафта вокруг, ни записи журнала. */ private revealAroundParty(token: FoundryTokenDocument, destination?: TokenCorner): void { const map = this.session.currentMap; if (map === null) return; // Хук перемещения приходит всем клиентам, а править сцену вправе только // ведущий. Клиент игрока, дошедший сюда, упирался в отказ Foundry — // «lacks permission to update Scene». Раскрытие записывает ведущий, игрок // получает его хуком updateScene. if (!isWritingClient()) { if (!hasWritingClient()) { logger.debug('Ведущий не подключён: раскрытие карты откладывается.'); } return; } const settings = readHexcrawlSettings(); if (!settings.autoReveal) return; const index = tokenHexIndex(map, token, destination); if (index < 0) return; const changed = revealAround(map, index, { sightRadius: settings.sightRadius }); const viewshed = isEnabled(this.visibilitySettings()) ? this.viewshed() : null; if (viewshed !== null) changed.push(...markSighted(map, viewshed, index)); if (changed.length === 0) return; this.session.redraw(changed); void this.session.persist().catch((error: unknown) => { logger.error('Не удалось сохранить раскрытие карты.', error); }); logger.debug(`Раскрыто гексов: ${changed.length}`); } /** * Открывает запись журнала гекса, создавая её при необходимости. * * Создание — обычный шаг рабочего цикла ведущего, а не отдельное решение: * записи заводятся ровно тогда, когда до гекса дошли руки. */ private async openHexJournal(index: number): Promise<void> { const map = this.session.currentMap; if (map === null) return; const host = new FoundryJournalHost(); const { journalId, created } = await ensureJournalEntry(map, index, host, (coords, name) => name === undefined ? tf('journal.hexName', coords) : tf('journal.hexNameNamed', { ...coords, name }), ); if (created) { await this.session.persist(); void this.inspector?.render(); } if (!openJournalSheet(journalId)) { ui.notifications?.error(t('journal.openFailed')); } } // --- Маршрут ------------------------------------------------------------- openTravel(): void { if (this.session.currentMap === null) { ui.notifications?.warn(t('travel.noMap')); return; } this.travelApp ??= new TravelApp({ map: () => this.session.currentMap, options: () => this.travelOptions(), from: () => this.routeFrom ?? this.partyHex(), to: () => this.routeTo, route: () => this.currentRoute(), pick: (end) => this.pickRouteEnd(end), clear: () => this.clearRoute(), focus: (index) => this.focusHex(index), }); void this.travelApp.render({ force: true }); } private travelOptions(): TravelOptions { return { ...DEFAULT_TRAVEL_OPTIONS, hexScaleKm: this.session.currentMeta?.hexScaleKm ?? DEFAULT_TRAVEL_OPTIONS.hexScaleKm, kmPerDay: readHexcrawlSettings().kmPerDay, }; } /** Гекс токена партии — начало маршрута по умолчанию. */ private partyHex(): number | null { const map = this.session.currentMap; const scene = canvas.scene; if (map === null || scene === null) return null; const token = partyTokenOf(scene); if (token === null) return null; const index = tokenHexIndex(map, token); return index < 0 ? null : index; } /** * Маршрут между выбранными точками. * * Считается заново при каждом обращении, а не кэшируется: правка местности * кистью меняет стоимость пути, и устаревший маршрут вводил бы в заблуждение. * Поиск по карте 40×40 занимает единицы миллисекунд. */ private currentRoute(): RouteEstimate | null { const map = this.session.currentMap; const from = this.routeFrom ?? this.partyHex(); const to = this.routeTo; if (map === null || from === null || to === null) return null; const route = planRoute(map, from, to, this.travelOptions()); if (route === null) { clearHighlight(ROUTE_LAYER); return null; } highlightRoute(map, route.path); return route; } private pickRouteEnd(end: 'from' | 'to'): void { const map = this.session.currentMap; if (map === null) return; this.picker.attach( () => this.session.currentMap, (index) => { if (end === 'from') this.routeFrom = index; else this.routeTo = index; this.picker.detach(); void this.travelApp?.render(); }, ); ui.notifications?.info(t('travel.pickHint')); } private clearRoute(): void { this.routeFrom = null; this.routeTo = null; this.picker.detach(); clearHighlight(ROUTE_LAYER); } // --- Кисть --------------------------------------------------------------- toggleBrush(active: boolean): void { if (!active) { this.brush.detach(); void this.brushApp?.close(); return; } if (this.session.currentMap === null) { ui.notifications?.warn(t('inspector.noMap')); setToolActive('brush', false); return; } // Инспектор гасится: оба инструмента цепляются к щелчкам по холсту. this.toggleInspector(false); setToolActive('inspect', false); this.brushApp ??= new BrushApp({ state: () => this.brush.state, setState: (state) => { this.brush.state = state; }, canUndo: () => this.brush.canUndo, canRedo: () => this.brush.canRedo, undo: () => void this.brush.undo(), redo: () => void this.brush.redo(), historyLabels: () => this.brush.historyLabels(), clearOverrides: () => void this.clearAllOverrides(), overriddenCount: () => this.session.currentMap?.overridden.size ?? 0, }); void this.brushApp.render({ force: true }); this.brush.attach(); } /** * Снимает все ручные правки карты. * * Отмене через историю не поддаётся и затрагивает всю карту разом, поэтому * требует подтверждения. Сами значения гексов при этом не меняются: снимается * только защита от перегенерации. */ private async clearAllOverrides(): Promise<void> { const map = this.session.currentMap; if (map === null || map.overridden.size === 0) return; const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: t('brush.title') }, content: `<p>${tf('brush.clearOverridesConfirm', { count: map.overridden.size })}</p>`, }); if (!confirmed) return; clearAllOverrides(map); await this.session.persist(); void this.brushApp?.render(); } /** * Часть запроса генерации, наследуемая от карты текущей сцены. * * Всё, что описывает мир, а не разовый выбор в форме: климат и ограничение * палитры. Архетип сюда не входит — форма суши подсцены выводится из самого * гекса, а не из того, каким был родительский материк. */ private parentTemplate(): Partial<GenerationRequest> { const meta = this.session.currentMeta; if (meta === null) return {}; return { climatePreset: meta.climatePreset, biomePalette: meta.biomePalette }; } /** Таблица биомов текущей карты. Без карты — умолчание. */ private biomeTable(): BiomeTable { const meta = this.session.currentMeta; if (meta === null) return new BiomeTable(); return new BiomeTable( CLIMATE_PRESETS[meta.climatePreset] ?? CLIMATE_PRESETS['temperate'], meta.biomePalette, ); } // --- Инспектор ----------------------------------------------------------- toggleInspector(active: boolean): void { if (!active) { this.picker.detach(); void this.inspector?.close(); return; } const map = this.session.currentMap; if (map === null) { ui.notifications?.warn(t('inspector.noMap')); setToolActive('inspect', false); return; } this.toggleBrush(false); setToolActive('brush', false); this.inspector ??= new InspectorApp({ map: () => this.session.currentMap, onEdited: (indices) => this.onHexesEdited(indices), focus: (index) => this.focusHex(index), openJournal: (index) => void this.openHexJournal(index), hasJournal: (index) => { const current = this.session.currentMap; return current !== null && hasJournal(current, index, new FoundryJournalHost()); }, openDetailScene: (index) => void this.openDetailScene(index), hasDetailScene: (index) => { const current = this.session.currentMap; return current !== null && detailSceneOf(current, index) !== null; }, }); // Окно не открывается вместе с инструментом: пустой инспектор с надписью // «щёлкните по гексу» — это не сведения, а помеха поверх карты. Оно // появляется в тот момент, когда есть что показать. ui.notifications?.info(t('inspector.pickHex')); this.picker.attach( () => this.session.currentMap, (index) => this.inspector?.show(index), ); } /** * Просит клиентов перечитать карту. * * Обычно не требуется: правки доходят сами хуком `updateScene`. Кнопка нужна, * когда клиент всё же отстал, а перезагружать ради этого мир несоразмерно. * * Идёт сокетом, а не перезаписью флага: переписать карту тем же значением * невозможно — Foundry отбрасывает правку без изменений, и `diff: false` * на флаги не действует. См. `refresh-socket.ts`. */ refreshMap(): void { const sceneId = canvas.scene?.id; if (this.session.currentMap === null || sceneId === undefined) { ui.notifications?.warn(t('inspector.noMap')); return; } if (!isWritingClient()) { ui.notifications?.warn(t('warning.gmOnly')); return; } broadcastRefresh(sceneId); ui.notifications?.info(t('refresh.sent')); } /** Гексы изменены: перерисовать, сохранить и сбросить зависящие от рельефа расчёты. */ private onHexesEdited(indices: number[]): void { this.session.redraw(indices); this.viewshedCache = null; void this.session.persist().catch((error: unknown) => { logger.error('Не удалось сохранить правку карты.', error); }); } // --- Атлас --------------------------------------------------------------- openAtlas(): void { this.atlasApp ??= new AtlasApp({ snapshot: () => this.atlasSnapshot(), sceneName: (sceneId) => game.scenes.get(sceneId)?.name ?? null, openScene: async (sceneId) => { const scene = game.scenes.get(sceneId); if (scene === undefined) throw new Error(`Сцены ${sceneId} больше нет.`); await scene.view(); }, createCell: (cell) => this.createCell(cell), parentLink: () => (canvas.scene === null ? null : parentLinkOf(canvas.scene)), parentName: (sceneId) => game.scenes.get(sceneId)?.name ?? null, returnToParent: () => this.returnToParent(), }); void this.atlasApp.render({ force: true }); } /** * Создаёт карту в пустой ячейке атласа и переходит в неё. * * Бесшовно продолжается только материковый архетип: остров не может * перетекать в соседнюю ячейку, у него по определению есть берег. Если такая * карта появляется рядом с уже существующей, об этом предупреждают, но не * запрещают — соседом острова вполне может быть море. */ private async createCell(cell: AtlasCell): Promise<void> { const atlas = this.atlas(); if (atlas.has(cell)) return; const values = this.generatorValues(); const request: GenerationRequest = { ...toRequest(values), worldSeed: atlas.worldSeed, atlasX: cell.x, atlasY: cell.y, columns: atlas.cellSize.columns, rows: atlas.cellSize.rows, hexScaleKm: atlas.hexScaleKm, }; if (request.archetype !== 'mainland' && this.hasOccupiedNeighbour(atlas, cell)) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: t('atlas.title') }, content: `<p>${tf('atlas.seamWarning', { archetype: t(archetypeLabelKey(request.archetype)) })}</p>`, }); if (!confirmed) return; } const result = generateMap(request); const name = tf('atlas.sceneName', { x: cell.x, y: cell.y }); const { scene } = await createMapScene(name, result.map, result.meta); await atlas.register(cell, scene.id); ui.notifications?.info(tf('atlas.created', { name })); await scene.view(); } private hasOccupiedNeighbour(atlas: WorldAtlas, cell: AtlasCell): boolean { return ATLAS_DIRECTIONS.some((direction) => atlas.has(neighbourCell(cell, direction))); } /** * Открывает сцену детализации гекса, создавая её при необходимости. * * Геометрического совмещения сеток не делается и не требуется: шестиугольник * не делится на шестиугольники в произвольном отношении. Подсцена засевается * от мирового сида и ГЛОБАЛЬНЫХ координат родительского гекса — поэтому она * воспроизводима и не повторяется у разных гексов. См. design.md. */ private async openDetailScene(index: number): Promise<void> { const map = this.session.currentMap; const parentScene = canvas.scene; if (map === null || parentScene === null) return; const existingId = detailSceneOf(map, index); if (existingId !== null) { const existing = game.scenes.get(existingId); if (existing !== undefined) { await existing.view(); return; } // Сцену удалили, а ссылка осталась: создаём заново вместо молчания. ui.notifications?.warn(t('atlas.detailMissing')); } const atlas = this.atlas(); const cell = atlas.cellOf(parentScene.id) ?? { x: 0, y: 0 }; // Шаблон берётся из РОДИТЕЛЬСКОЙ карты, а не из формы генератора: форма // хранит то, что ведущий набирал последним, и не имеет отношения к сцене, // из которой спускаются. Раньше климат подсцены приезжал именно оттуда. const plan = planDetailScene(atlas, cell, map, index, this.biomeTable(), { ...toRequest(this.generatorValues()), ...this.parentTemplate(), }); const result = generateMap(plan.request); const { col, row } = axialToOffset(map.coordAt(index)); const name = tf('atlas.detailName', { col, row }); const { scene } = await createMapScene(name, result.map, result.meta); await linkToParent(scene, { sceneId: parentScene.id, hexIndex: index }); linkDetailScene(map, index, scene.id); await this.session.persist(); void this.inspector?.render(); ui.notifications?.info(tf('atlas.detailCreated', { name, scale: plan.hexScaleKm })); await scene.view(); } /** Возвращается на родительскую карту и наводит обзор на гекс, из которого спустились. */ private async returnToParent(): Promise<void> { const scene = canvas.scene; if (scene === null) return; const link = parentLinkOf(scene); if (link === null) return; const parent = game.scenes.get(link.sceneId); if (parent === undefined) { ui.notifications?.warn(t('atlas.parentMissing')); return; } await parent.view(); // Обзор наводится после перестроения канваса: до него сетки новой сцены ещё нет. Hooks.once('canvasReady', (() => this.focusHex(link.hexIndex)) as ( ...args: never[] ) => unknown); } private atlasSnapshot(): AtlasSnapshot { const atlas = this.atlas(); return { worldSeed: atlas.worldSeed, hexScaleKm: atlas.hexScaleKm, cellSize: atlas.cellSize, entries: atlas.list(), currentSceneId: canvas.scene?.id ?? null, }; } // --- Панель обзора ------------------------------------------------------- openVisibility(): void { this.visibilityApp ??= new VisibilityApp({ isGM: game.user?.isGM === true, map: () => this.session.currentMap, observerIndex: () => this.observer, viewshed: () => this.viewshed(), settings: () => this.visibilitySettings(), sightOptions: () => this.sightOptions(), enable: () => this.enableVisibility(), pickObserver: () => this.pickObserver(), focus: (index) => this.focusHex(index), }); void this.visibilityApp.render({ force: true }); } private visibilitySettings(): VisibilitySettings { return readVisibilitySettings(this.settings); } private sightOptions(): SightOptions { return toSightOptions(this.visibilitySettings(), this.session.currentMeta?.hexScaleKm ?? 10); } /** * Считает видимость, если она ещё не посчитана. * * Расчёт ленивый именно потому, что высота и заметность пишутся при генерации * всегда: включить возможность на давно сделанной карте можно без перегенерации, * то есть не потеряв ручных правок. См. specs/distant-visibility. */ private viewshed(): Viewshed | null { const map = this.session.currentMap; if (map === null) return null; this.viewshedCache ??= Viewshed.compute(map, this.sightOptions()); return this.viewshedCache; } private async enableVisibility(): Promise<void> { await writeVisibilitySettings(this.settings, presetSettings('simple')); this.viewshedCache = null; } private pickObserver(): void { const map = this.session.currentMap; if (map === null) return; this.picker.attach( () => this.session.currentMap, (index) => { this.observer = index; this.picker.detach(); void this.visibilityApp?.render(); }, ); ui.notifications?.info(t('visibility.pickHint')); } // --- Общее --------------------------------------------------------------- /** Переводит обзор сцены на гекс. */ private focusHex(index: number): void { const map = this.session.currentMap; if (map === null || canvas.grid === null) return; const layout = new HexLayout(canvas.grid.size); const point = foundryCenter(layout, map.coordAt(index)); void canvas.animatePan({ x: point.x, y: point.y }); } private atlas(): WorldAtlas { return WorldAtlas.load(this.settings); } private refreshWindows(): void { for (const app of [ this.inspector, this.atlasApp, this.visibilityApp, this.brushApp, this.travelApp, ]) { if (app?.rendered === true) void app.render(); } } } function sceneContent(scene: FoundrySceneDocument): { tokens: number; tiles: number; drawings: number; notes: number; walls: number; } { return { tokens: scene.tokens.size, tiles: scene.tiles.size, drawings: scene.drawings.size, notes: scene.notes.size, walls: scene.walls.size, }; }