diff --git a/packages/engine/assets/fonts/OFL.txt b/packages/engine/assets/fonts/OFL.txt new file mode 100644 index 0000000..bcb092f --- /dev/null +++ b/packages/engine/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/engine/assets/fonts/VT323-Regular.ttf b/packages/engine/assets/fonts/VT323-Regular.ttf new file mode 100644 index 0000000..afa6909 --- /dev/null +++ b/packages/engine/assets/fonts/VT323-Regular.ttf Binary files differ diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts index 1071b72..39bee58 100644 --- a/packages/engine/src/assets/AssetLoader.ts +++ b/packages/engine/src/assets/AssetLoader.ts @@ -1,11 +1,13 @@ -import { Assets, Texture } from 'pixi.js'; +import { Assets, Spritesheet, Texture } from 'pixi.js'; /** * Загрузчик ассетов поверх Assets из PixiJS: группы, прогресс, готовые Texture. * URL ассетов передаётся функцией-резолвером (приложение решает, где лежат файлы). + * Атласы (Spritesheet JSON + PNG) загружаются тем же механизмом: loadAtlas(). */ export class AssetLoader { private textures = new Map(); + private atlases = new Map(); private initPromise: Promise | null = null; constructor(private resolveUrl: (key: string) => string) {} @@ -34,6 +36,48 @@ } } + /** + * Загрузить атлас (Spritesheet JSON). JSON должен ссылаться на PNG + * относительным путём — Pixi подгрузит картинку сам. + */ + async loadAtlas(key: string): Promise { + await this.ensureInit(); + const existing = this.atlases.get(key); + if (existing) return existing; + const sheet = await Assets.load(this.resolveUrl(key)); + this.atlases.set(key, sheet); + return sheet; + } + + /** + * Кадры анимации из загруженного атласа: все кадры, чьё имя начинается + * с prefix, отсортированные по числу в конце имени (hero_walk_1, hero_walk_2, ...). + */ + frames(key: string, prefix: string): Texture[] { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const entries = Object.keys(sheet.textures) + .filter((name) => name.startsWith(prefix)) + .sort((a, b) => { + const na = Number(a.match(/(\d+)$/)?.[1] ?? -1); + const nb = Number(b.match(/(\d+)$/)?.[1] ?? -1); + return na - nb; + }); + if (entries.length === 0) { + throw new Error(`В атласе ${key} нет кадров с префиксом ${prefix}`); + } + return entries.map((name) => sheet.textures[name]!); + } + + /** Готовая анимация из sheet.animations (если генератор заполняет animations). */ + animation(key: string, name: string): Texture[] { + const sheet = this.atlases.get(key); + if (!sheet) throw new Error(`Атлас не загружен: ${key}`); + const frames = sheet.animations[name]; + if (!frames) throw new Error(`В атласе ${key} нет анимации ${name}`); + return frames; + } + texture(key: string): Texture { const t = this.textures.get(key); if (!t) throw new Error(`Текстура не загружена: ${key}`); diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index ffa82ce..14379bb 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -1,11 +1,25 @@ /** - * Минимальная обёртка WebAudio: загрузка сэмплов в буферы и проигрывание. - * Музыку и сложные графы можно достроить позже на этой же базе. + * WebAudio с тремя шинами (master/music/sfx): громкости по шинам, + * кроссфейд музыки, кэш сэмплов. Игры подключают Settings.onChange + * и вызывают setBusVolume. */ + +export interface AudioBuses { + master: GainNode; + music: GainNode; + sfx: GainNode; +} + +export interface MusicHandle { + stop(fadeSeconds?: number): void; +} + export class AudioManager { private ctx: AudioContext | null = null; private buffers = new Map(); private urls = new Map(); + private buses: AudioBuses | null = null; + private currentMusic: { source: AudioBufferSourceNode; gain: GainNode } | null = null; constructor(private resolveUrl: (key: string) => string) {} @@ -13,32 +27,114 @@ async unlock(): Promise { if (!this.ctx) { this.ctx = new AudioContext(); + const master = this.ctx.createGain(); + const music = this.ctx.createGain(); + const sfx = this.ctx.createGain(); + music.connect(master); + sfx.connect(master); + master.connect(this.ctx.destination); + this.buses = { master, music, sfx }; } if (this.ctx.state === 'suspended') { await this.ctx.resume(); } } + /** Громкость шины 0..1 (без unlock просто запоминается на будущее). */ + setBusVolume(bus: 'master' | 'music' | 'sfx', volume: number): void { + if (this.buses) { + this.buses[bus].gain.value = Math.max(0, Math.min(1, volume)); + } + } + async load(key: string): Promise { this.urls.set(key, this.resolveUrl(key)); } - /** Проиграть (если звук не загружен — тихо ничего не делает). */ + /** + * Проиграть sfx (если звук не загружен — тихо ничего не делает). + * volume — относительная громкость в шине sfx. + */ async play(key: string, volume = 1): Promise { - if (!this.ctx) return; + const ctx = await this.ensureContext(); + if (!ctx) return; + const buf = await this.decode(key); + if (!buf) return; + + const source = ctx.createBufferSource(); + source.buffer = buf; + const gain = ctx.createGain(); + gain.gain.value = volume; + source.connect(gain).connect(this.buses!.sfx); + source.start(); + } + + /** + * Играть музыку в цикле с кроссфейдом: текущая трек затухает, + * новый нарастает за fadeSeconds. + */ + async playMusic(key: string, { loop = true, fade = 1, volume = 1 }: { loop?: boolean; fade?: number; volume?: number } = {}): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return null; + const buf = await this.decode(key); + if (!buf) return null; + + // Заглушить текущий трек + this.stopMusic(fade); + + const source = ctx.createBufferSource(); + source.buffer = buf; + source.loop = loop; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); + source.connect(gain).connect(this.buses!.music); + source.start(); + + this.currentMusic = { source, gain }; + return { + stop: (fadeSeconds = fade) => this.stopMusic(fadeSeconds) + }; + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds = 0.5): void { + const current = this.currentMusic; + if (!current || !this.ctx) return; + this.currentMusic = null; + const { source, gain } = current; + const t = this.ctx!.currentTime; + gain.gain.cancelScheduledValues(t); + gain.gain.setValueAtTime(gain.gain.value, t); + gain.gain.linearRampToValueAtTime(0.0001, t + fadeSeconds); + window.setTimeout(() => { + try { + source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + } + + private async ensureContext(): Promise { + await this.unlock(); + return this.ctx; + } + + private async decode(key: string): Promise { + if (!this.ctx) return null; let buf = this.buffers.get(key); const url = this.urls.get(key); if (!buf && url) { - const res = await fetch(url); - buf = await this.ctx.decodeAudioData(await res.arrayBuffer()); - this.buffers.set(key, buf); + try { + const res = await fetch(url); + buf = await this.ctx.decodeAudioData(await res.arrayBuffer()); + this.buffers.set(key, buf); + } catch (err) { + console.warn(`[audio] не удалось декодировать ${key}:`, err); + return null; + } } - if (!buf) return; - const source = this.ctx.createBufferSource(); - source.buffer = buf; - const gain = this.ctx.createGain(); - gain.gain.value = volume; - source.connect(gain).connect(this.ctx.destination); - source.start(); + return buf ?? null; } } \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index d208cff..fe1eaf9 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -1,8 +1,10 @@ -import { Application } from 'pixi.js'; +import { Application, Graphics } from 'pixi.js'; import { Renderer, RendererOptions } from '../render/Renderer'; import { Camera } from '../render/Camera'; +import { computeScale } from '../render/scale'; import { GameLoop } from './GameLoop'; import { EventBus } from './EventBus'; +import { TweenManager } from './Tween'; import { InputManager } from '../input/InputManager'; import { SceneManager } from '../scene/SceneManager'; @@ -13,6 +15,8 @@ export interface EngineOptions extends RendererOptions { /** FPS фиксированного шага. */ fixedFps: number; + /** Пересчитывать масштаб при ресайзе окна (по умолчанию true). */ + autoResize?: boolean; } export class Engine { @@ -20,13 +24,27 @@ readonly camera: Camera; readonly events = new EventBus(); readonly scenes = new SceneManager(); + /** Твины и таймеры: обновляются каждым фиксированным шагом. */ + readonly tweens = new TweenManager(); input!: InputManager; private loop: GameLoop; private started = false; + private readonly autoResize: boolean; + private onWindowResize = (): void => { + this.renderer.resize( + computeScale( + this.renderer.virtualWidth, + this.renderer.virtualHeight, + window.innerWidth, + window.innerHeight + ) + ); + }; constructor(options: EngineOptions) { this.renderer = new Renderer(options); this.camera = new Camera(options.virtualWidth, options.virtualHeight); + this.autoResize = options.autoResize ?? true; this.loop = new GameLoop(options.fixedFps, { update: (dt) => this.tick(dt), render: () => this.render() @@ -44,6 +62,15 @@ this.renderer.virtualWidth, this.renderer.virtualHeight ); + + // Оверлей затемнения для переходов между сценами — верхний слой UI. + const fade = new Graphics(); + this.renderer.uiRoot.addChild(fade); + this.scenes.setOverlay(fade, this.renderer.virtualWidth, this.renderer.virtualHeight); + + if (this.autoResize) { + window.addEventListener('resize', this.onWindowResize); + } return this.renderer.app; } @@ -56,11 +83,14 @@ stop(): void { this.loop.stop(); this.input?.dispose(); + window.removeEventListener('resize', this.onWindowResize); this.events.clear(); } /** Один фиксированный шаг. */ private tick(dt: number): void { + this.input?.update(); // опрос геймпадов до логики + this.tweens.update(dt); this.scenes.update(dt); this.input?.endTick(); } diff --git a/packages/engine/src/core/GameState.ts b/packages/engine/src/core/GameState.ts new file mode 100644 index 0000000..86b186b --- /dev/null +++ b/packages/engine/src/core/GameState.ts @@ -0,0 +1,103 @@ +/** + * Состояние игры: флаги сюжета и именованные переменные. + * Жанронезависимо: движок знает только механику, контент (какие флаги есть) + * определяется игрой. Сериализуется в/из сейва вместе с позицией и прочим. + */ + +export interface GameStateData { + version: number; + flags: string[]; + vars: Record; +} + +export type GameStateMigration = (data: GameStateData) => GameStateData; + +export class GameState { + private flags = new Set(); + private vars = new Map(); + private migrations: GameStateMigration[] = []; + + /** Текущая версия формата данных (растёт при изменениях структуры сейва). */ + dataVersion = 1; + + /** + * Зарегистрировать миграцию: применяется к сейвам с dataVersion меньше + * текущей. Миграции регистрируются по порядку от старых версий к новым. + */ + addMigration(m: GameStateMigration): void { + this.migrations.push(m); + } + + // --- флаги --- + + setFlag(flag: string): void { + this.flags.add(flag); + } + + hasFlag(flag: string): boolean { + return this.flags.has(flag); + } + + clearFlag(flag: string): void { + this.flags.delete(flag); + } + + /** Все флаги (для отладки). */ + get allFlags(): string[] { + return [...this.flags]; + } + + // --- переменные --- + + setVar(key: string, value: number | string | boolean): void { + this.vars.set(key, value); + } + + getVar(key: string): number | string | boolean | undefined { + return this.vars.get(key); + } + + getNumber(key: string): number { + const v = this.vars.get(key); + return typeof v === 'number' ? v : 0; + } + + getString(key: string): string { + const v = this.vars.get(key); + return typeof v === 'string' ? v : ''; + } + + getBool(key: string): boolean { + const v = this.vars.get(key); + return v === true || typeof v === 'number' && v !== 0; + } + + // --- сериализация --- + + serialize(): GameStateData { + return { + version: this.dataVersion, + flags: [...this.flags], + vars: Object.fromEntries(this.vars) + }; + } + + /** Загрузить данные (с применением миграций). Восстанавливает состояние поверх текущего. */ + load(data: GameStateData): void { + let d = data; + for (const m of this.migrations) { + if (d.version < this.dataVersion) { + d = m(d); + } + } + this.dataVersion = d.version; + this.flags = new Set(d.flags); + this.vars = new Map(Object.entries(d.vars)); + } + + /** Сбросить всё (новая игра). */ + reset(): void { + this.flags.clear(); + this.vars.clear(); + } +} \ No newline at end of file diff --git a/packages/engine/src/core/Settings.ts b/packages/engine/src/core/Settings.ts new file mode 100644 index 0000000..8166ead --- /dev/null +++ b/packages/engine/src/core/Settings.ts @@ -0,0 +1,89 @@ +/** + * Настройки игрока: громкости, язык, прочие предпочтения. + * Отдельный стор поверх SaveManager (слот «settings»), не попадает в сейвы игры. + */ + +import type { StorageLike } from '../save/SaveManager'; + +export interface SettingsData { + /** Громкости 0..1. */ + master: number; + music: number; + sfx: number; + /** Язык локализации (BCP-47, например 'ru'). */ + lang: string; + /** Прочие настройки игры (произвольные пары). */ + extra: Record; +} + +export const DEFAULT_SETTINGS: SettingsData = { + master: 1, + music: 1, + sfx: 1, + lang: 'ru', + extra: {} +}; + +/** Интерфейс подписки на изменения (например, AudioManager применяет громкости). */ +export type SettingsListener = (s: SettingsData) => void; + +export class Settings { + private current: SettingsData = { ...DEFAULT_SETTINGS, extra: {} }; + private listeners = new Set(); + + constructor(private storage: StorageLike, private slot = 'settings') {} + + get data(): Readonly { + return this.current; + } + + /** Загрузить из хранилища; отсутствующий/битый слот — настройки по умолчанию. */ + load(): void { + try { + const raw = this.storage.getItem(this.slot); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + this.current = { + ...DEFAULT_SETTINGS, + ...parsed, + extra: { ...(parsed.extra ?? {}) } + }; + } + } catch { + this.current = { ...DEFAULT_SETTINGS, extra: {} }; + } + } + + /** Изменить часть настроек, сохранить и уведомить слушателей. */ + update(patch: Partial> & { extra?: Record }): void { + this.current = { + ...this.current, + ...patch, + extra: { ...this.current.extra, ...(patch.extra ?? {}) } + }; + this.save(); + this.emit(); + } + + /** Вернуть настройку из extra (или undefined). */ + getExtra(key: string): number | string | boolean | undefined { + return this.data.extra[key]; + } + + onChange(listener: SettingsListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private save(): void { + try { + this.storage.setItem(this.slot, JSON.stringify(this.data)); + } catch { + // хранилище может быть недоступно (приватный режим) — работаем в памяти + } + } + + private emit(): void { + for (const l of this.listeners) l(this.data); + } +} \ No newline at end of file diff --git a/packages/engine/src/core/StateMachine.ts b/packages/engine/src/core/StateMachine.ts new file mode 100644 index 0000000..e9e48e5 --- /dev/null +++ b/packages/engine/src/core/StateMachine.ts @@ -0,0 +1,82 @@ +/** + * Конечный автомат: состояния с enter/exit/update и переходы по событиям. + * Используется для AI врагов, состояний игрока (idle/run/attack), экранов меню и т.п. + */ + +export interface StateDef { + /** Вызывается при входе в состояние. */ + enter?: () => void; + exit?: () => void; + /** dt в секундах, фиксированный шаг. */ + update?: (dt: number) => void; +} + +export class StateMachine { + private states = new Map(); + /** (состояние, событие) -> состояние-цель. */ + private transitions = new Map(); + private currentName: string | null = null; + private timeInState = 0; + + constructor( + /** Неизвестное событие в текущем состоянии (по умолчанию — просто игнор). */ + private onUnhandled?: (state: string, event: string) => void + ) {} + + add(name: string, def: StateDef): this { + this.states.set(name, def); + return this; + } + + /** Переход из состояния `from` по событию `event` в состояние `to`. */ + transition(from: string, event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.transitions.set(`${from}::${event}`, to); + return this; + } + + /** Перейти в состояние (exit текущего -> enter нового). Повторный вход в то же состояние игнорируется. */ + change(name: string): void { + if (!this.states.has(name)) { + throw new Error(`Неизвестное состояние: ${name}`); + } + if (this.currentName === name) return; + this.currentName ? this.states.get(this.currentName)!.exit?.() : undefined; + this.currentName = name; + this.timeInState = 0; + this.states.get(name)!.enter?.(); + } + + /** Обработать событие: если из текущего состояния есть переход — выполнить его. */ + handleEvent(event: string): void { + if (!this.currentName) return; + const to = this.transitions.get(`${this.currentName}::${event}`); + if (to) { + this.change(to); + } else { + this.onUnhandled?.(this.currentName, event); + } + } + + get current(): string | null { + return this.currentName; + } + + /** Секунд с момента входа в текущее состояние. */ + get time(): number { + return this.timeInState; + } + + update(dt: number): void { + if (!this.currentName) return; + this.timeInState += dt; + this.states.get(this.currentName)!.update?.(dt); + } + + reset(): void { + this.currentName = null; + this.timeInState = 0; + } +} \ No newline at end of file diff --git a/packages/engine/src/core/Tween.ts b/packages/engine/src/core/Tween.ts new file mode 100644 index 0000000..0e38503 --- /dev/null +++ b/packages/engine/src/core/Tween.ts @@ -0,0 +1,130 @@ +import { EaseFn, linear } from './easing'; + +/** + * Твины и таймеры: плавное изменение числовых свойств объекта и отложенные вызовы. + * Менеджер обновляется игровым циклом (движок делает это сам в Engine.tick). + */ + +export interface TweenOptions { + /** Длительность в секундах. */ + duration: number; + /** Функция сглаживания (по умолчанию linear). */ + ease?: EaseFn; + /** Задержка до старта в секундах. */ + delay?: number; + /** Вызывается по завершении. */ + onDone?: () => void; +} + +/** Управление конкретным твином/таймером: можно отменить. */ +export interface TweenHandle { + cancel(): void; +} + +interface ActiveTween { + target: Record; + props: Record; + from: Record | null; // снимается при старте (после delay) + elapsed: number; + options: TweenOptions; + cancelled: boolean; +} + +interface ActiveDelay { + remaining: number; + fn: () => void; + cancelled: boolean; +} + +export class TweenManager { + private tweens: ActiveTween[] = []; + private delays: ActiveDelay[] = []; + + /** + * Плавно привести числовые свойства target к заданным значениям. + * Начальные значения снимаются в момент старта (после delay). + */ + to(target: object, props: Record, options: TweenOptions): TweenHandle { + const tween: ActiveTween = { + target: target as Record, + props, + from: null, + elapsed: 0, + options: { ease: linear, ...options }, + cancelled: false + }; + this.tweens.push(tween); + return { cancel: () => (tween.cancelled = true) }; + } + + /** Вызвать fn через seconds секунд. */ + delay(seconds: number, fn: () => void): TweenHandle { + const d: ActiveDelay = { remaining: seconds, fn, cancelled: false }; + this.delays.push(d); + return { cancel: () => (d.cancelled = true) }; + } + + /** Отменить все твины, изменяющие target (таймеры не привязаны к target). */ + cancelFor(target: object): void { + for (const t of this.tweens) { + if (t.target === target) t.cancelled = true; + } + this.tweens = this.tweens.filter((t) => !t.cancelled); + } + + /** Отменить всё. */ + clear(): void { + for (const t of this.tweens) t.cancelled = true; + for (const d of this.delays) d.cancelled = true; + this.tweens = []; + this.delays = []; + } + + /** dt в секундах (фиксированный шаг). */ + update(dt: number): void { + // Твины + for (const t of this.tweens) { + if (t.cancelled) continue; + const delay = t.options.delay ?? 0; + let dtLeft = dt; + if (t.elapsed < delay) { + const skipped = Math.min(dtLeft, delay - t.elapsed); + t.elapsed += skipped; + dtLeft -= skipped; + if (dtLeft <= 0) continue; + } + if (!t.from) { + t.from = {}; + for (const key of Object.keys(t.props)) { + t.from[key] = t.target[key] ?? 0; + } + } + t.elapsed += dtLeft; + const raw = Math.min(1, (t.elapsed - (t.options.delay ?? 0)) / t.options.duration); + const k = t.options.ease!(raw); + for (const key of Object.keys(t.props)) { + t.target[key] = t.from[key] + (t.props[key] - t.from[key]) * k; + } + if (raw >= 1) { + t.cancelled = true; + t.options.onDone?.(); + } + } + this.tweens = this.tweens.filter((t) => !t.cancelled); + + // Таймеры + for (const d of this.delays) { + if (d.cancelled) continue; + d.remaining -= dt; + if (d.remaining <= 0) { + d.cancelled = true; + d.fn(); + } + } + this.delays = this.delays.filter((d) => !d.cancelled); + } + + get activeCount(): number { + return this.tweens.filter((t) => !t.cancelled).length + this.delays.filter((d) => !d.cancelled).length; + } +} \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/GameState.test.ts b/packages/engine/src/core/__tests__/GameState.test.ts new file mode 100644 index 0000000..5287983 --- /dev/null +++ b/packages/engine/src/core/__tests__/GameState.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { GameState } from '../GameState'; + +describe('GameState', () => { + it('флаги устанавливаются, проверяются и снимаются', () => { + const s = new GameState(); + expect(s.hasFlag('met_elder')).toBe(false); + s.setFlag('met_elder'); + expect(s.hasFlag('met_elder')).toBe(true); + s.clearFlag('met_elder'); + expect(s.hasFlag('met_elder')).toBe(false); + }); + + it('переменные типизированы по геттерам', () => { + const s = new GameState(); + s.setVar('gold', 42); + s.setVar('name', 'Ирвин'); + expect(s.getNumber('gold')).toBe(42); + expect(s.getString('name')).toBe('Ирвин'); + expect(s.getNumber('name')).toBe(0); // не число -> 0 + expect(s.getString('gold')).toBe(''); // не строка -> '' + expect(s.getVar('missing')).toBeUndefined(); + }); + + it('serialize/load сохраняет состояние', () => { + const s = new GameState(); + s.setFlag('a'); + s.setVar('hp', 7); + const data = s.serialize(); + + const s2 = new GameState(); + s2.load(data); + expect(s2.hasFlag('a')).toBe(true); + expect(s2.getNumber('hp')).toBe(7); + expect(s2.dataVersion).toBe(1); + }); + + it('миграция применяется к сейву старой версии', () => { + const s = new GameState(); + s.dataVersion = 2; + s.addMigration((d) => ({ + ...d, + version: 2, + vars: { ...d.vars, gold: 100 } // в v2 появилось золото + })); + + s.load({ version: 1, flags: ['x'], vars: {} }); + expect(s.dataVersion).toBe(2); + expect(s.getNumber('gold')).toBe(100); + expect(s.hasFlag('x')).toBe(true); + }); + + it('reset очищает всё', () => { + const s = new GameState(); + s.setFlag('a'); + s.setVar('b', 1); + s.reset(); + expect(s.allFlags).toEqual([]); + expect(s.getVar('b')).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Settings.test.ts b/packages/engine/src/core/__tests__/Settings.test.ts new file mode 100644 index 0000000..339dbae --- /dev/null +++ b/packages/engine/src/core/__tests__/Settings.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { Settings } from '../Settings'; +import type { StorageLike } from '../../save/SaveManager'; + +function memoryStorage(): StorageLike & { store: Map } { + const store = new Map(); + return { + store, + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => void store.set(k, v), + removeItem: (k) => void store.delete(k), + key: (i) => [...store.keys()][i] ?? null, + get length() { + return store.size; + } + }; +} + +describe('Settings', () => { + it('отсутствующий слот даёт настройки по умолчанию', () => { + const s = new Settings(memoryStorage()); + s.load(); + expect(s.data.master).toBe(1); + expect(s.data.lang).toBe('ru'); + }); + + it('update применяет патч, сохраняет в хранилище и уведомляет слушателей', () => { + const storage = memoryStorage(); + const s = new Settings(storage); + s.load(); + + let notified = 0; + s.onChange(() => notified++); + s.update({ master: 0.5, music: 0.2 }); + + expect(notified).toBe(1); + expect(s.data.master).toBe(0.5); + const saved = JSON.parse(storage.store.get('settings')!); + expect(saved.master).toBe(0.5); + expect(saved.music).toBe(0.2); + + // новый экземпляр читает то же + const s2 = new Settings(storage); + s2.load(); + expect(s2.data.master).toBe(0.5); + }); + + it('битый JSON в хранилище не роняет загрузку', () => { + const storage = memoryStorage(); + storage.store.set('settings', '{oops'); + const s = new Settings(storage); + expect(() => s.load()).not.toThrow(); + expect(s.data.master).toBe(1); + }); + + it('extra хранит произвольные значения', () => { + const s = new Settings(memoryStorage()); + s.load(); + s.update({ extra: { hintSeen: true } }); + expect(s.getExtra('hintSeen')).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/StateMachine.test.ts b/packages/engine/src/core/__tests__/StateMachine.test.ts new file mode 100644 index 0000000..dba4cd4 --- /dev/null +++ b/packages/engine/src/core/__tests__/StateMachine.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { StateMachine } from '../StateMachine'; + +describe('StateMachine', () => { + it('change вызывает exit предыдущего и enter нового', () => { + const log: string[] = []; + const sm = new StateMachine(); + sm.add('idle', { + enter: () => log.push('enter:idle'), + exit: () => log.push('exit:idle') + }); + sm.add('walk', { + enter: () => log.push('enter:walk') + }); + + sm.change('idle'); + sm.change('walk'); + expect(log).toEqual(['enter:idle', 'exit:idle', 'enter:walk']); + expect(sm.current).toBe('walk'); + }); + + it('повторный вход в то же состояние игнорируется', () => { + let enters = 0; + const sm = new StateMachine(); + sm.add('a', { enter: () => enters++ }); + sm.change('a'); + sm.change('a'); + expect(enters).toBe(1); + }); + + it('handleEvent выполняет переход по таблице', () => { + const sm = new StateMachine(); + sm.add('patrol', {}); + sm.add('chase', {}); + sm.transition('patrol', 'seePlayer', 'chase'); + sm.transition('chase', 'losePlayer', 'patrol'); + + sm.change('patrol'); + sm.handleEvent('seePlayer'); + expect(sm.current).toBe('chase'); + sm.handleEvent('losePlayer'); + expect(sm.current).toBe('patrol'); + }); + + it('неизвестное событие попадает в onUnhandled', () => { + let handled: string | undefined; + const sm = new StateMachine((state, event) => (handled = `${state}:${event}`)); + sm.add('idle', {}); + sm.change('idle'); + sm.handleEvent('boom'); + expect(handled).toBe('idle:boom'); + expect(sm.current).toBe('idle'); + }); + + it('update тикает текущее состояние и считает время в нём', () => { + let t = 0; + const sm = new StateMachine(); + sm.add('a', { update: (dt) => (t += dt) }); + sm.add('b', {}); + sm.change('a'); + sm.update(0.5); + sm.update(0.5); + expect(t).toBeCloseTo(1); + expect(sm.time).toBeCloseTo(1); + sm.change('b'); + expect(sm.time).toBe(0); + }); + + it('change в неизвестное состояние бросает', () => { + const sm = new StateMachine(); + expect(() => sm.change('nope')).toThrow(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Tween.test.ts b/packages/engine/src/core/__tests__/Tween.test.ts new file mode 100644 index 0000000..72264b2 --- /dev/null +++ b/packages/engine/src/core/__tests__/Tween.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { TweenManager } from '../Tween'; +import { linear, quadOut, cubicIn, sineInOut, quadIn, cubicOut } from '../easing'; + +describe('easing', () => { + it('края области определения всегда 0 и 1', () => { + for (const e of [linear, quadIn, quadOut, cubicIn, cubicOut, sineInOut]) { + expect(e(0)).toBeCloseTo(0); + expect(e(1)).toBeCloseTo(1); + } + }); + + it('quadOut в середине быстрее linear', () => { + expect(quadOut(0.5)).toBeGreaterThan(0.5); + expect(cubicIn(0.5)).toBeLessThan(0.5); + }); +}); + +describe('TweenManager', () => { + it('линейный твин доходит до цели за duration', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + tw.to(obj, { x: 10 }, { duration: 2 }); + + tw.update(1); + expect(obj.x).toBeCloseTo(5); + tw.update(1); + expect(obj.x).toBeCloseTo(10); + expect(tw.activeCount).toBe(0); + }); + + it('не проходит цель и вызывает onDone один раз', () => { + const tw = new TweenManager(); + const obj = { y: 0 }; + let done = 0; + tw.to(obj, { y: 8 }, { duration: 1, onDone: () => done++ }); + + tw.update(0.7); + expect(obj.y).toBeCloseTo(5.6); + tw.update(0.7); // переполнение — зажимается + expect(obj.y).toBeCloseTo(8); + expect(done).toBe(1); + + tw.update(5); + expect(done).toBe(1); + }); + + it('ease применяется к прогрессу', () => { + const tw = new TweenManager(); + const obj = { v: 0 }; + tw.to(obj, { v: 100 }, { duration: 2, ease: cubicIn }); + tw.update(1); + expect(obj.v).toBeCloseTo(100 * cubicIn(0.5)); + expect(obj.v).toBeLessThan(50); + }); + + it('delay откладывает старт: значения не трогаются до истечения', () => { + const tw = new TweenManager(); + const obj = { x: 1 }; + tw.to(obj, { x: 5 }, { duration: 1, delay: 0.5 }); + + tw.update(0.4); + expect(obj.x).toBe(1); // ещё в delay + tw.update(0.6); // delay закончился, первый кадр движения + expect(obj.x).toBeGreaterThan(1); + tw.update(1); + expect(obj.x).toBeCloseTo(5); + }); + + it('delay(seconds, fn) вызывает fn один раз', () => { + const tw = new TweenManager(); + let calls = 0; + tw.delay(0.5, () => calls++); + tw.update(0.3); + expect(calls).toBe(0); + tw.update(0.3); + expect(calls).toBe(1); + tw.update(1); + expect(calls).toBe(1); + }); + + it('cancel останавливает твин', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + const h = tw.to(obj, { x: 10 }, { duration: 1 }); + tw.update(0.5); + h.cancel(); + tw.update(0.5); + expect(obj.x).toBeCloseTo(5); + expect(tw.activeCount).toBe(0); + }); + + it('cancelFor отменяет только твины целевого объекта', () => { + const tw = new TweenManager(); + const a = { x: 0 }; + const b = { x: 0 }; + tw.to(a, { x: 10 }, { duration: 1 }); + tw.to(b, { x: 10 }, { duration: 1 }); + tw.cancelFor(a); + tw.update(1); + expect(a.x).toBe(0); + expect(b.x).toBeCloseTo(10); + }); + + it('начальные значения снимаются при старте (после delay), а не при создании', () => { + const tw = new TweenManager(); + const obj = { x: 0 }; + tw.to(obj, { x: 10 }, { duration: 1, delay: 1 }); + obj.x = 4; // изменили до старта + tw.update(1.5); // delay прошёл, половина движения + expect(obj.x).toBeCloseTo(7); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/easing.ts b/packages/engine/src/core/easing.ts new file mode 100644 index 0000000..db2113f --- /dev/null +++ b/packages/engine/src/core/easing.ts @@ -0,0 +1,17 @@ +/** + * Функции easing: t=0..1 -> 0..1. Используются в Tween и переходах сцен. + */ + +export type EaseFn = (t: number) => number; + +export const linear: EaseFn = (t) => t; + +export const quadIn: EaseFn = (t) => t * t; + +export const quadOut: EaseFn = (t) => 1 - (1 - t) * (1 - t); + +export const cubicIn: EaseFn = (t) => t * t * t; + +export const cubicOut: EaseFn = (t) => 1 - Math.pow(1 - t, 3); + +export const sineInOut: EaseFn = (t) => -(Math.cos(Math.PI * t) - 1) / 2; \ No newline at end of file diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts new file mode 100644 index 0000000..4c72cff --- /dev/null +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -0,0 +1,286 @@ +import { GameState } from '../core/GameState'; + +/** + * Рантайм диалоговых графов, отделённый от отрисовки. + * Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState. + * Игра рисует диалог своим view (например DialogueBox) и вызывает advance()/pick(). + */ + +/** Условие по переменной состояния. */ +export interface VarCondition { + key: string; + op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; + value: number | string; +} + +/** Действия, применяемые к GameState при входе в узел/выборе. */ +export interface DialogueEffects { + setFlags?: string[]; + clearFlags?: string[]; + setVars?: Record; +} + +/** Условия показа узла/варианта. */ +export interface DialogueConditions { + /** Все эти флаги должны быть установлены. */ + when?: string[]; + /** Ни один из этих флагов не должен быть установлен. */ + whenNot?: string[]; + /** Условие по переменной. */ + whenVar?: VarCondition; +} + +export interface DialogueChoice extends DialogueEffects, DialogueConditions { + text: string; + /** Следующий узел (по умолчанию — конец диалога). */ + next?: string; +} + +export interface DialogueNode extends DialogueEffects, DialogueConditions { + /** Имя говорящего (опционально). */ + speaker?: string; + /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ + text?: string; + /** Варианты ответа игрока. */ + choices?: DialogueChoice[]; + /** Следующий узел. */ + next?: string; + /** Явный конец диалога (для конечных узлов без choices/next). */ + end?: boolean; +} + +export interface DialogueGraph { + /** id стартового узла. */ + start: string; + nodes: Record; +} + +/** Вид: игра рисует реплику и варианты своими средствами. */ +export interface DialogueView { + /** Показать реплику. choices пуст, если выбора нет. */ + show(node: { speaker?: string; text: string; choices: { index: number; text: string }[] }): void; + hide(): void; +} + +const MAX_STEPS = 1000; // защита от циклов в графе без текста + +export class DialogueRunner { + /** Вызывается по завершении диалога. */ + onFinish: ((graph: DialogueGraph) => void) | null = null; + + private graph: DialogueGraph | null = null; + private view: DialogueView | null = null; + private shownChoices: { index: number; text: string }[] = []; + private pendingNext: string | undefined; + private waitingForChoice = false; + + constructor( + private state: GameState, + view?: DialogueView + ) { + this.view = view ?? null; + } + + /** Подключить/заменить view (например, после пересоздания UI). */ + setView(view: DialogueView): void { + this.view = view; + } + + get active(): boolean { + return this.graph !== null; + } + + /** Запустить диалог. */ + start(graph: DialogueGraph): void { + this.graph = graph; + this.waitingForChoice = false; + this.pendingNext = undefined; + this.enterNode(graph.start); + } + + /** + * «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется. + */ + advance(): void { + if (!this.graph || this.waitingForChoice) return; + const next = this.pendingNext; + if (next === undefined) { + this.finish(); + return; + } + this.enterNode(next); + } + + /** Выбрать вариант ответа. */ + pick(index: number): void { + if (!this.graph || !this.waitingForChoice) return; + const shown = this.shownChoices[index]; + if (!shown) return; + const choice = this.currentNode?.choices?.[shown.index]; + if (!choice) return; + + this.waitingForChoice = false; + this.shownChoices = []; + this.applyEffects(choice); + if (choice.next === undefined) { + this.finish(); + } else { + this.enterNode(choice.next); + } + } + + /** Завершить диалог досрочно. */ + finish(): void { + const graph = this.graph; + this.graph = null; + this.waitingForChoice = false; + this.shownChoices = []; + this.pendingNext = undefined; + this.view?.hide(); + if (graph) this.onFinish?.(graph); + } + + private get currentNode(): DialogueNode | null { + if (!this.graph || this.currentId === null) return null; + return this.graph.nodes[this.currentId] ?? null; + } + + private currentId: string | null = null; + + /** Войти в узел: проверить условия, применить эффекты, показать или продолжить. */ + private enterNode(id: string): void { + if (!this.graph) return; + const node = this.graph.nodes[id]; + if (!node) { + // несуществующий next трактуем как конец + this.finish(); + return; + } + + // Шагаем через «пустые» узлы (не прошедшие условия или без текста), + // пока не встретим реплику с выбором, реплику с ожиданием или конец. + let current: { id: string; node: DialogueNode } | null = { id, node }; + for (let steps = 0; current && steps < MAX_STEPS; steps++) { + const n: DialogueNode = current.node; + + if (n.when || n.whenNot || n.whenVar) { + if (!this.checkConditions(n)) { + // условие не прошло — уходим по next или заканчиваем + if (n.next !== undefined) { + const nextNode = this.graph.nodes[n.next]; + if (!nextNode) { + this.currentId = null; + this.finish(); + return; + } + current = { id: n.next, node: nextNode }; + continue; + } + this.currentId = null; + this.finish(); + return; + } + } + + this.currentId = current.id; + this.applyEffects(n); + + if (n.choices && n.choices.length > 0) { + const choices: { index: number; text: string }[] = n.choices + .map((c: DialogueChoice, index: number) => ({ c, index })) + .filter(({ c }) => this.checkConditions(c)) + .map(({ c, index }) => ({ index, text: c.text })); + if (n.text !== undefined) { + this.show(n, choices); + } else if (choices.length > 0) { + // выбор без реплики: показываем только варианты + this.show({ text: '' }, choices); + } else { + // нет ни текста, ни доступных вариантов — конец + this.currentId = null; + this.finish(); + return; + } + return; + } + + if (n.text !== undefined) { + this.pendingNext = n.next; + this.show(n, []); + return; + } + + // Действие без текста: продолжаем по next или заканчиваем + if (n.next === undefined || n.end) { + this.currentId = null; + this.finish(); + return; + } + const nextNode = this.graph.nodes[n.next]; + if (!nextNode) { + this.currentId = null; + this.finish(); + return; + } + current = { id: n.next, node: nextNode }; + } + + // Защита сработала — принудительный конец + this.currentId = null; + this.finish(); + } + + private show( + n: { speaker?: string; text?: string }, + choices: { index: number; text: string }[], + textlessChoice = false + ): void { + this.shownChoices = choices; + this.waitingForChoice = choices.length > 0; + this.view?.show({ + speaker: n.speaker, + text: textlessChoice ? '' : (n.text ?? ''), + choices + }); + } + + private applyEffects(e: DialogueEffects): void { + for (const f of e.setFlags ?? []) this.state.setFlag(f); + for (const f of e.clearFlags ?? []) this.state.clearFlag(f); + for (const [k, v] of Object.entries(e.setVars ?? {})) this.state.setVar(k, v); + } + + private checkConditions(c: DialogueConditions): boolean { + for (const f of c.when ?? []) { + if (!this.state.hasFlag(f)) return false; + } + for (const f of c.whenNot ?? []) { + if (this.state.hasFlag(f)) return false; + } + if (c.whenVar) { + const v = this.state.getVar(c.whenVar.key); + const { op, value } = c.whenVar; + switch (op) { + case 'eq': + if (v !== value) return false; + break; + case 'ne': + if (v === value) return false; + break; + case 'gt': + if (!(Number(v) > Number(value))) return false; + break; + case 'lt': + if (!(Number(v) < Number(value))) return false; + break; + case 'ge': + if (!(Number(v) >= Number(value))) return false; + break; + case 'le': + if (!(Number(v) <= Number(value))) return false; + break; + } + } + return true; + } +} \ No newline at end of file diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts new file mode 100644 index 0000000..29614e3 --- /dev/null +++ b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { DialogueRunner, type DialogueView } from '../DialogueRunner'; +import { GameState } from '../../core/GameState'; + +/** Тестовый view: запоминает показанные реплики. */ +interface ShownLine { + speaker?: string; + text: string; + choices: { index: number; text: string }[]; +} + +function makeView(): { view: DialogueView; shown: ShownLine[]; hidden: () => number } { + const shown: ShownLine[] = []; + let hideCount = 0; + const view: DialogueView = { + show: (n) => shown.push(n), + hide: () => hideCount++ + }; + return { view, shown, hidden: () => hideCount }; +} + +describe('DialogueRunner', () => { + it('линейный диалог: advance до конца, onFinish вызывается', () => { + const state = new GameState(); + const { view, shown, hidden } = makeView(); + const dr = new DialogueRunner(state, view); + let finished = 0; + dr.onFinish = () => finished++; + + dr.start({ + start: 'a', + nodes: { + a: { text: 'Привет.', next: 'b' }, + b: { text: 'Пока.', next: 'c' }, + c: { text: '...' } + } + }); + + expect(dr.active).toBe(true); + expect(shown[0]?.text).toBe('Привет.'); + dr.advance(); + expect(shown[1]?.text).toBe('Пока.'); + dr.advance(); + expect(shown[2]?.text).toBe('...'); + dr.advance(); + expect(finished).toBe(1); + expect(dr.active).toBe(false); + expect(hidden()).toBe(1); + }); + + it('узел без next — конец диалога', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + let finished = 0; + dr.onFinish = () => finished++; + dr.start({ start: 'a', nodes: { a: { text: 'Конец' } } }); + dr.advance(); + expect(finished).toBe(1); + }); + + it('выборы: показываются только прошедшие условия, pick уходит по next', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + + dr.start({ + start: 'q', + nodes: { + q: { + speaker: 'Ирвин', + text: 'Поможешь?', + choices: [ + { text: 'Да', next: 'yes', setFlags: ['quest_taken'] }, + { text: 'Нет', next: 'no' }, + { text: 'Секрет', when: ['knows_secret'], next: 'secret' } + ] + }, + yes: { text: 'Спасибо!' }, + no: { text: 'Жаль.' }, + secret: { text: 'Ты знаешь тайну.' } + } + }); + + expect(shown[0]?.speaker).toBe('Ирвин'); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Да', 'Нет']); + + dr.pick(0); + expect(state.hasFlag('quest_taken')).toBe(true); + // advance показывает следующий узел после применения эффекта выбора + dr.advance(); + expect(shown[1]?.text).toBe('Спасибо!'); + }); + + it('advance во время выбора игнорируется', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { q: { text: 'Выбор?', choices: [{ text: 'A', next: 'a2' }], next: 'x' }, a2: { text: 'A' }, x: { text: 'X' } } + }); + dr.advance(); + expect(shown.length).toBe(1); // реплика 'X' не показалась + }); + + it('узел-действие без текста применяет флаги и переходит дальше', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'give', + nodes: { + give: { setFlags: ['reward_given'], next: 'msg' }, + msg: { text: 'Готово.' } + } + }); + expect(shown[0]?.text).toBe('Готово.'); + expect(state.hasFlag('reward_given')).toBe(true); + }); + + it('when/whenNot фильтруют узлы: диалог идёт по другой ветке', () => { + const state = new GameState(); + state.setFlag('was_here'); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'check', + nodes: { + check: { when: ['was_here'], text: 'Опять ты.', next: 'end' }, + first: { text: 'Первый раз.' }, + end: { text: 'Конец.' } + } + }); + // check прошёл условие + expect(shown[0]?.text).toBe('Опять ты.'); + }); + + it('когда узел не прошёл условие, диалог уходит по его next', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'check', + nodes: { + check: { when: ['vip'], text: 'Ветка VIP.', next: 'end' }, + end: { text: 'Финал.' } + } + }); + // check не прошёл (нет vip) — но у check есть next: показывается end + expect(shown[0]?.text).toBe('Финал.'); + expect(dr.active).toBe(true); + }); + + it('узел-действие с whenNot переходит по next при проходе условия', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'hub', + nodes: { + hub: { whenNot: ['vip'], next: 'normal' }, + normal: { text: 'Обычная ветка.', next: 'end' }, + end: { text: 'Финал.' } + } + }); + // hub без текста: условие прошло, эффектов нет, next -> normal + expect(shown[0]?.text).toBe('Обычная ветка.'); + }); + + it('whenVar фильтрует по числовой переменной', () => { + const state = new GameState(); + state.setVar('gold', 5); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'q', + nodes: { + q: { + text: 'Торговец:', + choices: [ + { text: 'Купить (10 зол.)', whenVar: { key: 'gold', op: 'ge', value: 10 }, next: 'buy' }, + { text: 'Уйти', next: 'bye' } + ] + }, + buy: { text: 'Куплено.' }, + bye: { text: 'Пока.' } + } + }); + expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']); + }); + + it('setVars в выборах меняет состояние', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'q', + nodes: { + q: { text: 'Сколько?', choices: [{ text: 'Дать 30', setVars: { gold: 30 }, next: 'ok' }] }, + ok: { text: 'Ок.' } + } + }); + dr.pick(0); + expect(state.getNumber('gold')).toBe(30); + }); + + it('цикл из узлов без текста не зависает — защита по MAX_STEPS', () => { + const state = new GameState(); + const dr = new DialogueRunner(state, makeView().view); + dr.start({ + start: 'a', + nodes: { + a: { next: 'b' }, + b: { next: 'a' } + } + }); + expect(dr.active).toBe(false); // безопасно завершился + }); +}); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9f104e8..5c02b06 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -9,9 +9,24 @@ export { Engine, type EngineOptions } from './core/Engine'; export { GameLoop } from './core/GameLoop'; export { EventBus } from './core/EventBus'; +export { TweenManager, type TweenOptions, type TweenHandle } from './core/Tween'; +export { + linear, + quadIn, + quadOut, + cubicIn, + cubicOut, + sineInOut, + type EaseFn +} from './core/easing'; + +// state (см. core) +export { GameState, type GameStateData } from './core/GameState'; +export { StateMachine, type StateDef } from './core/StateMachine'; +export { Settings, type SettingsData } from './core/Settings'; // scene -export { SceneManager, type Scene } from './scene/SceneManager'; +export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; // ecs export { World, type Entity, type System } from './ecs/ecs'; @@ -25,26 +40,51 @@ DEFAULT_ISO, type IsoLayout } from './math/iso'; +export { createRng, type Rng } from './math/rng'; // map export { IsometricTileMap, type TileMapData, type TallSpec } from './map/IsometricTileMap'; export { findPath, type Grid } from './map/pathfinding'; +export { + encodeMap, + parseMap, + type MapFile, + type MapFileV1 +} from './map/mapFormat'; +export { fromTiledIso, type TiledMap } from './map/tiled'; // render export { Renderer, type RendererOptions } from './render/Renderer'; export { Camera } from './render/Camera'; +export { IsoDepthLayer } from './render/IsoDepthLayer'; +export { computeScale } from './render/scale'; +export { ParticleEmitter, type EmitterOptions } from './render/Particles'; // input export { InputManager, type PointerState } from './input/InputManager'; +export { VirtualJoystick, type VirtualJoystickOptions } from './input/VirtualJoystick'; // anim export { FrameAnimation } from './anim/FrameAnimation'; +// dialogue +export { + DialogueRunner, + type DialogueGraph, + type DialogueNode, + type DialogueChoice, + type DialogueView +} from './dialogue/DialogueRunner'; + // ui export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox'; +export { PixelText, type PixelTextOptions, ensurePixelFont } from './ui/PixelText'; +export { Panel, type PanelOptions } from './ui/Panel'; +export { Button, type ButtonOptions } from './ui/Button'; +export { MenuList, type MenuListOptions } from './ui/MenuList'; // audio -export { AudioManager } from './audio/AudioManager'; +export { AudioManager, type AudioBuses } from './audio/AudioManager'; // assets export { AssetLoader } from './assets/AssetLoader'; diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index 9189b74..22e0ef8 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -1,6 +1,6 @@ /** - * Ввод: клавиатура (маппинг «действие -> клавиши») и указатель (мышь/тач) - * в координатах виртуального разрешения. + * Ввод: клавиатура (маппинг «действие -> клавиши»), геймпад (стандартная карта) + * и указатель (мышь/тач) в координатах виртуального разрешения. */ export interface PointerState { @@ -27,6 +27,12 @@ private keysPressed = new Set(); private keyActions = new Map(); + // --- геймпад (стандартная карта кнопок) --- + /** action -> индексы кнопок стандартной карты. */ + private padActions = new Map(); + private padDown = new Set(); + private padPressed = new Set(); + private pointer: PointerState = { x: 0, y: 0, @@ -69,11 +75,65 @@ } } - /** Действие активно (зажато). */ + /** + * Маппинг геймпада: действие -> индексы кнопок стандартной карты + * (0=A, 1=B, 8=Select, 9=Start, 12..15 — крестовина). + */ + bindGamepad(map: Record): void { + for (const [action, buttons] of Object.entries(map)) { + this.padActions.set(action, buttons); + } + } + + /** + * Опрос геймпадов. Вызывается движком в начале каждого фиксированного шага + * (до scenes.update). + */ + update(): void { + this.padPressed.clear(); + if (typeof navigator === 'undefined' || !navigator.getGamepads) return; + for (const pad of navigator.getGamepads()) { + if (!pad) continue; + pad.buttons.forEach((btn, i) => { + const pressed = btn.pressed; + if (pressed && !this.padDown.has(i)) { + this.padPressed.add(i); + } + if (pressed) { + this.padDown.add(i); + } else { + this.padDown.delete(i); + } + }); + break; // опрашиваем первый подключённый геймпад + } + } + + /** Оси левого стика (-1..1) первого подключённого геймпада (или null). */ + getLeftStick(): { x: number; y: number } | null { + if (typeof navigator === 'undefined' || !navigator.getGamepads) return null; + for (const pad of navigator.getGamepads()) { + if (!pad) continue; + const [x = 0, y = 0] = pad.axes; + return { + x: Math.abs(x) < 0.15 ? 0 : x, // мёртвая зона + y: Math.abs(y) < 0.15 ? 0 : y + }; + } + return null; + } + + /** Действие активно (зажато): клавиатура или геймпад. */ isActionActive(action: string): boolean { for (const [code, a] of this.keyActions) { if (a === action && this.keysDown.has(code)) return true; } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padDown.has(b)) return true; + } + } return false; } @@ -82,6 +142,12 @@ for (const code of this.keysPressed) { if (this.keyActions.get(code) === action) return true; } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padPressed.has(b)) return true; + } + } return false; } @@ -100,6 +166,7 @@ /** Вызывается в конце каждого тика: очищает «just pressed/released». */ endTick(): void { this.keysPressed.clear(); + this.padPressed.clear(); this.pointer.justPressed = false; this.pointer.justReleased = false; } diff --git a/packages/engine/src/input/VirtualJoystick.ts b/packages/engine/src/input/VirtualJoystick.ts new file mode 100644 index 0000000..6637ab8 --- /dev/null +++ b/packages/engine/src/input/VirtualJoystick.ts @@ -0,0 +1,112 @@ +import { Container, Graphics, Rectangle } from 'pixi.js'; + +/** + * Виртуальный джойстик для тач-устройств: рисуется в uiRoot, показывает + * базовый круг и ручку. Игра читает getVector() и двигает персонажа. + * Появляется там, где игрок коснулся экрана (полупрозрачно), исчезает при отпускании. + */ +export interface VirtualJoystickOptions { + /** Радиус базового круга (виртуальные пиксели). */ + radius?: number; + /** Цвета. */ + color?: number; + /** Отступ от краёв экрана, за который джойстик не заходит. */ + margin?: number; + /** Полные размеры экрана (виртуальные), чтобы клэмпить позицию. */ + screen: { width: number; height: number }; +} + +export class VirtualJoystick extends Container { + private base: Graphics; + private knob: Graphics; + private radius: number; + private margin: number; + private screen: { width: number; height: number }; + private color: number; + private dragging = false; + + /** Текущий вектор (-1..1 по каждой оси; длина ≤ 1). */ + private vector = { x: 0, y: 0 }; + + constructor(options: VirtualJoystickOptions) { + super(); + this.radius = options.radius ?? 28; + this.margin = options.margin ?? 12; + this.screen = options.screen; + this.color = options.color ?? 0x8899aa; + + this.visible = false; + this.eventMode = 'static'; + this.hitArea = new Rectangle(0, 0, this.screen.width, this.screen.height); + + this.base = new Graphics(); + this.knob = new Graphics(); + this.addChild(this.base, this.knob); + this.drawStatic(); + + this.on('pointerdown', (e) => this.onDown(e.global.x, e.global.y)); + this.on('pointermove', (e) => { + if (this.dragging) this.onMove(e.global.x, e.global.y); + }); + this.on('pointerup', () => this.release()); + this.on('pointerupoutside', () => this.release()); + } + + /** Вектор управления; активен ли джойстик сейчас. */ + getVector(): { x: number; y: number } { + return { x: this.vector.x, y: this.vector.y }; + } + + get active(): boolean { + return this.dragging; + } + + /** Обновить размеры экрана (при ресайзе). */ + setScreen(width: number, height: number): void { + this.screen = { width, height }; + } + + private onDown(px: number, py: number): void { + this.dragging = true; + this.visible = true; + this.reposition(px, py); + this.updateKnob(px, py); + } + + private onMove(px: number, py: number): void { + this.updateKnob(px, py); + } + + private release(): void { + this.dragging = false; + this.visible = false; + this.vector = { x: 0, y: 0 }; + } + + private reposition(px: number, py: number): void { + const x = Math.min(Math.max(px, this.margin + this.radius), this.screen.width - this.margin - this.radius); + const y = Math.min(Math.max(py, this.margin + this.radius), this.screen.height - this.margin - this.radius); + this.position.set(x, y); + } + + private updateKnob(px: number, py: number): void { + let dx = px - this.x; + let dy = py - this.y; + const len = Math.hypot(dx, dy); + const max = this.radius; + if (len > max) { + dx = (dx / len) * max; + dy = (dy / len) * max; + } + this.knob.position.set(dx, dy); + this.vector = { x: dx / max, y: dy / max }; + } + + private drawStatic(): void { + this.base.clear(); + this.base.circle(0, 0, this.radius).stroke({ color: this.color, width: 1, alpha: 0.5 }); + this.base.circle(0, 0, this.radius * 0.6).fill({ color: this.color, alpha: 0.08 }); + this.knob.clear(); + this.knob.circle(0, 0, this.radius * 0.35).fill({ color: this.color, alpha: 0.6 }); + } +} \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/mapFormat.test.ts b/packages/engine/src/map/__tests__/mapFormat.test.ts new file mode 100644 index 0000000..da70764 --- /dev/null +++ b/packages/engine/src/map/__tests__/mapFormat.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { encodeMap, parseMap, rleEncode, rleDecode } from '../mapFormat'; +import type { TileMapData } from '../IsometricTileMap'; + +const sample: TileMapData = { + width: 3, + height: 2, + tiles: [0, 0, 0, 1, 1, 2], + blocked: [2], + tall: { 3: { height: 48, ground: 0 } } +}; + +describe('RLE', () => { + it('кодирует серии одинаковых id', () => { + expect(rleEncode([0, 0, 0, 1, 1, 2])).toEqual([ + [0, 3], + [1, 2], + [2, 1] + ]); + }); + + it('декодирует обратно', () => { + expect(rleDecode([[0, 3], [1, 2], [2, 1]])).toEqual([0, 0, 0, 1, 1, 2]); + }); + + it('пустой массив', () => { + expect(rleEncode([])).toEqual([]); + expect(rleDecode([])).toEqual([]); + }); +}); + +describe('encodeMap/parseMap', () => { + it('roundtrip с RLE сохраняет карту', () => { + const file = encodeMap(sample); + expect(file.format).toBe('rpg-map'); + expect(file.encoding).toBe('rle'); + const parsed = parseMap(JSON.parse(JSON.stringify(file))); + expect(parsed).toEqual(sample); + }); + + it('roundtrip с raw', () => { + const file = encodeMap(sample, 'raw'); + expect(file.tiles).toEqual([0, 0, 0, 1, 1, 2]); + const parsed = parseMap(file); + expect(parsed.tiles).toEqual(sample.tiles); + }); + + it('RLE реально сжимает однородную карту', () => { + const big: TileMapData = { ...sample, width: 28, height: 28, tiles: new Array(784).fill(0) }; + const file = encodeMap(big); + expect(JSON.stringify(file.tiles).length).toBeLessThan(20); + }); + + it('parseMap отвергает не-объект', () => { + expect(() => parseMap('nope')).toThrow(); + expect(() => parseMap(null)).toThrow(); + }); + + it('parseMap отвергает чужой format/версию', () => { + expect(() => parseMap({ ...encodeMap(sample), format: 'other' })).toThrow(/format/); + expect(() => parseMap({ ...encodeMap(sample), version: 99 })).toThrow(/версия|версию|version/); + }); + + it('parseMap отвергает неверное число тайлов', () => { + const file = encodeMap(sample, 'raw'); + (file as { tiles: number[] }).tiles = [0, 1, 2]; + expect(() => parseMap(file)).toThrow(/784|тайлов|6/); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/map/__tests__/tiled.test.ts b/packages/engine/src/map/__tests__/tiled.test.ts new file mode 100644 index 0000000..2cd153c --- /dev/null +++ b/packages/engine/src/map/__tests__/tiled.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { fromTiledIso, type TiledMap } from '../tiled'; + +const tiled: TiledMap = { + width: 4, + height: 3, + tilewidth: 32, + tileheight: 16, + orientation: 'isometric', + tilesets: [{ firstgid: 1 }], + layers: [ + { type: 'tilelayer', name: 'ground', data: [1, 1, 2, 1, 1, 3, 1, 1, 0, 1, 1, 1] }, + { type: 'tilelayer', name: 'props', data: [0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0] }, + { type: 'objectgroup', name: 'spawns', objects: [] } + ] +} as unknown as TiledMap; + +describe('fromTiledIso', () => { + it('смещает gid на firstgid и склеивает слои', () => { + const map = fromTiledIso(tiled, { blocked: [2] }); + expect(map.width).toBe(4); + expect(map.height).toBe(3); + expect(map.tiles).toEqual([ + 0, 0, 1, 0, // ground: 1,1,2,1 + 0, 2, 0, 0, // ground: 1,3,1,1 + 2, 0, 0, 0 // props: верхний слой кладёт 2 на пустое место (index 8) + ]); + expect(map.blocked).toEqual([2]); + }); + + it('фильтрует слои по имени', () => { + const map = fromTiledIso(tiled, { layers: ['ground'] }); + expect(map.tiles[9]).toBe(0); // props-слой не применён + }); + + it('отвергает не-изометрическую ориентацию', () => { + expect(() => fromTiledIso({ ...tiled, orientation: 'orthogonal' })).toThrow(/isometric/); + }); + + it('отвергает слой неверного размера', () => { + const bad = { + ...tiled, + layers: [{ type: 'tilelayer', name: 'bad', data: [1, 2, 3] }] + }; + expect(() => fromTiledIso(bad)).toThrow(/размера/); + }); + + it('игнорирует невидимые слои', () => { + const hidden = { + ...tiled, + layers: [ + { type: 'tilelayer', name: 'ground', visible: false, data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, + { type: 'tilelayer', name: 'ok', data: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } + ] + }; + const map = fromTiledIso(hidden); + expect(map.tiles[0]).toBe(1); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/map/mapFormat.ts b/packages/engine/src/map/mapFormat.ts new file mode 100644 index 0000000..9ad768a --- /dev/null +++ b/packages/engine/src/map/mapFormat.ts @@ -0,0 +1,97 @@ +import type { TileMapData, TallSpec } from './IsometricTileMap'; + +/** + * Файловый формат карт движка: JSON с RLE-сжатием тайлов. + * RLE — пары [id, длина серии]: карта лугов 28×28 сжимается в разы, + * и её можно править текстовым редактором. + */ + +export interface MapFileV1 { + format: 'rpg-map'; + version: 1; + width: number; + height: number; + /** id тайлов, которые считаются блокирующими. */ + blocked: number[]; + /** Высокие объекты: id -> высота или полная спецификация. */ + tall?: Record; + /** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */ + encoding: 'rle' | 'raw'; + tiles: number[] | [number, number][]; +} + +export type MapFile = MapFileV1; + +/** Упаковать тайлы в RLE: последовательности одинаковых id -> пары [id, длина]. */ +export function rleEncode(tiles: number[]): [number, number][] { + const out: [number, number][] = []; + let i = 0; + while (i < tiles.length) { + const id = tiles[i]!; + let run = 1; + while (i + run < tiles.length && tiles[i + run] === id) run++; + out.push([id, run]); + i += run; + } + return out; +} + +/** Распаковать RLE обратно в плоский массив. */ +export function rleDecode(pairs: [number, number][]): number[] { + const out: number[] = []; + for (const [id, run] of pairs) { + for (let k = 0; k < run; k++) out.push(id); + } + return out; +} + +/** Сериализовать карту в файловый формат (RLE по умолчанию). */ +export function encodeMap(data: TileMapData, encoding: 'rle' | 'raw' = 'rle'): MapFileV1 { + return { + format: 'rpg-map', + version: 1, + width: data.width, + height: data.height, + blocked: data.blocked, + tall: data.tall, + encoding, + tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles + }; +} + +/** Распарсить файл карты (с валидацией размеров). */ +export function parseMap(file: unknown): TileMapData { + if (typeof file !== 'object' || file === null) { + throw new Error('Карта: ожидался JSON-объект'); + } + const f = file as Partial; + if (f.format !== 'rpg-map') { + throw new Error('Карта: неверный format (ожидался rpg-map)'); + } + if (f.version !== 1) { + throw new Error(`Карта: неподдерживаемая версия ${f.version}`); + } + if (typeof f.width !== 'number' || typeof f.height !== 'number' || !Array.isArray(f.tiles)) { + throw new Error('Карта: missing width/height/tiles'); + } + let tiles: number[]; + if (f.encoding === 'rle') { + tiles = rleDecode(f.tiles as [number, number][]); + } else { + tiles = f.tiles as number[]; + if (tiles.some((t) => typeof t !== 'number')) { + throw new Error('Карта: raw-тайлы должны быть числами'); + } + } + const expected = f.width * f.height; + if (tiles.length !== expected) { + throw new Error(`Карта: тайлов ${tiles.length}, ожидалось ${expected} (${f.width}×${f.height})`); + } + return { + width: f.width, + height: f.height, + tiles, + blocked: f.blocked ?? [], + tall: f.tall + }; +} \ No newline at end of file diff --git a/packages/engine/src/map/tiled.ts b/packages/engine/src/map/tiled.ts new file mode 100644 index 0000000..f1f8231 --- /dev/null +++ b/packages/engine/src/map/tiled.ts @@ -0,0 +1,76 @@ +import type { TileMapData } from './IsometricTileMap'; + +/** + * Импортёр карт из Tiled (https://www.mapeditor.org). + * Поддерживается isometric-карта с tilelayer-слоями (CSV или сжатые — + * Tiled отдаёт их уже распакованными в JSON). GID 0 — пустой тайл; + * gid смещаются на firstgid первого тайлсета, чтобы id шли с 0. + */ + +/** Минимум того, что читаем из JSON Tiled. */ +export interface TiledMap { + width: number; + height: number; + tilewidth: number; + tileheight: number; + orientation: string; + layers: { + type: string; + name?: string; + data?: number[]; + visible?: boolean; + }[]; + tilesets?: { firstgid: number }[]; +} + +export interface FromTiledOptions { + /** Имена слоёв в порядке снизу вверх (по умолчанию — все tilelayer в порядке следования). */ + layers?: string[]; + /** id тайлов, которые считаются блокирующими (после смещения). */ + blocked?: number[]; + /** Высокие объекты. */ + tall?: TileMapData['tall']; +} + +/** + * Преобразовать isometric-карту Tiled в TileMapData движка. + * Верхние слои перезаписывают нули (пустые клетки) нижних. + */ +export function fromTiledIso(tiled: TiledMap, options: FromTiledOptions = {}): TileMapData { + if (tiled.orientation !== 'isometric') { + throw new Error(`Tiled: ожидается orientation=isometric, получен ${tiled.orientation}`); + } + const { width, height } = tiled; + + const firstGid = tiled.tilesets?.[0]?.firstgid ?? 1; + const layers = tiled.layers.filter( + (l) => + l.type === 'tilelayer' && + l.visible !== false && + (options.layers ? options.layers.includes(l.name ?? '') : true) + ); + if (layers.length === 0) { + throw new Error('Tiled: в карте нет tilelayer-слоёв'); + } + + const tiles = new Array(width * height).fill(0); + for (const layer of layers) { + if (!layer.data || layer.data.length !== width * height) { + throw new Error(`Tiled: слой ${layer.name ?? '?'} неверного размера`); + } + for (let i = 0; i < layer.data.length; i++) { + const gid = layer.data[i]!; + if (gid !== 0 && tiles[i] === 0) { + tiles[i] = gid - firstGid; + } + } + } + + return { + width, + height, + tiles, + blocked: options.blocked ?? [], + tall: options.tall + }; +} \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/rng.test.ts b/packages/engine/src/math/__tests__/rng.test.ts new file mode 100644 index 0000000..7f0c206 --- /dev/null +++ b/packages/engine/src/math/__tests__/rng.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { createRng } from '../rng'; + +describe('createRng (mulberry32)', () => { + it('одинаковый seed — одинаковая последовательность', () => { + const a = createRng(12345); + const b = createRng(12345); + for (let i = 0; i < 100; i++) { + expect(a.next()).toBe(b.next()); + } + }); + + it('разные seed — разные последовательности', () => { + const a = createRng(1); + const b = createRng(2); + const seqA = Array.from({ length: 10 }, () => a.next()); + const seqB = Array.from({ length: 10 }, () => b.next()); + expect(seqA).not.toEqual(seqB); + }); + + it('next в диапазоне 0..1', () => { + const rng = createRng(42); + for (let i = 0; i < 1000; i++) { + const v = rng.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('int внутри [min, max] включительно', () => { + const rng = createRng(7); + for (let i = 0; i < 500; i++) { + const v = rng.int(2, 5); + expect(v).toBeGreaterThanOrEqual(2); + expect(v).toBeLessThanOrEqual(5); + expect(Number.isInteger(v)).toBe(true); + } + }); + + it('int достигает обоих концов диапазона', () => { + const rng = createRng(9); + const seen = new Set(); + for (let i = 0; i < 500; i++) seen.add(rng.int(1, 3)); + expect(seen.has(1)).toBe(true); + expect(seen.has(3)).toBe(true); + }); + + it('pick берёт элемент из массива', () => { + const rng = createRng(3); + const arr = ['a', 'b', 'c'] as const; + for (let i = 0; i < 50; i++) { + expect(arr).toContain(rng.pick(arr)); + } + }); + + it('pick из пустого массива бросает', () => { + const rng = createRng(3); + expect(() => rng.pick([])).toThrow(); + }); + + it('chance(0) всегда false, chance(1) всегда true', () => { + const rng = createRng(11); + for (let i = 0; i < 100; i++) { + expect(rng.chance(0)).toBe(false); + expect(rng.chance(1)).toBe(true); + } + }); + + it('state() отражает позицию в последовательности (для сейва в связке с seed)', () => { + const a = createRng(777); + a.next(); + const s1 = a.state(); + a.next(); + const s2 = a.state(); + expect(s2).not.toBe(s1); + + // тот же seed и те же два вызова дают то же состояние + const b = createRng(777); + b.next(); + b.next(); + expect(b.state()).toBe(s2); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/math/rng.ts b/packages/engine/src/math/rng.ts new file mode 100644 index 0000000..c5fbe59 --- /dev/null +++ b/packages/engine/src/math/rng.ts @@ -0,0 +1,39 @@ +/** + * Детерминированный RNG (mulberry32): одинаковый seed — одинаковая последовательность. + * Используется для генерации мира, лута, частиц — везде, где нужен повторяемый «случай». + */ + +export interface Rng { + /** Следующее число 0..1. */ + next(): number; + /** Целое в диапазоне [min, max] включительно. */ + int(min: number, max: number): number; + /** Случайный элемент массива (массив должен быть непустым). */ + pick(arr: readonly T[]): T; + /** true с вероятностью p (0..1). */ + chance(p: number): boolean; + /** Текущее состояние — можно сохранить и восстановить для продолжения последовательности. */ + state(): number; +} + +/** Создать RNG из 32-битного seed. */ +export function createRng(seed: number): Rng { + let s = seed >>> 0; + const next = (): number => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + next, + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + pick: (arr: readonly T[]) => { + if (arr.length === 0) throw new Error('pick из пустого массива'); + return arr[Math.floor(next() * arr.length)]; + }, + chance: (p) => next() < p, + state: () => s + }; +} \ No newline at end of file diff --git a/packages/engine/src/render/IsoDepthLayer.ts b/packages/engine/src/render/IsoDepthLayer.ts new file mode 100644 index 0000000..0fac28f --- /dev/null +++ b/packages/engine/src/render/IsoDepthLayer.ts @@ -0,0 +1,28 @@ +import { Container } from 'pixi.js'; + +/** + * Контейнер с сортировкой глубины для изометрической сцены. + * Глубина = tx + ty (классика изометрии 2:1): чем юго-восточнее объект, + * тем «ближе к камере» он рисуется. Герой, NPC, деревья и прочие сущности + * добавляются сюда, а не прямо в слой карты. + * + * Если объект движется, каждый кадр (или при смене тайла) вызывайте setDepth. + */ +export class IsoDepthLayer extends Container { + constructor() { + super(); + this.sortableChildren = true; + } + + /** Добавить объект и выставить глубину по тайловым координатам. */ + add(view: Container, tx: number, ty: number): Container { + this.setDepth(view, tx, ty); + this.addChild(view); + return view; + } + + /** Обновить глубину объекта по тайловым координатам. */ + setDepth(view: Container, tx: number, ty: number): void { + view.zIndex = tx + ty; + } +} \ No newline at end of file diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts new file mode 100644 index 0000000..e6b6e6b --- /dev/null +++ b/packages/engine/src/render/Particles.ts @@ -0,0 +1,112 @@ +import { Container, Sprite, Texture } from 'pixi.js'; +import { Rng, createRng } from '../math/rng'; + +/** + * Простая система частиц: эмиттер спавнит точки (Texture.WHITE, подкрашенные) + * с заданной скоростью, временем жизни и затуханием. + * Используется для атмосферы: пепел, мотыльки, пыль, искры. + */ +export interface EmitterOptions { + /** Цвет частиц (0xRRGGBB). */ + color: number; + /** Частиц в секунду. */ + rate: number; + /** Время жизни частицы, секунды (min..max). */ + lifetime: [number, number]; + /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */ + velocity: { x: [number, number]; y: [number, number] }; + /** Ускорение (гравитация/ветер), пикселей/сек². */ + acceleration?: { x?: number; y?: number }; + /** Размер частицы в пикселях (1..4). */ + size?: number; + /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */ + spawnArea?: { width: number; height: number }; + /** Затухание альфы к концу жизни (по умолчанию true). */ + fadeOut?: boolean; + /** Seed для повторяемого движения. */ + seed?: number; +} + +interface Particle { + sprite: Sprite; + vx: number; + vy: number; + age: number; + lifetime: number; +} + +export class ParticleEmitter extends Container { + private particles: Particle[] = []; + private spawnTimer = 0; + private rng: Rng; + + constructor(private options: EmitterOptions) { + super(); + this.rng = createRng(options.seed ?? 0x9e3779b9); + } + + update(dt: number): void { + // Спавн + this.spawnTimer += dt * this.options.rate; + while (this.spawnTimer >= 1) { + this.spawnTimer -= 1; + this.spawn(); + } + + // Обновление + const accX = this.options.acceleration?.x ?? 0; + const accY = this.options.acceleration?.y ?? 0; + for (const p of this.particles) { + p.age += dt; + p.vx += accX * dt; + p.vy += accY * dt; + p.sprite.x += p.vx * dt; + p.sprite.y += p.vy * dt; + if (this.options.fadeOut ?? true) { + p.sprite.alpha = Math.max(0, 1 - p.age / p.lifetime); + } + } + + // Сбор «умерших» + const dead = this.particles.filter((p) => p.age >= p.lifetime); + for (const p of dead) { + p.sprite.destroy(); + } + if (dead.length > 0) { + this.particles = this.particles.filter((p) => p.age < p.lifetime); + } + } + + /** Убрать все частицы (например, при выходе со сцены). */ + clear(): void { + for (const p of this.particles) p.sprite.destroy(); + this.particles = []; + } + + get count(): number { + return this.particles.length; + } + + private spawn(): void { + const size = this.options.size ?? 2; + const sprite = new Sprite(Texture.WHITE); + sprite.width = size; + sprite.height = size; + sprite.tint = this.options.color; + + const area = this.options.spawnArea; + sprite.x = area ? this.rng.int(0, area.width) - area.width / 2 : 0; + sprite.y = area ? this.rng.int(0, area.height) - area.height / 2 : 0; + sprite.alpha = 0; // появится в следующем кадре без «вспышки» + + const v = this.options.velocity; + this.particles.push({ + sprite, + vx: v.x[0] + this.rng.next() * (v.x[1] - v.x[0]), + vy: v.y[0] + this.rng.next() * (v.y[1] - v.y[0]), + age: 0, + lifetime: this.options.lifetime[0] + this.rng.next() * (this.options.lifetime[1] - this.options.lifetime[0]) + }); + this.addChild(sprite); + } +} \ No newline at end of file diff --git a/packages/engine/src/render/Renderer.ts b/packages/engine/src/render/Renderer.ts index 385e131..d5501d6 100644 --- a/packages/engine/src/render/Renderer.ts +++ b/packages/engine/src/render/Renderer.ts @@ -26,7 +26,12 @@ readonly virtualWidth: number; readonly virtualHeight: number; - readonly scale: number; + private _scale: number; + + /** Текущий целочисленный масштаб. */ + get scale(): number { + return this._scale; + } private readonly parentEl: HTMLElement; private readonly initPromise: Promise; @@ -34,7 +39,7 @@ constructor(options: RendererOptions) { this.virtualWidth = options.virtualWidth; this.virtualHeight = options.virtualHeight; - this.scale = options.scale; + this._scale = options.scale; this.parentEl = options.parent; this.app = new Application(); @@ -59,11 +64,25 @@ const canvas = this.app.canvas; canvas.style.imageRendering = 'pixelated'; - canvas.style.width = `${this.virtualWidth * this.scale}px`; - canvas.style.height = `${this.virtualHeight * this.scale}px`; + this.applySize(); this.parentEl.appendChild(canvas); } + /** + * Сменить целочисленный масштаб (обычно при ресайзе окна). + * Виртуальное разрешение не меняется — меняется только CSS-размер канваса. + */ + resize(scale: number): void { + this._scale = Math.max(1, Math.floor(scale)); + this.applySize(); + } + + private applySize(): void { + const canvas = this.app.canvas; + canvas.style.width = `${this.virtualWidth * this._scale}px`; + canvas.style.height = `${this.virtualHeight * this._scale}px`; + } + /** Рендер кадра (вызывается игровым циклом). */ render(): void { this.app.renderer.render(this.app.stage); diff --git a/packages/engine/src/render/scale.ts b/packages/engine/src/render/scale.ts new file mode 100644 index 0000000..e8df89c --- /dev/null +++ b/packages/engine/src/render/scale.ts @@ -0,0 +1,8 @@ +/** + * Целочисленный масштаб виртуального разрешения под размер окна (pixel-perfect). + */ + +/** Наибольший целый множитель, при котором виртуальный экран влезает в окно. */ +export function computeScale(vw: number, vh: number, winW: number, winH: number): number { + return Math.max(1, Math.floor(Math.min(winW / vw, winH / vh))); +} \ No newline at end of file diff --git a/packages/engine/src/scene/SceneManager.ts b/packages/engine/src/scene/SceneManager.ts index bec8884..0255316 100644 --- a/packages/engine/src/scene/SceneManager.ts +++ b/packages/engine/src/scene/SceneManager.ts @@ -1,3 +1,5 @@ +import { Graphics } from 'pixi.js'; + /** * Сцена — экран игры (меню, локация, бой). Менеджер держит стек, * но в 2D-RPG чаще всего достаточно push/pop/replace. @@ -11,15 +13,53 @@ render(): void; } +/** Затемнение экрана при смене сцены. */ +export interface SceneTransition { + /** Общая длительность (затухание + проявление), секунды. По умолчанию 0.5. */ + duration?: number; + /** Цвет затемнения. По умолчанию чёрный. */ + color?: number; +} + +type TransitionKind = 'push' | 'pop' | 'replace'; + +interface PendingTransition { + kind: TransitionKind; + scene?: Scene; + duration: number; + color: number; + phase: 'out' | 'in'; + t: number; +} + export class SceneManager { private stack: Scene[] = []; + private overlay: Graphics | null = null; + private overlayWidth = 0; + private overlayHeight = 0; + private pending: PendingTransition | null = null; - async push(scene: Scene): Promise { + /** Движок подключает оверлей затемнения (верхний слой uiRoot). */ + setOverlay(g: Graphics, width: number, height: number): void { + this.overlay = g; + this.overlayWidth = width; + this.overlayHeight = height; + } + + async push(scene: Scene, transition?: SceneTransition): Promise { + if (transition) { + this.begin('push', scene, transition); + return; + } this.stack.push(scene); await scene.enter(); } - async pop(): Promise { + async pop(transition?: SceneTransition): Promise { + if (transition) { + this.begin('pop', undefined, transition); + return; + } const scene = this.stack.pop(); if (scene) { await scene.exit(); @@ -27,7 +67,11 @@ } /** Заменить верхнюю сцену (например меню -> локация). */ - async replace(scene: Scene): Promise { + async replace(scene: Scene, transition?: SceneTransition): Promise { + if (transition) { + this.begin('replace', scene, transition); + return; + } await this.pop(); await this.push(scene); } @@ -36,7 +80,15 @@ return this.stack[this.stack.length - 1]; } + /** Идёт ли сейчас переход (для блокировки ввода). */ + get transitioning(): boolean { + return this.pending !== null; + } + update(dt: number): void { + if (this.pending) { + this.stepTransition(dt); + } this.current?.update(dt); } @@ -45,4 +97,68 @@ scene.render(); } } + + private begin(kind: TransitionKind, scene: Scene | undefined, tr: SceneTransition): void { + if (this.pending) return; // параллельные переходы запрещены + this.pending = { + kind, + scene, + duration: tr.duration ?? 0.5, + color: tr.color ?? 0x000000, + phase: 'out', + t: 0 + }; + } + + private stepTransition(dt: number): void { + const p = this.pending!; + p.t += dt; + + if (p.phase === 'out') { + this.drawOverlay(p.color, Math.min(1, p.t / p.duration)); + if (p.t < p.duration) return; + // экран скрыт — меняем сцену + this.performSwap(p); + p.phase = 'in'; + p.t -= p.duration; + } + + // фаза 'in': проявление + this.drawOverlay(p.color, Math.max(0, 1 - p.t / p.duration)); + if (p.t >= p.duration) { + this.pending = null; + this.overlay?.clear(); + } + } + + private performSwap(p: PendingTransition): void { + switch (p.kind) { + case 'push': + if (p.scene) { + this.stack.push(p.scene); + } + break; + case 'pop': + this.stack.pop()?.exit(); + break; + case 'replace': + this.stack.pop()?.exit(); + if (p.scene) { + this.stack.push(p.scene); + } + break; + } + // enter может быть асинхронным — переход не ждёт его, + // новая сцена проявляется параллельно со своей инициализацией. + void this.stack[this.stack.length - 1]?.enter(); + } + + private drawOverlay(color: number, alpha: number): void { + if (!this.overlay) return; + const g = this.overlay; + g.clear(); + if (alpha > 0) { + g.rect(0, 0, this.overlayWidth, this.overlayHeight).fill({ color, alpha }); + } + } } \ No newline at end of file diff --git a/packages/engine/src/ui/Button.ts b/packages/engine/src/ui/Button.ts new file mode 100644 index 0000000..175db00 --- /dev/null +++ b/packages/engine/src/ui/Button.ts @@ -0,0 +1,96 @@ +import { Container, Graphics, Rectangle, FederatedPointerEvent } from 'pixi.js'; +import { PixelText } from './PixelText'; + +/** + * Кнопка UI: пиксельная рамка, состояния normal/hover/pressed/focused. + * Активация — клик мышью или onSelect() из клавиатурной навигации (MenuList). + */ +export interface ButtonOptions { + label: string; + width: number; + height: number; + size?: number; + onSelect?: () => void; +} + +type ButtonState = 'normal' | 'hover' | 'pressed' | 'focused'; + +const STATE_COLORS: Record = { + normal: { fill: 0x16161f, border: 0x8899aa, text: 0xcccccc }, + hover: { fill: 0x1e1e2a, border: 0xf0d878, text: 0xffffff }, + pressed: { fill: 0x2a2a38, border: 0xf0d878, text: 0xffffff }, + focused: { fill: 0x1e1e2a, border: 0xd99a32, text: 0xffffff } +}; + +export class Button extends Container { + readonly labelText: PixelText; + onSelect: (() => void) | null; + + private bg: Graphics; + private state: ButtonState = 'normal'; + private readonly w: number; + private readonly h: number; + private readonly size: number; + + constructor(options: ButtonOptions) { + super(); + this.w = options.width; + this.h = options.height; + this.size = options.size ?? 10; + this.onSelect = options.onSelect ?? null; + + this.bg = new Graphics(); + this.labelText = new PixelText({ + text: options.label, + size: this.size, + color: 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.draw(); + } + + /** Клавиатурный фокус (визуально — рамка акцентного цвета). */ + set focused(value: boolean) { + if (value && this.state === 'normal') this.setState('focused'); + else if (!value && this.state === 'focused') this.setState('normal'); + } + + get focused(): boolean { + return this.state === 'focused'; + } + + /** Программная активация (Enter из MenuList). */ + activate(): void { + this.onSelect?.(); + } + + private setState(s: ButtonState): void { + this.state = s; + this.labelText.style.fill = STATE_COLORS[s].text; + this.draw(); + } + + private draw(): void { + const c = 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 }); + } +} \ No newline at end of file diff --git a/packages/engine/src/ui/MenuList.ts b/packages/engine/src/ui/MenuList.ts new file mode 100644 index 0000000..b6cf4f6 --- /dev/null +++ b/packages/engine/src/ui/MenuList.ts @@ -0,0 +1,69 @@ +import { Container } from 'pixi.js'; +import { Button } from './Button'; +import { ListCursor } from './listCursor'; + +/** + * Вертикальное меню: список кнопок с клавиатурным фокусом и навигацией. + * Игра сама читает ввод и вызывает moveCursor/activate — движок не привязан к раскладке. + */ +export interface MenuListOptions { + width: number; + height: number; + /** Отступ между кнопками (виртуальные пиксели). */ + gap?: number; + size?: number; +} + +export class MenuList extends Container { + private buttons: Button[] = []; + private listCursor: ListCursor; + + constructor(private options: MenuListOptions) { + super(); + this.listCursor = new ListCursor(0); + } + + /** Пересобрать список пунктов (старые кнопки уничтожаются). */ + setItems( + items: { label: string; onSelect?: () => void }[] + ): void { + for (const b of this.buttons) b.destroy({ children: true }); + this.buttons = []; + let y = 0; + for (const item of items) { + const btn = new Button({ + label: item.label, + width: this.options.width, + height: this.options.height, + size: this.options.size, + onSelect: item.onSelect + }); + btn.position.set(0, y); + this.addChild(btn); + this.buttons.push(btn); + y += this.options.height + (this.options.gap ?? 2); + } + this.listCursor.setCount(items.length); + this.applyFocus(); + } + + /** Сдвинуть фокус (↑ = -1, ↓ = +1). */ + moveCursor(delta: number): void { + this.listCursor.move(delta); + this.applyFocus(); + } + + /** Выбранный индекс. */ + get index(): number { + return this.listCursor.index; + } + + /** Активировать кнопку под фокусом (Enter). */ + activate(): void { + this.buttons[this.listCursor.index]?.activate(); + } + + private applyFocus(): void { + this.buttons.forEach((b, i) => (b.focused = i === this.listCursor.index)); + } +} \ No newline at end of file diff --git a/packages/engine/src/ui/Panel.ts b/packages/engine/src/ui/Panel.ts new file mode 100644 index 0000000..569bd41 --- /dev/null +++ b/packages/engine/src/ui/Panel.ts @@ -0,0 +1,42 @@ +import { Container, Graphics, Texture, NineSliceSprite } from 'pixi.js'; + +/** + * Панель UI: тёмный прямоугольник с однопиксельной рамкой — базовый стиль движка. + * Если передана текстура, рисуется NineSliceSprite (растяжимые углы 4px). + */ +export interface PanelOptions { + width: number; + height: number; + fill?: number; + border?: number; + /** Прозрачность заливки 0..1. */ + fillAlpha?: number; + /** Текстура 9-slice (опционально). */ + texture?: Texture; +} + +export class Panel extends Container { + constructor(options: PanelOptions) { + super(); + if (options.texture) { + const nine = new NineSliceSprite({ + texture: options.texture, + leftWidth: 4, + topHeight: 4, + rightWidth: 4, + bottomHeight: 4, + width: options.width, + height: options.height + }); + this.addChild(nine); + return; + } + + const g = new Graphics(); + g.rect(0, 0, options.width, options.height) + .fill({ color: options.fill ?? 0x101018, alpha: options.fillAlpha ?? 0.92 }); + g.rect(0, 0, options.width, options.height) + .stroke({ color: options.border ?? 0x8899aa, width: 1 }); + this.addChild(g); + } +} \ No newline at end of file diff --git a/packages/engine/src/ui/PixelText.ts b/packages/engine/src/ui/PixelText.ts new file mode 100644 index 0000000..3f34e69 --- /dev/null +++ b/packages/engine/src/ui/PixelText.ts @@ -0,0 +1,71 @@ +import { Text, TextStyleOptions } from 'pixi.js'; + +/** + * Пиксельный шрифт движка: VT323 (OFL, есть кириллица). + * Загружается через FontFace API; PixelText — обёртка над Pixi Text + * с этим шрифтом. Если игры понадобится свой шрифт — меняется источник, + * обёртка остаётся. + */ + +export const PIXEL_FONT_FAMILY = 'VT323'; + +/** Фолбэк, если шрифт ещё не загрузился или не поддерживается. */ +export const PIXEL_FONT_FALLBACK = 'monospace'; + +let fontLoaded: Promise | null = null; + +/** + * Загрузить пиксельный шрифт. url указывает на TTF (приложение решает, где он лежит). + * Повторные вызовы возвращают уже начатую загрузку. Безопасно вызывать до Pixi. + */ +export function ensurePixelFont(url: string, family: string = PIXEL_FONT_FAMILY): Promise { + if (fontLoaded) return fontLoaded; + fontLoaded = (async () => { + try { + const face = new FontFace(family, `url(${url})`); + await face.load(); + document.fonts.add(face); + } catch (err) { + // Не критично: PixelText останется с фолбэком monospace. + console.warn(`[engine] пиксельный шрифт не загрузился (${url}):`, err); + } + })(); + return fontLoaded; +} + +export interface PixelTextOptions { + text: string; + /** Размер в виртуальных пикселях (шрифт растровый — целые числа). */ + size: number; + /** Цвет (числом, 0xRRGGBB). */ + color?: number; + /** Перенос по ширине (виртуальные пиксели). */ + wordWrapWidth?: number; + lineHeight?: number; + align?: 'left' | 'center' | 'right'; + letterSpacing?: number; + fontFamily?: string; +} + +/** Текст пиксельным шрифтом движка. */ +export class PixelText extends Text { + constructor(options: PixelTextOptions) { + const family = `${options.fontFamily ?? PIXEL_FONT_FAMILY}, ${PIXEL_FONT_FALLBACK}`; + const style: TextStyleOptions = { + fontFamily: family, + fontSize: options.size, + fill: options.color ?? 0xffffff, + letterSpacing: options.letterSpacing ?? 0, + lineHeight: options.lineHeight + }; + if (options.wordWrapWidth !== undefined) { + style.wordWrap = true; + style.wordWrapWidth = options.wordWrapWidth; + style.breakWords = true; + } + if (options.align !== undefined) { + style.align = options.align; + } + super({ text: options.text, style }); + } +} \ 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 new file mode 100644 index 0000000..9d0d4f9 --- /dev/null +++ b/packages/engine/src/ui/__tests__/listCursor.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { ListCursor } from '../listCursor'; + +describe('ListCursor', () => { + it('move зацикливается по кругу', () => { + const c = new ListCursor(3); + c.move(1); + expect(c.index).toBe(1); + c.move(1); + c.move(1); + expect(c.index).toBe(0); // 2 + 1 -> 0 + c.move(-1); + expect(c.index).toBe(2); + }); + + it('пустой список не двигается', () => { + const c = new ListCursor(0); + c.move(1); + c.move(-1); + expect(c.empty).toBe(true); + expect(c.index).toBe(0); + }); + + it('setCount зажимает курсор при уменьшении списка', () => { + const c = new ListCursor(5); + c.move(4); + expect(c.index).toBe(4); + c.setCount(2); + expect(c.index).toBe(1); + }); + + it('setCount с ростом списка сохраняет индекс', () => { + const c = new ListCursor(2); + c.move(1); + c.setCount(4); + expect(c.index).toBe(1); + }); + + it('home/end ставят крайние позиции', () => { + const c = new ListCursor(4); + c.end(); + expect(c.index).toBe(3); + c.home(); + expect(c.index).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/ui/listCursor.ts b/packages/engine/src/ui/listCursor.ts new file mode 100644 index 0000000..5d02e2c --- /dev/null +++ b/packages/engine/src/ui/listCursor.ts @@ -0,0 +1,38 @@ +/** + * Чистая логика курсора вертикального списка: без Pixi, тестируется изолированно. + * MenuList использует её для клавиатурной навигации. + */ + +export class ListCursor { + /** Индекс выбранного элемента. */ + index = 0; + + constructor(private count: number) {} + + /** Изменить количество элементов (список пересобран); курсор зажимается. */ + setCount(count: number): void { + this.count = count; + if (this.index >= count) { + this.index = Math.max(0, count - 1); + } + } + + move(delta: number): void { + if (this.count === 0) return; + this.index = (this.index + delta + this.count) % this.count; + } + + /** Курсор в начало/конец. */ + home(): void { + this.index = 0; + } + + end(): void { + if (this.count > 0) this.index = this.count - 1; + } + + /** Есть ли элементы. */ + get empty(): boolean { + return this.count === 0; + } +} \ No newline at end of file