diff --git a/apps/game/assets/tiles/house_big.png b/apps/game/assets/tiles/house_big.png new file mode 100644 index 0000000..82b59ba --- /dev/null +++ b/apps/game/assets/tiles/house_big.png Binary files differ diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index c011949..8c50896 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -111,6 +111,8 @@ const data = this.game.mapFiles.get(area.id)!; this.map = new IsometricTileMap(data, this.tileTextures()); this.world.addChild(this.map.view, this.actors); + // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. + for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); this.game.renderer.worldRoot.addChild(this.world); const startTile = entry ?? save?.pos ?? area.spawn; diff --git a/apps/game/tools/aiart/probe-c2.png b/apps/game/tools/aiart/probe-c2.png new file mode 100644 index 0000000..fce1a3d --- /dev/null +++ b/apps/game/tools/aiart/probe-c2.png Binary files differ diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs index 614f2cc..bb964e9 100644 --- a/apps/game/tools/pixelart/gen.mjs +++ b/apps/game/tools/pixelart/gen.mjs @@ -247,6 +247,57 @@ return c; } +/** + * Большой дом 64x80 (проба работы C): двухмодульный дом на footprint 2×2, + * композит из повторяемых частей — стены-щиты + стойки, крыша из шпал, + * фундамент из рельсов. Якорь — низ-центр footprint'а (ставит движок, prop). + */ +function bigHouseTile() { + const c = new Canvas(64, 80); + const r = rng(4242); + const planks = (x0, x1, y0, y1) => { + for (let y = y0; y <= y1; y++) { + const seam = (y - y0) % 6 === 0; + for (let x = x0; x <= x1; x++) c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + }; + const windowAt = (x0, y0) => { + for (let y = y0; y <= y0 + 7; y++) for (let x = x0; x <= x0 + 6; x++) c.set(x, y, 'P2'); + for (let x = x0 - 1; x <= x0 + 7; x++) { c.set(x, y0 - 1, 'M0'); c.set(x, y0 + 8, 'M0'); } + for (let y = y0 - 1; y <= y0 + 8; y++) { c.set(x0 - 1, y, 'M0'); c.set(x0 + 7, y, 'M0'); } + for (let x = x0; x <= x0 + 6; x++) c.set(x, y0 + 3, 'M1'); // переплёт + c.set(x0 + 3, y0, 'P5'); // пепел на раме + }; + + // Стены: два модуля щитов, разделены и обрамлены стойками. + planks(5, 58, 31, 74); + for (const x of [5, 31, 32, 58]) for (let y = 30; y <= 74; y++) c.set(x, y, 'M1'); + for (let x = 4; x <= 59; x++) c.set(x, 30, 'M0'); // верхний венец + + // Крыша: треугольник шпал, расширяется книзу, свес по краям. + for (let i = 0; i <= 27; i++) { + const y = 2 + i; + const half = Math.min(28, 2 + Math.floor(i * 0.95)); + for (let dx = -half; dx <= half; dx++) c.set(32 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); + } + for (let x = 2; x <= 61; x++) c.set(x, 29, 'T0'); + + // Дверь правого модуля + козырёк. + for (let y = 56; y <= 74; y++) for (let x = 44; x <= 52; x++) c.set(x, y, 'P1'); + c.set(50, 65, 'B0'); + for (let i = 0; i <= 5; i++) for (let dx = -i; dx <= i + 7; dx++) c.set(48 + dx, 50 + i, i === 5 ? 'T0' : 'T1'); + + // Окна: по одному на модуль. + windowAt(10, 38); + windowAt(37, 38); + + // Фундамент из рельсов, пепел у стен. + for (let x = 3; x <= 60; x++) { c.set(x, 75, 'R1'); c.set(x, 76, 'R0'); } + c.set(2, 77, 'P5'); c.set(61, 77, 'P5'); c.set(8, 78, 'P4'); c.set(55, 78, 'P4'); + + return c; +} + /** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ /** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ function floorTile() { @@ -601,6 +652,7 @@ save(bellflowerTile(), 'tiles/bellflower.png'); save(treeTile(), 'tiles/tree.png'); save(houseTile(), 'tiles/house.png'); +save(bigHouseTile(), 'tiles/house_big.png'); save(towerTile(), 'tiles/tower.png'); save(floorTile(), 'tiles/floor.png'); save(wallTile(), 'tiles/wall.png'); diff --git a/docs/art-style.md b/docs/art-style.md index 37d2db4..670044f 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -81,6 +81,13 @@ - Персонаж: **16×24**, якорь — центр между ступней (низ спрайта = центр ромба тайла). - Дерево (высокий объект): **32×48**, якорь — центр ствола у земли. - Пропсы (бочки, тележка Милы, рельсы): 16×16 или 16×24, тоже якорь у земли. +- Крупные объекты (`props`, footprint w×h): спрайт покрывает весь footprint, якорь — + низ-центр footprint'а (движок ставит сам). Слоты: + - дом 2×2: **64×80** (footprint 64×32 + стены/крыша); + - дом 3×3: **96×96**; + - большое здание 4×4: **128×112**. + Плотность пикселя — та же, что у тайлов и персонажей (1 px спрайта = 1 px мира): + апскейл-болванки из AI-генерации в эти слоты не попадают — только как плейсхолдеры. - Шрифт интерфейса: системный monospace до появления пиксельного шрифта (8px в виртуальном разрешении). ## 3. Стиль @@ -108,6 +115,7 @@ | 5 | `tiles/bellflower.png` | трава + цветок B1/B2 (квестовый тайл) | | 6 | `tiles/tower.png` | башня Звенца — 32×80, tall-объект 2.5 юнита: бак на ногах-рельсах, колокол B0/B2 под баком | | 7 | `tiles/house.png` | дом Звенца — 32×56, tall-объект 1.75 юнита: стены из вагонных щитов, крыша из шпал | +| — | `tiles/house_big.png` | большой дом — 64×80, prop-объект footprint 2×2: два модуля щитов, козырёк над дверью, окна с переплётом | ### Персонажи (16×24) diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 782f99e..7b0a768 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -54,6 +54,31 @@ map.setTile(x, y, id); // изменить тайл (сбор предметов, посадка) — данные + перерисовка ячейки ``` +## Крупные объекты: props (footprint w×h) + +Зданиям и крупным деревьям одного тайла мало. `props` — объекты с footprint'ом +от северо-западного угла (x, y): коллизия — весь прямоугольник, земля под ним — +`ground` (если задан), спрайт якорится низом в центр footprint'а. Спрайт карта +не рисует сама, а отдаёт в `map.propViews` — сцена вставляет его в свой +`IsoDepthLayer` через `addRect`, глубина считается по прямоугольнику +(диагональ юго-восточного угла), и герои корректно перекрываются стенами: + +```ts +const data: TileMapData = { + // ... + props: [{ id: TILE_HOUSE, x: 5, y: 4, w: 3, h: 3, ground: TILE_FLOOR }] +}; +const map = new IsometricTileMap(data, textures); +for (const p of map.propViews) { + depthLayer.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); +} +map.isWalkable(6, 5); // false — внутри footprint +``` + +В файловом формате (`encodeMap`/`parseMap`) пропы валидируются: w/h целые ≥ 1, +footprint целиком внутри карты, высота (если задана) — в мировых юнитах. +Без текстуры проп рисуется колонной-плейсхолдером (как `tall`). + ## A* (pathfinding) ```ts diff --git a/docs/engine/render.md b/docs/engine/render.md index 3e59135..35e3a34 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -91,12 +91,16 @@ actors.add(heroView, tileX, tileY); // добавить и выставить глубину actors.setDepth(heroView, tileX, tileY); // обновить при движении (по смене тайла) + +actors.addRect(houseView, x, y, w, h); // крупный объект: глубина по footprint ``` Слой карты (`IsometricTileMap.view`) и слой сущностей — соседи: карта рисует высокие объекты со своей внутренней сортировкой, сущности сортируются отдельно. Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями — либо используйте `tall`-объекты карты и держите сущности поверх (см. recipes). +Крупные объекты (`props`, footprint w×h) сортируются через `addRect` — глубина +по прямоугольнику, а не по точке якоря (см. maps.md). ## Particles diff --git a/docs/plan.md b/docs/plan.md index 162aae6..e8ec369 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -17,6 +17,7 @@ | 3 | AI-генерация аудио (работа A, ниже) | 📋 спланировано, отложено | | 4 | Акт 2: новые локации/сюжет по `docs/world.md` | очередь после акта 1+AI-ассетов | | 5 | Замена сгенерированных плейсхолдеров арта на AI/ручной арт | в составе работы B | +| 6 | Крупные объекты: footprint в движке, слоты арта (работа C, ниже) | в работе | Приоритет между работами A и B: **B (изображения) выше** — визуальный стиль блокирует восприятие акта 2; аудио сейчас закрывается процедурным генератором @@ -112,6 +113,44 @@ --- +## Работа C — крупные объекты (footprint + большие спрайты) + +Акт 2 захочет здания и крупные деревья — объекты больше 1×1 тайла. Сегодня +движок умеет только `TallSpec` (1 тайл footprint + произвольная высота) и +ручной `blocked`. Большой спрайт, якоренный в один тайл, сортируется глубиной +по точке — герой сбоку от дома перекрывается неверно. + +### Что нужно + +1. **Движок: footprint w×h** — коллизия по всему прямоугольнику, земля под ним, + спрайт якорем низ-центр footprint'а; глубина по прямоугольнику + (`(x+w-1)+(y+h-1)`, `IsoDepthLayer.addRect`), а не по точке якоря. +2. **Арт-библия: слоты крупных спрайтов** — фиксированные размеры с якорями + (дом 3×3, большое здание 4×4…), чтобы любой источник рисовал в готовый слот. +3. **Арт: три пути** — крупный объект кодом в `gen.mjs` (композит: модули стен + + крыша — наш бесплатный путь); апскейл nearest из 24×24 (только болванка — + чужая плотность пикселя рядом со спрайтами 1×); RD API (умеет большие + размеры и тайлы, платный). + +### Этапы + +- **C0 (движок) ✅**: `props` в `TileMapData` (footprint, коллизия по всему + прямоугольнику, земля под ним), `IsoDepthLayer.addRect` (глубина по + диагонали юго-восточного угла footprint'а), валидация в `mapFormat`, + тесты; `LocationScene` вставляет `map.propViews` в общий depth-слой. +- **C1 (арт-библия) ✅**: слоты крупных спрайтов в `docs/art-style.md` + (дом 2×2 — 64×80, дом 3×3 — 96×96, здание 4×4 — 128×112; якорь ставит + движок). Плотность пикселя — как у тайлов; AI-апскейл в слоты не попадает. +- **C2 (проба) ✅**: большой дом 2×2 кодом в `gen.mjs` + (`tiles/house_big.png`, 64×80, композит: модули щитов + крыша шпал + + козырёк + окна) против апскейл-болванок 24→48 из B1 + (`apps/game/tools/aiart/probe-c2.png`). Вывод: **для крупных объектов — + код-композит в `gen.mjs`**; апскейл даёт мыло с чужой плотностью пикселя и + залитым фоном (только черновая болванка), нативно больших размеров локальные + модели не умеют. RD API остаётся опцией, если код-композита не хватит. + +--- + ## Работа A — AI-генерация аудио ### Классы потребностей (по пользовательскому сценарию) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 86c9e2e..43e3074 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -87,7 +87,14 @@ } from './math/shapes'; // map -export { IsometricTileMap, type TileMapData, type TallSpec } from './map/IsometricTileMap'; +export { + IsometricTileMap, + type TileMapData, + type TallSpec, + type PropData, + type PropPlaced, + type PropView +} from './map/IsometricTileMap'; export { findPath, findPathToNeighbor, type Grid } from './map/pathfinding'; export { encodeMap, diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index f46117e..9d6e70b 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -13,6 +13,40 @@ } /** + * Крупный объект на несколько тайлов (дом, большое дерево): 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; тайлы «высоких» объектов @@ -27,6 +61,8 @@ blocked: number[]; /** Тайлы с этими id рисуются как вертикальные объекты. */ tall?: Record; + /** Крупные объекты с footprint'ом в несколько тайлов. */ + props?: PropData[]; } /** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */ @@ -43,6 +79,11 @@ private textures: Map; private blockedSet: Set; private tallSpecs: Map; + /** Пропы после нормализации; их footprint блокирует клетки карты. */ + readonly props: PropPlaced[]; + /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */ + readonly propViews: PropView[] = []; + private propBlocked = new Set(); private groundLayer: Container; private objectsLayer: Container; private cells = new Map(); @@ -86,6 +127,71 @@ 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; } /** @@ -141,23 +247,14 @@ this.objectsLayer.addChild(s); fresh.object = s; } else { - const block = new Graphics(); - const h = tall.height * iso.tileW; - const hw = iso.tileW / 2; - const hh = iso.tileH / 2; - block.poly([p.x, p.y - h, p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x - hw, p.y + hh - h]); - block.fill(0x6b5a44); - block.poly([p.x - hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); - block.fill(0x4a3b2a); - block.poly([p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x + hw, p.y + hh]); - block.fill(0x352a1e); - this.objectsLayer.addChild(block); + 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); } diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts index d90fd48..9b6a68b 100644 --- a/packages/engine/src/map/__tests__/isometricMap.test.ts +++ b/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -33,4 +33,40 @@ // данные не изменились expect(map.data.tiles.every((t) => t === 0)).toBe(true); }); +}); + +describe('IsometricTileMap.props', () => { + it('блокирует весь footprint и нормализует размеры', () => { + const data = makeData(); + data.props = [{ id: 7, x: 1, y: 1, w: 2, h: 2 }]; + const map = new IsometricTileMap(data, new Map()); + + expect(map.props).toEqual([{ id: 7, x: 1, y: 1, w: 2, h: 2 }]); + // весь footprint 2×2 непроходим, вокруг — проходим + expect(map.isWalkable(1, 1)).toBe(false); + expect(map.isWalkable(2, 2)).toBe(false); + expect(map.isWalkable(0, 1)).toBe(true); + expect(map.isWalkable(1, 3)).toBe(true); + // спрайт пропа отдан сцене для depth-слоя + expect(map.propViews).toHaveLength(1); + expect(map.propViews[0]!.prop).toBe(map.props[0]); + }); + + it('footprint по умолчанию 1×1', () => { + const data = makeData(); + data.props = [{ id: 7, x: 2, y: 0 }]; + const map = new IsometricTileMap(data, new Map()); + expect(map.props[0]!.w).toBe(1); + expect(map.props[0]!.h).toBe(1); + expect(map.isWalkable(2, 0)).toBe(false); + expect(map.isWalkable(3, 0)).toBe(true); + }); + + it('setTile внутри footprint не снимает блокировку', () => { + const data = makeData(); + data.props = [{ id: 7, x: 1, y: 1 }]; + const map = new IsometricTileMap(data, new Map()); + map.setTile(1, 1, 0); + expect(map.isWalkable(1, 1)).toBe(false); + }); }); \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/mapFormat.test.ts b/packages/engine/src/map/__tests__/mapFormat.test.ts index eda5a54..8dbfcb1 100644 --- a/packages/engine/src/map/__tests__/mapFormat.test.ts +++ b/packages/engine/src/map/__tests__/mapFormat.test.ts @@ -51,6 +51,33 @@ expect(JSON.stringify(file.tiles).length).toBeLessThan(20); }); + it('roundtrip сохраняет пропы с footprint', () => { + const withProps: TileMapData = { + ...sample, + props: [{ id: 7, x: 0, y: 0, w: 2, h: 2, ground: 1, height: 3 }] + }; + const parsed = parseMap(JSON.parse(JSON.stringify(encodeMap(withProps)))); + expect(parsed.props).toEqual(withProps.props); + }); + + it('parseMap отвергает проп за границей карты', () => { + // карта 3×2, footprint 2×2 от (2, 1) вылезает вправо и вниз + const file = encodeMap({ ...sample, props: [{ id: 7, x: 2, y: 1, w: 2, h: 2 }] }); + expect(() => parseMap(file)).toThrow(/границ/); + }); + + it('parseMap отвергает дробный/нулевой footprint', () => { + const bad = (props: unknown) => + parseMap({ ...encodeMap(sample), props }); + expect(() => bad([{ id: 7, x: 0, y: 0, w: 1.5, h: 1 }])).toThrow(/w\/h/); + expect(() => bad([{ id: 7, x: 0, y: 0, w: 0, h: 1 }])).toThrow(/w\/h/); + }); + + it('parseMap отвергает высоту пропа в пикселях', () => { + const file = encodeMap({ ...sample, props: [{ id: 7, x: 0, y: 0, height: 96 }] }); + expect(() => parseMap(file)).toThrow(/пикселях/); + }); + it('parseMap отвергает не-объект', () => { expect(() => parseMap('nope')).toThrow(); expect(() => parseMap(null)).toThrow(); diff --git a/packages/engine/src/map/mapFormat.ts b/packages/engine/src/map/mapFormat.ts index 4694729..f52a03e 100644 --- a/packages/engine/src/map/mapFormat.ts +++ b/packages/engine/src/map/mapFormat.ts @@ -1,4 +1,4 @@ -import type { TileMapData, TallSpec } from './IsometricTileMap'; +import type { TileMapData, TallSpec, PropData } from './IsometricTileMap'; /** * Файловый формат карт движка: JSON с RLE-сжатием тайлов. @@ -15,6 +15,8 @@ blocked: number[]; /** Высокие объекты: id -> высота или полная спецификация. */ tall?: Record; + /** Крупные объекты с footprint'ом в несколько тайлов. */ + props?: PropData[]; /** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */ encoding: 'rle' | 'raw'; tiles: number[] | [number, number][]; @@ -54,6 +56,7 @@ height: data.height, blocked: data.blocked, tall: data.tall, + props: data.props, encoding, tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles }; @@ -95,11 +98,32 @@ throw new Error(`Карта: tall.height=${h} — похоже, высота задана в пикселях (ожидается мировые юниты)`); } } + validateProps(f.props ?? [], f.width, f.height); return { width: f.width, height: f.height, tiles, blocked: f.blocked ?? [], - tall: f.tall + tall: f.tall, + props: f.props }; +} + +/** Валидация пропов: целые w/h ≥ 1 и footprint целиком внутри карты. */ +function validateProps(props: PropData[], width: number, height: number): void { + for (const p of props) { + const w = p.w ?? 1; + const h = p.h ?? 1; + if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1) { + throw new Error(`Карта: prop (${p.x}, ${p.y}) — w/h должны быть целыми ≥ 1, получено ${w}×${h}`); + } + if (p.x < 0 || p.y < 0 || p.x + w > width || p.y + h > height) { + throw new Error( + `Карта: prop (${p.x}, ${p.y}) ${w}×${h} выходит за границы карты ${width}×${height}` + ); + } + if (p.height !== undefined && p.height > 16) { + throw new Error(`Карта: prop.height=${p.height} — похоже, высота задана в пикселях (ожидается мировые юниты)`); + } + } } \ No newline at end of file diff --git a/packages/engine/src/render/IsoDepthLayer.ts b/packages/engine/src/render/IsoDepthLayer.ts index 0fac28f..60a018d 100644 --- a/packages/engine/src/render/IsoDepthLayer.ts +++ b/packages/engine/src/render/IsoDepthLayer.ts @@ -25,4 +25,20 @@ setDepth(view: Container, tx: number, ty: number): void { view.zIndex = tx + ty; } + + /** Добавить крупный объект (проп) с footprint w×h от северо-западного угла. */ + addRect(view: Container, x: number, y: number, w: number, h: number): Container { + this.setDepthRect(view, x, y, w, h); + this.addChild(view); + return view; + } + + /** + * Глубина крупного объекта по footprint'у: диагональ юго-восточного + * (ближнего к камере) угла. Актёр перед южным краем дома рисуется поверх, + * за северным — под ним. + */ + setDepthRect(view: Container, x: number, y: number, w: number, h: number): void { + view.zIndex = x + w - 1 + (y + h - 1); + } } \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts b/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts new file mode 100644 index 0000000..7f025e2 --- /dev/null +++ b/packages/engine/src/render/__tests__/IsoDepthLayer.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { Container } from 'pixi.js'; +import { IsoDepthLayer } from '../IsoDepthLayer'; + +describe('IsoDepthLayer', () => { + it('глубина точки = диагональ тайла', () => { + const layer = new IsoDepthLayer(); + const v = new Container(); + layer.add(v, 3, 4); + expect(v.zIndex).toBe(7); + layer.setDepth(v, 1, 1); + expect(v.zIndex).toBe(2); + }); + + it('глубина footprint = диагональ юго-восточного угла', () => { + const layer = new IsoDepthLayer(); + const house = new Container(); + layer.addRect(house, 2, 2, 3, 3); // юго-восточный угол (4, 4) + expect(house.zIndex).toBe(8); + + const north = new Container(); + layer.addRect(north, 2, 0, 1, 1); // герой к северу — под домом + const southwest = new Container(); + layer.addRect(southwest, 2, 5, 1, 1); // у южной стены — стена перекрывает + const southeast = new Container(); + layer.addRect(southeast, 4, 6, 1, 1); // перед южным углом — поверх дома + expect(north.zIndex).toBeLessThan(house.zIndex); + expect(southwest.zIndex).toBeLessThan(house.zIndex); + expect(southeast.zIndex).toBeGreaterThan(house.zIndex); + }); +}); \ No newline at end of file