/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/module.ts
282 строки
12 KB
docNemo
fix(sync): рассылка карты клиентам сокетом вместо перезаписи флага
03 авг 2026, 13:58
03 авг 2026, 13:58
9b16019
Код
Авторство
О чём код?
import { AssetCatalog, type AssetManifest } from '@core/asset-manifest'; import { I18N_PREFIX, MODULE_ID } from '@core/constants'; import { logger } from '@core/log'; import { HexLayout, foundryCenter, foundryOriginOffset } from '@core/layout'; import { HexMap } from '@core/map-model'; import { generateMap } from '@generation/pipeline'; import { checkFoundryCompatibility } from '@foundry/compatibility'; import { registerContentGenerator } from '@hexcrawl/content'; import { SUMMARY_GENERATOR_ID, summaryGenerator } from '@hexcrawl/summary'; import { t, tf } from '@foundry/i18n'; import { MapSession } from '@foundry/session'; import { listenForRefresh } from '@foundry/refresh-socket'; import { mapFlagChanged } from '@foundry/scene-storage'; import { sceneSetupData, sceneDimensions } from '@foundry/scene-setup'; import { HexMapLayer } from '@render/hex-layer'; import { TextureCache } from '@render/textures'; import { bakeMapToImage, canBake, defaultBakeOptions } from '@render/baker'; import { FoundrySettingsHost, registerSettings } from '@foundry/settings'; import { UiController } from '@ui/controller'; import { registerSceneControls } from '@ui/scene-controls'; /** * Точка входа модуля. * * Здесь регистрируются только хуки жизненного цикла и публичный API. Вся предметная * логика живёт в своих подсистемах: этот файл должен оставаться коротким и читаемым * как оглавление. */ /** * Публичный API модуля. * * Доступен как `game.modules.get('hex-map-editor').api`. Существует не только ради * тестирования: hexcrawl-таблицы и сторонние инструменты сообщества подключаются * именно через него, а для открытого проекта это основной способ расширения. */ export interface HexMapEditorApi { generateMap: typeof generateMap; sceneSetupData: typeof sceneSetupData; sceneDimensions: typeof sceneDimensions; HexMap: typeof HexMap; HexLayout: typeof HexLayout; HexMapLayer: typeof HexMapLayer; TextureCache: typeof TextureCache; AssetCatalog: typeof AssetCatalog; foundryCenter: typeof foundryCenter; foundryOriginOffset: typeof foundryOriginOffset; /** Загруженный манифест ассетов; `null`, пока модуль не готов. */ catalog: AssetCatalog | null; /** Состояние текущей сцены: карта и её слой. `null`, пока модуль не готов. */ session: MapSession | null; /** Окна модуля и их состояние. `null`, пока модуль не готов. */ controller: UiController | null; } const api: HexMapEditorApi = { generateMap, sceneSetupData, sceneDimensions, HexMap, HexLayout, HexMapLayer, TextureCache, AssetCatalog, foundryCenter, foundryOriginOffset, catalog: null, session: null, controller: null, }; /** Базовый путь модуля в каталоге данных Foundry. */ function modulePath(): string { return `modules/${MODULE_ID}`; } async function loadCatalog(): Promise<AssetCatalog | null> { try { const response = await fetch(`${modulePath()}/assets/MANIFEST.json`); if (!response.ok) { logger.error(`Манифест ассетов недоступен: HTTP ${response.status}`); return null; } const manifest = (await response.json()) as AssetManifest; const catalog = new AssetCatalog(manifest, modulePath()); const problems = catalog.validate(); if (problems.length > 0) { logger.error('Манифест ассетов противоречив:', problems); } return catalog; } catch (error) { logger.error('Не удалось прочитать манифест ассетов.', error); return null; } } async function runBake(): Promise<void> { const layer = api.session?.currentLayer ?? null; if (layer === null) { ui.notifications?.warn('На этой сцене нет карты модуля.'); return; } if (!canBake()) { ui.notifications?.error(t('bake.noPermission')); return; } ui.notifications?.info(t('bake.working')); const scene = canvas.scene; const result = await bakeMapToImage( layer, defaultBakeOptions(game.world.id, scene?.id ?? 'scene'), ); if (result.ok) { ui.notifications?.info( tf('bake.success', { path: result.path, width: result.width, height: result.height }), ); } else { ui.notifications?.error(tf('bake.failed', { message: result.message ?? '' })); } } /** * Действие, требующее готового интерфейса. * * Окна собираются только после загрузки манифеста ассетов, а панель инструментов * существует с самого начала: до этого момента её кнопки объясняют, что модуль * ещё не готов, вместо того чтобы молча ничего не делать. */ function withUi(action: (ui: UiController) => void): () => void { return () => { if (api.controller === null) { ui.notifications?.warn(t('warning.notReady')); return; } action(api.controller); }; } /** То же для переключаемых инструментов панели. */ function withToggle( action: (ui: UiController, active: boolean) => void, ): (active: boolean) => void { return (active) => { if (api.controller === null) { ui.notifications?.warn(t('warning.notReady')); return; } action(api.controller, active); }; } Hooks.once('init', () => { logger.info(`Инициализация модуля ${MODULE_ID}`); const module = game.modules.get(MODULE_ID); if (module !== undefined) { (module as unknown as { api: HexMapEditorApi }).api = api; } // Настройки регистрируются до всего остального: обращение к незарегистрированной // настройке — ошибка, а реестр мира понадобится сразу при открытии сцены. registerSettings(); // Хук панели вызывается в КОНСТРУКТОРЕ SceneControls, то есть ровно один раз // за загрузку страницы и до готовности мира. Регистрация где-либо позже — // например, после асинхронной загрузки манифеста — молча не сработает: // панель уже построена, и повторная отрисовка хук не вызывает. // Выяснено разбором исходника панели в живом Foundry 14.365. registerSceneControls({ openGenerator: withUi((controller) => controller.openGenerator()), toggleBrush: withToggle((controller, active) => controller.toggleBrush(active)), toggleInspector: withToggle((controller, active) => controller.toggleInspector(active)), bakeMap: () => void runBake(), refreshMap: withUi((controller) => controller.refreshMap()), assignParty: withUi((controller) => void controller.assignParty()), openTravel: withUi((controller) => controller.openTravel()), openAtlas: withUi((controller) => controller.openAtlas()), openVisibility: withUi((controller) => controller.openVisibility()), }); registerEditorKeybindings(); }); /** * Отмена и повтор правок кисти. * * Регистрируются механизмом Foundry, а не собственным слушателем клавиатуры: * иначе они спорили бы с отменой самого Foundry и с чужими модулями, а * пользователь не смог бы их переназначить. * * Клавиши работают только при включённой кисти: в остальное время сочетание * должно доставаться Foundry. */ function registerEditorKeybindings(): void { const handle = (action: (controller: UiController) => boolean) => (): boolean => { const controller = api.controller; if (controller === null || !controller.brushActive) return false; return action(controller); }; game.keybindings.register(MODULE_ID, 'undo', { name: `${I18N_PREFIX}.brush.undo`, editable: [{ key: 'KeyZ', modifiers: ['Control'] }], onDown: handle((controller) => controller.undo()), }); game.keybindings.register(MODULE_ID, 'redo', { name: `${I18N_PREFIX}.brush.redo`, editable: [{ key: 'KeyZ', modifiers: ['Control', 'Shift'] }], onDown: handle((controller) => controller.redo()), }); } Hooks.once('ready', () => { checkFoundryCompatibility(); // Встроенная сводка по гексу — заодно рабочий пример точки расширения. // Регистрируется здесь, а не в самом генераторе, потому что ему для перевода // нужен готовый Foundry. registerContentGenerator( SUMMARY_GENERATOR_ID, summaryGenerator({ biome: t, special: t, feature: t, heading: t('journal.summaryHeading'), riverLine: t('journal.river'), roadLine: t('journal.road'), landmarkLine: (name) => tf('journal.landmark', { name }), }), ); void loadCatalog().then(async (catalog) => { api.catalog = catalog; if (catalog === null) return; logger.info(`Манифест ассетов загружен: ${catalog.all().length} записей`); const session = new MapSession(catalog); api.session = session; const controller = new UiController(session, new FoundrySettingsHost(), catalog); api.controller = controller; // Слой живёт ровно столько же, сколько активная сцена: при переходе // на другую его нужно снять и построить заново. Hooks.on('canvasReady', (() => { void session.activate().then(() => controller.onSceneChanged()); }) as (...args: never[]) => unknown); Hooks.on('canvasTearDown', (() => session.detach()) as (...args: never[]) => unknown); // Правка карты приходит с клиента ведущего изменением флага сцены. Без этого // хука клиенты игроков ничего о ней не узнают: карта у них остаётся такой, // какой была на момент загрузки сцены. Hooks.on('updateScene', ((scene: FoundrySceneDocument, changed: Record<string, unknown>) => { if (scene.id !== canvas.scene?.id) return; if (!mapFlagChanged(changed)) return; void session.activate().then(() => controller.onSceneChanged()); }) as (...args: never[]) => unknown); // Перемещение токена партии раскрывает карту. // // Хук именно `moveToken`, а не `updateToken`: замер показал, что при движении // в v14 обновление документа приходит с одним лишь `_id` — координат в списке // изменений НЕТ, и обработчик, ждущий там `x` или `y`, не сработает никогда. // Точка назначения лежит в описании перемещения. Hooks.on('moveToken', ((token: FoundryTokenDocument, movement: FoundryTokenMovement) => { controller.onTokenMoved(token, movement.destination); }) as (...args: never[]) => unknown); listenForRefresh(() => { void session.activate().then(() => controller.onSceneChanged()); }); await session.activate(); }); logger.info('Модуль готов к работе'); });