import { Container, Graphics, Texture, Sprite } from 'pixi.js';
import { Grid } from './pathfinding';
import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso';
/**
* Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или,
* если текстуры нет, колонной высотой height мировых юнитов (·tileW px).
* ground — id тайла, каким рисовать землю под объектом (по умолчанию — сам объект).
*/
export interface TallSpec {
height: number;
ground?: number;
}
/**
* Крупный объект на несколько тайлов (дом, большое дерево): footprint w×h
* от северо-западного угла (x, y). Коллизия — весь footprint; земля под ним —
* ground (если задан); спрайт якорится низом в центр footprint'а.
* Спрайт живёт в map.propViews — сцена добавляет его в свой IsoDepthLayer
* через addRect, чтобы герои корректно перекрывались объектом.
*/
export interface PropData {
/** id текстуры спрайта. */
id: number;
/** Северо-западный тайл footprint. */
x: number;
y: number;
/** Размер footprint в тайлах (по умолчанию 1×1). */
w?: number;
h?: number;
/** Чем рисовать землю под footprint (по умолчанию — не трогать). */
ground?: number;
/** Высота в мировых юнитах — для плейсхолдера без текстуры. */
height?: number;
}
/** Проп после нормализации (w/h заполнены). */
export interface PropPlaced extends PropData {
w: number;
h: number;
}
/** Спрайт пропа + его footprint — сцена вставляет view в свой depth-слой. */
export interface PropView {
view: Container;
prop: PropPlaced;
}
/**
* Изометрическая тайл-карта.
* Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture.
* Проходимость определяется набором блокирующих id; тайлы «высоких» объектов
* (стены, деревья) рисуются поверх земли по строкам глубины.
*/
export interface TileMapData {
width: number;
height: number;
/** Индексы тайлов, length = width * height. */
tiles: number[];
/** Тайлы с этими id непроходимы. */
blocked: number[];
/** Тайлы с этими id рисуются как вертикальные объекты. */
tall?: Record<number, number | TallSpec>;
/** Крупные объекты с footprint'ом в несколько тайлов. */
props?: PropData[];
}
/** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */
interface CellSprites {
ground?: Sprite;
object?: Sprite;
/** id тайла, по которому создан спрайт (для регистрации в анимациях). */
groundId?: number;
objectId?: number;
}
/** Анимация тайлов одного id: кадры, fps и общий таймлайн на все клетки. */
interface TileAnim {
frames: Texture[];
fps: number;
t: number;
sprites: Set<Sprite>;
}
export class IsometricTileMap implements Grid {
readonly data: TileMapData;
readonly iso: IsoLayout;
readonly view: Container;
private textures: Map<number, Texture>;
private blockedSet: Set<number>;
private tallSpecs: Map<number, TallSpec>;
/** Пропы после нормализации; их footprint блокирует клетки карты. */
readonly props: PropPlaced[];
/** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */
readonly propViews: PropView[] = [];
private propBlocked = new Set<number>();
private groundLayer: Container;
private objectsLayer: Container;
private cells = new Map<number, CellSprites>();
private anims = new Map<number, TileAnim>();
private spriteAnims = new Map<Sprite, number>();
get width(): number {
return this.data.width;
}
get height(): number {
return this.data.height;
}
constructor(data: TileMapData, textures: Map<number, Texture>, iso: IsoLayout = DEFAULT_ISO) {
this.data = data;
this.iso = iso;
this.textures = textures;
this.blockedSet = new Set(data.blocked);
this.tallSpecs = new Map(
Object.entries(data.tall ?? {}).map(([id, spec]) => [
Number(id),
typeof spec === 'number' ? { height: spec } : spec
])
);
this.view = new Container();
const ground = new Container();
const objects = new Container();
this.groundLayer = ground;
this.objectsLayer = objects;
this.view.addChild(ground, objects);
// Сортировка глубины по диагонали (tx + ty) — классика изометрии.
const cells: { tx: number; ty: number }[] = [];
for (let ty = 0; ty < data.height; ty++) {
for (let tx = 0; tx < data.width; tx++) {
cells.push({ tx, ty });
}
}
cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty));
for (const cell of cells) {
this.drawCell(cell.tx, cell.ty);
}
// Пропы: footprint блокирует клетки, под ним рисуем ground, спрайт —
// в propViews (сцена вставит его в свой depth-слой с сортировкой addRect).
this.props = (data.props ?? []).map((p) => ({ ...p, w: p.w ?? 1, h: p.h ?? 1 }));
for (const prop of this.props) {
for (let ty = prop.y; ty < prop.y + prop.h; ty++) {
for (let tx = prop.x; tx < prop.x + prop.w; tx++) {
this.propBlocked.add(ty * data.width + tx);
}
}
this.drawPropGround(prop);
this.propViews.push({ view: this.makePropView(prop), prop });
}
}
/** Земля под footprint'ом пропа (если задан ground и текстура есть). */
private drawPropGround(prop: PropPlaced): void {
const groundTex = prop.ground !== undefined ? this.textures.get(prop.ground) : undefined;
if (!groundTex) return;
for (let ty = prop.y; ty < prop.y + prop.h; ty++) {
for (let tx = prop.x; tx < prop.x + prop.w; tx++) {
const p = isoToScreen(tx, ty, this.iso);
const s = new Sprite(groundTex);
s.anchor.set(0.5, 0);
s.position.set(p.x, p.y);
this.groundLayer.addChild(s);
}
}
}
/** Спрайт пропа (якорь низ-центр footprint'а) или колонна-плейсхолдер. */
private makePropView(prop: PropPlaced): Container {
const c = isoToScreen(prop.x + prop.w / 2, prop.y + prop.h / 2, this.iso);
const tex = this.textures.get(prop.id);
if (tex) {
const s = new Sprite(tex);
s.anchor.set(0.5, 1);
s.position.set(c.x, c.y);
return s;
}
return this.drawPlaceholder(c.x, c.y, prop.w, prop.h, prop.height ?? 2);
}
/**
* Колонна-плейсхолдер: основание — параллелограмм footprint (w×h юнитов),
* выдавленный вверх на height юнитов. Три видимые грани: верх, лево, право.
*/
private drawPlaceholder(cx: number, cy: number, w: number, h: number, height: number): Container {
const hw = this.iso.tileW / 2;
const hh = this.iso.tileH / 2;
const top = height * this.iso.tileW;
// Углы основания относительно центра (проекция мирового смещения).
const nw = { x: ((h - w) / 2) * hw, y: -((w + h) / 2) * hh };
const ne = { x: ((w + h) / 2) * hw, y: ((h - w) / 2) * hh };
const se = { x: ((w - h) / 2) * hw, y: ((w + h) / 2) * hh };
const sw = { x: -((w + h) / 2) * hw, y: ((w - h) / 2) * hh };
const g = new Graphics();
const face = (a: typeof nw, b: typeof nw, fill: number) =>
g.poly([cx + a.x, cy + a.y - top, cx + b.x, cy + b.y - top, cx + b.x, cy + b.y, cx + a.x, cy + a.y]).fill(fill);
// верхняя грань
g.poly([cx + nw.x, cy + nw.y - top, cx + ne.x, cy + ne.y - top, cx + se.x, cy + se.y - top, cx + sw.x, cy + sw.y - top]).fill(0x6b5a44);
// левые и правые грани (к юго-западному и юго-восточному ребру)
face(sw, se, 0x4a3b2a);
face(se, ne, 0x352a1e);
return g;
}
/**
* Изменить тайл: обновляет данные и перерисовывает одну ячейку
* (сбор предметов, посадка цветов, разрушаемые стены и т.п.).
*/
setTile(x: number, y: number, id: number): void {
if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return;
this.data.tiles[y * this.data.width + x] = id;
this.drawCell(x, y);
}
/**
* Анимированный тайл: клетки с этим id переключают кадры по одному общему
* таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже
* нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...).
*/
setTileAnimation(id: number, frames: Texture[], fps: number): void {
if (frames.length === 0 || fps <= 0) return;
this.anims.set(id, { frames, fps, t: 0, sprites: new Set() });
for (const cell of this.cells.values()) {
if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id);
if (cell.object && cell.objectId === id) this.registerAnim(cell.object, id);
}
}
/** Тик анимаций тайлов; из update сцены (no-op, если анимаций нет). */
update(dt: number): void {
for (const anim of this.anims.values()) {
anim.t += dt;
const tex = anim.frames[Math.floor(anim.t * anim.fps) % anim.frames.length];
for (const s of anim.sprites) s.texture = tex;
}
}
private registerAnim(sprite: Sprite, id: number): void {
this.anims.get(id)?.sprites.add(sprite);
this.spriteAnims.set(sprite, id);
}
private unregisterAnim(sprite: Sprite): void {
const id = this.spriteAnims.get(sprite);
if (id === undefined) return;
this.anims.get(id)?.sprites.delete(sprite);
this.spriteAnims.delete(sprite);
}
/** Нарисовать (или перерисовать после setTile) одну ячейку карты. */
private drawCell(tx: number, ty: number): void {
const iso = this.iso;
const id = this.data.tiles[ty * this.data.width + tx];
const key = ty * this.data.width + tx;
const old = this.cells.get(key);
if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); }
if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); }
const fresh: CellSprites = {};
this.cells.set(key, fresh);
const tall = this.tallSpecs.get(id);
const p = isoToScreen(tx, ty, iso);
// Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер).
const groundId = tall?.ground ?? id;
fresh.groundId = groundId;
const groundTex = this.textures.get(groundId);
if (groundTex) {
const s = new Sprite(groundTex);
s.anchor.set(0.5, 0);
s.position.set(p.x, p.y);
this.groundLayer.addChild(s);
fresh.ground = s;
this.registerAnim(s, groundId);
} else {
// Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок).
const g = new Graphics();
const hw = iso.tileW / 2;
const hh = iso.tileH / 2;
g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]);
g.fill(this.blockedSet.has(id) ? 0x4a3b2a : 0x2d5a27);
this.groundLayer.addChild(g);
}
// Высокий объект: спрайт с якорем в центре ромба, иначе колонна.
if (tall) {
fresh.objectId = id;
const objTex = this.textures.get(id);
if (objTex) {
const s = new Sprite(objTex);
s.anchor.set(0.5, 1);
s.position.set(p.x, p.y + iso.tileH / 2);
this.objectsLayer.addChild(s);
fresh.object = s;
this.registerAnim(s, id);
} else {
this.objectsLayer.addChild(this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height));
}
}
}
isWalkable(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false;
if (this.propBlocked.has(y * this.data.width + x)) return false;
const id = this.data.tiles[y * this.data.width + x];
return !this.blockedSet.has(id);
}
/** Тайл накрыт footprint'ом пропа (для LOS/снарядов: дом — препятствие). */
isPropBlocked(x: number, y: number): boolean {
return this.propBlocked.has(y * this.data.width + x);
}
/** Границы карты в мировых юнитах (для ограничения камеры). */
get worldBounds(): { x: number; y: number; width: number; height: number } {
return { x: 0, y: 0, width: this.data.width, height: this.data.height };
}
}