/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/ui/brush-controller.ts
267 строк
10 KB
docNemo
feat(ui): кисть местности на холсте с отменой и подсветкой стыков
01 авг 2026, 12:13
01 авг 2026, 12:13
f0193ae
Код
Авторство
О чём код?
import { hexSpiral, type HexDirection } from '@core/hex'; import { edgeDirectionFromOffset } from '@core/layout'; import { logger } from '@core/log'; import type { HexMap } from '@core/map-model'; import { beginBiomeStroke, beginSpecialStroke, type BrushStroke } from '@editor/brush'; import { hasEdge, setEdge } from '@editor/edges'; import { EditHistory, type EditTransaction } from '@editor/history'; import { TransitionMode, type HexEdge } from '@editor/transitions'; import type { BiomeTable } from '@presets/biome-table'; import { t } from '@foundry/i18n'; import { DEFAULT_BRUSH_STATE, edgeLayerOf, isEdgeTarget, operationLabelKey, type BrushState, } from './brush-state'; import { HexStrokeInput } from './hex-picker'; import { clearAllHighlights, clearHighlight, highlightBrush, highlightWarnings, WARNING_LAYER, } from './highlight'; /** * Кисть местности: превращает протягивание мышью в правки карты. * * Один мазок — одна операция истории, даже если он прошёл через десяток гексов. * Поэтому транзакция открывается на нажатии и закрывается на отпускании, а режим * контроля стыков применяется один раз в конце: применять его на каждом * промежуточном гексе бессмысленно, следующий всё равно изменит картину. */ export interface BrushHost { map(): HexMap | null; /** Таблица биомов текущей карты — нужна для контроля стыков. */ table(): BiomeTable; /** Гексы изменены: перерисовать и сохранить. */ onEdited(indices: number[]): void; /** Состояние кисти или истории изменилось — обновить панель. */ onStateChanged(): void; } export class BrushController { state: BrushState = { ...DEFAULT_BRUSH_STATE }; private readonly input = new HexStrokeInput(); private history: EditHistory | null = null; /** Карта, для которой заведена история: у другой карты снимки недействительны. */ private historyMap: HexMap | null = null; private transaction: EditTransaction | null = null; private stroke: BrushStroke | null = null; /** Рёбра, уже затронутые текущим мазком: ключ вида «меньший:больший». */ private readonly touchedEdges = new Set<string>(); /** * Наносит ли текущий мазок рёбра или снимает их. * * Решается по первому ребру: если оно было пустым — мазок рисует, если занятым — * стирает. Переключение каждого ребра по отдельности превращало бы протягивание * в мигание там, где русло пересекает само себя. */ private addingEdges = true; constructor(private readonly host: BrushHost) {} get active(): boolean { return this.input.active; } get canUndo(): boolean { return this.history?.canUndo === true; } get canRedo(): boolean { return this.history?.canRedo === true; } /** * Включает кисть. * * Карта не запоминается: она берётся у сессии на каждом событии, потому что * перезагрузка сцены подменяет её объект целиком. */ attach(): boolean { const map = this.host.map(); if (map === null) return false; this.ensureHistory(map); this.input.attach(() => this.host.map(), { begin: (index, point) => this.withMap((current) => this.begin(current, index, point)), extend: (index, point) => this.withMap((current) => this.extend(current, index, point)), end: () => this.end(), hover: (index) => this.withMap((current) => this.previewArea(current, index)), }); return true; } private withMap(action: (map: HexMap) => void): void { const map = this.host.map(); if (map !== null) action(map); } detach(): void { this.input.detach(); this.cancelStroke(); clearAllHighlights(); } /** История привязана к карте: смена сцены обесценивает снимки. */ private ensureHistory(map: HexMap): EditHistory { if (this.history === null || this.historyMap !== map) { this.history = new EditHistory(map); this.historyMap = map; } return this.history; } private begin(map: HexMap, index: number, point: { x: number; y: number }): void { const history = this.ensureHistory(map); this.transaction = history.begin(t(operationLabelKey(this.state.target))); this.touchedEdges.clear(); clearHighlight(WARNING_LAYER); if (isEdgeTarget(this.state.target)) { const direction = this.edgeAt(map, index, point); this.addingEdges = !hasEdge(map, index, direction, edgeLayerOf(this.state.target)); this.paintEdge(map, index, direction); return; } this.stroke = this.state.target === 'biome' ? beginBiomeStroke(map, this.host.table(), this.transaction, this.state.biome, { radius: this.state.radius, mode: this.state.mode, }) : beginSpecialStroke(map, this.host.table(), this.transaction, this.state.special, { radius: this.state.radius, mode: this.state.mode, }); this.stroke.extendTo(map.coordAt(index)); } private extend(map: HexMap, index: number, point: { x: number; y: number }): void { if (this.transaction === null) return; if (isEdgeTarget(this.state.target)) { this.paintEdge(map, index, this.edgeAt(map, index, point)); return; } this.stroke?.extendTo(map.coordAt(index)); } private end(): void { const map = this.host.map(); const transaction = this.transaction; if (map === null || transaction === null) return; // Рёберная кисть не проходит через режим стыков: русло не подчиняется // таблице биомов, а перерисовать надо оба гекса каждого затронутого ребра. const result = this.stroke?.finish() ?? { affected: [...this.touchedEdges].flatMap((key) => key.split(':').map(Number)), warnings: [] as HexEdge[], }; const operation = this.ensureHistory(map).push(transaction); this.cancelStroke(); if (operation !== null) { this.host.onEdited([...new Set(result.affected)]); } // Подсветка стыков держится до следующего мазка: она и нужна для того, // чтобы её рассмотреть. if (this.state.mode === TransitionMode.Warn && result.warnings.length > 0) { highlightWarnings(map, result.warnings); logger.debug(`Несовместимых стыков после мазка: ${result.warnings.length}`); } this.host.onStateChanged(); } private cancelStroke(): void { this.transaction = null; this.stroke = null; this.touchedEdges.clear(); } /** Направление ребра, к которому ближе точка щелчка. */ private edgeAt(map: HexMap, index: number, point: { x: number; y: number }): HexDirection { const grid = canvas.grid; if (grid === null) return 0; const row = Math.floor(index / map.columns); const centre = grid.getCenterPoint({ i: row, j: index - row * map.columns }); return edgeDirectionFromOffset(point.x - centre.x, point.y - centre.y); } private paintEdge(map: HexMap, index: number, direction: HexDirection): void { const transaction = this.transaction; if (transaction === null || !isEdgeTarget(this.state.target)) return; const change = setEdge( map, transaction, index, direction, edgeLayerOf(this.state.target), this.addingEdges, ); if (change === null) return; const [a, b] = change.affected; if (a === undefined || b === undefined) return; this.touchedEdges.add(a < b ? `${a}:${b}` : `${b}:${a}`); } /** Показывает, какие гексы затронет мазок. */ private previewArea(map: HexMap, index: number): void { if (isEdgeTarget(this.state.target)) { highlightBrush(map, [index]); return; } const area: number[] = []; for (const coord of hexSpiral(map.coordAt(index), this.state.radius)) { const target = map.indexOf(coord); if (target >= 0) area.push(target); } highlightBrush(map, area); } undo(): boolean { const changed = this.history?.undo() ?? null; if (changed === null) return false; clearHighlight(WARNING_LAYER); this.host.onEdited(changed); this.host.onStateChanged(); return true; } redo(): boolean { const changed = this.history?.redo() ?? null; if (changed === null) return false; clearHighlight(WARNING_LAYER); this.host.onEdited(changed); this.host.onStateChanged(); return true; } /** Названия операций от последней к первой. */ historyLabels(): string[] { return this.history?.labels() ?? []; } /** Сцена сменилась: история прежней карты больше не применима. */ reset(): void { this.detach(); this.history = null; this.historyMap = null; } }