diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 95652f8..5e6548c 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -1,8 +1,8 @@ import { checkBounds, checkWalkable, + gridOf, mergeInvariants, - type Grid, type Invariant, type TileMapData, type DialogueGraph @@ -23,17 +23,6 @@ const WHERE = 'data'; -/** Grid-адаптер над сырыми данными карты (для checkBounds/checkWalkable/A*). */ -function gridOf(data: TileMapData): Grid { - return { - width: data.width, - height: data.height, - isWalkable: (x, y) => - x >= 0 && y >= 0 && x < data.width && y < data.height && - !data.blocked.includes(data.tiles[y * data.width + x]) - }; -} - /** Граф диалога: start/next/choices существуют, узлы-сироты (warn). */ export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] { const out: Invariant[] = []; diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 2ea7868..1d07bb2 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -212,7 +212,6 @@ (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)), this.objects.registry ); - this.fauna.setBlocked(data.blocked); this.fauna.setFrames( this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture] ); diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 1f77106..700b7fd 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -7,12 +7,15 @@ worldToTile, tileToWorld, moveTowardsW, + moveCircle, + circleFits, SpriteAnimator, Container, Sprite, Texture, type Vec2 } from '@rpg/engine'; +import { PLAYER_COMBAT } from './combat/stats'; /** Кадры героя по направлениям. side смотрит влево; вправо — флип. */ export interface HeroTextures { @@ -150,17 +153,14 @@ const w = screenToWorld(dir.x, dir.y); const n = worldNorm(w.x, w.y); const step = this.speed * this.speedMul * dt; - const nx = this.pos.x + n.x * step; - const ny = this.pos.y + n.y * step; - // Двигаемся только если новая точка внутри карты и не в стене - const tile = worldToTile(nx, ny, this.map.data.width, this.map.data.height); - if (tile && this.map.isWalkable(tile.x, tile.y)) { + // Круг тела скользит вдоль стен (оси раздельно). + const moved = moveCircle(this.map, this.pos, { x: n.x * step, y: n.y * step }, PLAYER_COMBAT.radius); + if (moved) { // Поворот — по экранным компонентам смещения (кадры down/up/side). const proj = worldToScreen(n.x, n.y); this.setFacing( Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' ); - this.pos = { x: nx, y: ny }; this.animator.resume(); this.animator.play(this.walkClip()); this.animator.update(dt); @@ -203,13 +203,13 @@ this.syncView(); } - /** Отброс: сдвиг позиции с проверкой проходимости (для боя), юниты. */ + /** Отброс: сдвиг позиции, только если тело помещается (для боя), юниты. */ applyKnockback(dir: Vec2, dist: number): void { const nx = this.pos.x + dir.x * dist; const ny = this.pos.y + dir.y * dist; - const tile = worldToTile(nx, ny, this.map.data.width, this.map.data.height); - if (tile && this.map.isWalkable(tile.x, tile.y)) { - this.pos = { x: nx, y: ny }; + if (circleFits(this.map, { x: nx, y: ny }, PLAYER_COMBAT.radius)) { + this.pos.x = nx; + this.pos.y = ny; this.syncView(); } this.stop(); diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index f1310aa..ecdb012 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -9,6 +9,7 @@ inCircleW, inConeW, tileToWorld, + moveCircle, type Entity, type SceneRegistry, type System, @@ -76,21 +77,16 @@ /** Бег (отступление) быстрее шага. */ const RUN_MULT = 1.3; -/** Движение врага: шаг в мировых юнитах с отбраковкой по проходимости. */ +/** Движение врага: круг тела скользит вдоль стен (движковые коллизии). */ function stepWithCollision( map: IsometricTileMap, pos: Vec2, dir: Vec2, speed: number, + radius: number, dt: number ): void { - const nx = pos.x + dir.x * speed * dt; - const ny = pos.y + dir.y * speed * dt; - const tile = worldToTile(nx, ny, map.data.width, map.data.height); - if (tile && map.isWalkable(tile.x, tile.y)) { - pos.x = nx; - pos.y = ny; - } + moveCircle(map, pos, { x: dir.x * speed * dt, y: dir.y * speed * dt }, radius); } // ---------- системы ECS ---------- @@ -101,7 +97,8 @@ private opaque: (tx: number, ty: number) => boolean; constructor(private combat: CombatWorld) { - this.opaque = tileOpaque({ data: combat.deps.map.data }); + // Дома (пропы) тоже перекрывают взгляд — сгустки не видят сквозь них. + this.opaque = tileOpaque(combat.deps.map); } update(_world: World, dt: number): void { @@ -147,6 +144,7 @@ en.pos, intent.dir, en.kind.speed * (intent.run ? RUN_MULT : 1), + en.kind.radius, dt ); this.combat.deps.registry?.move(`enemy:${e}`, en.pos); @@ -187,9 +185,10 @@ this.combat.killProjectile(e); continue; } - // Вне карты или срок истёк + // Вне карты, срок истёк или плевок в стену/дом (вода прозрачна — + // летит над прудом; правило игры, не движка). const tile = worldToTile(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height); - if (pr.life <= 0 || !tile) { + if (pr.life <= 0 || !tile || this.combat.opaque(tile.x, tile.y)) { this.combat.killProjectile(e); } } @@ -204,11 +203,14 @@ readonly projectiles = new Map(); /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ private lastNoise: { origin: Vec2; level: number; age: number } | null = null; + /** Непрозрачность тайлов (стены/дома) — для конуса удара и снарядов. */ + readonly opaque: (tx: number, ty: number) => boolean; constructor( readonly deps: CombatDeps, private onEnemyKilled?: (kind: EnemyKindDef) => void ) { + this.opaque = tileOpaque(deps.map); this.world.addSystem(new EnemySystem(this)); this.world.addSystem(new ProjectileSystem(this)); } @@ -277,6 +279,8 @@ for (const [e, en] of this.enemies) { if (en.brain.dead) continue; if (!inConeW(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; + // Звон не огибает дома: цель должна быть в прямой видимости. + if (!hasLineOfSight(from, en.pos, this.opaque)) continue; this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); hits++; } diff --git a/apps/game/src/systems/combat/los.ts b/apps/game/src/systems/combat/los.ts index 37389a7..cf7b99a 100644 --- a/apps/game/src/systems/combat/los.ts +++ b/apps/game/src/systems/combat/los.ts @@ -33,15 +33,22 @@ } /** - * Непрозрачен ли тайл карты: высокий объект (TallSpec) перекрывает взгляд. - * Вода и ровный пепел прозрачны — сгустки видят через низину. + * Непрозрачен ли тайл карты: высокий объект (TallSpec) или footprint пропа + * (дом) перекрывает взгляд. Вода и ровный пепел прозрачны — сгустки видят + * через низину. extra — дополнительные непрозрачные тайлы (правила игры). */ -export function tileOpaque(map: { - data: { width: number; height: number; tiles: number[]; tall?: Record }; -}): (tx: number, ty: number) => boolean { +export function tileOpaque( + map: { + isPropBlocked?: (tx: number, ty: number) => boolean; + data: { width: number; height: number; tiles: number[]; tall?: Record }; + }, + extra?: (tx: number, ty: number) => boolean +): (tx: number, ty: number) => boolean { return (tx: number, ty: number): boolean => { const { width, height, tiles, tall } = map.data; if (tx < 0 || ty < 0 || tx >= width || ty >= height) return true; + if (map.isPropBlocked?.(tx, ty)) return true; + if (extra?.(tx, ty)) return true; return tall?.[tiles[ty * width + tx]] !== undefined; }; } \ No newline at end of file diff --git a/apps/game/src/systems/combat/stats.ts b/apps/game/src/systems/combat/stats.ts index dce8623..0a1b63f 100644 --- a/apps/game/src/systems/combat/stats.ts +++ b/apps/game/src/systems/combat/stats.ts @@ -9,6 +9,8 @@ export const PLAYER_COMBAT = { /** Здоровье героя (сердечки). */ maxHp: 5, + /** Радиус тела героя (юниты; < 0.5 — путь A* по центрам остаётся проходим). */ + radius: 0.35, /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ coneRange: 1.0, coneHalfAngle: (55 * Math.PI) / 180, diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts index 0916c70..52ec67c 100644 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -7,7 +7,9 @@ moveTowardsW, worldDist, worldToScreen, + gridOf, type EventBus, + type Grid, type IsoDepthLayer, type SceneRegistry, type TileMapData, @@ -45,16 +47,19 @@ private entities: FaunaEntity[] = []; private rng: () => number; private off: () => void; + /** Проходимость карты (blocked-иды + пропы) — один адаптер на систему. */ + private grid: Grid; constructor( private actors: IsoDepthLayer, events: EventBus, - private data: TileMapData, + data: TileMapData, spawns: Vec2[], /** Реестр сцены (опция): олени регистрируются для «кто рядом». */ private registry?: SceneRegistry, seed = 20260908 ) { + this.grid = gridOf(data); // Детерминированный ГПСЧ — брожение одинаково между запусками. let s = seed; this.rng = () => { @@ -164,15 +169,7 @@ private walkable(p: Vec2): boolean { const tx = Math.floor(p.x); const ty = Math.floor(p.y); - if (tx < 0 || ty < 0 || tx >= this.data.width || ty >= this.data.height) return false; - return !this.blocked.includes(this.data.tiles[ty * this.data.width + tx]!); - } - - private blocked: number[] = []; - - /** Задать непроходимые id тайлов (сцена синхронизирует со своей картой). */ - setBlocked(ids: number[]): void { - this.blocked = ids; + return this.grid.isWalkable(tx, ty); } update(dt: number): void { diff --git a/apps/game/src/testing/combatWorld.ts b/apps/game/src/testing/combatWorld.ts index 3784a9d..07ad708 100644 --- a/apps/game/src/testing/combatWorld.ts +++ b/apps/game/src/testing/combatWorld.ts @@ -16,7 +16,8 @@ blocked: [], tall: undefined }; - return { data, isWalkable: () => true } as unknown as IsometricTileMap; + // width/height наверху — контракт Grid для движковых коллизий. + return { width: w, height: h, data, isWalkable: () => true } as unknown as IsometricTileMap; } /** Боевой мир над фейковой картой: события глушатся, герой в заданной точке. */ diff --git a/docs/engine/README.md b/docs/engine/README.md index f7ffeb3..2aea41f 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -12,7 +12,7 @@ | [anim.md](anim.md) | Клипы, SpriteAnimator, реестр тика engine.fx | | [render.md](render.md) | Renderer, pixel-perfect, камера, IsoDepthLayer, частицы | | [input.md](input.md) | Действия, клавиатура, мышь/тач, геймпад, виртуальный джойстик | -| [maps.md](maps.md) | Изометрия, A*, формат карт, импорт из Tiled | +| [maps.md](maps.md) | Изометрия, A*, коллизии, формат карт, импорт из Tiled | | [ui-and-dialogue.md](ui-and-dialogue.md) | UI-кит, PixelText, диалоговые графы | | [cutscene.md](cutscene.md) | CutsceneRunner: кат-сцены из шагов | | [registry.md](registry.md) | SceneRegistry: объекты сцены, индекс по тайлам, запросы at/near | diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 95f8060..78f5f29 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -105,6 +105,37 @@ const path = findPathToNeighbor(map, from, npcTile); // к краю тайла NPC ``` +## Коллизии (круг поверх сетки) + +Движковые коллизии — чистая математика в `map/collision.ts`, без Pixi: + +```ts +import { gridOf, circleFits, moveCircle, pushOutOfWalls, separateCircles } from '@rpg/engine'; + +const grid = gridOf(data); // Grid из TileMapData: blocked-иды + footprint пропов +circleFits(grid, pos, 0.35); // помещается ли круг в точку (точная проверка) +moveCircle(grid, pos, { x: dx, y: dy }, 0.35); // сдвиг со скольжением вдоль стен (оси X -> Y) +pushOutOfWalls(grid, pos, 0.35); // вытолкнуть из стены в центр ближайшего тайла +separateCircles(a, ra, b, rb); // выталкивает только a из b (каждый сам) +``` + +Правила стиля: + +- **Радиус тела < 0.5 юнита** — тело уже тайла, и путь A* по центрам тайлов + остаётся проходимым при движении тела по маршруту. Проверка круга — точная + (ближайшая точка квадрата Blocked-тайла), не выборка крайних точек: выборка + пропускает угловой прокол Blocked-тайла по диагонали. +- `IsometricTileMap` реализует `Grid` (`isWalkable` уже учитывает пропы), так + что коллизии работают и над живой картой, и над `gridOf(data)` без карты. +- `moveCircle` — раздельно по осям: прижатая к стене ось не блокирует движение + по другой — это и есть скольжение. +- `separateCircles` двигает только `a` — расталкивание акторов делается + циклом «каждый выталкивает себя» (см. `registry.near` в `registry.md`). +- Отброс (нокбэк) — не повод для push-out: сдвигайте позицию только если тело + помещается (`circleFits`), иначе — оставайтесь на месте. «Прищёлкивание» + к центру тайла после отброса меняет динамику боя (проверено на пробах + `agent:check` — погони у прудов шли иначе). Push-out — для телепортов. + ## Формат карт (JSON + RLE) Карты можно хранить файлами: `encodeMap` упаковывает тайлы в RLE (пары `[id, длина]`), diff --git a/docs/llms.txt b/docs/llms.txt index ac04270..67f337c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -16,7 +16,7 @@ - [Ядро](engine/core.md): GameLoop, GameState, Settings, EventBus, Tween. - [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, pixel-perfect масштаб. - [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. -- [Карты](engine/maps.md): изометрия, A*, формат rpg-map, Tiled-импорт. +- [Карты](engine/maps.md): изометрия, A*, коллизии (круг поверх сетки), формат rpg-map, Tiled-импорт. - [UI и диалоги](engine/ui-and-dialogue.md): PixelText, Panel, Button, MenuList, DialogueBox, DialogueRunner. - [Кат-сцены](engine/cutscene.md): раннер кат-сцен. - [Реестр объектов сцены](engine/registry.md): SceneRegistry — «кто на карте», индекс по тайлам. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7ed6452..20934a1 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -103,6 +103,13 @@ } from './map/IsometricTileMap'; export { findPath, findPathToNeighbor, type Grid } from './map/pathfinding'; export { + gridOf, + circleFits, + moveCircle, + pushOutOfWalls, + separateCircles +} from './map/collision'; +export { encodeMap, parseMap, type MapFile, diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 8db326d..a14fa4e 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -311,6 +311,11 @@ 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 }; diff --git a/packages/engine/src/map/__tests__/collision.test.ts b/packages/engine/src/map/__tests__/collision.test.ts new file mode 100644 index 0000000..1ed35c6 --- /dev/null +++ b/packages/engine/src/map/__tests__/collision.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { circleFits, gridOf, moveCircle, pushOutOfWalls, separateCircles } from '../collision'; +import type { Grid } from '../pathfinding'; +import type { TileMapData } from '../IsometricTileMap'; + +/** Карта 8×8: трава (0) везде, стена по x=4 (id 1), дом-проп 2×1 в (6,1). */ +function testMap(): TileMapData { + const w = 8; + const h = 8; + const tiles = new Array(w * h).fill(0); + for (let y = 0; y < h; y++) tiles[y * w + 4] = 1; // вертикальная стена + return { + width: w, + height: h, + tiles, + blocked: [1], + props: [{ id: 2, x: 6, y: 1, w: 2, h: 1 }] + }; +} + +/** Grid из набора строк-символов: '.' — проходимо, '#' — стена. */ +function gridFrom(rows: string[]): Grid { + return { + width: rows[0]!.length, + height: rows.length, + isWalkable: (x, y) => rows[y]?.[x] === '.' + }; +} + +describe('gridOf', () => { + it('blocked-иды и footprint пропов блокируют тайлы', () => { + const g = gridOf(testMap()); + expect(g.isWalkable(0, 0)).toBe(true); + expect(g.isWalkable(4, 3)).toBe(false); // стена + expect(g.isWalkable(6, 1)).toBe(false); // дом + expect(g.isWalkable(7, 1)).toBe(false); // дом (вторая клетка footprint) + expect(g.isWalkable(6, 2)).toBe(true); + expect(g.isWalkable(-1, 0)).toBe(false); + expect(g.isWalkable(8, 0)).toBe(false); + }); +}); + +describe('circleFits', () => { + it('тело меньше тайла проходит в свободном тайле', () => { + const g = gridOf(testMap()); + expect(circleFits(g, { x: 2.5, y: 2.5 }, 0.35)).toBe(true); + }); + + it('не заходит в стену с любой стороны', () => { + const g = gridOf(testMap()); + const r = 0.35; + expect(circleFits(g, { x: 3.5, y: 2.5 }, r)).toBe(true); + expect(circleFits(g, { x: 3.66, y: 2.5 }, r)).toBe(false); // x+r = 4.01 > 4 + expect(circleFits(g, { x: 4.34, y: 2.5 }, r)).toBe(false); // x-r = 3.99 < 5 + expect(circleFits(g, { x: 3.65, y: 2.5 }, r)).toBe(true); // касание вплотную + }); + + it('ловит угловой прокол диагонального тайла (выборка точек его пропускает)', () => { + const g = gridFrom([ + '....', + '...#', + '....', + '....' + ]); + // Угол blocked-тайла (3,1) в круге, а крайние точки — в свободных. + expect(circleFits(g, { x: 2.8, y: 1.8 }, 0.45)).toBe(false); + }); + + it('вне карты — стена', () => { + const g = gridOf(testMap()); + expect(circleFits(g, { x: 0.2, y: 0.5 }, 0.35)).toBe(false); + }); +}); + +describe('moveCircle — скольжение вдоль стены', () => { + it('прижим к стене не мешает движению вдоль неё', () => { + const g = gridOf(testMap()); + const pos = { x: 3.6, y: 1.0 }; + // Вдоль стены (вниз по y): x-компонента блокирована, y — проходит. + const moved = moveCircle(g, pos, { x: 0.2, y: 0.5 }, 0.35); + expect(moved).toBe(true); + expect(pos.x).toBeCloseTo(3.6); // x не сдвинулся + expect(pos.y).toBeCloseTo(1.5); + }); + + it('в угол — обе оси блокированы, позиция не меняется', () => { + const g = gridFrom([ + '.#', + '#.' + ]); + const pos = { x: 0.5, y: 0.5 }; + expect(moveCircle(g, pos, { x: 0.4, y: 0.4 }, 0.3)).toBe(false); + expect(pos).toEqual({ x: 0.5, y: 0.5 }); + }); +}); + +describe('moveCircle — совместимость с A*-маршрутом', () => { + it('путь по центрам тайлов проходим телом r ≤ 0.45 (диагональ у стены)', () => { + const g = gridOf(testMap()); + const path = [{ x: 2, y: 2 }, { x: 3, y: 3 }, { x: 3, y: 4 }]; + const pos = { x: 2.5, y: 2.5 }; + const near = (a: number, b: number) => Math.abs(a - b) < 0.01; + for (const tile of path) { + // Шаг к центру следующего тайла — так двигает waypoint-логика. + const target = { x: tile.x + 0.5, y: tile.y + 0.5 }; + for (let i = 0; i < 200 && !(near(pos.x, target.x) && near(pos.y, target.y)); i++) { + const dx = Math.sign(target.x - pos.x) * 0.1; + const dy = Math.sign(target.y - pos.y) * 0.1; + const before = { ...pos }; + moveCircle(g, pos, { x: dx, y: dy }, 0.35); + expect(pos).not.toEqual(before); // продвижение есть — путь не застрял + } + expect(near(pos.x, target.x) && near(pos.y, target.y)).toBe(true); // дошли + } + }); +}); + +describe('pushOutOfWalls', () => { + it('уже свободен — позиция не тронута', () => { + const g = gridOf(testMap()); + const pos = { x: 2.31, y: 2.87 }; + pushOutOfWalls(g, pos, 0.35); + expect(pos).toEqual({ x: 2.31, y: 2.87 }); + }); + + it('в стене — выталкивает в центр ближайшего свободного тайла', () => { + const g = gridOf(testMap()); + const pos = { x: 4.5, y: 3.5 }; // внутри стены + pushOutOfWalls(g, pos, 0.35); + expect(g.isWalkable(Math.floor(pos.x), Math.floor(pos.y))).toBe(true); + expect(circleFits(g, pos, 0.35)).toBe(true); + }); + + it('вне карты — вытолкнут обратно', () => { + const g = gridOf(testMap()); + const pos = { x: 9.5, y: 3.5 }; + pushOutOfWalls(g, pos, 0.35); + expect(circleFits(g, pos, 0.35)).toBe(true); + }); +}); + +describe('separateCircles', () => { + it('перекрытие — a выталкивается ровно на глубину, b не трогается', () => { + const a = { x: 1.0, y: 0.0 }; + const b = { x: 1.3, y: 0.0 }; + separateCircles(a, 0.35, b, 0.2); + // Перекрытие = (0.35+0.2) − 0.3 = 0.25. + expect(b).toEqual({ x: 1.3, y: 0.0 }); + expect(a.x).toBeCloseTo(0.75); + expect(a.y).toBeCloseTo(0.0); + }); + + it('без перекрытия — ничего не двигается', () => { + const a = { x: 0.0, y: 0.0 }; + const b = { x: 2.0, y: 0.0 }; + separateCircles(a, 0.35, b, 0.35); + expect(a).toEqual({ x: 0.0, y: 0.0 }); + }); + + it('слитые в точку — расходятся по +X (детерминированный фолбэк)', () => { + const a = { x: 5.0, y: 5.0 }; + separateCircles(a, 0.3, { x: 5.0, y: 5.0 }, 0.3); + expect(a.x).toBeCloseTo(5.6); + expect(a.y).toBeCloseTo(5.0); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/map/collision.ts b/packages/engine/src/map/collision.ts new file mode 100644 index 0000000..0934536 --- /dev/null +++ b/packages/engine/src/map/collision.ts @@ -0,0 +1,128 @@ +import type { Grid } from './pathfinding'; +import type { TileMapData } from './IsometricTileMap'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Круговые коллизии поверх тайловой сетки (чистая математика, без Pixi). + * Актор — круг радиуса r в мировых юнитах (тайл = 1×1); правило стиля: + * r < 0.5 — тело уже тайла, тогда путь A* по центрам тайлов остаётся + * проходимым и проверка сводится к соседним с центром тайлам. + * Проверка круга — точная (ближайшая точка квадрата тайла), не выборка + * точек: выборка пропускает угловой прокол Blocked-тайла по диагонали. + */ + +/** Grid-адаптер над сырыми данными карты: blocked-иды + footprint пропов. */ +export function gridOf(data: TileMapData): Grid { + const blockedSet = new Set(data.blocked); + const propBlocked = new Set(); + for (const p of data.props ?? []) { + const w = p.w ?? 1; + const h = p.h ?? 1; + for (let ty = p.y; ty < p.y + h; ty++) { + for (let tx = p.x; tx < p.x + w; tx++) propBlocked.add(ty * data.width + tx); + } + } + return { + width: data.width, + height: data.height, + isWalkable: (x, y) => + x >= 0 && y >= 0 && x < data.width && y < data.height && + !propBlocked.has(y * data.width + x) && + !blockedSet.has(data.tiles[y * data.width + x]!) + }; +} + +/** Тайл блокирует движение (вне карты — тоже стена). */ +function isBlocked(grid: Grid, tx: number, ty: number): boolean { + return tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height || !grid.isWalkable(tx, ty); +} + +/** Помещается ли круг в позицию (точное пересечение с blocked-тайлами bbox). */ +export function circleFits(grid: Grid, pos: Vec2, r: number): boolean { + if (r <= 0) { + return !isBlocked(grid, Math.floor(pos.x), Math.floor(pos.y)); + } + // r < 0.5 — bbox накрывает не более 2×2 тайлов. + const x0 = Math.floor(pos.x - r); + const x1 = Math.floor(pos.x + r); + const y0 = Math.floor(pos.y - r); + const y1 = Math.floor(pos.y + r); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + if (!isBlocked(grid, tx, ty)) continue; + // Ближайшая к центру круга точка квадрата тайла. + const nx = Math.max(tx, Math.min(pos.x, tx + 1)); + const ny = Math.max(ty, Math.min(pos.y, ty + 1)); + const dx = pos.x - nx; + const dy = pos.y - ny; + if (dx * dx + dy * dy < r * r) return false; // касание стеной разрешено + } + } + return true; +} + +/** + * Сдвинуть круг на delta со скольжением вдоль стен: оси проверяются + * раздельно (X -> Y), прижатая к стене ось не блокирует движение по другой. + * Мутирует pos. Возвращает true, если хоть одна ось сместилась. + */ +export function moveCircle(grid: Grid, pos: Vec2, delta: Vec2, r: number): boolean { + let moved = false; + const nx = pos.x + delta.x; + if (circleFits(grid, { x: nx, y: pos.y }, r)) { + pos.x = nx; + moved = moved || delta.x !== 0; + } + const ny = pos.y + delta.y; + if (circleFits(grid, { x: pos.x, y: ny }, r)) { + pos.y = ny; + moved = moved || delta.y !== 0; + } + return moved; +} + +/** + * Вытолкнуть круг из стены (после отброса/телепорта): ищет ближайший по + * спирали тайл, в чей центр круг помещается, и ставит pos в его центр. + * Свободного в радиусе 3 тайлов нет — позиция не меняется. + */ +export function pushOutOfWalls(grid: Grid, pos: Vec2, r: number): void { + if (circleFits(grid, pos, r)) return; + const cx = Math.floor(pos.x); + const cy = Math.floor(pos.y); + for (let ring = 0; ring <= 3; ring++) { + for (let ty = cy - ring; ty <= cy + ring; ty++) { + for (let tx = cx - ring; tx <= cx + ring; tx++) { + // Кольцо: периметр квадрата (внутренние уже проверены). + const onRing = Math.max(Math.abs(tx - cx), Math.abs(ty - cy)) === ring; + if (!onRing) continue; + const center = { x: tx + 0.5, y: ty + 0.5 }; + if (circleFits(grid, center, r)) { + pos.x = center.x; + pos.y = center.y; + return; + } + } + } + } +} + +/** + * Растолкать круг a из круга b: a сдвигается на глубину перекрытия + * (двигаем только a — каждый актор выталкивает себя сам). Слившиеся + * в точку круги расходятся по +X (детерминированный фолбэк). + */ +export function separateCircles(a: Vec2, ra: number, b: Vec2, rb: number): void { + const dx = a.x - b.x; + const dy = a.y - b.y; + const d = Math.hypot(dx, dy); + const overlap = ra + rb - d; + if (overlap <= 0) return; + if (d < 1e-6) { + a.x += overlap; + return; + } + const k = overlap / d; + a.x += dx * k; + a.y += dy * k; +} \ No newline at end of file