diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 14a9976..0fc4736 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -199,6 +199,9 @@ } // Тайлы деревьев (все виды) — для осыпания пепла с крон. this.crownTiles = TREE_TILES.flatMap((id) => this.layerTilePos.get(id) ?? []); + // Tall-объекты (деревья, дома-тайлы, валуны) — в общий depth-слой: + // герой за деревом/домом действительно прячется (глубина tx+ty, как у героев). + this.map.setTallDepthLayer(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); diff --git a/apps/game/tools/checks/hero_depth.mjs b/apps/game/tools/checks/hero_depth.mjs new file mode 100644 index 0000000..ad20780 --- /dev/null +++ b/apps/game/tools/checks/hero_depth.mjs @@ -0,0 +1,85 @@ +/** + * Сценарий hero_depth — герой за tall-объектами (задача 19): + * деревья/дома-тайлы/валуны живут в одном depth-слое с героем (глубина tx+ty), + * поэтому с юга герой поверх дерева, с севера — дерево поверх героя. + * Дерево ищется по коллизии: blocked-клетка с проходимым севером и югом, + * не покрытая footprint'ом пропов. + * Запуск: node tools/agent.mjs run tools/checks/hero_depth.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function () { + return withChecks( + 'hero_depth', + async (t) => { + const { c } = t; + await c.run('снапшот: ищем дерево (blocked с проходимым севером/югом)', async () => { + await t.boot(); + await t.sleepEnemies(); + // Новая игра стартует в лугах — переход не нужен. + const s = await t.ctx.agent.snapshot(); + const w = s.collision.width; + const blocked = (x, y) => s.collision.blocked[y * w + x] === 1; + const covered = (x, y) => + (s.collision.props ?? []).some( + (p) => x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h + ); + const cand = []; + for (let y = 1; y < s.collision.height - 1; y++) { + for (let x = 1; x < w - 1; x++) { + if (blocked(x, y) && !covered(x, y) && !blocked(x, y - 1) && !blocked(x, y + 1)) { + cand.push({ x, y }); + } + } + } + c.expect(cand.length > 0, 'в лугах не нашлось дерева с проходимым севером/югом'); + t.tree = cand[0]; + return null; + }); + await c.run('глубина: с юга герой поверх, с севера — за деревом', async () => { + const { x, y } = t.tree; + const teleport = async (tx, ty, name) => { + await t.ctx.agent.command('scene:teleport', { x: tx, y: ty }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === tx && s.hero.tile.y === ty, + `телепорт в (${tx},${ty}) не удался`, + s.hero.tile + ); + await t.ctx.agent.step(2, { render: true }); + await t.ctx.page.screenshot({ path: `/tmp/rpg_hero_depth_${name}.png` }); + // Численно: zIndex дерева (tx+ty) против героя — кто «ближе» + // к камере, тот и рисуется позже (sortableChildren). + const z = await t.ctx.page.evaluate(() => { + const scene = window.__game.scenes.current; + return { + heroZ: scene.player.view.zIndex, + // zIndex всех прочих детей actors: среди них — дерево. + treeZs: scene.actors.children + .filter((ch) => ch !== scene.player.view) + .map((ch) => ch.zIndex) + }; + }); + c.expect( + z.treeZs.includes(x + y), + `tall-вьюха дерева (z=${x + y}) не в actors-слое`, + z.treeZs?.slice(0, 12) + ); + return z; + }; + const south = await teleport(x, y + 1, 'south'); // герой перед деревом + const north = await teleport(x, y - 1, 'north'); // герой за деревом + c.expect( + south.heroZ > x + y && north.heroZ < x + y, + 'глубина героя относительно дерева не меняется при телепорте', + { south: south.heroZ, north: north.heroZ, tree: x + y } + ); + return null; + }); + const inv = await t.ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты не чисты', errs); + } + ); +} \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index e2ef208..d8ac27b 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -15,7 +15,7 @@ | SceneManager (replace/push/pop, fade) | меню → локация → сумка/сейвы; переходы локаций | готово | | InputManager (действия, геймпад, мульти-биндинг) | advance/attack/menu/inventory/debug; геймпад-карта | готово | | VirtualJoystick | тач в локациях (включается при первом касании) | готово | -| IsometricTileMap + A* | карты локаций ×2 (внешние области 48–56 тайлов), движение кнопками (мост: scene:walk); tall-объекты: деревья, изометрические дома (5 видов, две стены + крыша), башня, колодцы, валун и куст; пропы с footprint — большой дом 2×2 в Звенце (коллизия и глубина по прямоугольнику, земля под ним) | готово | +| IsometricTileMap + A* | карты локаций ×2 (внешние области 48–56 тайлов), движение кнопками (мост: scene:walk); tall-объекты: деревья, изометрические дома (5 видов, две стены + крыша), башня, колодцы, валун и куст — живут в общем depth-слое с героем (`setTallDepthLayer`): герой за деревом/домом действительно прячется; пропы с footprint — большой дом 2×2 в Звенце (коллизия и глубина по прямоугольнику, земля под ним) | готово | | mapFormat (JSON+RLE) | карты — файлы `assets/maps/*.map`, генерация `npm run maps` | готово | | renderMinimap (чистый рендер карты → RGBA) | предмет «Карта окрестностей» (Ирвин даёт с квестом): оверлей `MapScene` — **карта мира «Низина Звенца»**: композит миниатюр 5 внешних областей (cell = 1) на одном листе, дороги между переходами, подписи областей, маркеры переходов/NPC текущей области, мигающий герой; пропы красятся footprint'ом цветом `colors[prop.id]`; интерьеры и зал Машины не показаны | готово | | Tiled-импорт | не задействован (дока + тест есть) | резерв | diff --git a/docs/engine/render.md b/docs/engine/render.md index 997aec1..861b4eb 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -222,12 +222,14 @@ actors.addRect(houseView, x, y, w, h); // крупный объект: глубина по footprint ``` -Слой карты (`IsometricTileMap.view`) и слой сущностей — соседи: карта рисует -высокие объекты со своей внутренней сортировкой, сущности сортируются отдельно. -Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями — -либо используйте `tall`-объекты карты и держите сущности поверх (см. recipes). -Крупные объекты (`props`, footprint w×h) сортируются через `addRect` — глубина -по прямоугольнику, а не по точке якоря (см. maps.md). Эффекты (искры, кольца) — +Слой карты (`IsometricTileMap.view`) и слой сущностей — соседи. Чтобы tall-объекты +карты (деревья, дома-тайлы, валуны) сортировались вместе с сущностями, отдайте их +в тот же depth-слой: `map.setTallDepthLayer(actors)` — движок переносит tall-вьюхи +клеток в слой с глубиной `tx+ty` (кулинг и `setTile` продолжают работать — видимость +переключается по ссылке, не по родителю). `setTallDepthLayer(null)` возвращает их +в `objectsLayer` карты (порядок по строкам, как раньше). Пропы с footprint +сортируются вручную через `addRect(view, x, y, w, h)` — глубина по прямоугольнику, +а не по точке якоря (см. maps.md). Эффекты (искры, кольца) — через `addFx(view, tx, ty, bias = 0.5)`: глубина тайла с надбавкой, чтобы искры удара были поверх актёра на том же тайле. diff --git a/docs/plan.md b/docs/plan.md index 4a51f02..0b83bec 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -37,7 +37,7 @@ | 16 | Переделка по фидбеку после игры: свет заново («тьма с прорезями»), ветер лугов, квестовая ясность (HUD-трекер, тосты, направления, журнал), баги UI (флип side-анимации, подложка диалога, переносы шрифтов) | ✅ сделано (2026-09-08): L1 920d0b0 (движок: RT-карта силы тьмы, erase-прорези, dirty-bake, day fast-path), L2 93491b7 (солнечный день, `duskColor` золотого часа, тюнинг радиусов/арт), L3 64e5ce8 (ветер: воздушная полоса 250–1800 Гц вместо «прибоя»), L4 64efcf5 (`questTracker` в HUD, тост «Тон (N/3)», направления в квестах/диалогах, журнал активных задач, фикс вталкивания врагов в стены), L5 8490bf9 (флип side-анимации, панель диалога сразу полной высоты, wordWrap имён/кнопок/тостов) | | 17 | Второй раунд фидбека: баг мотов/сейвы/маркер NPC, свет +25% и фонарь, замах+след боя, деревья (варианты+качание+пепел), фасады домов, пепельная трава и читаемые зоны наката, амбиент день/ночь, миникарта (движок + предмет + оверлей) | ✅ сделано (2026-09-08): B1 (моты/автосейв на переходах и закрытии вкладки/маркер), B2 (лампа 2.0/1.0, спрайт фонаря, центр внизу), B3 (сектор замаха + дуга-след, `conePath` в движке), B4 7f14fb0 (3 вида крон × 2 кадра качания, осыпание пепла), B5 7ae42cf (3 фасада домов, явная расстановка в Звенце), B6 1a906e0 (трава сквозь пепел, плотнее летающий пепел, тёплый DARK_SPOT_TINT), B7 19ce175 (ночные лупы областей + ashflow, кроссфейд по `dayNightFactor`, positions-слои зон), B8 ef8d9b7+8516872 (`renderMinimap` в движке, предмет `map_scroll` у Ирвина, оверлей `MapScene`) | | 18 | Третий раунд фидбека: свет в центре персонажа, пепел с заметной травой, изометрические объёмные дома (5 видов), валун и куст, карты внешних локаций ×2, карта мира «Низина Звенца» (композит областей), амбиент лугов заново (тихий «дыхательный» ветер + события) | ✅ сделано (2026-09-08): C1 25d2875 (свет лампы в центре тела героя), C2 6c3e23b (стебли травы в пепле заметнее, ярче, пучками), C3 9883a24 (изо-дома: две стены + двускатная крыша, 5 видов), C4 a8622f5 (валун BOULDER и куст BUSH — новые тайлы), C5 1dada11 (все внешние карты ×2, пересчёт координат контента/тулз), C6 b86b458 (карта мира — композит миниатюр областей с дорогами и подписями), C7 0dacab3 (новая фактура ветра: мягкий низкий шум с порывами, шуршание травы, скрипы и далёкий звон, тише) | -| 19 | Герой за tall-объектами: деревья/дома-тайлы/башня/валуны/кусты сейчас рисуются в `objectsLayer` карты — целиком под слоем `actors`, герой всегда поверх (пропы с footprint уже сортируются правильно через `actors.addRect`). Решение по образцу пропов: движок отдаёт `tallViews` (как `propViews`), сцена вставляет их в тот же `IsoDepthLayer` с глубиной `x+y`; перерисовка `setTile` на tall-клетке (зелёные цветы) должна убирать/вставлять вьюху обратно | 📋 записано (2026-09-09) | +| 19 | Герой за tall-объектами: деревья/дома-тайлы/башня/валуны/кусты рисуются в `objectsLayer` карты — целиком под слоем `actors`, герой всегда поверх (пропы с footprint уже сортируются правильно через `actors.addRect`) | ✅ сделано (2026-09-09): движок — `IsometricTileMap.setTallDepthLayer(layer\|null)` переносит tall-вьюхи клеток в общий `IsoDepthLayer` с глубиной `tx+ty` (кулинг/`setTile` работают по ссылке, `null` возвращает в карту); игра — `LocationScene` вызывает `setTallDepthLayer(this.actors)` + `actors.addRect` для пропов; тесты движка (2 новых), сценарий `hero_depth.mjs` (численные zIndex-assert + скриншоты) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 93a37c0..a3aca30 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -3,6 +3,7 @@ import { StaticGrid } from './collision'; import { IsoLayout, DEFAULT_ISO, isoToScreen, screenToIso } from '../math/iso'; import { hashTile } from '../math/rng'; +import { IsoDepthLayer } from '../render/IsoDepthLayer'; /** * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или, @@ -126,6 +127,8 @@ readonly props: PropPlaced[]; /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */ readonly propViews: PropView[] = []; + /** Depth-слой для tall-объектов (null — рисовать в objectsLayer, как раньше). */ + private tallDepthLayer: IsoDepthLayer | null = null; private groundLayer: Container; private decorLayer: Container; private objectsLayer: Container; @@ -289,6 +292,31 @@ } /** + * Отдавать tall-спрайты в depth-слой актёров (герой за деревом/домом + * прячется): глубина tx+ty, как у героев. null — вернуть в objectsLayer + * (порядок отрисовки по строкам, как до вызова). Куллинг не ломается: + * видимость переключается по ссылке из cells независимо от родителя. + */ + setTallDepthLayer(layer: IsoDepthLayer | null): void { + this.tallDepthLayer = layer; + for (const [key, cell] of this.cells) { + const tx = key % this.data.width; + const ty = Math.floor(key / this.data.width); + this.placeTallView(cell, tx, ty); + } + } + + /** Положить tall-вьюху клетки в назначенный слой (или обратно в карту). */ + private placeTallView(cell: CellSprites, tx: number, ty: number): void { + const target: Container | null = this.tallDepthLayer ?? this.objectsLayer; + const view = cell.object ?? cell.objectPlaceholder; + if (target && view && view.parent !== target) { + if (this.tallDepthLayer) this.tallDepthLayer.add(view, tx, ty); + else target.addChild(view); + } + } + + /** * Анимированный тайл: клетки с этим id переключают кадры по одному общему * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...). @@ -391,14 +419,13 @@ 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, objId); } else { - const ph = this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height); - this.objectsLayer.addChild(ph); - fresh.objectPlaceholder = ph; + fresh.objectPlaceholder = this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height); } + // Слой: depth-слой актёров (если назначен) или objectsLayer карты. + this.placeTallView(fresh, tx, ty); } } diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts index c325a6a..089d689 100644 --- a/packages/engine/src/map/__tests__/isometricMap.test.ts +++ b/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { Texture } from 'pixi.js'; +import { Container, Texture } from 'pixi.js'; import { IsometricTileMap, type TileMapData } from '../IsometricTileMap'; +import { IsoDepthLayer } from '../../render/IsoDepthLayer'; import { isoToScreen } from '../../math/iso'; function makeData(): TileMapData { @@ -37,6 +38,47 @@ }); }); +describe('IsometricTileMap.setTallDepthLayer', () => { + it('tall-вьюхи живут в depth-слое с глубиной tx+ty — герой за деревом прячется', () => { + const map = new IsometricTileMap(makeData(), new Map()); + const layer = new IsoDepthLayer(); + const hero = new Container(); + layer.add(hero, 1, 4); // юг дерева: глубина 5 + map.setTallDepthLayer(layer); + + map.setTile(1, 3, 3); // tall-объект: вьюха сразу в слое, глубина 4 + const tree = layer.children.find((c) => c !== hero); + expect(tree?.parent).toBe(layer); + expect(tree?.zIndex).toBe(1 + 3); + + layer.sortableChildren = true; // север дерева (глубина 3) — дерево поверх героя + hero.zIndex = 1 + 2; + const orderBehind = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); + expect(orderBehind.indexOf(hero)).toBeLessThan(orderBehind.indexOf(tree!)); + + hero.zIndex = 1 + 4; // юг (глубина 5) — герой поверх дерева + const orderFront = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex); + expect(orderFront.indexOf(tree!)).toBeLessThan(orderFront.indexOf(hero)); + }); + + it('setTile убирает вьюху из слоя, setTallDepthLayer(null) возвращает в карту', () => { + const map = new IsometricTileMap(makeData(), new Map()); + const layer = new IsoDepthLayer(); + map.setTallDepthLayer(layer); + map.setTile(2, 2, 3); + expect(layer.children.length).toBe(1); + + map.setTile(2, 2, 0); // tall больше нет — вьюха уничтожена + expect(layer.children.length).toBe(0); + + map.setTile(2, 2, 3); + map.setTallDepthLayer(null); // назад в objectsLayer карты + const objectsLayer = map.view.children.at(-1)!; + expect(objectsLayer.children.length).toBe(1); + expect(layer.children.length).toBe(0); + }); +}); + describe('IsometricTileMap.variants', () => { it('вариант выбирается по хэшу координат и стабилен при setTile', () => { const map = new IsometricTileMap(makeDataWithVariants(), new Map());