diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 8c0060c..aeaa1e9 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -10,6 +10,7 @@ isoToScreen, screenToIsoExact, inCircle, + findPathToNeighbor, DebugOverlay, VirtualJoystick, DEFAULT_ISO, @@ -60,6 +61,11 @@ /** Цель, выбранная кликом по врагу (авто-подход и удар). */ private target: Entity | null = null; private repathTimer = 0; + /** Отложенное взаимодействие: сработает, когда герой подойдёт. */ + private pendingInteraction: + | { kind: 'talk'; def: NpcDef } + | { kind: 'flower'; x: number; y: number } + | null = null; /** Таймер мигания при неуязвимости (свой, не боевой). */ private blinkT = 0; /** Тач-джойстик (активен только для касаний). */ @@ -286,6 +292,7 @@ } else { this.player.update(dt); } + this.resolvePendingInteraction(); const tile = this.player.currentTile(); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); @@ -451,19 +458,19 @@ DEFAULT_ISO ); - // Клик по тайлу NPC — диалог. + // Клик по тайлу NPC — диалог (в радиусе сразу, издалека — подходим). if (clicked) { const npc = this.npcs.find( (n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y ); if (npc) { - this.talkTo(npc.def); + this.requestTalk(npc.def); return; } // Клик по лунному колокольчику (пруды) — собрать цветок. if (this.location.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { - this.collectFlower(clicked.x, clicked.y); + this.requestCollect(clicked.x, clicked.y); return; } } @@ -480,9 +487,64 @@ } this.target = null; + this.pendingInteraction = null; this.player.onWorldClick(worldX, worldY); } + /** Радиус взаимодействия: расстояние от ног героя до центра тайла цели. */ + private static readonly INTERACT_RANGE = 44; + + private inInteractRange(targetPx: Vec2): boolean { + return inCircle(this.player.position, LocationScene.INTERACT_RANGE, targetPx); + } + + /** + * Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла, + * диалог начнётся на месте. force — сюжетное исключение без подхода. + */ + private requestTalk(def: NpcDef, force = false): void { + this.pendingInteraction = null; + const center = this.tileCenter(def.tile.x, def.tile.y); + if (force || this.inInteractRange(center)) { + this.talkTo(def); + return; + } + const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile); + if (path) { + this.pendingInteraction = { kind: 'talk', def }; + this.player.followPath(path); + } + } + + /** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */ + private requestCollect(x: number, y: number): void { + this.pendingInteraction = null; + const center = this.tileCenter(x, y); + if (this.inInteractRange(center)) { + this.collectFlower(x, y); + return; + } + const path = findPathToNeighbor(this.map, this.player.currentTile(), { x, y }); + if (path) { + this.pendingInteraction = { kind: 'flower', x, y }; + this.player.followPath(path); + } + } + + /** Сработать отложенным взаимодействием, когда герой остановился. */ + private resolvePendingInteraction(): void { + if (!this.pendingInteraction || this.player.moving) return; + const p = this.pendingInteraction; + this.pendingInteraction = null; + if (p.kind === 'talk') { + const center = this.tileCenter(p.def.tile.x, p.def.tile.y); + if (this.inInteractRange(center)) this.talkTo(p.def); + } else { + const center = this.tileCenter(p.x, p.y); + if (this.inInteractRange(center)) this.collectFlower(p.x, p.y); + } + } + /** id тайла карты (для кликов по сборным объектам). */ private tileId(x: number, y: number): number { return this.map.data.tiles[y * this.map.data.width + x]; diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index e994d7f..9fd6424 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -98,11 +98,17 @@ } const path = findPath(this.map, this.currentTile(), tile, false); if (path && path.length > 0) { - this.path = path; - this.advanceWaypoint(); + this.followPath(path); } } + /** Пойти по готовому пути тайлов (например, к краю занятого NPC/объектом тайла). */ + followPath(path: { x: number; y: number }[]): void { + if (path.length === 0) return; + this.path = path; + this.advanceWaypoint(); + } + update(dt: number): void { if (!this.waypoint) return; this.pos = moveTo(this.pos, this.waypoint, this.speed * dt); diff --git a/docs/demo.md b/docs/demo.md index d7123d3..1f331d5 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -76,6 +76,8 @@ мульти-биндинг (одна клавиша → несколько действий). - `core/StateMachine.transitionAny` — переход из любого состояния. - `map/IsometricTileMap.setTile` — изменение тайла с перерисовкой ячейки. +- `map/pathfinding.findPathToNeighbor` — путь к краю занятого/непроходимого тайла + (гейтинг взаимодействий по дистанции в демо). ## Дорожная карта (что осталось за срезом) diff --git a/docs/engine/maps.md b/docs/engine/maps.md index f633c1c..20701e2 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -55,6 +55,16 @@ Диагонали включаются параметром `allowDiagonal = true`, но **без среза углов**: диагональный шаг разрешён, только если оба ортогональных соседа проходимы. +Для взаимодействия с занятым тайлом (NPC, объект) есть `findPathToNeighbor`: +цель может быть непроходимой — путь проложится к ближайшей проходимой клетке +рядом с ней (8-соседство), а если цель проходима — обычный путь к ней: + +```ts +import { findPathToNeighbor } from '@rpg/engine'; + +const path = findPathToNeighbor(map, from, npcTile); // к краю тайла NPC +``` + ## Формат карт (JSON + RLE) Карты можно хранить файлами: `encodeMap` упаковывает тайлы в RLE (пары `[id, длина]`), diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 4e5ad8e..d318817 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -51,7 +51,7 @@ // map export { IsometricTileMap, type TileMapData, type TallSpec } from './map/IsometricTileMap'; -export { findPath, type Grid } from './map/pathfinding'; +export { findPath, findPathToNeighbor, type Grid } from './map/pathfinding'; export { encodeMap, parseMap, diff --git a/packages/engine/src/map/__tests__/pathfinding.test.ts b/packages/engine/src/map/__tests__/pathfinding.test.ts index ecc8e2c..eb101ce 100644 --- a/packages/engine/src/map/__tests__/pathfinding.test.ts +++ b/packages/engine/src/map/__tests__/pathfinding.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { findPath, type Grid } from '../pathfinding'; +import { findPath, findPathToNeighbor, type Grid } from '../pathfinding'; function makeGrid(width: number, height: number, walls: string[] = []): Grid { const wallSet = new Set(walls); @@ -64,4 +64,28 @@ const path = findPath(grid, { x: 0, y: 0 }, { x: 1, y: 1 }, true); expect(path).toEqual([{ x: 1, y: 1 }]); }); -}); \ No newline at end of file +}); +describe('findPathToNeighbor', () => { + it('цель проходима — всё равно останавливаемся рядом', () => { + const path = findPathToNeighbor(makeGrid(10, 10), { x: 0, y: 0 }, { x: 2, y: 0 }); + const end = path!.at(-1)!; + expect(Math.max(Math.abs(end.x - 2), Math.abs(end.y - 0))).toBe(1); + }); + + it('цель занята — путь к соседней клетке', () => { + const grid = makeGrid(10, 10, ['5,5']); + const path = findPathToNeighbor(grid, { x: 0, y: 5 }, { x: 5, y: 5 }); + expect(path).not.toBeNull(); + const end = path!.at(-1)!; + expect(Math.max(Math.abs(end.x - 5), Math.abs(end.y - 5))).toBe(1); + }); + + it('цель окружена — путь не найден', () => { + const grid = makeGrid(5, 5, [ + '1,1', '2,1', '3,1', + '1,2', '2,2', '3,2', + '1,3', '2,3', '3,3' + ]); + expect(findPathToNeighbor(grid, { x: 0, y: 4 }, { x: 2, y: 2 })).toBeNull(); + }); +}); diff --git a/packages/engine/src/map/pathfinding.ts b/packages/engine/src/map/pathfinding.ts index 5059b19..7089f59 100644 --- a/packages/engine/src/map/pathfinding.ts +++ b/packages/engine/src/map/pathfinding.ts @@ -90,4 +90,28 @@ } return null; -} \ No newline at end of file +} +/** + * Путь к ближайшей проходимой клетке, соседней с goal (8-соседство). + * Идём к краю цели: goal может быть непроходим (тайл NPC/объекта) или занят — + * стоять на нём не нужно. Пути нет — null. + */ +export function findPathToNeighbor( + grid: Grid, + start: { x: number; y: number }, + goal: { x: number; y: number } +): { x: number; y: number }[] | null { + let best: { x: number; y: number }[] | null = null; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const tx = goal.x + dx; + const ty = goal.y + dy; + if (tx < 0 || ty < 0 || tx >= grid.width || ty >= grid.height) continue; + if (!grid.isWalkable(tx, ty)) continue; + const p = findPath(grid, start, { x: tx, y: ty }); + if (p && (best === null || p.length < best.length)) best = p; + } + } + return best; +} diff --git a/tools/far-test.mjs b/tools/far-test.mjs new file mode 100644 index 0000000..56beb2e --- /dev/null +++ b/tools/far-test.mjs @@ -0,0 +1,46 @@ +import puppeteer from 'puppeteer-core'; +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); +page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); +const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); +await page.goto('http://localhost:5201/', { waitUntil: 'networkidle2', timeout: 20000 }); +await new Promise((r) => setTimeout(r, 2000)); +await page.mouse.click(480, 283); +await booted; +await new Promise((r) => setTimeout(r, 1500)); + +// Клик по тайлу Милы (16,12) издалека. +const pt = await page.evaluate(() => { + const g = window.__game; + const sc = g.scenes.current; + const p = sc.tileCenter(16, 12); + const rect = document.querySelector('canvas').getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + return { x: rect.left + ((p.x + wr.x) / 480) * rect.width, + y: rect.top + ((p.y + wr.y) / 270) * rect.height }; +}); +await page.mouse.click(pt.x, pt.y); +await new Promise((r) => setTimeout(r, 400)); + +const moving = await page.evaluate(() => window.__game.scenes.current.player.moving); +console.log(`Герой пошёл: ${moving}`); + +// Ждём, пока диалог начнётся (подошёл и заговорил) +for (let i = 0; i < 20; i++) { + const st = await page.evaluate(() => ({ + active: window.__game.scenes.current.dialogue.active, + tile: window.__game.scenes.current.player.currentTile() + })); + if (st.active) { + console.log(`Диалог начался, герой на тайле (${st.tile.x},${st.tile.y}) — рядом с Милой (16,12)`); + break; + } + await new Promise((r) => setTimeout(r, 200)); +} +await page.screenshot({ path: '/tmp/far_check.png' }); +await browser.close();