/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/render/baker.ts
202 строки
8 KB
docNemo
feat(ui): панель инструментов и запекание карты в фон
01 авг 2026, 05:31
01 авг 2026, 05:31
217c66f
Код
Авторство
О чём код?
import { logger } from '@core/log'; import type { HexMapLayer } from './hex-layer'; /** * Запекание карты в фоновое изображение сцены. * * Собственный слой быстр и правится на лету, но существует только пока включён * модуль. Запекание даёт вторую, независимую форму: обычную картинку, которую * Foundry покажет и без модуля, и которую можно распечатать или отдать наружу. * * Для открытого проекта это ещё и страховка пользователю: карта не заперта * в формате модуля. См. design.md — «Отрисовка: собственный слой плюс запекание». */ /** Право, без которого сохранить файл невозможно. Подтверждено замером на v14.365. */ export const UPLOAD_PERMISSION = 'FILES_UPLOAD'; export interface BakeOptions { /** * Масштаб относительно натуральной величины слоя. * * Единица даёт файл в полное разрешение сцены — для карты 40×40 при гексе * в 200 пикселей это порядка 8000×7000, десятки мегабайт. Половина обычно * неотличима на глаз при обычном приближении и вчетверо дешевле. */ scale: number; /** Каталог для сохранения относительно корня данных. */ directory: string; /** Имя файла без расширения. */ fileName: string; /** Назначить ли результат фоном сцены. */ applyToScene: boolean; } export function defaultBakeOptions(worldId: string, sceneId: string): BakeOptions { return { scale: 0.5, directory: `worlds/${worldId}/hex-maps`, fileName: `map-${sceneId}`, applyToScene: true, }; } export type BakeFailure = 'no-permission' | 'no-canvas' | 'encode-failed' | 'upload-failed'; export interface BakeResult { ok: boolean; failure?: BakeFailure; /** Путь к сохранённому файлу. */ path?: string; width?: number; height?: number; bytes?: number; message?: string; } /** Может ли текущий пользователь запекать карту. */ export function canBake(): boolean { return game.user?.can(UPLOAD_PERMISSION) === true; } /** * Создаёт каталог назначения вместе с промежуточными. * * Обязательный шаг, а не подстраховка: `FilePicker.upload` в несуществующий * каталог возвращает пустой результат БЕЗ ошибки и без записи файла. Выяснено * замером — запекание молча не срабатывало, и понять причину по поведению было * невозможно. * * Повторное создание существующего каталога считается успехом: отличить его от * настоящего отказа по тексту ошибки ненадёжно, а последующая загрузка всё равно * покажет, доступен ли каталог. */ async function ensureDirectory(directory: string): Promise<void> { const parts = directory.split('/').filter((part) => part.length > 0); let path = ''; for (const part of parts) { path = path === '' ? part : `${path}/${part}`; try { await foundry.applications.apps.FilePicker.createDirectory('data', path, {}); } catch { // Каталог уже есть — это нормальный и ожидаемый исход. } } } /** * Назначает изображение фоном сцены. * * ВАЖНО про v14: у сцены больше НЕТ поля `background` — оно принадлежит уровню * (`scene.levels`), а `scene.background` остался лишь геттером. Обновление * `scene.update({ background: ... })` при этом не выдаёт ошибки: Foundry молча * выбрасывает неизвестный ключ, `update` завершается успешно, и фон не меняется. * Выяснено замером — по поведению причина неотличима от отказа прав или * недоступности файла. * * Фон назначается нижнему уровню: карта — это ландшафт, а не надстройка над ним. * * @returns удалось ли назначить */ async function applyAsBackground(path: string): Promise<boolean> { const scene = canvas.scene; if (scene === null || scene.levels.size === 0) return false; const levels = [...scene.levels.contents].sort((a, b) => a.sort - b.sort); const bottom = levels[0]; if (bottom === undefined) return false; await bottom.update({ background: { src: path } }); return true; } /** * Запекает слой в изображение и, по желанию, назначает его фоном сцены. * * Отказ по правам возвращается значением, а не исключением: это ожидаемый исход * для игрока, а не сбой. */ export async function bakeMapToImage( layer: HexMapLayer, options: BakeOptions, ): Promise<BakeResult> { if (!canBake()) { return { ok: false, failure: 'no-permission', message: 'Для запекания карты нужно право загрузки файлов.', }; } const source = canvas.app.renderer.extract.canvas(layer.root); if (source.width === 0 || source.height === 0) { return { ok: false, failure: 'no-canvas', message: 'Слой карты пуст.' }; } const width = Math.max(1, Math.round(source.width * options.scale)); const height = Math.max(1, Math.round(source.height * options.scale)); const target = document.createElement('canvas'); target.width = width; target.height = height; const context = target.getContext('2d'); if (context === null) { return { ok: false, failure: 'encode-failed', message: 'Не удалось получить контекст холста.' }; } context.imageSmoothingEnabled = true; context.drawImage(source, 0, 0, width, height); const blob = await new Promise<Blob | null>((resolve) => target.toBlob(resolve, 'image/webp', 0.9), ); if (blob === null) { return { ok: false, failure: 'encode-failed', message: 'Не удалось закодировать изображение.' }; } const file = new File([blob], `${options.fileName}.webp`, { type: 'image/webp' }); try { await ensureDirectory(options.directory); const uploaded = await foundry.applications.apps.FilePicker.upload( 'data', options.directory, file, {}, { notify: false }, ); const path = uploaded?.path; if (uploaded?.status !== 'success' || path === undefined) { return { ok: false, failure: 'upload-failed', message: uploaded?.message !== undefined && uploaded.message !== '' ? uploaded.message : `Загрузка в «${options.directory}» не удалась.`, }; } if (options.applyToScene) { const applied = await applyAsBackground(path); if (!applied) { return { ok: true, path, width, height, bytes: blob.size, message: 'Файл сохранён, но назначить его фоном не удалось: у сцены нет уровней.', }; } } logger.info(`Карта запечена в ${path} (${width}×${height}, ${blob.size} байт)`); return { ok: true, path, width, height, bytes: blob.size }; } catch (error) { logger.error('Запекание не удалось.', error); return { ok: false, failure: 'upload-failed', message: String(error) }; } }