diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index c7b4211..a897638 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -36,6 +36,7 @@ import { InteractionRouter, resolvePixelTile } from '../systems/ClickRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; +import { ActorWalker } from '../systems/cutscene/CutsceneActors'; import { DIALOGUES } from '../data/dialogues'; import { questDialogueFor } from '../data/quests'; import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; @@ -117,6 +118,8 @@ private eventOffs: (() => void)[] = []; /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ private cutscene = new CutsceneRunner(); + /** Ходоки актёров кат-сцен, по def NPC (создаются лениво). */ + private walkers = new Map(); /** Тач-джойстик (активен только для касаний). */ private joystick: VirtualJoystick; private debug: DebugOverlay; @@ -494,12 +497,14 @@ this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам this.atmosphere.updateVignette(); this.fauna.update(dt); - // Кат-сцена: мир на паузе, камера под контролем раннера. - if (this.cutscene.active) { - this.cutscene.update(dt); - return; - } + // Ходоки актёров: тикаются всегда (исполнители until в кат-сценах). + if (this.walkers.size > 0) for (const w of this.walkers.values()) w.update(dt); + // Кат-сцена и диалог живут в одном тике: сцена может вести диалог + // посреди кат-сцены (call(talkTo) + until(диалог закрыт)). Геймплей + // на паузе, пока активна хоть одна из них; камера при кат-сцене — + // под контролем раннера (follow не тикаем). const input = this.game.engine.input; + if (this.cutscene.active) this.cutscene.update(dt); if (this.dialogue.active) { // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор. this.dialogue.update(dt); @@ -508,8 +513,8 @@ if (input.getPointer().justPressed || input.isActionJustPressed('advance')) { this.dialogue.advance(); } - return; } + if (this.cutscene.active || this.dialogue.active) return; if (this.game.engine.scenes.transitioning) return; if (input.isActionJustPressed('menu')) { @@ -614,6 +619,19 @@ // ---------- остальное ---------- + /** Ходок актёра по def (лениво); depth-слой обновляется при смене тайла. */ + walkerFor(def: NpcDef): ActorWalker { + let walker = this.walkers.get(def); + if (!walker) { + const record = this.npcs.find((n) => n.def === def); + walker = new ActorWalker(this.map, record!.view, def.tile, (t) => + this.actors.setDepth(record!.view, t.x, t.y) + ); + this.walkers.set(def, walker); + } + return walker; + } + private updateCameraFollow(snap = false): void { // Непрерывное следование за ногами героя; snap — телепорты/спавн. const p = this.player.position; diff --git a/apps/game/src/systems/cutscene/CutsceneActors.ts b/apps/game/src/systems/cutscene/CutsceneActors.ts new file mode 100644 index 0000000..e6b51e1 --- /dev/null +++ b/apps/game/src/systems/cutscene/CutsceneActors.ts @@ -0,0 +1,110 @@ +import { + findPath, + tileToWorld, + worldToScreen, + worldToTile, + type Container, + type CutsceneStep, + type IsometricTileMap, + type Vec2 +} from '@rpg/engine'; + +/** + * Актёры кат-сцен: ходок по A*-пути для вьюхи NPC и фабрики шагов. + * Мир на время сцены на паузе — ходок без боя/расталкивания, тикается + * сценой независимо от раннера (исполнитель шага until). + */ + +/** Скорость ходьбы актёра по умолчанию (юниты/сек) — чуть медленнее героя. */ +const WALKER_SPEED = 1.4; + +/** Таймаут ожидания ходьбы по умолчанию (сек) — страховка скипа/зависания. */ +const WALK_TIMEOUT = 30; + +/** + * Ходок: ведёт вьюху по пути A* в мировых юнитах, синхронизирует вью + * (округление до px — единственная точка мир→экран, как у героя). + */ +export class ActorWalker { + private pos: Vec2; + private waypoints: Vec2[] = []; + + constructor( + private map: IsometricTileMap, + private view: Container, + startTile: { x: number; y: number }, + /** Смена тайла (для depth-слоя сцены). */ + private onTile?: (tile: Vec2) => void, + private speed = WALKER_SPEED + ) { + this.pos = tileToWorld(startTile.x, startTile.y); + } + + /** Идёт ли сейчас (шаг until должен ждать). */ + get done(): boolean { + return this.waypoints.length === 0; + } + + get position(): Vec2 { + return { x: this.pos.x, y: this.pos.y }; + } + + /** Построить путь к тайлу и пойти. Непроходимый тайл — остаётся на месте. */ + walkTo(tile: { x: number; y: number }): void { + const from = worldToTileClamped(this.pos, this.map); + const path = findPath(this.map, from, tile, false); + this.waypoints = path ? path.map((t) => tileToWorld(t.x, t.y)) : []; + } + + update(dt: number): void { + if (this.waypoints.length === 0) return; + const target = this.waypoints[0]!; + const dx = target.x - this.pos.x; + const dy = target.y - this.pos.y; + const dist = Math.hypot(dx, dy); + const step = this.speed * dt; + let crossedTile = false; + if (dist <= step) { + this.pos = { x: target.x, y: target.y }; + this.waypoints.shift(); + crossedTile = true; + } else { + this.pos = { x: this.pos.x + (dx / dist) * step, y: this.pos.y + (dy / dist) * step }; + } + this.syncView(); + if (crossedTile) this.onTile?.(worldToTileClamped(this.pos, this.map)); + } + + /** Вью-позиция: округление до px (вьюха лежит в worldRoot — мировые px). */ + private syncView(): void { + const s = worldToScreen(this.pos.x, this.pos.y); + this.view.position.set(Math.round(s.x), Math.round(s.y)); + } +} + +/** Тайл под позицией (вне карты — ближайший краевой, как у героя). */ +function worldToTileClamped(pos: Vec2, map: IsometricTileMap): Vec2 { + const w = map.data.width; + const h = map.data.height; + const t = worldToTile(pos.x, pos.y, w, h); + return ( + t ?? { + x: Math.min(w - 1, Math.max(0, Math.floor(pos.x))), + y: Math.min(h - 1, Math.max(0, Math.floor(pos.y))) + } + ); +} + +/** Шаги «актёр идёт к тайлу»: запуск + ожидание прихода (со страховкой). */ +export function npcWalkSteps(walker: ActorWalker, tile: Vec2, timeout = WALK_TIMEOUT): CutsceneStep[] { + return [ + { kind: 'call', fn: () => walker.walkTo(tile) }, + { kind: 'until', predicate: () => walker.done, label: `актёр к ${tile.x},${tile.y}`, timeout } + ]; +} + +/** Шаг «вьюха плавно уходит в мировую точку» (глайд без пути — по прямой). */ +export function npcMoveView(view: Container, toWorld: Vec2, seconds: number): CutsceneStep { + const p = worldToScreen(toWorld.x, toWorld.y); + return { kind: 'moveView', view, x: p.x, y: p.y, seconds }; +} \ No newline at end of file