diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index e650f49..cecebe1 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -616,6 +616,13 @@ const p = this.player.position; if (snap) this.camera.snap(p.x, p.y); else this.camera.follow(p.x, p.y); + this.cullMap(); + } + + /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира). */ + private cullMap(): void { + const wp = this.game.renderer.worldRoot.position; + this.map.cullToRect({ x: -wp.x, y: -wp.y, width: Game.VIRTUAL_W, height: Game.VIRTUAL_H }); } /** Экранные координаты центра ромба тайла (с Origin карты). */ diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index f78ca43..34f28ca 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -115,7 +115,8 @@ /** Бенчмарк производительности (не входит в check — замер, а не гейт). */ async perf() { - return runScenario('perf', 'apps/game/tools/checks/perf.mjs'); + await runScenario('perf', 'apps/game/tools/checks/perf.mjs'); + return 0; }, /** Снимок снапшота в файл/stdout. */ diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 85981ad..0049b4b 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -58,6 +58,27 @@ map.update(dt); // тик тайл-анимаций (no-op, если их нет) — из update сцены ``` +### Culling тайлов (видимое окно камеры) + +Спрайты ячеек создаются один раз и живут всё время — на большой карте их в разы +больше видимых. `cullToRect` прячет всё, что не пересекает прямоугольник +(в px слоя view: экранный канвас минус позиция `worldRoot`), переключая +`visible` ячеек и декора: + +```ts +// из update сцены, после сдвига камеры: +const wp = renderer.worldRoot.position; +map.cullToRect({ x: -wp.x, y: -wp.y, width: VIRTUAL_W, height: VIRTUAL_H }); +map.cullToRect(null); // вернуть всё видимым (переход/смена карты) +``` + +Пересчёт — только при сдвиге тайлового окна: пока камера движется в пределах +тех же тайлов, спрайты не трогаются. Запас по краям: ±1 тайл по X (спрайты шире +точки якоря) и вверх — на высоту самого высокого `tall`-объекта (растёт вверх +от якоря, поэтому южные ряды могут нависать над окном). `isTileVisible(x, y)` — +проверка текущего окна (тесты/отладка). Спрайты в `IsoDepthLayer` (герои, NPC) +не прячутся — их обычно единицы. + ### Варианты текстур (микс) Одна текстура на все клетки травы выглядит однородно. `variants` в `TileMapData` diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 0776ae4..ff38833 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -1,7 +1,7 @@ import { Container, Graphics, Texture, Sprite } from 'pixi.js'; import { Grid } from './pathfinding'; import { StaticGrid } from './collision'; -import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso'; +import { IsoLayout, DEFAULT_ISO, isoToScreen, screenToIso } from '../math/iso'; import { hashTile } from '../math/rng'; /** @@ -136,6 +136,8 @@ private spriteAnims = new Map(); /** Варианты текстур по id (из данных или setTileVariants). */ private variants = new Map(); + /** Окно куллинга (тайлы); null — без куллинга (всё видно). */ + private cullRange: { x0: number; y0: number; x1: number; y1: number } | null = null; get width(): number { return this.data.width; @@ -405,6 +407,71 @@ return ids ? ids[hashTile(tx, ty, id) % ids.length]! : id; } + /** + * Показать только тайлы, чьи спрайты пересекают прямоугольник в px слоя + * view (мировые px: экранный канвас минус position worldRoot); null — + * вернуть всё видимым. Спрайты переключаются только при смене тайлового + * окна; запас по краям — на tall-объекты (растут вверх от якоря). + */ + cullToRect(rect: { x: number; y: number; width: number; height: number } | null): void { + if (!rect) { + if (this.cullRange !== null) this.applyCull(null); + return; + } + const { x, y } = screenToIso(rect.x, rect.y, this.iso); + const { x: x1, y: y1 } = screenToIso(rect.x + rect.width, rect.y, this.iso); + const { x: x2, y: y2 } = screenToIso(rect.x, rect.y + rect.height, this.iso); + const { x: x3, y: y3 } = screenToIso(rect.x + rect.width, rect.y + rect.height, this.iso); + const margin = this.tallMarginRows(); + const range = { + x0: Math.min(x, x1, x2, x3) - 1, // спрайты шире точки якоря + x1: Math.max(x, x1, x2, x3) + 1, + y0: Math.min(y, y1, y2, y3) - margin, // tall растёт вверх по строкам + y1: Math.max(y, y1, y2, y3) + 1 + }; + const last = this.cullRange; + if (last && last.x0 === range.x0 && last.x1 === range.x1 && last.y0 === range.y0 && last.y1 === range.y1) { + return; // окно в тайлах не сдвинулось — спрайты не трогаем + } + this.applyCull(range); + } + + /** Видим ли тайл при текущем окне куллинга (для тестов и отладки). */ + isTileVisible(tx: number, ty: number): boolean { + const r = this.cullRange; + if (!r) return true; + return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; + } + + /** Запас строк сверху: высота самого высокого tall-спрайта, переведённая в тайлы. */ + private tallMarginRows(): number { + let maxPx = 0; + for (const spec of this.tallSpecs.values()) { + maxPx = Math.max(maxPx, spec.height * this.iso.tileW); + } + return Math.ceil(maxPx / this.iso.tileH) + 1; + } + + /** Переключить видимость всех спрайтов ячеек/декора под окном. */ + private applyCull(range: { x0: number; y0: number; x1: number; y1: number } | null): void { + this.cullRange = range; + const w = this.data.width; + for (const [key, cell] of this.cells) { + const visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); + if (cell.ground) cell.ground.visible = visible; + if (cell.object) cell.object.visible = visible; + if (cell.groundPlaceholder) cell.groundPlaceholder.visible = visible; + if (cell.objectPlaceholder) cell.objectPlaceholder.visible = visible; + } + for (const [key, s] of this.decorSprites) { + s.visible = range === null || this.tileInRange(key % w, Math.floor(key / w), range); + } + } + + private tileInRange(tx: number, ty: number, r: { x0: number; y0: number; x1: number; y1: number }): boolean { + return tx >= r.x0 && tx <= r.x1 && ty >= r.y0 && ty <= r.y1; + } + isWalkable(x: number, y: number): boolean { return this.grid.isWalkable(x, y); } diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts index 4fbadbe..6405452 100644 --- a/packages/engine/src/map/__tests__/isometricMap.test.ts +++ b/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -136,6 +136,31 @@ }); }); +describe('IsometricTileMap.cullToRect', () => { + it('прячет тайлы вне прямоугольника видимости, null возвращает всё', () => { + const data = makeData(); + delete data.tall; // без tall-объектов минимальный запас по краям + const map = new IsometricTileMap(data, new Map()); + expect(map.isTileVisible(0, 0)).toBe(true); // до куллинга видно всё + + // окно вокруг центра тайла (3,3) размером в полтайла + const c = isoToScreen(3, 3, map.iso); + map.cullToRect({ + x: c.x - map.iso.tileW / 4, + y: c.y - map.iso.tileH / 4, + width: map.iso.tileW / 2, + height: map.iso.tileH / 2 + }); + expect(map.isTileVisible(3, 3)).toBe(true); + expect(map.isTileVisible(2, 2)).toBe(true); // сосед — внутри запаса + expect(map.isTileVisible(0, 0)).toBe(false); // дальний угол спрятан + expect(map.isTileVisible(3, 0)).toBe(false); + + map.cullToRect(null); // сброс — снова видно всё + expect(map.isTileVisible(0, 0)).toBe(true); + }); +}); + describe('IsometricTileMap.props', () => { it('блокирует весь footprint и нормализует размеры', () => { const data = makeData(); diff --git a/packages/engine/src/render/IsoDepthLayer.ts b/packages/engine/src/render/IsoDepthLayer.ts index 5ed363a..a82c6a6 100644 --- a/packages/engine/src/render/IsoDepthLayer.ts +++ b/packages/engine/src/render/IsoDepthLayer.ts @@ -21,9 +21,10 @@ return view; } - /** Обновить глубину объекта по тайловым координатам. */ + /** Обновить глубину объекта по тайловым координатам (без смены — сортировку не будим). */ setDepth(view: Container, tx: number, ty: number): void { - view.zIndex = tx + ty; + const z = tx + ty; + if (view.zIndex !== z) view.zIndex = z; } /** Добавить крупный объект (проп) с footprint w×h от северо-западного угла. */ @@ -39,7 +40,8 @@ * за северным — под ним. */ setDepthRect(view: Container, x: number, y: number, w: number, h: number): void { - view.zIndex = x + w - 1 + (y + h - 1); + const z = x + w - 1 + (y + h - 1); + if (view.zIndex !== z) view.zIndex = z; } /** diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index c8bd627..3c25e17 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -231,6 +231,7 @@ setAmbient(color: number, fadeSec = 0): void { this.ambientFade.to(color, Math.max(0, fadeSec)); this.ambient.tint = this.ambientFade.value; + this.ambient.visible = this.ambientFade.value !== 0xffffff; // белый — no-op } /** Целевой (заданный) ambient-цвет. */ @@ -336,6 +337,7 @@ this.pulseAmb.tint = args.color; this.ambPulse = { ...args, elapsed: 0 }; this.pulseAmb.alpha = 0; + this.pulseAmb.visible = true; // оживёт в ближайшем update } /** Текущая альфа ambient-вспышки (0..1) — для тестов и снапшота. */ @@ -386,8 +388,10 @@ this.time += dt; this.ambientFade.update(dt); this.ambient.tint = this.ambientFade.value; + this.ambient.visible = this.ambientFade.value !== 0xffffff; // белый multiply — пустой квад this.vignetteFade.update(dt); this.vignette.alpha = this.vignetteFade.value; + this.vignette.visible = this.vignetteFade.value > 0; for (const [id, p] of this.pulses) { p.elapsed += dt; if (p.elapsed >= pulseTotal(p.spec)) { @@ -403,9 +407,11 @@ if (this.ambPulse.elapsed >= pulseTotal(this.ambPulse.spec)) { this.ambPulse = null; this.pulseAmb.alpha = 0; + this.pulseAmb.visible = false; } else { const e = pulseEnvelope(this.ambPulse.elapsed, this.ambPulse.spec); this.pulseAmb.alpha = Math.min(1, this.ambPulse.peak * e); + this.pulseAmb.visible = this.pulseAmb.alpha > 0; } } }