diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 4394bdd..849cdd0 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -57,7 +57,7 @@ menu.setItems([ { label: 'Продолжить', onSelect: () => loadGame() }, { label: 'Новая игра', onSelect: () => newGame() }, - { label: 'Выход', onSelect: () => window.close() } + { label: '— пусто —', disabled: true } // недоступный пункт: курсор пропускает, клик игнорируется ]); uiRoot.addChild(menu); @@ -68,7 +68,42 @@ ``` Навигация зациклена (после последнего пункта — первый). Логика курсора — чистый -класс `ListCursor`, покрыт тестами. +класс `ListCursor` (с предикатом выбираемости для disabled), покрыт тестами. + +## Меню-сцены: MenuSceneBase + menuInput + +Панель поверх вызывающей сцены (push/pop) с клавиатурной навигацией — не +копируйте «up/down/confirm/cancel → moveCursor/activate + гард перехода» в +каждую сцену; наследуйтесь от `MenuSceneBase`: + +```ts +class SettingsScene extends MenuSceneBase { + constructor(game: Game, onBack: () => void) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', left: 'left', right: 'right' } + ); + this.onBack = onBack; + } + protected build(): void { + // панель, заголовок, this.menu = new MenuList(...), uiRoot.addChild(this.view) + } + protected onAdjust(delta: number, index: number): void { /* громкость ±0.1 */ } + protected onCancel(): void { this.onBack(); } +} +``` + +Хуки сцены: `onMove`/`onConfirm` (дефолты — `menu.moveCursor/activate`), +`onAdjust` (left/right для значений вроде громкости), `onCancel` (Esc), +`onAction(action)` (extra: удаление сейва, открытие сумки). Имена действий +движок не знает — карта `MenuActionMap` задаётся сценой (бинды живут в игре). +Чтение ввода — чистая функция `readMenuInput(input, index, map)`: +порядок за тик `cancel → move → (adjust XOR confirm) → extra`; нажатые +left/right подавляют confirm, чтобы Enter в настройках не давал двойной шаг. + +Сцены под push-сценой **не обновляются** (`SceneManager.update` тикает только +верхнюю) — «пауза под меню» получается бесплатно; это осознанный контракт: +флага «обновлять сцену под меню» в движке нет. ## DialogueRunner (графы диалогов) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index cde1b36..31ac1c0 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -174,7 +174,9 @@ export { PixelText, type PixelTextOptions, ensurePixelFont, setPixelTextResolution } from './ui/PixelText'; export { Panel, type PanelOptions } from './ui/Panel'; export { Button, type ButtonOptions } from './ui/Button'; -export { MenuList, type MenuListOptions } from './ui/MenuList'; +export { MenuList, type MenuListOptions, type MenuItem } from './ui/MenuList'; +export { readMenuInput, type MenuActionMap, type MenuNavEvent, type MenuInputSource } from './ui/menuInput'; +export { MenuSceneBase, type MenuSceneContext } from './ui/MenuSceneBase'; // audio export { AudioManager, type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './audio/AudioManager'; diff --git a/packages/engine/src/ui/Button.ts b/packages/engine/src/ui/Button.ts index 175db00..53ba64b 100644 --- a/packages/engine/src/ui/Button.ts +++ b/packages/engine/src/ui/Button.ts @@ -10,6 +10,8 @@ width: number; height: number; size?: number; + /** Недоступная кнопка: приглушена, клик/активация игнорируются. */ + disabled?: boolean; onSelect?: () => void; } @@ -22,6 +24,8 @@ focused: { fill: 0x1e1e2a, border: 0xd99a32, text: 0xffffff } }; +const DISABLED_COLORS = { fill: 0x101018, border: 0x555560, text: 0x777780 }; + export class Button extends Container { readonly labelText: PixelText; onSelect: (() => void) | null; @@ -31,37 +35,42 @@ private readonly w: number; private readonly h: number; private readonly size: number; + private readonly disabled: boolean; constructor(options: ButtonOptions) { super(); this.w = options.width; this.h = options.height; this.size = options.size ?? 10; + this.disabled = options.disabled ?? false; this.onSelect = options.onSelect ?? null; this.bg = new Graphics(); this.labelText = new PixelText({ text: options.label, size: this.size, - color: STATE_COLORS.normal.text + color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text }); this.labelText.anchor.set(0.5); this.labelText.position.set(this.w / 2, this.h / 2); this.addChild(this.bg, this.labelText); - this.eventMode = 'static'; - this.cursor = 'pointer'; - this.hitArea = new Rectangle(0, 0, this.w, this.h); - this.on('pointerover', () => this.setState(this.state === 'pressed' ? 'pressed' : 'hover')); - this.on('pointerout', () => this.setState(this.focused ? 'focused' : 'normal')); - this.on('pointerdown', (e: FederatedPointerEvent) => { - e.stopPropagation(); - this.setState('pressed'); - }); - this.on('pointerup', () => this.setState('hover')); - this.on('pointerupoutside', () => this.setState('normal')); - this.on('pointertap', () => this.onSelect?.()); + // Недоступная кнопка прозрачна для ввода (клики проходят сквозь) + this.eventMode = this.disabled ? 'none' : 'static'; + if (!this.disabled) { + this.cursor = 'pointer'; + this.hitArea = new Rectangle(0, 0, this.w, this.h); + this.on('pointerover', () => this.setState(this.state === 'pressed' ? 'pressed' : 'hover')); + this.on('pointerout', () => this.setState(this.focused ? 'focused' : 'normal')); + this.on('pointerdown', (e: FederatedPointerEvent) => { + e.stopPropagation(); + this.setState('pressed'); + }); + this.on('pointerup', () => this.setState('hover')); + this.on('pointerupoutside', () => this.setState('normal')); + this.on('pointertap', () => this.onSelect?.()); + } this.draw(); } @@ -78,6 +87,7 @@ /** Программная активация (Enter из MenuList). */ activate(): void { + if (this.disabled) return; this.onSelect?.(); } @@ -88,7 +98,7 @@ } private draw(): void { - const c = STATE_COLORS[this.state]; + const c = this.disabled ? DISABLED_COLORS : STATE_COLORS[this.state]; this.bg.clear(); this.bg.rect(0, 0, this.w, this.h).fill({ color: c.fill, alpha: 0.95 }); this.bg.rect(0, 0, this.w, this.h).stroke({ color: c.border, width: 1 }); diff --git a/packages/engine/src/ui/MenuList.ts b/packages/engine/src/ui/MenuList.ts index b6cf4f6..ba509e5 100644 --- a/packages/engine/src/ui/MenuList.ts +++ b/packages/engine/src/ui/MenuList.ts @@ -5,7 +5,17 @@ /** * Вертикальное меню: список кнопок с клавиатурным фокусом и навигацией. * Игра сама читает ввод и вызывает moveCursor/activate — движок не привязан к раскладке. + * Недоступные пункты (disabled) приглушены: курсор их пропускает, клик игнорируется. */ + +/** Пункт меню. */ +export interface MenuItem { + label: string; + onSelect?: () => void; + /** Недоступный пункт: приглушён, курсор пропускает, активация игнорируется. */ + disabled?: boolean; +} + export interface MenuListOptions { width: number; height: number; @@ -24,9 +34,7 @@ } /** Пересобрать список пунктов (старые кнопки уничтожаются). */ - setItems( - items: { label: string; onSelect?: () => void }[] - ): void { + setItems(items: MenuItem[]): void { for (const b of this.buttons) b.destroy({ children: true }); this.buttons = []; let y = 0; @@ -36,6 +44,7 @@ width: this.options.width, height: this.options.height, size: this.options.size, + disabled: item.disabled, onSelect: item.onSelect }); btn.position.set(0, y); @@ -43,11 +52,12 @@ this.buttons.push(btn); y += this.options.height + (this.options.gap ?? 2); } - this.listCursor.setCount(items.length); + this.listCursor = new ListCursor(items.length, (i) => !items[i]?.disabled); + if (items[0]?.disabled) this.listCursor.move(1); // старт на выбираемом this.applyFocus(); } - /** Сдвинуть фокус (↑ = -1, ↓ = +1). */ + /** Сдвинуть фокус (↑ = -1, ↓ = +1); недоступные пункты пропускаются. */ moveCursor(delta: number): void { this.listCursor.move(delta); this.applyFocus(); diff --git a/packages/engine/src/ui/MenuSceneBase.ts b/packages/engine/src/ui/MenuSceneBase.ts new file mode 100644 index 0000000..9e55209 --- /dev/null +++ b/packages/engine/src/ui/MenuSceneBase.ts @@ -0,0 +1,86 @@ +import { Container } from 'pixi.js'; +import type { Scene } from '../scene/SceneManager'; +import type { MenuList } from './MenuList'; +import { readMenuInput, type MenuActionMap } from './menuInput'; + +/** + * Базовая сцена-меню: панель поверх вызывающей сцены (push/pop) с клавиатурной + * навигацией. Устраняет дублирование «up/down/confirm/cancel → moveCursor/ + * activate + гард перехода» из четырёх сцен игры. Игра строит панель и меню + * своим стилем в build(); ввод читается по карте действий сцены. + */ +export interface MenuSceneContext { + /** Ввод (InputManager или заглушка). */ + input: { isActionJustPressed(action: string): boolean }; + /** true, пока стек сцен в переходе — ввод блокируется. */ + inputBlocked(): boolean; +} + +export abstract class MenuSceneBase implements Scene { + /** Корень вью сцены; build() добавляет его в uiRoot. */ + protected readonly view = new Container(); + /** Список меню (null — сцена без списка, например просмотр сумки). */ + protected menu: MenuList | null = null; + + protected constructor( + private readonly ctx: MenuSceneContext, + private readonly actions: MenuActionMap + ) {} + + /** Построить панель/заголовок/меню и добавить this.view в uiRoot. */ + protected abstract build(): void; + + enter(): void { + this.build(); + } + + exit(): void { + this.view.destroy({ children: true }); + this.menu = null; + } + + render(): void {} + + update(_dt: number): void { + if (this.ctx.inputBlocked()) return; + const index = this.menu?.index ?? 0; + for (const ev of readMenuInput(this.ctx.input, index, this.actions)) { + switch (ev.kind) { + case 'move': + this.onMove(ev.delta, ev.index); + break; + case 'adjust': + this.onAdjust(ev.delta, ev.index); + break; + case 'confirm': + this.onConfirm(ev.index); + break; + case 'cancel': + this.onCancel(); + break; + case 'action': + this.onAction(ev.action, ev.index); + break; + } + } + } + + /** Дефолт: двигать курсор списка. */ + protected onMove(delta: number, _index: number): void { + this.menu?.moveCursor(delta); + } + + /** Подстройка значения выбранной строки (настройки); по умолчанию — ничего. */ + protected onAdjust(_delta: number, _index: number): void {} + + /** Дефолт: активировать кнопку под фокусом. */ + protected onConfirm(_index: number): void { + this.menu?.activate(); + } + + /** Назад/закрыть; по умолчанию — ничего (главное меню не закрывается). */ + protected onCancel(): void {} + + /** Прочее действие сцены (extra из карты); по умолчанию — ничего. */ + protected onAction(_action: string, _index: number): void {} +} \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/listCursor.test.ts b/packages/engine/src/ui/__tests__/listCursor.test.ts index 9d0d4f9..8d9b0cd 100644 --- a/packages/engine/src/ui/__tests__/listCursor.test.ts +++ b/packages/engine/src/ui/__tests__/listCursor.test.ts @@ -43,4 +43,29 @@ c.home(); expect(c.index).toBe(0); }); + + it('предикат выбираемости пропускает недоступные пункты', () => { + const c = new ListCursor(4, (i) => i !== 1 && i !== 2); // 1 и 2 — disabled + c.setCount(4); + c.move(1); // 0 -> 3 (1 и 2 пропущены) + expect(c.index).toBe(3); + c.move(1); // 3 -> 0 (по кругу, через 1 и 2) + expect(c.index).toBe(0); + c.move(-1); // 0 -> 3 + expect(c.index).toBe(3); + }); + + it('setCount на недоступном пункте сдвигается к выбираемому', () => { + const c = new ListCursor(3, (i) => i !== 1); + c.index = 1; + c.setCount(3); + expect(c.index).toBe(2); + }); + + it('все пункты недоступны — курсор стоит на месте', () => { + const c = new ListCursor(2, () => false); + c.move(1); + c.move(-1); + expect(c.index).toBe(0); + }); }); \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/menuInput.test.ts b/packages/engine/src/ui/__tests__/menuInput.test.ts new file mode 100644 index 0000000..0fea326 --- /dev/null +++ b/packages/engine/src/ui/__tests__/menuInput.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { readMenuInput, type MenuActionMap, type MenuInputSource } from '../menuInput'; + +/** Фейковый источник: нажатия задаются списком действий на тик. */ +function fakeInput(pressed: string[]): MenuInputSource & { pressed: string[] } { + return { pressed, isActionJustPressed: (a) => pressed.includes(a) }; +} + +const MAP: MenuActionMap = { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu' }; + +describe('readMenuInput', () => { + it('движение и подтверждение по карте действий', () => { + const ev = readMenuInput(fakeInput(['down']), 2, MAP); + expect(ev).toEqual([{ kind: 'move', delta: 1, index: 2 }]); + expect(readMenuInput(fakeInput(['advance']), 1, MAP)).toEqual([ + { kind: 'confirm', index: 1 } + ]); + expect(readMenuInput(fakeInput(['menu']), 1, MAP)).toEqual([{ kind: 'cancel' }]); + }); + + it('cancel замыкает тик — остальные события не читаются', () => { + const ev = readMenuInput(fakeInput(['menu', 'down', 'advance']), 0, MAP); + expect(ev).toEqual([{ kind: 'cancel' }]); + }); + + it('up и down в один тик дают один move', () => { + const ev = readMenuInput(fakeInput(['up', 'down']), 0, MAP); + expect(ev).toEqual([{ kind: 'move', delta: -1, index: 0 }]); + }); + + it('нажатые left/right подавляют confirm (настройки: без двойного шага)', () => { + const map: MenuActionMap = { ...MAP, left: 'left', right: 'right' }; + const ev = readMenuInput(fakeInput(['right', 'advance']), 3, map); + expect(ev).toEqual([{ kind: 'adjust', delta: 1, index: 3 }]); + const onlyEnter = readMenuInput(fakeInput(['advance']), 3, map); + expect(onlyEnter).toEqual([{ kind: 'confirm', index: 3 }]); + }); + + it('extra-действия читаются последними', () => { + const map: MenuActionMap = { ...MAP, extra: ['attack'] }; + const ev = readMenuInput(fakeInput(['down', 'attack']), 1, map); + expect(ev).toEqual([ + { kind: 'move', delta: 1, index: 1 }, + { kind: 'action', action: 'attack', index: 1 } + ]); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/ui/listCursor.ts b/packages/engine/src/ui/listCursor.ts index 5d02e2c..a6f3e80 100644 --- a/packages/engine/src/ui/listCursor.ts +++ b/packages/engine/src/ui/listCursor.ts @@ -1,13 +1,17 @@ /** * Чистая логика курсора вертикального списка: без Pixi, тестируется изолированно. - * MenuList использует её для клавиатурной навигации. + * MenuList использует её для клавиатурной навигации; предикат выбираемости + * пропускает недоступные пункты (disabled). */ export class ListCursor { /** Индекс выбранного элемента. */ index = 0; - constructor(private count: number) {} + constructor( + private count: number, + private selectable: (index: number) => boolean = () => true + ) {} /** Изменить количество элементов (список пересобран); курсор зажимается. */ setCount(count: number): void { @@ -15,11 +19,27 @@ if (this.index >= count) { this.index = Math.max(0, count - 1); } + if (!this.selectable(this.index)) this.move(1); } + /** Сдвиг на |delta| шагов; каждый шаг — к соседнему выбираемому элементу. */ move(delta: number): void { if (this.count === 0) return; - this.index = (this.index + delta + this.count) % this.count; + const dir = Math.sign(delta); + let steps = Math.abs(delta); + while (steps > 0) { + let landed = false; + for (let i = 1; i <= this.count; i++) { + const next = (((this.index + dir * i) % this.count) + this.count) % this.count; + if (this.selectable(next)) { + this.index = next; + landed = true; + break; + } + } + if (!landed) return; // все элементы недоступны — курсор стоит + steps--; + } } /** Курсор в начало/конец. */ diff --git a/packages/engine/src/ui/menuInput.ts b/packages/engine/src/ui/menuInput.ts new file mode 100644 index 0000000..39a9aa1 --- /dev/null +++ b/packages/engine/src/ui/menuInput.ts @@ -0,0 +1,49 @@ +/** + * Чистое чтение навигации меню: без Pixi, тестируется изолированно. + * Движок не привязан к раскладке — имена действий даёт сцена (биндятся игрой). + * Порядок событий за тик: cancel → move → (adjust XOR confirm) → extra. + */ + +/** Карта действий меню (все — имена действий InputManager). */ +export interface MenuActionMap { + up: string; + down: string; + /** Активировать выбранный пункт. */ + confirm: string; + /** Назад / закрыть панель. */ + cancel: string; + /** Подстройка значения (настройки); нажатые left/right подавляют confirm. */ + left?: string; + right?: string; + /** Прочие действия сцены (удаление сейва, открытие сумки и т.п.). */ + extra?: readonly string[]; +} + +export type MenuNavEvent = + | { kind: 'move'; delta: -1 | 1; index: number } + | { kind: 'adjust'; delta: -1 | 1; index: number } + | { kind: 'confirm'; index: number } + | { kind: 'cancel' } + | { kind: 'action'; action: string; index: number }; + +/** Источник «нажато в этом тике» — InputManager (или заглушка в тестах). */ +export interface MenuInputSource { + isActionJustPressed(action: string): boolean; +} + +/** Прочитать навигационные события за один тик. */ +export function readMenuInput(input: MenuInputSource, index: number, map: MenuActionMap): MenuNavEvent[] { + if (input.isActionJustPressed(map.cancel)) return [{ kind: 'cancel' }]; + const events: MenuNavEvent[] = []; + if (input.isActionJustPressed(map.up)) events.push({ kind: 'move', delta: -1, index }); + else if (input.isActionJustPressed(map.down)) events.push({ kind: 'move', delta: 1, index }); + // left/right перекрывают confirm: в настройках Enter не должен давать двойной шаг + const left = map.left !== undefined && input.isActionJustPressed(map.left); + const right = map.right !== undefined && input.isActionJustPressed(map.right); + if (left || right) events.push({ kind: 'adjust', delta: right ? 1 : -1, index }); + else if (input.isActionJustPressed(map.confirm)) events.push({ kind: 'confirm', index }); + for (const action of map.extra ?? []) { + if (input.isActionJustPressed(action)) events.push({ kind: 'action', action, index }); + } + return events; +} \ No newline at end of file