diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 6998ddb..05770eb 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -208,6 +208,8 @@ dialogue: DialogueSystem; cutscene: CutsceneRunner; lastToast(): { text: string; tick: number } | null; + /** Ближайшая цель кнопки действия (или null) — из роутера взаимодействия. */ + interactHint(): { kind: string; label: string; tile: { x: number; y: number } } | null; inHazard(): HazardDef | null; /** Урон герою полным боевым путём (как у сгустков — for scene:damagePlayer). */ damagePlayer(damage: number, from?: Vec2): void; @@ -275,7 +277,8 @@ lighting: lightingLayer(d.lighting().snapshot()).lighting, dialogue: dialogueLayer(d.dialogue.agentState).dialogue, cutscene: { active: d.cutscene.active }, - lastToast: d.lastToast() + lastToast: d.lastToast(), + interactHint: d.interactHint() }; return layer; } diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 97d4af1..beb287d 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -164,7 +164,8 @@ lighting: { ambient: 0xffffff, vignette: 0, sources: [] }, dialogue: null, cutscene: null, - lastToast: null + lastToast: null, + interactHint: null }; it('все top-level ключи сцены и слоя игры есть в списке', () => { diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index a98b1cc..95d0535 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -22,6 +22,7 @@ 'dialogue', 'cutscene', 'lastToast', + 'interactHint', // слой игры (gameLayer) 'flags', 'vars', @@ -142,6 +143,8 @@ cutscene: { active: boolean } | null; /** Последний тост (текст + тик) — единственный канал текста реакций. */ lastToast: { text: string; tick: number } | null; + /** Ближайшая цель кнопки действия (или null — вне радиуса взаимодействия). */ + interactHint: { kind: string; label: string; tile: { x: number; y: number } } | null; } /** Время суток (ход игры — непрерывный, 1 с реального = 1 игровая минута). */ diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 75bff31..b8b96af 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -20,6 +20,7 @@ engine.input.bindActions({ advance: ['Space', 'Enter'], attack: ['Space'], // вне диалога: тап — удар, удержание — резонанс + interact: ['KeyE'], // действие вблизи: NPC/объекты/цветы/переходы (только в локации) menu: ['Escape'], inventory: ['KeyI'], debug: ['F3'], @@ -33,6 +34,7 @@ engine.input.bindGamepad({ advance: [0], // A attack: [2], // X + interact: [0], // A — действие в локации (в диалоге та же кнопка = advance) menu: [9], // Start inventory: [3], // Y up: [12], diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index f0b0f4b..d0cc433 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -53,6 +53,7 @@ import { Interactables } from '../systems/Interactables'; import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; import { SceneObjects } from '../systems/SceneObjects'; +import { InteractHint } from '../systems/InteractHint'; import { LocationAudio } from '../systems/LocationAudio'; import { InteractableViews, makeExitGlow } from '../systems/InteractableViews'; import { LocationHud } from '../systems/LocationHud'; @@ -105,6 +106,8 @@ private uiFx: FxLayer; /** Маршрутизация клика/переходов/отложенных взаимодействий. */ private router: InteractionRouter; + /** Подпись «[E] Имя» над ближайшей целью кнопки действия. */ + private hint: InteractHint; /** Интерактивные объекты области (сундуки, очаги, прилавки). */ private interactables: Interactables; /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */ @@ -426,6 +429,10 @@ ); this.game.renderer.uiRoot.addChild(this.flow.healthBar); + // Подпись «[E] Имя» над ближайшей целью действия: поверх акторов мира. + this.hint = new InteractHint(); + this.world.addChild(this.hint.view); + // Агентный мост сцены (снапшот/инварианты/команды). this.agentView = new SceneAgentView({ game: this.game, @@ -441,6 +448,7 @@ dialogue: this.dialogue, cutscene: this.cutscene, lastToast: () => this.lastToast, + interactHint: () => this.router.interactTarget(), inHazard: () => this.atmosphere.inHazard, damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position), lighting: () => this.lighting, @@ -480,7 +488,7 @@ // Первая подсказка сбора: как брать лунные колокольчики (один раз). if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) { this.game.state.setFlag(FLAGS.hint_bells); - this.showToast('Лунные колокольчики в земле: подойди и щёлкни по цветку.'); + this.showToast('Лунные колокольчики в земле: подойди и нажми E.'); } } @@ -501,6 +509,7 @@ this.lighting.destroy(); this.lightView.destroy({ children: true }); this.hud.destroy(); + this.hint.destroy(); this.joystick.destroy({ children: true }); this.debug.view.destroy({ children: true }); this.charDebug.view.destroy({ children: true }); @@ -523,6 +532,9 @@ // на паузе, пока активна хоть одна из них; камера при кат-сцене — // под контролем раннера (follow не тикаем). const input = this.game.engine.input; + // Гвард «было занято до тика»: клавиша, закрывшая диалог (advance/attack + // мульти-биндинг), не должна в тот же тик ударить/переоткрыть разговор. + const wasBusy = this.cutscene.active || this.dialogue.active; if (this.cutscene.active) this.cutscene.update(dt); if (this.dialogue.active) { // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор. @@ -563,12 +575,16 @@ } // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- - if (input.isActionJustPressed('attack')) { + if (!wasBusy && input.isActionJustPressed('attack')) { this.flow.onAttackPressed(); } - if (input.isActionJustReleased('attack')) { + if (!wasBusy && input.isActionJustReleased('attack')) { this.flow.onAttackReleased(); } + // --- кнопка действия: ближайшая цель в радиусе (E / геймпад A) --- + if (!wasBusy && input.isActionJustPressed('interact')) { + this.router.resolveInteract(); + } const pointer = input.getPointer(); if (pointer.justPressed) { @@ -590,8 +606,9 @@ // Мигание героя при неуязвимости: duty blink-оживителя живой. this.heroMotion.params.blink!.duty = this.flow.playerCombat.invuln ? 0.5 : 1; - if (this.joystick.active) { - this.player.moveFree(this.joystick.getVector(), dt); + const mv = this.moveVector(); + if (mv.x !== 0 || mv.y !== 0) { + this.player.moveFree(mv, dt); // нулевой вектор в moveFree = stop() — не вызываем } else { this.player.update(dt); } @@ -602,12 +619,8 @@ this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); this.router.checkTransitions(tile); - // Подписи объектов: видны вблизи, пока объект не использован (once). - for (const iv of this.interactViews.items) { - iv.label.visible = - this.router.inInteractRange(iv.def.tile.x, iv.def.tile.y) && - !(iv.def.once && this.interactables.isUsed(iv.def.id)); - } + // Подпись кнопки действия: одна вьюха над ближайшей целью (или скрыта). + this.hint.show(this.router.interactTarget()); // Отладочные строки собираются только при видимой панели (F3): иначе — // три template-строки и массив каждый тик впустую. @@ -624,6 +637,19 @@ render(): void {} + /** Вектор прямого движения: тач-джойстик приоритетнее, иначе клавиши + стик геймпада. */ + private moveVector(): Vec2 { + if (this.joystick.active) return this.joystick.getVector(); + const input = this.game.engine.input; + const stick = input.getLeftStick(); + const x = (input.isActionActive('right') ? 1 : 0) - (input.isActionActive('left') ? 1 : 0); + const y = (input.isActionActive('down') ? 1 : 0) - (input.isActionActive('up') ? 1 : 0); + return { + x: Math.max(-1, Math.min(1, (stick?.x ?? 0) + x)), + y: Math.max(-1, Math.min(1, (stick?.y ?? 0) + y)) + }; + } + // ---------- агентный мост (SceneAgent → SceneAgentView) ---------- /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index 39ade51..9550ba3 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -182,6 +182,12 @@ /** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ const INTERACT_RANGE = 1.5; +/** Дистанция до центра тайла цели: истинная евклидова линейка (юнит = 1 тайл). */ +function tileDist(playerPos: Vec2, tx: number, ty: number): number { + const c = tileToWorld(tx, ty); + return Math.hypot(c.x - playerPos.x, c.y - playerPos.y); +} + /** * Радиус взаимодействия: истинная евклидова линейка (юнит = 1 тайл), не * проекционная. Проекция анизотропна (2:1) — «юниты» в ней вытянуты вдоль @@ -190,8 +196,56 @@ * сосед 1.414 ✓ (findPathToNeighbor приводит в него), 2 тайла ✗. */ export function inInteractRangePos(playerPos: Vec2, tx: number, ty: number): boolean { - const c = tileToWorld(tx, ty); - return Math.hypot(c.x - playerPos.x, c.y - playerPos.y) <= INTERACT_RANGE; + return tileDist(playerPos, tx, ty) <= INTERACT_RANGE; +} + +/** Кандидат кнопки действия: то, с чем можно взаимодействовать в радиусе. */ +export interface InteractCandidate { + kind: 'npc' | 'transition' | 'locked' | 'interact' | 'flower'; + tile: { x: number; y: number }; + /** Дистанция от героя до центра тайла (юниты) — решает выбор. */ + dist: number; + /** Человекочитаемое имя для подписи «[E] …». */ + label: string; + npc?: NpcDef; + inter?: InteractableDef; + trans?: TransitionDef; + /** Текст «заперто» для kind 'locked'. */ + text?: string; + /** Тайл цветка (kind 'flower'). */ + x?: number; + y?: number; +} + +/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */ +const PICK_PRIORITY: Record = { + npc: 0, + transition: 1, + locked: 1, + interact: 2, + flower: 3 +}; + +/** Эпсилон ничьей по дистанции (юниты): ближе — выигрывает дистанция, иначе приоритет. */ +const PICK_EPS = 0.05; + +/** + * Выбор цели кнопки действия: ближайший кандидат в радиусе; при ничьей + * (~эпсилон) — по приоритету kind. Чистая функция — тестируется в Vitest. + */ +export function pickInteract(candidates: InteractCandidate[]): InteractCandidate | null { + let best: InteractCandidate | null = null; + for (const c of candidates) { + if (c.dist > INTERACT_RANGE) continue; + if ( + best === null || + c.dist < best.dist - PICK_EPS || + (c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind]) + ) { + best = c; + } + } + return best; } /** @@ -355,6 +409,88 @@ this.deps.callbacks.useTransition(pick.def); } + // ---------- кнопка действия: ближайшая цель в радиусе ---------- + + /** Кандидаты кнопки действия вокруг героя: NPC, click-переходы, интерактивы, цветы. */ + interactCandidates(): InteractCandidate[] { + const player = this.deps.player.position; + const heroTile = this.deps.player.currentTile(); + const out: InteractCandidate[] = []; + for (const def of this.deps.objects.npcs()) { + out.push({ + kind: 'npc', + npc: def, + tile: def.tile, + dist: tileDist(player, def.tile.x, def.tile.y), + label: def.name + }); + } + for (const t of this.deps.area.transitions) { + if ((t.trigger ?? 'step') !== 'click') continue; + const pick = resolveTransition(this.deps.area.transitions, t.tile, 'click', this.transitionCtx(), null); + if (!pick) continue; + out.push({ + kind: pick.ok ? 'transition' : 'locked', + trans: pick.ok ? pick.def : undefined, + text: pick.ok ? undefined : pick.lockedText, + tile: t.tile, + dist: tileDist(player, t.tile.x, t.tile.y), + label: t.label ?? 'проход' + }); + } + for (const def of this.deps.area.interactables ?? []) { + if (def.once && this.deps.interactables.isUsed(def.id)) continue; + out.push({ + kind: 'interact', + inter: def, + tile: def.tile, + dist: tileDist(player, def.tile.x, def.tile.y), + label: def.label ?? def.id + }); + } + // Цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области. + if (this.deps.area.harvestTile !== undefined) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const x = heroTile.x + dx; + const y = heroTile.y + dy; + if (!this.isFlower(x, y)) continue; + out.push({ kind: 'flower', x, y, tile: { x, y }, dist: tileDist(player, x, y), label: 'лунный колокольчик' }); + } + } + } + return out; + } + + /** Ближайшая цель кнопки действия — для подписи «[E] имя» и снапшота моста. */ + interactTarget(): { kind: InteractCandidate['kind']; label: string; tile: { x: number; y: number } } | null { + const c = pickInteract(this.interactCandidates()); + return c && { kind: c.kind, label: c.label, tile: c.tile }; + } + + /** Кнопка действия: сработать ближайшей целью в радиусе (вне радиуса — ничего). */ + resolveInteract(): void { + const c = pickInteract(this.interactCandidates()); + if (!c) return; + switch (c.kind) { + case 'npc': + this.deps.callbacks.talkTo(c.npc!); + return; + case 'transition': + this.deps.callbacks.useTransition(c.trans!); + return; + case 'locked': + this.deps.callbacks.showToast(c.text ?? 'Не пускает.'); + this.deps.callbacks.playUiClick(); + return; + case 'interact': + this.deps.interactables.tryInteract(c.inter!); + return; + case 'flower': + this.deps.callbacks.collectFlower(c.x!, c.y!); + } + } + /** Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла. */ requestTalk(def: NpcDef): void { this.pendingInteraction = null; diff --git a/apps/game/src/systems/InteractHint.ts b/apps/game/src/systems/InteractHint.ts new file mode 100644 index 0000000..500047b --- /dev/null +++ b/apps/game/src/systems/InteractHint.ts @@ -0,0 +1,42 @@ +import { Container, PixelText, worldToScreen } from '@rpg/engine'; + +/** Цель кнопки действия (от роутера): kind — для будущих иконок. */ +export interface InteractTargetInfo { + kind: string; + label: string; + tile: { x: number; y: number }; +} + +/** + * Подпись «[E] Имя» над ближайшей целью кнопки действия. Один вью на сцену + * (в worldRoot — масштабируется зумом камеры): позиция над тайлом цели, + * видна только когда цель есть. Заменяет прежние подписи per-object — + * NPC впервые получают подписи тоже. + */ +export class InteractHint { + readonly view = new Container(); + private text: PixelText; + + constructor() { + this.text = new PixelText({ text: '', size: 9, color: 0xd8c79a }); + this.text.anchor.set(0.5, 1); + this.view.addChild(this.text); + this.view.visible = false; + } + + /** Показать цель (null — скрыть). Центр тайла переводится в px внутри worldRoot. */ + show(target: InteractTargetInfo | null): void { + if (!target) { + this.view.visible = false; + return; + } + const c = worldToScreen(target.tile.x + 0.5, target.tile.y + 0.5); + this.text.text = `[E] ${target.label}`; + this.view.position.set(Math.round(c.x), Math.round(c.y) - 20); + this.view.visible = true; + } + + destroy(): void { + this.view.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/LocationHud.ts b/apps/game/src/systems/LocationHud.ts index 8bc6b59..e24233c 100644 --- a/apps/game/src/systems/LocationHud.ts +++ b/apps/game/src/systems/LocationHud.ts @@ -20,7 +20,7 @@ this.motesLabel.position.set(474, 17); // под названием локации this.view.addChild(this.motesLabel); const hint = new PixelText({ - text: 'клик — идти/атаковать · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', + text: 'WASD — идти · E — действие · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', size: 10, color: 0x999988 }); diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts index ca8b3f5..707e079 100644 --- a/apps/game/src/systems/SceneObjects.ts +++ b/apps/game/src/systems/SceneObjects.ts @@ -52,6 +52,16 @@ return (this.registry.at(x, y, 'npc')?.ref as NpcDef | undefined) ?? null; } + /** Все NPC сцены (кандидаты кнопки действия): статичные + размещения по флагам. */ + npcs(): NpcDef[] { + const out: NpcDef[] = []; + for (const obj of this.registry.byKind('npc')) { + const def = obj.ref as NpcDef | undefined; + if (def) out.push(def); + } + return out; + } + /** * Дополнительный NPC после создания сцены — размещения по флагам (акт 2): * попадает и в клик-роутинг, и в кэш занятых тайлов (если тот уже построен). diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts index f178751..deec9aa 100644 --- a/apps/game/src/systems/__tests__/ClickRouting.test.ts +++ b/apps/game/src/systems/__tests__/ClickRouting.test.ts @@ -2,10 +2,12 @@ import { worldToScreen, type Vec2 } from '@rpg/engine'; import { inInteractRangePos, + pickInteract, pointerToWorld, resolveClick, resolvePixelTile, - type ClickProbe + type ClickProbe, + type InteractCandidate } from '../ClickRouting'; import { TILES } from '../../data/map'; import type { NpcDef } from '../../data/npcs'; @@ -134,6 +136,55 @@ }); }); +describe('pickInteract — выбор цели кнопки действия', () => { + /** Кандидат-заглушка: тайл задаёт дистанцию (герой в (0,0)). */ + function cand( + kind: InteractCandidate['kind'], + tile: { x: number; y: number }, + label = 'цель' + ): InteractCandidate { + return { kind, tile, dist: Math.hypot(tile.x, tile.y), label }; + } + + it('ближайший кандидат в радиусе — он', () => { + const c = pickInteract([cand('npc', { x: 2, y: 0 }), cand('interact', { x: 1, y: 0 })]); + expect(c?.label).toBe('цель'); + expect(c?.kind).toBe('interact'); + expect(c?.tile).toEqual({ x: 1, y: 0 }); + }); + + it('вне радиуса (два тайла и дальше) — не выбирается', () => { + expect(pickInteract([cand('npc', { x: 2, y: 0 })])).toBeNull(); + expect(pickInteract([])).toBeNull(); + }); + + it('ничья по дистанции — приоритет NPC выше интерактива', () => { + const c = pickInteract([cand('interact', { x: 1, y: 0 }), cand('npc', { x: 0, y: 1 })]); + expect(c?.kind).toBe('npc'); + }); + + it('почти-ничья (в эпсилоне) — приоритет: переход выше цветка', () => { + const c = pickInteract([cand('flower', { x: 1, y: 1 }), cand('transition', { x: 1.04, y: 0 })]); + expect(c?.kind).toBe('transition'); + }); + + it('заметно ближе — дистанция побеждает приоритет', () => { + const c = pickInteract([cand('npc', { x: 1, y: 0 }), cand('flower', { x: 0.5, y: 0 })]); + expect(c?.kind).toBe('flower'); + }); + + it('заперто — кандидат locked с текстом', () => { + const c: InteractCandidate = { + kind: 'locked', + tile: { x: 1, y: 0 }, + dist: 1, + label: 'дверь', + text: 'Заперто.' + }; + expect(pickInteract([c])?.text).toBe('Заперто.'); + }); +}); + /** Карта 4×4: весь — трава, базовый тайл (3,3) — высокое дерево. */ function mapData(treeAt: { x: number; y: number } | null = { x: 3, y: 3 }) { const tiles = new Array(16).fill(TILES.GRASS);