diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index cb2991b..ed604e5 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -70,6 +70,25 @@ Навигация зациклена (после последнего пункта — первый). Логика курсора — чистый класс `ListCursor` (с предикатом выбираемости для disabled), покрыт тестами. +### Скролл длинных списков, значения пунктов + +`visibleRows` задаёт окно видимых строк: список из 20+ пунктов скроллится, +курсор у края листает окно (чистая логика — `ListScroll`, покрыт тестами). +Кнопки строятся все сразу (окно только двигает их) — для меню до ~30 строк; +для сотен нужен ленивый пул. Hover мышью/тачем переставляет курсор. + +Пункт с `value` рисует значение справа (громкость, счётчик); обновляется +без пересборки списка — `menu.setValue(index, '▮▮▮▯')`: + +```ts +const menu = new MenuList({ width: 160, height: 14, visibleRows: 6 }); +menu.setItems([{ label: 'Общая', value: '▮▮▮▮▮' }]); +menu.setValue(0, '▮▮▮▯▯'); +``` + +TextInput в движке нет — потребителей пока не появлялось (имя сейва и т.п.), +заведём вместе с первым. + ## Меню-сцены: MenuSceneBase + menuInput Панель поверх вызывающей сцены (push/pop) с клавиатурной навигацией — не diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a32a22e..03ef304 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -230,6 +230,7 @@ export { Panel, type PanelOptions } from './ui/Panel'; export { Button, type ButtonOptions } from './ui/Button'; export { MenuList, type MenuListOptions, type MenuItem } from './ui/MenuList'; +export { ListScroll } from './ui/listScroll'; export { readMenuInput, type MenuActionMap, type MenuNavEvent, type MenuInputSource } from './ui/menuInput'; export { MenuSceneBase, type MenuSceneContext } from './ui/MenuSceneBase'; diff --git a/packages/engine/src/ui/Button.ts b/packages/engine/src/ui/Button.ts index 53ba64b..e0cf26f 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; + /** Значение справа (громкость, счётчик) — меняется без пересборки списка. */ + value?: string; /** Недоступная кнопка: приглушена, клик/активация игнорируются. */ disabled?: boolean; onSelect?: () => void; @@ -26,8 +28,12 @@ const DISABLED_COLORS = { fill: 0x101018, border: 0x555560, text: 0x777780 }; +/** Цвет значения (приглушённый акцент — не путать с меткой). */ +const VALUE_COLOR = 0xb8c4cc; + export class Button extends Container { readonly labelText: PixelText; + readonly valueText: PixelText | null = null; onSelect: (() => void) | null; private bg: Graphics; @@ -49,12 +55,28 @@ this.labelText = new PixelText({ text: options.label, size: this.size, - color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text + color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text, + align: options.value !== undefined ? 'left' : 'center' }); - this.labelText.anchor.set(0.5); - this.labelText.position.set(this.w / 2, this.h / 2); + if (options.value !== undefined) { + // С value метка прижата влево, значение — к правому краю. + this.labelText.anchor.set(0, 0.5); + this.labelText.position.set(8, this.h / 2); + this.valueText = new PixelText({ + text: options.value, + size: this.size, + color: VALUE_COLOR, + align: 'right' + }); + this.valueText.anchor.set(1, 0.5); + this.valueText.position.set(this.w - 8, this.h / 2); + } else { + this.labelText.anchor.set(0.5); + this.labelText.position.set(this.w / 2, this.h / 2); + } this.addChild(this.bg, this.labelText); + if (this.valueText) this.addChild(this.valueText); // Недоступная кнопка прозрачна для ввода (клики проходят сквозь) this.eventMode = this.disabled ? 'none' : 'static'; @@ -94,9 +116,15 @@ private setState(s: ButtonState): void { this.state = s; this.labelText.style.fill = STATE_COLORS[s].text; + if (this.valueText) this.valueText.style.fill = this.disabled ? DISABLED_COLORS.text : VALUE_COLOR; this.draw(); } + /** Обновить значение справа (без пересборки списка). */ + set value(v: string) { + if (this.valueText) this.valueText.text = v; + } + private draw(): void { const c = this.disabled ? DISABLED_COLORS : STATE_COLORS[this.state]; this.bg.clear(); diff --git a/packages/engine/src/ui/MenuList.ts b/packages/engine/src/ui/MenuList.ts index eb80ede..9221648 100644 --- a/packages/engine/src/ui/MenuList.ts +++ b/packages/engine/src/ui/MenuList.ts @@ -1,11 +1,14 @@ import { Container } from 'pixi.js'; import { Button } from './Button'; import { ListCursor } from './listCursor'; +import { ListScroll } from './listScroll'; /** * Вертикальное меню: список кнопок с клавиатурным фокусом и навигацией. * Игра сама читает ввод и вызывает moveCursor/activate — движок не привязан к раскладке. * Недоступные пункты (disabled) приглушены: курсор их пропускает, клик игнорируется. + * Длинные списки: visibleRows задаёт окно видимых строк (скролл — ListScroll); + * hover мышью переставляет курсор; пункт может нести value (громкость, счётчик). */ /** Пункт меню. */ @@ -14,6 +17,8 @@ onSelect?: () => void; /** Недоступный пункт: приглушён, курсор пропускает, активация игнорируется. */ disabled?: boolean; + /** Значение справа (громкость, счётчик) — обновляется через setValue. */ + value?: string; } export interface MenuListOptions { @@ -22,11 +27,14 @@ /** Отступ между кнопками (виртуальные пиксели). */ gap?: number; size?: number; + /** Видимых строк (скролл); нет/0 — окно = весь список, как раньше. */ + visibleRows?: number; } export class MenuList extends Container { private buttons: Button[] = []; private listCursor: ListCursor; + private scroll: ListScroll | null = null; constructor(private options: MenuListOptions) { super(); @@ -37,6 +45,8 @@ setItems(items: MenuItem[]): void { for (const b of this.buttons) b.destroy({ children: true }); this.buttons = []; + const rows = this.options.visibleRows ?? 0; + this.scroll = rows > 0 ? new ListScroll(rows) : null; let y = 0; for (const item of items) { const btn = new Button({ @@ -44,13 +54,22 @@ width: this.options.width, height: this.options.height, size: this.options.size, + value: item.value, disabled: item.disabled, onSelect: item.onSelect }); btn.position.set(0, y); + // Hover мышью/тачем переставляет курсор (как выбор в DialogueBox). + const index = this.buttons.length; + btn.on('pointerover', () => { + if (!item.disabled && index !== this.listCursor.index) { + this.listCursor.index = index; + this.applyFocus(); + } + }); this.addChild(btn); this.buttons.push(btn); - y += this.options.height + (this.options.gap ?? 2); + y += this.step(); } const keep = Math.min(this.listCursor.index, Math.max(0, items.length - 1)); this.listCursor = new ListCursor(items.length, (i) => !items[i]?.disabled); @@ -75,7 +94,30 @@ this.buttons[this.listCursor.index]?.activate(); } + /** Обновить значение пункта справа (настройки — без пересборки списка). */ + setValue(index: number, value: string): void { + const btn = this.buttons[index]; + if (btn) btn.value = value; + } + + /** Окно видимых строк (или весь список, если скролла нет). */ + get window(): { first: number; last: number } | null { + return this.scroll ? { first: this.scroll.first, last: this.scroll.last } : null; + } + private applyFocus(): void { this.buttons.forEach((b, i) => (b.focused = i === this.listCursor.index)); + if (this.scroll) { + this.scroll.sync(this.listCursor.index, this.buttons.length); + for (let i = 0; i < this.buttons.length; i++) { + const visible = i >= this.scroll.first && i < this.scroll.last; + this.buttons[i]!.visible = visible; + this.buttons[i]!.position.y = (i - this.scroll.first) * this.step(); + } + } + } + + private step(): number { + return this.options.height + (this.options.gap ?? 2); } } \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/listScroll.test.ts b/packages/engine/src/ui/__tests__/listScroll.test.ts new file mode 100644 index 0000000..a2fd645 --- /dev/null +++ b/packages/engine/src/ui/__tests__/listScroll.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { ListScroll } from '../listScroll'; + +describe('ListScroll — окно видимых строк', () => { + it('курсор внутри окна — offset не меняется', () => { + const s = new ListScroll(5); + s.sync(0, 20); + s.sync(3, 20); + expect(s.offset).toBe(0); + expect(s.first).toBe(0); + expect(s.last).toBe(5); + }); + + it('движение вниз у нижнего края листает окно на 1', () => { + const s = new ListScroll(5); + s.sync(4, 20); + s.sync(5, 20); + expect(s.offset).toBe(1); + expect(s.first).toBe(1); + expect(s.last).toBe(6); + }); + + it('движение вверх у верхнего края листает окно на 1', () => { + const s = new ListScroll(5); + s.sync(5, 20); + s.sync(4, 20); + expect(s.offset).toBe(1); + s.sync(0, 20); + expect(s.offset).toBe(0); + }); + + it('прыжок в конец показывает хвост списка', () => { + const s = new ListScroll(5); + s.sync(19, 20); + expect(s.offset).toBe(15); + expect(s.last).toBe(20); + }); + + it('список короче окна — offset 0, clamped', () => { + const s = new ListScroll(5); + s.sync(3, 4); + expect(s.offset).toBe(0); + expect(s.last).toBe(4); + expect(s.clamped).toBe(true); + }); + + it('пересборка списка короче — offset зажимается', () => { + const s = new ListScroll(5); + s.sync(15, 20); + expect(s.offset).toBe(11); // окно [11..16), курсор в конце окна + s.sync(2, 6); // после пересборки курсор в начале, список почти пуст + expect(s.offset).toBe(1); // 6-5=1, курсор 2 в окне [1,6) + }); +}); \ No newline at end of file diff --git a/packages/engine/src/ui/listScroll.ts b/packages/engine/src/ui/listScroll.ts new file mode 100644 index 0000000..800756e --- /dev/null +++ b/packages/engine/src/ui/listScroll.ts @@ -0,0 +1,40 @@ +/** + * Чистая логика окна видимых строк вертикального списка: без Pixi, + * тестируется изолированно (по образцу ListCursor). MenuList использует её + * для скролла длинных списков: курсор всегда в окне, окно — в [0, count]. + */ +export class ListScroll { + /** Индекс первой видимой строки. */ + offset = 0; + /** Число элементов (из последнего sync) — для clamped. */ + private count = 0; + + constructor(readonly rows: number) {} + + /** Пересчёт окна под выбранный индекс; возвращает offset. */ + sync(index: number, count: number): number { + this.count = count; + const max = Math.max(0, count - this.rows); + if (this.offset > max) this.offset = max; // список пересобран короче + if (index < this.offset) this.offset = index; // курсор ушёл выше окна + else if (index >= this.offset + this.rows) { + this.offset = Math.min(max, index - this.rows + 1); // ниже окна + } + return this.offset; + } + + /** Индекс первой видимой строки. */ + get first(): number { + return this.offset; + } + + /** Исключающая граница окна (после последней видимой строки). */ + get last(): number { + return Math.min(this.count, this.offset + this.rows); + } + + /** Список короче окна: всё видно, прокрутки нет. */ + get clamped(): boolean { + return this.count <= this.rows; + } +} \ No newline at end of file