/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/render/hex-layer.ts
363 строки
14 KB
docNemo
feat(art): особые зоны накладываются на биом, а не подменяют его
03 авг 2026, 15:35
03 авг 2026, 15:35
d94bebf
Код
Авторство
О чём код?
import type { AssetCatalog } from '@core/asset-manifest'; import { HEX_DIRECTIONS, edgeRotationDegrees, hexNeighbor, type HexDirection } from '@core/hex'; import { HexLayout, foundryOriginOffset } from '@core/layout'; import { ExplorationState, SpecialZone, isWater, type HexMap } from '@core/map-model'; import type { TextureCache } from './textures'; /** * Слой отрисовки гексагональной карты. * * Карта рисуется собственным контейнером PIXI, а не Tile-документами Foundry: * полторы тысячи документов замедляют загрузку сцены, раздувают базу мира и * превращают каждый штрих кисти в запись в неё. Здесь перерисовка гекса не касается * базы вообще. См. design.md — «Отрисовка: собственный слой плюс запекание». * * Контейнер подключается к группе `primary` — туда же, где живут фон сцены и тайлы, * то есть под токенами и сеткой. Подтверждено замером: группа принимает обычный * `PIXI.Container`, порядок внутри неё задаётся `zIndex`. */ /** Порядок слоёв внутри контейнера карты. */ const Z = { terrain: 0, /** Особая зона лежит поверх биома в том же контейнере. */ special: 5, coast: 10, roads: 20, rivers: 30, features: 40, fog: 50, } as const; /** * Место слоя внутри группы `primary`. * * Фон сцены — `PrimarySpriteMesh` с `elevation: 0` и `sort: 0`. Обычный * `PIXI.Container` этих полей не имеет вовсе, и относительно фона он встаёт * недетерминированно. Пока фон пуст, это незаметно; стоит запечь карту в фон — * и слой может уйти ПОД картинку. Тогда игрок видит запечённую карту целиком, * а туман войны остаётся под ней и ничего не скрывает. * * Та же высота, что у фона, и больший `sort`: над фоном, но под тайлами, * токенами и всем, что выше по высоте. */ const LAYER_ELEVATION = 0; const LAYER_SORT = 1; /** Цвет затемнения неизвестных гексов. */ const FOG_TINT = 0x000000; const FOG_ALPHA = 0.92; /** Прозрачность силуэта замеченного издалека ориентира. */ const SIGHTED_ALPHA = 0.35; export interface HexLayerOptions { /** Ширина гекса в пикселях сцены. */ hexPixels: number; /** Показывать ли карту целиком, игнорируя состояния изученности. */ revealAll: boolean; } export const DEFAULT_LAYER_OPTIONS: HexLayerOptions = { hexPixels: 200, revealAll: false, }; export class HexMapLayer { readonly root: PixiContainer; private readonly terrain: PixiContainer; private readonly overlays: PixiContainer; private readonly features: PixiContainer; private readonly fog: PixiContainer; /** Спрайты рельефа по индексу гекса — для частичной перерисовки. */ private readonly terrainSprites = new Map<number, PixiSprite>(); private readonly specialSprites = new Map<number, PixiSprite>(); private readonly overlaySprites = new Map<number, PixiSprite[]>(); private readonly featureSprites = new Map<number, PixiSprite>(); private readonly fogSprites = new Map<number, PixiSprite>(); private layout: HexLayout; constructor( private readonly map: HexMap, private readonly catalog: AssetCatalog, private readonly textures: TextureCache, private options: HexLayerOptions = DEFAULT_LAYER_OPTIONS, ) { this.layout = new HexLayout(options.hexPixels); this.root = new PIXI.Container(); this.root.name = 'hex-map-editor'; this.root.sortableChildren = true; this.root.elevation = LAYER_ELEVATION; this.root.sort = LAYER_SORT; this.root.zIndex = LAYER_SORT; this.terrain = this.makeChild(Z.terrain); this.overlays = this.makeChild(Z.coast); this.features = this.makeChild(Z.features); this.fog = this.makeChild(Z.fog); } private makeChild(zIndex: number): PixiContainer { const container = new PIXI.Container(); container.sortableChildren = true; container.zIndex = zIndex; this.root.addChild(container); return container; } /** Центр гекса в координатах сцены Foundry. */ private centreOf(index: number): PixiPoint { const centre = this.layout.center(this.map.coordAt(index)); // Смещение считается от ВЫСОТЫ: это единица раскладки flat-top. Передача // сюда ширины даёт сдвиг ровно в 2/√3 раза — вся карта уезжает вправо. const offset = foundryOriginOffset(this.layout.hexHeight); return { x: centre.x + offset.x, y: centre.y + offset.y }; } /** * Спрайт гекса. Масштабируется чуть крупнее натуральной величины, чтобы закрыть * щели от округления координат: художник рисует ровно шестиугольник, без вылета * за границу. См. design.md — «Спрайт: без вылета, со сглаживанием швов». */ private makeSprite(path: string, index: number, zIndex = 0): PixiSprite { const sprite = new PIXI.Sprite(this.textures.getOrMissing(path)); const size = this.layout.spriteSize(); const centre = this.centreOf(index); sprite.anchor.set(0.5, 0.5); sprite.width = size.width; sprite.height = size.height; sprite.x = centre.x; sprite.y = centre.y; sprite.zIndex = zIndex; return sprite; } /** Полная отрисовка карты. */ draw(): void { this.clearSprites(); for (const index of this.map.indices()) { this.drawHex(index); } } /** * Перерисовка одного гекса и его соседей. * * Соседи нужны потому, что рёберные наложения — берег, реки, дороги — хранятся * у обоих смежных гексов: изменив один, легко оставить половину русла от прежнего. */ redrawHex(index: number): void { this.removeHex(index); this.drawHex(index); } redrawArea(indices: Iterable<number>): void { for (const index of indices) this.redrawHex(index); } private drawHex(index: number): void { const state = this.map.getExploration(index); const visible = this.options.revealAll || state !== ExplorationState.Unknown; if (!visible) { this.drawFog(index); return; } // Замеченный издалека гекс не раскрывается: показывается лишь силуэт ориентира // поверх скрытой области, без ландшафта и окружения. if (!this.options.revealAll && state === ExplorationState.Sighted) { this.drawFog(index); this.drawSilhouette(index); return; } this.drawTerrain(index); this.drawOverlays(index); this.drawFeature(index); } /** * Рельеф гекса: тайл биома, а поверх него — особая зона. * * Особая зона именно НАКЛАДЫВАЕТСЯ, а не подменяет собой биом. Раньше она * подменяла, и горы в пустыне были неотличимы от гор в тайге: под ними не было * ничего. Тайлы особых зон нарисованы с альфой ровно ради этого — сквозь них * видно, какая местность лежит в основании. */ private drawTerrain(index: number): void { const variant = this.map.variant[index] ?? 0; const base = this.makeSprite(this.catalog.biomeSprite(this.map.getBiome(index), variant), index); this.terrain.addChild(base); this.terrainSprites.set(index, base); const special = this.map.getSpecial(index); if (special === SpecialZone.None) return; const path = this.catalog.specialSprite(special, variant); if (path === null) return; const overlay = this.makeSprite(path, index, Z.special); this.terrain.addChild(overlay); this.specialSprites.set(index, overlay); } /** * Наложения: берег, дороги, реки. * * Каждое ребро рисуется ОДНИМ спрайтом, повёрнутым на `60° × номер ребра`. * Альтернатива — отдельный файл на каждую из 64 комбинаций соседей — похоронила бы * художественную часть проекта. См. assets/ART-SPEC.md, раздел 4. */ private drawOverlays(index: number): void { const sprites: PixiSprite[] = []; const coord = this.map.coordAt(index); if (!isWater(this.map.getBiome(index))) { for (const direction of HEX_DIRECTIONS) { const neighbourIndex = this.map.indexOf(hexNeighbor(coord, direction)); if (neighbourIndex < 0) continue; if (!isWater(this.map.getBiome(neighbourIndex))) continue; sprites.push(this.edgeSprite('coast', 'edge', index, direction, Z.coast)); } } for (const [mask, group, zIndex] of [ [this.map.roads[index] ?? 0, 'road', Z.roads], [this.map.rivers[index] ?? 0, 'river', Z.rivers], ] as const) { if (mask === 0) continue; for (const direction of HEX_DIRECTIONS) { if ((mask & (1 << direction)) === 0) continue; sprites.push(this.edgeSprite(group, 'stub', index, direction, zIndex)); } } if (sprites.length > 0) { for (const sprite of sprites) this.overlays.addChild(sprite); this.overlaySprites.set(index, sprites); } } private edgeSprite( group: string, name: string, index: number, direction: HexDirection, zIndex: number, ): PixiSprite { const sprite = this.makeSprite(this.catalog.overlaySprite(group, name), index, zIndex); sprite.angle = edgeRotationDegrees(direction); return sprite; } private drawFeature(index: number): void { const path = this.catalog.featureSprite(this.map.getFeature(index)); if (path === null) return; const sprite = this.makeSprite(path, index, Z.features); this.features.addChild(sprite); this.featureSprites.set(index, sprite); } /** Затемнение неизвестного гекса. */ private drawFog(index: number): void { const sprite = this.makeSprite(this.catalog.biomeSprite(this.map.getBiome(index), 0), index); sprite.tint = FOG_TINT; sprite.alpha = FOG_ALPHA; sprite.zIndex = Z.fog; this.fog.addChild(sprite); this.fogSprites.set(index, sprite); } /** * Силуэт замеченного издалека ориентира. * * Раскрывать тайл целиком нельзя: одинокий раскрытый гекс посреди тьмы читается * как дефект отрисовки. Партия видит пик, а не то, что у его подножия. */ private drawSilhouette(index: number): void { const special = this.map.getSpecial(index); const path = special !== SpecialZone.None ? (this.catalog.specialSprite(special, 0) ?? this.catalog.biomeSprite(this.map.getBiome(index), 0)) : (this.catalog.featureSprite(this.map.getFeature(index)) ?? this.catalog.biomeSprite(this.map.getBiome(index), 0)); // Силуэт кладётся в контейнер тумана, а не объектов: сортировка спрайтов // работает лишь внутри своего контейнера, и в контейнере объектов (zIndex 40) // силуэт оказывался под самим туманом (zIndex 50), то есть невидимым. const sprite = this.makeSprite(path, index, Z.fog + 1); sprite.alpha = SIGHTED_ALPHA; this.fog.addChild(sprite); this.featureSprites.set(index, sprite); } private removeHex(index: number): void { for (const store of [ this.terrainSprites, this.specialSprites, this.featureSprites, this.fogSprites, ]) { const sprite = store.get(index); if (sprite !== undefined) { sprite.destroy(); store.delete(index); } } const overlays = this.overlaySprites.get(index); if (overlays !== undefined) { for (const sprite of overlays) sprite.destroy(); this.overlaySprites.delete(index); } } private clearSprites(): void { for (const container of [this.terrain, this.overlays, this.features, this.fog]) { for (const child of [...container.children]) child.destroy(); container.removeChildren(); } this.terrainSprites.clear(); this.specialSprites.clear(); this.overlaySprites.clear(); this.featureSprites.clear(); this.fogSprites.clear(); } setOptions(options: Partial<HexLayerOptions>): void { const next = { ...this.options, ...options }; const relayout = next.hexPixels !== this.options.hexPixels; this.options = next; if (relayout) this.layout = new HexLayout(next.hexPixels); this.draw(); } /** Число спрайтов на слое — для диагностики. */ spriteCount(): number { let overlays = 0; for (const list of this.overlaySprites.values()) overlays += list.length; return ( this.terrainSprites.size + this.specialSprites.size + overlays + this.featureSprites.size + this.fogSprites.size ); } /** Подключает слой к группе канваса. */ attach(group: PixiContainer): void { group.addChild(this.root); } destroy(): void { this.clearSprites(); this.root.destroy({ children: true }); } }