diff --git a/CLAUDE.md b/CLAUDE.md index 64ead1a..f19dc45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ 1. **PixiJS — только рендер-бэкенд.** Игровой цикл, сцены, ввод, камера, изометрия — собственные (`packages/engine/src/core`, `scene`, `input`, `render`). Pixi используется как низкоуровневый слой отрисовки спрайтов. 2. **Пиксель-арт pixel-perfect, резкий UI.** Логика — в виртуальном разрешении 480×270 (`Game.VIRTUAL_W/H`); CSS-размер канваса — виртуальное × **целое** (`computeScale` в `@rpg/engine`, движок сам слушает `window.resize`), а бэкинг-стор — в пикселях устройства (scale × dpr): текст/UI резкие, мир пиксельный (текстуры `AssetLoader` сэмплируются `nearest`). `roundPixels: true`, `antialias: false`. При изменении разрешения следи, чтобы CSS-масштаб оставался целым. 3. **Фиксированный шаг.** `GameLoop` (60 Гц update) + аккумулятор с ограничением 5 шагов/кадр; `InputManager.endTick()` очищает «just pressed» в конце каждого тика — не вызывать update вне цикла движка. -4. **Изометрия 2:1.** Конверсии `isoToScreen`/`screenToIsoExact` в `math/iso.ts` — источник истины для позиций. Тайловые координаты всюду именуются `{x, y}` (как `Grid` в pathfinding), экранные тоже `{x, y}`. +4. **Изометрия 2:1, мир в юнитах.** Источник истины для позиций — **мировые юниты** (float в плоскости изометрии, 1 юнит = 1 тайл): `worldToScreen`/`screenToWorld`/`worldDist`/`worldNorm` в `math/iso.ts`. Две «линейки»: точка проецируется анизотропно (`tileW/2` px по X, `tileH/2` по Y), скаляры (дистанция, радиус, скорость, высота) — через `unitsToPx` (`px = units·tileW`, 32 px/юнит). Округление до целого px — только на границе мир→экран. Тайловые координаты всюду именуются `{x, y}` (как `Grid` в pathfinding); для них есть мосты `tileToWorld`/`worldToTile`. 5. **Собственные классы движка с инъекцией хранилища.** `SaveManager` принимает `StorageLike` (в браузере — `localStorage`), чтобы тесты работали без DOM. Чистую математику (iso, A*, ECS) держи без зависимостей от Pixi — она тестируется в Vitest без браузера. ### Где что лежит @@ -64,7 +64,7 @@ - **Pixi `Assets.load` без `Assets.init()` виснет навсегда** (без ошибок!) — `AssetLoader` делает init сам, не обходи его. - **`app.renderer` недоступен до завершения `app.init()`** — `Renderer.setup()` ждёт init-промис; не трогай renderer раньше. -- **Изометрический «ромб» уходит в минус по X** (западный угол карты при x≈-448) — границы камеры задаются как `CameraBounds { x, y, width, height }` от западного угла, а не от (0,0). Координаты тайлов/героя/NPC — единое пространство мировых пикселей, ничего дополнительно не сдвигать. +- **Изометрический «ромб» уходит в минус по X** (экранный bbox западного угла при 28×28 ≈ -448) — но границы камеры задаются в юнитах через `map.worldBounds` (`{0, 0, w, h}`), клэмп через проекцию сам учитывает отрицательный угол. - **Vite-алиас `@rpg/engine` — только regex** (`{ find: /^@rpg\/engine$/, replacement: ... }`). Строковый алиас перехватывает и под-пути (`@rpg/engine/assets/...`), ломая их в `index.ts/...`. - **Игровые PNG должны попадать в dist**: в `apps/game/vite.config.ts` стоит `publicDir: 'assets'`, а `resolveUrl` не добавляет префикс `assets/`. Без этого прод-сборка пустая. - **Шрифт движка подключается `?url`-импортом** (`import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'`) — для этого в exports движка есть `"./assets/*"`. Обычный `new URL(...)` вне корня Vite не обрабатывает. @@ -74,7 +74,8 @@ - **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). - **В headless Chromium `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` не резолвится** — смоук-скрипты обязаны передавать этот флаг, а навигация не должна ждать `.then` у `audio.play`. - **Одна клавиша маппится на несколько действий** (`bindActions` дополняет, а не перезаписывает): Space = `advance` в диалоге и `attack` вне его — не развести обратно нельзя, гардится уровнем сцены. -- **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Не смешивай пространства в кликах по миру. +- **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Цепочка клика по миру: `pointer px − worldRoot.position → screenToWorld → юниты → worldToTile`. Не смешивай пространства: позиция героя/врагов — юниты, вью — px. +- **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. - Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `tools/pixelart/palette.mjs`. ## Рабочие привычки diff --git a/docs/demo.md b/docs/demo.md index 1f331d5..e708ac5 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -44,11 +44,12 @@ ## Боевая модель (реальное время) -- Герой: HP 5 (сердечки), удар-конус по facing (34 px, 55°, урон 2, кд 0.45 с), - резонанс удержанием ≥0.6 с (импульс r=96, усыпляет на 4 с, кд 3 с), - неуязвимость 0.8 с после урона, отброс 24 px. +- Герой: HP 5 (сердечки), удар-конус по facing (1.0 юнита, 55°, урон 2, кд 0.45 с), + резонанс удержанием ≥0.6 с (импульс r=3.0 юнита, усыпляет на 4 с, кд 3 с), + неуязвимость 0.8 с после урона, отброс 0.75 юнита. Дистанции/скорости — в + мировых юнитах (1 юнит = 1 тайл; на экране 32 px/юнит по линейке проекции). - Враги «пепельные сгустки»: ползун (HP 3, стая), плевун (HP 2, дистанция + плевок), - наст (HP 8, замах → удар по площади). Звук — ресурс: удар излучает гул r=150 + наст (HP 8, замах → удар по площади). Звук — ресурс: удар излучает гул r=4.5 юнита и будит спящих сгустков. - Ввод: клик по врагу — авто-подход и удар; Space тап/удержание; геймпад X. diff --git a/docs/engine/README.md b/docs/engine/README.md index ce45bf5..370ef06 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -32,12 +32,14 @@ 5 шагов за кадр). `dt` в `Scene.update` всегда одинаковый — физика и таймеры детерминированы. Рендер происходит после каждого шага. -### 3. Pixel-perfect +### 3. Мир в юнитах, UI в пикселях +- Внутренние координаты мира — **мировые юниты** (1 юнит = 1 тайл, float); + экран — проекция с округлением до целого пикселя только на границе мир→экран + (пиксель-арт резкий, `image-rendering: pixelated`, `roundPixels: true`). - Виртуальное разрешение (по умолчанию 480×270) растягивается **целым** числом - на экран (`computeScale`), `image-rendering: pixelated`, `roundPixels: true`. -- Камера округляет позицию до целого пикселя — арт не «дрожит». -- Пиксельный шрифт (VT323) рисуется в тех же виртуальных пикселях. + на экран (`computeScale`); UI, текст (VT323) и все display-эффекты живут в нём. +- Дистанции/радиусы/скорости — скаляры в юнитах (`px = units·tileW`, 32 px/юнит). ### 4. Движок жанронезависим @@ -78,7 +80,7 @@ - `Assets.load` без предварительного `Assets.init({})` **висит навсегда** без ошибок — `AssetLoader` делает это сам, но если грузите напрямую через Pixi, не забудьте. - `app.renderer` недоступен до завершения `app.init()` — `Renderer.setup()` это учитывает. -- Изометрический ромб уходит в минус по X: границы камеры начинаются от - западного угла карты (`{x: -size.width/2, ...}`). +- Экранный bbox изометрического ромба уходит в минус по X — но границы камеры + задаются в юнитах (`map.worldBounds`), клэмп через проекцию считает это сам. - `document.fonts` — шрифт загружается асинхронно; `ensurePixelFont()` вызывается до создания текста, но фолбэк monospace всегда безопасен. \ No newline at end of file diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 20701e2..782f99e 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -1,24 +1,34 @@ # Карты: изометрия, A*, формат карт, Tiled -## Изометрия 2:1 +## Мировые юниты (1 юнит = 1 тайл) -Тайл 32×16 (`DEFAULT_ISO`). Тайловые координаты всегда `{x, y}` (целые), экранные — `{x, y}` в виртуальных пикселях: +Мир живёт в **мировых юнитах** (1 юнит = 1 тайл, float в плоскости изометрии); +экран — проекция юнитов. Тайловые координаты `{x, y}` (целые) — частный случай: +границы тайлов в юнитах точные (`floor(wx), floor(wy)`). Экранные оси — мировые +диагонали: экранное «вниз» = мировой `(1, 1)`, «вправо» = `(1, −1)`. ```ts -import { isoToScreen, screenToIso, screenToIsoExact, DEFAULT_ISO } from '@rpg/engine'; +import { worldToScreen, screenToWorld, unitsToPx, pxToUnits } from '@rpg/engine'; -isoToScreen(tx, ty); // экранные координаты ВЕРХНЕЙ вершины ромба -screenToIso(px, py); // приближённый тайл (floor) -screenToIsoExact(px, py, w, h); // точное попадание в ромб или null (для кликов) +worldToScreen(wx, wy); // проекция точки в px (float, БЕЗ округления — округляет вью) +screenToWorld(px, py); // обратно: экранные px -> юниты (для кликов) +unitsToPx(units); // скаляр (дистанция, радиус, скорость): px = units·tileW (32 px/юнит) +pxToUnits(px); // обратная линейка ``` -Клик по миру: сначала переведите экранные координаты в мировые (позиция worldRoot -учитывает камеру), затем `screenToIsoExact`: +Для «точных» тайловых задач есть мосты `tileToWorld`/`worldToTile` и экранный +пикинг по ромбу (`isoToScreen`/`screenToIso`/`screenToIsoExact`) — для прямых +кликов по спрайтам без юнитов. + +Клик по миру: из виртуальных px указателя вычтите позицию `worldRoot` +(камера), затем `screenToWorld` → юниты → `worldToTile`: ```ts -const worldX = pointer.x - engine.renderer.worldRoot.position.x; -const worldY = pointer.y - engine.renderer.worldRoot.position.y; -const tile = screenToIsoExact(worldX, worldY, map.width, map.height); +const w = screenToWorld( + pointer.x - engine.renderer.worldRoot.position.x, + pointer.y - engine.renderer.worldRoot.position.y +); +const tile = worldToTile(w.x, w.y, map.width, map.height); ``` ## IsometricTileMap @@ -27,19 +37,20 @@ `tall` — высокие объекты, рисуются поверх земли по строкам глубины: ```ts -import { IsometricTileMap } from '@rpg/engine'; +import { IsometricTileMap, DEFAULT_ISO } from '@rpg/engine'; const data: TileMapData = { width: 28, height: 28, tiles: [...], // length = width * height blocked: [TILE_WATER, TILE_TREE], - tall: { [TILE_TREE]: { height: 48, ground: TILE_GRASS } } // высота в px, ground — чем рисовать землю под ним + // высота в мировых юнитах (·tileW = px на экране), ground — чем рисовать землю под ним + tall: { [TILE_TREE]: { height: 1.5, ground: TILE_GRASS } } }; const map = new IsometricTileMap(data, textures, DEFAULT_ISO); worldRoot.addChild(map.view); map.isWalkable(x, y); // Grid для A* -map.screenSize; // { width, height } для границ камеры +map.worldBounds; // { x: 0, y: 0, width, height } — границы камеры в юнитах map.setTile(x, y, id); // изменить тайл (сбор предметов, посадка) — данные + перерисовка ячейки ``` @@ -90,7 +101,7 @@ const data = fromTiledIso(tiledJson, { layers: ['ground', 'props'], // имена слоёв снизу вверх (по умолчанию все tilelayer) blocked: [2, 5], // id после смещения на firstgid - tall: { 3: { height: 48 } } + tall: { 3: { height: 1.5 } } // высоты — в мировых юнитах }); ``` diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index 5d25579..9a50dba 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -13,22 +13,17 @@ const map = new IsometricTileMap(data, tileTextures(), DEFAULT_ISO); engine.renderer.worldRoot.addChild(map.view); - const size = map.screenSize; - engine.camera.bounds = { - x: -size.width / 2, y: 0, // ромб уходит в минус по X! - width: size.width, height: size.height - }; + engine.camera.bounds = map.worldBounds; // границы камеры в юнитах + engine.camera.deadZonePx = { width: 180, height: 120 }; - // центры тайлов для позиционирования сущностей - const p = isoToScreen(tx, ty); - const cx = p.x, cy = p.y + DEFAULT_ISO.tileH / 2; - engine.camera.follow(cx, cy); + // центр стартового тайла в юнитах — источник истины для движения + const u = tileToWorld(tx, ty); + engine.camera.snap(u.x, u.y); } update(dt: number) { // ... движение героя ... - const c = tileCenter(hero.tile); - engine.camera.follow(c.x, c.y); // каждый тик за героем + engine.camera.follow(hero.pos.x, hero.pos.y); // каждый тик за героем } } ``` @@ -39,9 +34,12 @@ // в update сцены: const pointer = engine.input.getPointer(); if (pointer.justPressed) { - const worldX = pointer.x - engine.renderer.worldRoot.position.x; - const worldY = pointer.y - engine.renderer.worldRoot.position.y; - const target = screenToIsoExact(worldX, worldY, map.width, map.height); + // виртуальные px указателя -> юниты (учёт камеры) + const w = screenToWorld( + pointer.x - engine.renderer.worldRoot.position.x, + pointer.y - engine.renderer.worldRoot.position.y + ); + const target = worldToTile(w.x, w.y, map.width, map.height); if (target) { this.path = findPath(map, hero.tile, target); } @@ -50,8 +48,8 @@ // следование пути с фиксированной скоростью: if (this.path && this.path.length > 0) { const next = this.path[0]; - const c = tileCenter(next.x, next.y); - // двигаем спрайт к c, по достижении — hero.tile = next, path.shift() + const w = tileToWorld(next.x, next.y); + // двигаем позицию (юниты) к w, по достижении — hero.tile = next, path.shift() } ``` diff --git a/docs/engine/render.md b/docs/engine/render.md index 053b35a..3e59135 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -1,9 +1,22 @@ # Рендер: Renderer, pixel-perfect, камера, IsoDepthLayer, частицы +## Мир в юнитах, экран в пикселях + +Внутренние координаты мира — **мировые юниты** (1 юнит = 1 тайл, float в плоскости +изометрии). Экранные пиксели — это **проекция** юнитов: округление до целого пикселя +происходит только на границе мир→экран (`worldToScreen` + округление во вью/`Camera.apply`), +поэтому пиксель-арт остаётся резким. UI живёт в виртуальных пикселях (480×270) и +никогда не переводится в юниты. Две «линейки»: + +- **точка** — анизотропная проекция: 1 юнит = `tileW/2` px по X экрана и `tileH/2` px по Y; +- **скаляр** (дистанция, радиус, скорость, высота) — «линейка проекции»: `px = units·tileW` (32 px/юнит). + +Правило отображения: *«внутри вью — всегда px; вью позиционируется в мире через `worldToScreen`»*. + ## Renderer и pixel-perfect -Виртуальное разрешение (например 480×270) определяет **логическую** систему координат: -всё игровое кодирование позиций — в виртуальных пикселях. Отрисовка двухслойная: +Виртуальное разрешение (например 480×270) определяет **экранную** систему координат: +UI и вью-позиции — в виртуальных пикселях, мир — в юнитах. Отрисовка двухслойная: - CSS-размер канваса — виртуальное разрешение, растянутое **целым** числом (`computeScale`), без зазоров и обрезки; @@ -30,31 +43,27 @@ ## Camera -Позиция камеры — центр взгляда в **мировых виртуальных пикселях**: +Позиция камеры (`x`, `y`) и `bounds` — в **мировых юнитах** (центр взгляда в плоскости +изометрии). Клэмп через проекцию: экранный bbox границ не обязан лежать в положительных +координатах — у ромба карты западный угол отрицательный, движок считает это сам: ```ts const camera = engine.camera; -camera.follow(targetX, targetY); // следить за точкой -camera.bounds = { x, y, width, height }; // прямоугольник мира -``` - -Важно: границы могут начинаться с отрицательных координат. Изометрическая карта -(ромб) уходит в минус по X — её западный угол при 28×28 тайлах ≈ `-448`: - -```ts -const size = map.screenSize; // { width, height } в экранных пикселях -camera.bounds = { x: -size.width / 2, y: 0, width: size.width, height: size.height }; +camera.follow(targetX, targetY); // следить за точкой (юниты) +camera.bounds = map.worldBounds; // { x: 0, y: 0, width, height } в юнитах ``` Позиция округляется до целого пикселя (`apply`), поэтому арт не дрожит при движении. «Мёртвая зона» — современное поведение камеры: пока цель внутри окна, камера -неподвижна; у границ окна цель «толкает» камеру (каждая ось независимо): +неподвижна; у границ окна цель «толкает» камеру (каждая ось независимо). Окно — +**экранное**, остаётся в виртуальных пикселях (мировые прямоугольники проецируются +в экранное окно строго 2:1, поэтому произвольное окно в юнитах невыразимо): ```ts -camera.deadZone = { width: 180, height: 120 }; // окно в виртуальных пикселях -camera.deadZone = null; // снова жёсткое центрирование -camera.snap(x, y); // мгновенно, минуя окно (спавн/респаун) +camera.deadZonePx = { width: 180, height: 120 }; // окно в виртуальных пикселях +camera.deadZonePx = null; // снова жёсткое центрирование +camera.snap(x, y); // мгновенно, минуя окно (спавн/респаун) ``` Тряска — для ударов, взрывов и урона. Движок сам тикает камеру (`camera.update(dt)` @@ -135,6 +144,8 @@ Интеграция частицы (`stepParticle`, `sampleSpawn`, `sampleBurst`) вынесена в чистый модуль `render/particleSim` — её можно тикать и без Pixi (например, в тестах боя). +Частицы — display-layer: все внутренние величины эмиттера — экранные px относительно +его origin; в мире эмиттер позиционируется через `worldToScreen`. ## Порядок отрисовки