From f6596f765e06fef29ec9dcf3a37c1d0aebe4a4fe Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 12:07:22 +0500 Subject: [PATCH] feat: move to typescript, miniplex ecs --- README.md | 87 ++- build.js | 32 +- ecs-refactoring.md | 111 ++- game.html | 11 +- index.html | 2 +- js/config.js | 402 ----------- js/enemy.js | 292 -------- js/game.js | 339 --------- js/index.js | 43 -- js/input.js | 113 --- js/intro.js | 156 ---- js/main.js | 32 - js/particle.js | 28 - js/projectile.js | 121 ---- js/tmi.min.js | 1 - js/utils.js | 230 ------ package.json | 19 +- pnpm-lock.yaml | 671 +++++++++++++++++- pnpm-workspace.yaml | 2 + src/app/GameApp.ts | 124 ++++ src/config/gameConfig.ts | 169 +++++ src/config/words.ts | 72 ++ src/core/CanvasTextMeasurer.ts | 20 + src/core/GameClock.ts | 45 ++ src/core/color.ts | 9 + src/core/eventBus.ts | 24 + src/core/metrics.ts | 183 +++++ src/core/types.ts | 9 + src/ecs/components.ts | 45 ++ src/ecs/spawn.ts | 170 +++++ src/ecs/world.ts | 46 ++ src/entries/game.ts | 8 + src/entries/index.ts | 60 ++ src/game/Enemy.ts | 104 +++ src/game/GameStore.ts | 38 + src/game/PlayerStats.ts | 44 ++ src/game/Simulation.ts | 140 ++++ src/game/events.ts | 30 + src/input/InputRouter.ts | 36 + src/input/InputSource.ts | 32 + src/input/sources/LocalKeyboardInputSource.ts | 75 ++ src/input/sources/TwitchChatInputSource.ts | 51 ++ src/render/BannerLayer.ts | 64 ++ src/render/EnemyLayer.ts | 55 ++ src/render/EnemyView.ts | 241 +++++++ src/render/GridLayer.ts | 48 ++ src/render/IntroSequence.ts | 220 ++++++ src/render/ParticleLayer.ts | 52 ++ src/render/PlayerView.ts | 31 + src/render/ProjectileLayer.ts | 41 ++ src/render/ProjectileView.ts | 102 +++ src/render/Renderer.ts | 114 +++ src/render/textures.ts | 31 + src/systems/context.ts | 20 + src/systems/cullSystem.ts | 15 + src/systems/escapeSystem.ts | 22 + src/systems/fuseSystem.ts | 29 + src/systems/homingSystem.ts | 38 + src/systems/integrationSystem.ts | 17 + src/systems/lifetimeSystem.ts | 13 + src/systems/missSystem.ts | 23 + src/systems/waveSystem.ts | 49 ++ src/ui/HudController.ts | 36 + src/ui/InputFeedbackController.ts | 61 ++ src/ui/OverlayScaler.ts | 76 ++ src/ui/PlayerStatsView.ts | 68 ++ src/ui/ScreensController.ts | 58 ++ src/ui/dom.ts | 5 + tsconfig.json | 15 + 69 files changed, 3932 insertions(+), 1838 deletions(-) delete mode 100644 js/config.js delete mode 100644 js/enemy.js delete mode 100644 js/game.js delete mode 100644 js/index.js delete mode 100644 js/input.js delete mode 100644 js/intro.js delete mode 100644 js/main.js delete mode 100644 js/particle.js delete mode 100644 js/projectile.js delete mode 100644 js/tmi.min.js delete mode 100644 js/utils.js create mode 100644 pnpm-workspace.yaml create mode 100644 src/app/GameApp.ts create mode 100644 src/config/gameConfig.ts create mode 100644 src/config/words.ts create mode 100644 src/core/CanvasTextMeasurer.ts create mode 100644 src/core/GameClock.ts create mode 100644 src/core/color.ts create mode 100644 src/core/eventBus.ts create mode 100644 src/core/metrics.ts create mode 100644 src/core/types.ts create mode 100644 src/ecs/components.ts create mode 100644 src/ecs/spawn.ts create mode 100644 src/ecs/world.ts create mode 100644 src/entries/game.ts create mode 100644 src/entries/index.ts create mode 100644 src/game/Enemy.ts create mode 100644 src/game/GameStore.ts create mode 100644 src/game/PlayerStats.ts create mode 100644 src/game/Simulation.ts create mode 100644 src/game/events.ts create mode 100644 src/input/InputRouter.ts create mode 100644 src/input/InputSource.ts create mode 100644 src/input/sources/LocalKeyboardInputSource.ts create mode 100644 src/input/sources/TwitchChatInputSource.ts create mode 100644 src/render/BannerLayer.ts create mode 100644 src/render/EnemyLayer.ts create mode 100644 src/render/EnemyView.ts create mode 100644 src/render/GridLayer.ts create mode 100644 src/render/IntroSequence.ts create mode 100644 src/render/ParticleLayer.ts create mode 100644 src/render/PlayerView.ts create mode 100644 src/render/ProjectileLayer.ts create mode 100644 src/render/ProjectileView.ts create mode 100644 src/render/Renderer.ts create mode 100644 src/render/textures.ts create mode 100644 src/systems/context.ts create mode 100644 src/systems/cullSystem.ts create mode 100644 src/systems/escapeSystem.ts create mode 100644 src/systems/fuseSystem.ts create mode 100644 src/systems/homingSystem.ts create mode 100644 src/systems/integrationSystem.ts create mode 100644 src/systems/lifetimeSystem.ts create mode 100644 src/systems/missSystem.ts create mode 100644 src/systems/waveSystem.ts create mode 100644 src/ui/HudController.ts create mode 100644 src/ui/InputFeedbackController.ts create mode 100644 src/ui/OverlayScaler.ts create mode 100644 src/ui/PlayerStatsView.ts create mode 100644 src/ui/ScreensController.ts create mode 100644 src/ui/dom.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 365199f..c6df158 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,18 @@ Shooting Word — это browser-source виджет для стримов, пр ## Типы врагов -| Тип | Описание | Очки | -| ----------- | --------------------------------------------- | ---- | -| **Обычный** | Одно слово (hack, code, data, git...) | 10 | -| **Тяжёлый** | Два слова подряд (fire wall, null pointer...) | 50 | -| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 | +| Тип | Описание | Очки | +| ----------- | ------------------------------------------------------ | ---- | +| **Обычный** | Одно слово (hack, code, data, git...) | 10 | +| **Тяжёлый** | Два слова-слоя подряд (fire → wall, null → pointer...) | 50 | +| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 | -Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала. +Броня отображается и пробивается только по текущему (верхнему) слою. Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала. ## Возможности - **Волны** — количество врагов и их скорость растут с каждой волной -- **Боссы** — multi-phase враги со слоями защиты +- **Боссы** — multi-layer враги со слоями защиты - **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов - **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне - **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot) @@ -33,10 +33,20 @@ Shooting Word — это browser-source виджет для стримов, пр ## Установка +### Сборка из исходников + +```bash +pnpm install +pnpm build # typecheck + сборка в dist/ +pnpm serve # локальный сервер на dist/ +``` + +Для разработки: `pnpm dev` — esbuild watch, пересобирает dist при изменениях в src/. + ### Быстрый старт -1. Открой [страницу настройки](http://ku6epxboctuk.is-a.dev/shooting-word-html/) -2. Введи имя.twitch канала +1. Открой [страницу настройки](https://xboctuk.duckdns.org/shooting-word/) +2. Введи имя twitch канала 3. Нажми **СКОПИРОВАТЬ ССЫЛКУ** 4. В OBS: Sources → Browser → вставь ссылку 5. Рекомендуемая высота — не менее 800px @@ -44,7 +54,7 @@ Shooting Word — это browser-source виджет для стримов, пр ### Прямая ссылка ```txt -http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА +https://xboctuk.duckdns.org/shooting-word/game.html?channel=ИМЯ_КАНАЛА ``` ### Параметры URL @@ -61,41 +71,45 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН ## Технологии -- Vanilla JavaScript (ES6+), без фреймворков и сборщиков -- Canvas 2D для рендеринга +- TypeScript (strict), сборка esbuild +- [PixiJS v8](https://pixijs.com/) для рендера (WebGL/WebGPU) +- [miniplex](https://github.com/hmans/miniplex) — ECS-ядро - [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC -- Один HTML-файл + CSS + JS модули - Хостится на GitHub Pages +Архитектура описана в [ecs-refactoring.md](ecs-refactoring.md): ввод отделён интерфейсом +`InputSource` (переход на Twurple и запуск через баллы канала = новый источник без правок +игры), логика отделена от рендера, сущности и системы — на miniplex. + ## Структура проекта ```txt -├── index.html # Страница настройки (выбор канала) -├── game.html # Игровой экран -├── style.css # Стили ( cyberpunk-тема: чёрный фон, зелёный неон) -├── js/ -│ ├── config.js # Конфигурация и словарь слов -│ ├── game.js # Основной игровой цикл -│ ├── enemy.js # Класс врага (обычный / тяжёлый / босс) -│ ├── projectile.js # Снаряды -│ ├── particle.js # Система частиц -│ ├── input.js # Обработка ввода (чат + локальный) -│ ├── intro.js # Заставка -│ ├── utils.js # Утилиты -│ ├── main.js # Точка входа -│ └── tmi.min.js # Twitch Messaging Interface +├── index.html # Страница настройки (выбор канала) +├── game.html # Игровой экран +├── style.css # Стили (cyberpunk-тема: чёрный фон, зелёный неон) +├── build.js # esbuild-сборка двух бандлов + копирование статики в dist/ +├── src/ +│ ├── entries/ # Точки входа: game.ts (игра), index.ts (настройки) +│ ├── app/ # GameApp — сборка приложения, фазы, такт +│ ├── config/ # gameConfig.ts (константы), words.ts (словарь слов) +│ ├── core/ # Шина событий, игровой цикл, метрики, измеритель текста +│ ├── ecs/ # Компоненты, мир miniplex, спавн-функции +│ ├── systems/ # Системы: волны, fuse, наведение, интеграция, побег, lifetime +│ ├── game/ # Simulation (урон/оркестрация), GameStore, события, статистика +│ ├── input/ # InputSource-интерфейс, роутер, источники: tmi.js + клавиатура +│ ├── render/ # Pixi: сетка, частицы, враги, снаряды, баннер, интро +│ └── ui/ # DOM: HUD, экраны, статистика, фидбек ввода, скейлинг ├── img/ -│ └── sprite.svg # Иконки (GitHub, Twitch) -└── LICENSE # MIT +│ └── sprite.svg # Иконки (GitHub, Twitch) +└── LICENSE # MIT ``` ## Добавление своих слов -Отредактируй `js/config.js`: +Отредактируй `src/config/words.ts`: -WordGenerator.simpleWords - простые враги из одного слова - -WordGenerator.heavyWords - бронированные враги - два слова +- `SIMPLE_WORDS` — простые враги из одного слова +- `HEAVY_WORDS` — бронированные враги: пары слов, каждое слово — отдельный слой брони ## Лицензия @@ -103,10 +117,9 @@ MIT ## Связь -- [GitHub Issues](https://github.com/Ku6epXBOCTuK/shooting-word-html/issues) — баги и идеи -- [Twitch](https://www.twitch.tv/ku6ep_xboctuk) — смотри игру в действии +- [Обратная связь - telegram chat](https://t.me/Ku6epXBOCTuK_chat) — баги и идеи +- [Twitch](https://www.twitch.tv/ku6epxboctuk) — смотри игру в действии ## Похожие проекты -Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget). -Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva +Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта более крупного проекта multi-widget diff --git a/build.js b/build.js index c0cc715..a1f91e4 100644 --- a/build.js +++ b/build.js @@ -1,12 +1,13 @@ +import { build, context } from 'esbuild' import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' const root = dirname(fileURLToPath(import.meta.url)) const dist = join(root, 'dist') +const watch = process.argv.includes('--watch') const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json'] -const jsDir = 'js' const imgFiles = ['sprite.svg'] function listFiles(dir) { @@ -22,14 +23,33 @@ for (const file of rootFiles) { copyFileSync(join(root, file), join(dist, file)) } -cpSync(join(root, jsDir), join(dist, jsDir), { recursive: true }) - mkdirSync(join(dist, 'img')) for (const file of imgFiles) { copyFileSync(join(root, 'img', file), join(dist, 'img', file)) } -console.log('Собрано в dist:') -for (const file of listFiles(dist)) { - console.log(' ' + relative(dist, file)) +const options = { + entryPoints: { + game: join(root, 'src/entries/game.ts'), + index: join(root, 'src/entries/index.ts'), + }, + outdir: dist, + bundle: true, + minify: true, + sourcemap: false, + format: 'iife', + target: 'es2020', + logLevel: 'info', +} + +if (watch) { + const contexts = await Promise.all([context(options)]) + await Promise.all(contexts.map((c) => c.watch())) + console.log('Слежу за изменениями src/...') +} else { + await build(options) + console.log('Собрано в dist:') + for (const file of listFiles(dist)) { + console.log(' ' + relative(dist, file)) + } } diff --git a/ecs-refactoring.md b/ecs-refactoring.md index f66fb65..e40e2a1 100644 --- a/ecs-refactoring.md +++ b/ecs-refactoring.md @@ -1,33 +1,98 @@ -# ECS рефакторинг — план +# Архитектура (ECS на miniplex) -## Суть +Проект переписан на TypeScript + PixiJS с ECS-ядром на [miniplex](https://github.com/hmans/miniplex). +Этот документ — описание того, как всё устроено сейчас. -Вместо классов врагов/снарядов с методами update() и draw() — единый контейнер сущностей, где каждая сущность это набор компонентов (позиция, скорость, тип, слово и т.д.). Логика вынесена в отдельные функции-системы, которые каждую итерацию проходят по нужным подмножествам сущностей и обновляют их. +## Слои -## Компоненты +```txt +ввод (InputSource) → шина событий → симуляция (мир + системы) → шина событий → рендер (Pixi) и DOM-UI +``` -Позиция и скорость — общие для всех движущихся сущностей. Тип врага и данные слова — только для врагов. Ссылка на цель — только для снарядов. Броня и босс-данные — для усиленных и босс-врагов. Пустой маркер — чтобы система знала, что сущность является снарядом. +Каждый слой знает только соседей через интерфейсы: логика не знает про Pixi и DOM, +рендер не содержит игровой логики, ввод не знает про игру — только команды. -## Системы (по порядку за кадр) +## Сущности и компоненты (src/ecs) -1. Система волн — считает таймеры, решает когда спавнить нового врага и какого типа, создаёт сущность в контейнере. -2. Система ввода — получает слово, ищет лучшего врага (точное совпадение > более позднее слово > частичное), создаёт снаряд с ссылкой на цель. -3. Система движения — для всех сущностей с позицией и скоростью: позиция += скорость. -4. Система наведения — для снарядов с целью: вычисляет расстояние, если рядом — помечает попадание, если цель исчезла — удаляет снаряд, иначе двигает к цели. -5. Система урона — для снарядов с пометкой попадания: проверяет броню (если есть и не ноль — снимает), проверяет босс-данные (считает убитые слова в слое, если слой пройден — переключает), если враг умер — удаляет и вызывает callback. -6. Система рендера — берёт все враги, сортирует по высоте, рисует форму/слово/планку брони. Отдельно рисует снаряды. Отрисовка HUD отдельно от ECS. -7. Система очистки — удаляет из контейнера все сущности с пометкой "мёртвый". +Сущность — объект с опциональными компонентами; «есть компонент» = свойство не `undefined`. -## Что остаётся за пределами ECS +| Компонент | Смысл | +| -------------------------------------------------------- | ------------------------------------------------------------------------- | +| `position`, `velocity`, `damping` | Движение (враги, снаряды, частицы — одна система интеграции) | +| `enemyKind`, `enemyBox` | Тип (simple/heavy/boss) и габариты рамки | +| `armor` | Слои слов: текущий слой отображается и уязвим только он | +| `projectile`, `player`, `target` / `targetPoint`, `fuse` | Снаряд: слово, кто выстрелил, цель (или точка промаха), задержка выстрела | +| `lifetime`, `particle` | Частицы — полноценные сущности, а не особый массив | +| `waveState` | Синглтон-сущность с состоянием волны и баннером | -Волны, ввод, рендер HUD, очистка — это системы, но не entity. Жизни/счёт/номер волны — простые переменные, не сущности. Частицы — не сущности, рисуются как визуальный эффект по времени от взрыва. +Броня единая для всех типов: simple — 1 слой из 1 слова, heavy — 2 слоя по 1 слову +(порядок строгий), boss — 3 слоя по 3 слова (внутри слоя порядок свободный). +Урон снимает только текущий слой; после каждого попадания вью перерисовывается по диффу +`layerIndex:killed`. -## Порядок миграции +## Системы (порядок за кадр, src/systems) -1. Контейнер сущностей — замена глобальных массивов. -2. Система движения — самая простая, проверка что всё работает. -3. Система наведения и урона — core gameplay. -4. Система волн — спавн. -5. Система ввода — поиск цели + создание снаряда. -6. Система рендера — вся отрисовка. -7. Остальное — очистка, game loop, удаление старых классов. +1. `fuseSystem` — снаряд стоит у стрелка, пока не истечёт задержка ввода (150 мс), потом получает скорость. +2. `homingSystem` — наведение на цель, касание → событие `projectile:hit`; цель исчезла — снаряд удаляется. +3. `missSystem` — промах долетает до точки → `projectile:miss`. +4. `waveSystem` — таймеры волны, спавн, пауза, боссы; пишет в синглтон `waveState`. +5. `integrationSystem` — единое движение для всех сущностей с position+velocity (и damping). +6. `cullSystem` — снаряды за экраном. +7. `escapeSystem` — враг дошёл до низа → `enemy:escaped` → урон. +8. `lifetimeSystem` — затухание частиц. + +`Simulation` (src/game) задаёт порядок систем, принимает команды ввода (поиск цели по слову), +обрабатывает попадания/промахи/урон и держит скаляры партии (счёт, жизни, фаза, тряска) +в `GameStore` — по договорённости это не сущности. + +## Ввод (src/input) — отдельный интерфейс + +Игра зависит только от порта `InputSource`, а не от библиотеки: + +```ts +interface InputSource { + readonly origin: "local" | "chat"; + start(): void; + stop(): void; + onCommand(handler: (command: InputCommand) => void): () => void; +} + +type InputCommand = + | { kind: "start" } // !play, награда за баллы канала и т.п. + | { kind: "clear" } + | { kind: "word"; word: string; player: Player }; +``` + +Сейчас реализованы `TwitchChatInputSource` (tmi.js, парсит `!play` и первое слово сообщения) +и `LocalKeyboardInputSource` (тест без Twitch). Будущий переход на Twurple и запуск через +баллы канала = новый источник, который эмитит `{ kind: 'start' }` на событие награды; +`InputRouter` просто регистрирует его рядом с остальными. + +## Рендер (src/render) + +- `GridLayer` — сетка одним `TilingSprite` (вместо ~40 `stroke()` за кадр). +- `ParticleLayer` — все частицы в одном `ParticleContainer` (один draw call, пул объектов). +- Свечение — заранее запечённая радиальная текстура с аддитивным блендингом + вместо попиксельного `shadowBlur`. +- Вью врагов/снарядов создаются и уничтожаются по сигналам мира + (`world.onEntityAdded/onEntityRemoved`), каждый кадр только синхронизируют позицию/альфу. +- Тряска экрана — смещение корневого контейнера, состояние в `GameStore`. +- Цикл: внешний `GameClock` (rAF, delta в 60-кадрах), pixi-ticker остановлен; в простое + (интро закончилось, партия не идёт) рендер вообще не тикает — canvas хранит последний кадр. + Сбой одного кадра не убивает цикл. + +## События (src/game/events.ts) + +Типизированная шина `EventBus`: ввод, бой (`projectile:hit/miss`, `enemy:*`) и состояние +партии (`game:started/over`, `score/wave/lives`, `effect:glitch`). Рендер и DOM-UI — только +подписчики. + +## DOM-UI (src/ui) + +HUD, экраны старта/поражения, таблица зрителей (имена вставляются через `textContent` — +ник из чата не должен стать разметкой), фидбек ввода, масштабирование оверлея под окно. + +## Отладка + +В консоли браузера доступен хук `window.__sw` — мир, стор, шина, метрики и рендерер +(`__sw.world.with('armor').entities`, `__sw.store.score` и т.д.). diff --git a/game.html b/game.html index 69947e3..4759363 100644 --- a/game.html +++ b/game.html @@ -5,7 +5,6 @@ Shooting word -
@@ -38,14 +37,6 @@
- - - - - - - - - + diff --git a/index.html b/index.html index 9a5c1ab..270d9a3 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,6 @@ - + diff --git a/js/config.js b/js/config.js deleted file mode 100644 index 7bec1eb..0000000 --- a/js/config.js +++ /dev/null @@ -1,402 +0,0 @@ -const CONFIG = { - commands: { play: "!play" }, - texts: { - startTitle: "SHOOTING WORD", - startSubtitle: "Введи !play чтобы играть", - gameOverTitle: "ЧАТ, ВЫ ПРОИГРАЛИ!", - gameOverSubtitle: "Введи !play чтобы играть", - scoreLabel: "SCORE:", - waveLabel: "WAVE:", - livesLabel: "LIVES:", - finalScoreLabel: "Final Score:", - armorLabel: "ARMOR", - }, - game: { - startLives: 10, - startWave: 1, - startScore: 0, - enemiesPerWaveBase: 5, - enemiesPerWaveInc: 1, - waveInterval: 900, - heavyEnemyChanceBase: 0.25, - heavyEnemyChancePerWave: 0.02, - scoreSimple: 10, - scoreHeavy: 50, - scoreBoss: 200, - bossEveryNthWave: 10, - bossLayers: 3, - bossWordsPerLayer: 3, - gameOverDelay: 10000, - }, - speed: { - slowEnemyCrossTimeStart: 240, - slowEnemyCrossTimeCap: 30, - slowEnemyCrossTimeCapWave: 50, - fastEnemyCrossTimeStart: 120, - fastEnemyCrossTimeCap: 15, - fastEnemyCrossTimeCapWave: 50, - bossCrossTime: 60, - projectileCrossTime: 0.43, - gridSpeed: 0.5, - }, - visual: { - gridSize: 80, - enemyPaddingX: 60, - enemyAlienOffset: 16, - armorBarPad: 20, - armorBarFont: 20, - missTargetPadX: 60, - missTargetPadY: 100, - inputAreaHeight: 105, - removeZone: 60, - playerY: 32, - playerRadius: 16, - playerGlow: 30, - enemySimpleHeight: 30, - enemyHeavyHeight: 42, - enemySimpleFont: 28, - enemyHeavyFont: 30, - enemyArmorBarHeight: 6, - enemyArmorBarOffset: 11, - enemyArmorLabelOffset: 15, - enemyGlow: 10, - enemyFlashGlow: 50, - enemyLineWidth: 3, - projectileGlow: 30, - projectileHitRadius: 30, - projectileMissRadius: 30, - projectileFont: 28, - projectileCircleRadius: 30, - shakeHit: 8, - shakeMiss: 6, - shakeDamage: 16, - particleVel: 12, - particleSizeMin: 4, - particleSizeMax: 10, - particleGlow: 16, - trailSizeBase: 4, - trailSizeInc: 1, - trailGlow: 20, - shooterOffset: 50, - }, - visualRatio: { - missTargetYRange: 0.5, - projectileTrailLength: 8, - projectileTrailDecay: 0.15, - projectileTrailAlpha: 0.6, - projectileCircleAlpha: 0.5, - enemyPulseRate: 0.05, - enemyAlphaBase: 1.0, - enemyAlphaRange: 0.0, - enemyGlowPulse: 5, - particleDecayBase: 0.02, - particleDecayRange: 0.03, - particleDamping: 0.98, - }, - counts: { - projectileTrailLength: 8, - particleHit: 15, - particleMiss: 20, - particleDestroySimple: 15, - particleDestroyHeavy: 30, - particleArmorBreak: 15, - flashDuration: 10, - shakeHitDuration: 6, - shakeMissDuration: 5, - shakeDamageDuration: 10, - trailMaxLength: 10, - trailDecay: 0.12, - trailAlpha: 0.5, - }, - timing: { - inputShootDelay: 150, - glitchDuration: 300, - }, - colors: { - primary: "#00ff41", - primaryDim: "#00cc33", - primaryGlow: "rgba(0,255,65,0.2)", - primaryGrid: "rgba(0,255,65,0.08)", - heavy: "#ffaa00", - heavyDim: "#cc8800", - boss: "#ff4444", - bossDim: "#cc2222", - miss: "#ff0044", - white: "#ffffff", - red: "#ff0000", - armorBarBg: "rgba(255,0,0,0.3)", - armorBarFill: "#ffaa00", - playerBase: "rgba(0,255,65,0.15)", - scanline: "rgba(0,255,65,0.03)", - }, - intro: { - fontSize: 44, - textYR: 0.35, - subtitleYOffset: 60, - fadeInFrames: 40, - shot1Frame: 45, - shot2Frame: 70, - shot3Frame: 95, - shotSpeed: 0.045, - particleCountPerHit: 18, - slideStartFrame: 122, - slideDuration: 20, - totalFrames: 180, - startDelay: 300, - glow: 50, - trailGlow: 20, - projectileGlow: 30, - projectileRadius: 8, - trailRect: 8, - trailMax: 10, - trailDecay: 0.12, - trailAlpha: 0.5, - subtitleFont: 32, - subtitleGlow: 20, - }, -}; - -const WordGenerator = { - simpleWords: [ - "hack", - "code", - "data", - "node", - "link", - "byte", - "bit", - "net", - "web", - "sys", - "run", - "log", - "bug", - "fix", - "api", - "cpu", - "ram", - "ssd", - "gpu", - "dns", - "ip", - "url", - "tag", - "css", - "js", - "sql", - "git", - "cli", - "env", - "dev", - "app", - "bot", - "ai", - "io", - "os", - "ui", - "ux", - "vm", - "vpn", - "lan", - "wan", - "ftp", - "ssh", - "http", - "json", - "xml", - "yaml", - "md", - "py", - "go", - "rust", - "java", - "php", - "cpp", - "ts", - "rb", - "kt", - "swift", - "dart", - "lua", - "perl", - "bash", - "zsh", - "fish", - "vim", - "nano", - "emacs", - "sed", - "awk", - "grep", - "find", - "cat", - "ls", - "cd", - "mv", - "cp", - "rm", - "mkdir", - "chmod", - "chown", - "ping", - "curl", - "wget", - "nc", - "nmap", - "tcp", - "udp", - "ip4", - "ip6", - "mac", - "hex", - "bin", - "dec", - "oct", - "xor", - "and", - "or", - "not", - "shl", - "shr", - "key", - "lock", - "door", - "gate", - "wall", - "fire", - "ice", - "wind", - "storm", - "rain", - "sun", - "moon", - "star", - "void", - "null", - "zero", - "one", - "two", - "ten", - "max", - ], - heavyWords: [ - ["root", "access"], - ["data", "breach"], - ["fire", "wall"], - ["deep", "web"], - ["dark", "net"], - ["cloud", "base"], - ["neural", "net"], - ["quantum", "bit"], - ["block", "chain"], - ["smart", "contract"], - ["machine", "learn"], - ["deep", "fake"], - ["zero", "day"], - ["back", "door"], - ["side", "channel"], - ["man", "middle"], - ["denial", "service"], - ["brute", "force"], - ["social", "engineer"], - ["phishing", "attack"], - ["sql", "inject"], - ["cross", "site"], - ["buffer", "over"], - ["heap", "spray"], - ["stack", "pivot"], - ["return", "orient"], - ["format", "string"], - ["race", "condition"], - ["time", "check"], - ["use", "after"], - ["double", "free"], - ["null", "pointer"], - ["integer", "over"], - ["type", "confus"], - ["memory", "leak"], - ["dead", "lock"], - ["live", "lock"], - ["starvation", "mode"], - ["priority", "invert"], - ["cache", "miss"], - ["branch", "mispred"], - ["speculative", "exec"], - ["meltdown", "flaw"], - ["spectre", "bug"], - ["row", "hammer"], - ["cold", "boot"], - ["evil", "maid"], - ["supply", "chain"], - ["hardware", "troj"], - ["firmware", "root"], - ["bios", "implant"], - ["uefi", "shell"], - ["smm", "exploit"], - ["ring", "zero"], - ["kernel", "panic"], - ["blue", "screen"], - ["kernel", "oops"], - ["seg", "fault"], - ["bus", "error"], - ["illegal", "op"], - ["trap", "divide"], - ["trap", "debug"], - ["trap", "nmi"], - ["trap", "break"], - ["trap", "overflow"], - ["trap", "bound"], - ["trap", "invalid"], - ["trap", "device"], - ["trap", "double"], - ["trap", "copro"], - ["trap", "tss"], - ["trap", "segment"], - ["trap", "stack"], - ["trap", "general"], - ["trap", "page"], - ["trap", "x87"], - ["trap", "align"], - ["trap", "machine"], - ["trap", "simd"], - ["trap", "virtual"], - ["security", "check"], - ["stack", "cookie"], - ["aslr", "bypass"], - ["dep", "bypass"], - ["cfg", "bypass"], - ["cet", "bypass"], - ["shadow", "stack"], - ["control", "flow"], - ["indirect", "call"], - ["jump", "oriented"], - ["call", "oriented"], - ["data", "oriented"], - ["counterfeit", "obj"], - ["use", "after"], - ["heap", "feng"], - ["house", "spirit"], - ["house", "lore"], - ["house", "force"], - ["fast", "bin"], - ["tcache", "poison"], - ["unsorted", "bin"], - ["large", "bin"], - ["small", "bin"], - ["mmap", "chunk"], - ["top", "chunk"], - ["wilderness", "area"], - ["arena", "corrupt"], - ["thread", "cache"], - ["per", "thread"], - ["main", "arena"], - ], - - generateWord(isHeavy) { - if (isHeavy) { - const pair = this.heavyWords[Math.floor(Math.random() * this.heavyWords.length)]; - return pair; - } - return this.simpleWords[Math.floor(Math.random() * this.simpleWords.length)]; - }, -}; diff --git a/js/enemy.js b/js/enemy.js deleted file mode 100644 index 1b24bd3..0000000 --- a/js/enemy.js +++ /dev/null @@ -1,292 +0,0 @@ -class Enemy { - constructor(boss) { - this.isBoss = !!boss; - this.isHeavy = false; - - if (this.isBoss) { - this.layers = []; - this.currentLayer = 0; - this.killedInLayer = new Set(); - for (let i = 0; i < CONFIG.game.bossLayers; i++) { - const layerWords = []; - for (let j = 0; j < CONFIG.game.bossWordsPerLayer; j++) { - layerWords.push(WordGenerator.generateWord(false)); - } - this.layers.push(layerWords); - } - this.height = S.enemyHeavyHeight * 1.5; - this.speed = crossTimeToSpeed(getEnemyCrossTime("boss")); - } else { - const isHeavy = Math.random() < CONFIG.game.heavyEnemyChanceBase + wave * CONFIG.game.heavyEnemyChancePerWave; - this.isHeavy = isHeavy; - const wordData = WordGenerator.generateWord(isHeavy); - - if (isHeavy) { - this.words = wordData; - this.currentWordIndex = 0; - this.text = this.words[0]; - } else { - this.words = [wordData]; - this.currentWordIndex = 0; - this.text = wordData; - } - - this.height = isHeavy ? S.enemyHeavyHeight : S.enemySimpleHeight; - - if (isHeavy) { - this.speed = crossTimeToSpeed(getEnemyCrossTime("slow")); - } else { - this.speed = crossTimeToSpeed(getEnemyCrossTime("fast")); - } - } - - this.y = 0; - this.pulse = 0; - this.flashTimer = 0; - this.width = 0; - this.x = this._calculateSpawnX(); - } - - _calculateSpawnX() { - const padding = S.enemyPaddingX; - const fontSize = this.isBoss - ? S.enemyHeavyFont * 1.2 - : this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont; - const fontWeight = (this.isBoss || this.isHeavy) ? "bold " : ""; - - ctx.save(); - ctx.font = `${fontWeight}${fontSize}px 'Courier New', monospace`; - - let maxTextWidth = 0; - if (this.isBoss) { - for (const layer of this.layers) { - let w = 0; - for (const word of layer) { - w += ctx.measureText(word).width; - } - w += (layer.length - 1) * 24; - if (w > maxTextWidth) maxTextWidth = w; - } - } else { - for (let word of this.words) { - const w = ctx.measureText(word).width; - if (w > maxTextWidth) maxTextWidth = w; - } - } - ctx.restore(); - - this.width = maxTextWidth + 24; - const halfWidth = this.width / 2; - - const minX = padding + halfWidth; - const maxX = W - padding - halfWidth; - - return minX + Math.random() * (maxX - minX); - } - - hitWord(word) { - if (this.isBoss) { - this.killedInLayer.add(word); - this.flashTimer = S.flashDuration; - - const layer = this.layers[this.currentLayer]; - const allKilled = layer.every(w => this.killedInLayer.has(w)); - - if (allKilled) { - this.currentLayer++; - this.killedInLayer.clear(); - if (this.currentLayer >= this.layers.length) { - return true; - } - } - return false; - } - - this.currentWordIndex++; - this.flashTimer = S.flashDuration; - if (this.currentWordIndex >= this.words.length) { - return true; - } - this.text = this.words[this.currentWordIndex]; - return false; - } - - update() { - this.y += this.speed * frameFactor; - this.pulse += 0.05 * frameFactor; - if (this.flashTimer > 0) this.flashTimer -= frameFactor; - } - - draw() { - const alpha = S.enemyAlphaBase + Math.sin(this.pulse) * S.enemyAlphaRange; - const flash = this.flashTimer > 0; - - ctx.save(); - - const fontSize = this.isBoss - ? S.enemyHeavyFont * 1.2 - : this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont; - const fontWeight = (this.isBoss || this.isHeavy) ? "bold " : ""; - - const boxW = this.width; - const boxH = this.height; - - const color = this.isBoss ? CONFIG.colors.boss : this.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; - ctx.shadowColor = flash ? CONFIG.colors.white : color; - ctx.shadowBlur = flash ? S.enemyFlashGlow : S.enemyGlow + Math.sin(this.pulse * 2) * S.enemyGlowPulse; - - const rgb = this.isBoss ? "255,68,68" : this.isHeavy ? "255,170,0" : "0,255,65"; - ctx.strokeStyle = flash ? CONFIG.colors.white : `rgba(${rgb},${alpha})`; - ctx.lineWidth = flash ? S.enemyLineWidth * 2 : S.enemyLineWidth; - - if (this.isBoss) { - const off = S.enemyAlienOffset; - ctx.beginPath(); - ctx.moveTo(this.x - boxW / 2 + off, this.y - boxH / 2); - ctx.lineTo(this.x + boxW / 2 - off, this.y - boxH / 2); - ctx.lineTo(this.x + boxW / 2, this.y - boxH / 2 + off); - ctx.lineTo(this.x + boxW / 2, this.y + boxH / 2 - off); - ctx.lineTo(this.x + boxW / 2 - off, this.y + boxH / 2); - ctx.lineTo(this.x - boxW / 2 + off, this.y + boxH / 2); - ctx.lineTo(this.x - boxW / 2, this.y + boxH / 2 - off); - ctx.lineTo(this.x - boxW / 2, this.y - boxH / 2 + off); - ctx.closePath(); - ctx.fillStyle = flash ? "#ffffff" : "#0a0a0a"; - ctx.fill(); - ctx.stroke(); - - const barW = boxW - S.armorBarPad; - const barH = S.enemyArmorBarHeight; - const totalLayers = this.layers.length; - const remainingLayers = totalLayers - this.currentLayer; - ctx.fillStyle = CONFIG.colors.armorBarBg; - ctx.fillRect(this.x - barW / 2, this.y - boxH / 2 - S.enemyArmorBarOffset, barW, barH); - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.armorBarFill; - ctx.fillRect( - this.x - barW / 2, - this.y - boxH / 2 - S.enemyArmorBarOffset, - barW * (remainingLayers / totalLayers), - barH, - ); - - if (wave <= 5) { - ctx.font = `${S.armorBarFont}px "Courier New", monospace`; - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.boss; - ctx.textAlign = "center"; - ctx.fillText( - `BOSS ${remainingLayers}/${totalLayers}`, - this.x, - this.y - boxH / 2 - S.enemyArmorLabelOffset, - ); - } - - ctx.shadowBlur = 0; - ctx.font = `${fontWeight}${fontSize}px 'Courier New', monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - const layer = this.layers[this.currentLayer]; - const wordGap = 24; - - let totalTextW = 0; - const wordWidths = []; - for (const word of layer) { - const w = ctx.measureText(word).width; - wordWidths.push(w); - totalTextW += w; - } - totalTextW += (layer.length - 1) * wordGap; - - let drawX = this.x - totalTextW / 2; - - for (let i = 0; i < layer.length; i++) { - const word = layer[i]; - const w = wordWidths[i]; - const wordX = drawX + w / 2; - - if (this.killedInLayer.has(word)) { - ctx.fillStyle = flash ? CONFIG.colors.white : `rgba(${rgb},0.3)`; - ctx.fillText(word, wordX, this.y); - ctx.beginPath(); - ctx.moveTo(wordX - w / 2, this.y); - ctx.lineTo(wordX + w / 2, this.y); - ctx.strokeStyle = flash ? CONFIG.colors.white : CONFIG.colors.boss; - ctx.lineWidth = 2; - ctx.stroke(); - } else { - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.boss; - ctx.fillText(word, wordX, this.y); - } - - drawX += w + wordGap; - } - } else if (this.isHeavy) { - const off = S.enemyAlienOffset; - ctx.beginPath(); - ctx.moveTo(this.x - boxW / 2 + off, this.y - boxH / 2); - ctx.lineTo(this.x + boxW / 2 - off, this.y - boxH / 2); - ctx.lineTo(this.x + boxW / 2, this.y); - ctx.lineTo(this.x + boxW / 2 - off, this.y + boxH / 2); - ctx.lineTo(this.x - boxW / 2 + off, this.y + boxH / 2); - ctx.lineTo(this.x - boxW / 2, this.y); - ctx.closePath(); - ctx.fillStyle = flash ? "#ffffff" : "#0a0a0a"; - ctx.fill(); - ctx.stroke(); - - const barW = boxW - S.armorBarPad; - const barH = S.enemyArmorBarHeight; - const totalLayers = this.words.length; - const remainingLayers = totalLayers - this.currentWordIndex; - ctx.fillStyle = CONFIG.colors.armorBarBg; - ctx.fillRect(this.x - barW / 2, this.y - boxH / 2 - S.enemyArmorBarOffset, barW, barH); - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.armorBarFill; - ctx.fillRect( - this.x - barW / 2, - this.y - boxH / 2 - S.enemyArmorBarOffset, - barW * (remainingLayers / totalLayers), - barH, - ); - - if (wave <= 5) { - ctx.font = `${S.armorBarFont}px "Courier New", monospace`; - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.heavy; - ctx.textAlign = "center"; - ctx.fillText( - `${CONFIG.texts.armorLabel} ${remainingLayers}/${totalLayers}`, - this.x, - this.y - boxH / 2 - S.enemyArmorLabelOffset, - ); - } - - ctx.shadowBlur = 0; - ctx.font = `${fontWeight}${fontSize}px 'Courier New', monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.heavy; - ctx.fillText(this.text, this.x, this.y); - } else { - ctx.fillStyle = flash ? "#ffffff" : "#0a0a0a"; - ctx.fillRect(this.x - boxW / 2, this.y - boxH / 2, boxW, boxH); - ctx.strokeRect(this.x - boxW / 2, this.y - boxH / 2, boxW, boxH); - - ctx.shadowBlur = 0; - ctx.font = `${fontWeight}${fontSize}px 'Courier New', monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.primary; - ctx.fillText(this.text, this.x, this.y); - } - - ctx.restore(); - } - - getBounds() { - return { - left: this.x - this.width / 2, - right: this.x + this.width / 2, - top: this.y - this.height / 2, - bottom: this.y + this.height / 2, - }; - } -} diff --git a/js/game.js b/js/game.js deleted file mode 100644 index c5580a2..0000000 --- a/js/game.js +++ /dev/null @@ -1,339 +0,0 @@ -const canvas = document.getElementById("game-canvas"); -const ctx = canvas.getContext("2d"); -let W, H; - -let gameState = "start"; -let score = 0, - wave = 1, - lives = 3; -let enemies = []; -let particles = []; -let projectiles = []; -let spawnTimer = 0; -let waveEnemiesLeft = 0; -let waveTimer = 0; -let wavePauseTimer = 0; -let wavePauseText = ""; -let wavePauseActive = false; -let shakeTimer = 0; -let shakeAmount = 0; -let bgOffset = 0; -let gameLoopStarted = false; -let introPlaying = false; - -let playerStats = {}; - -const params = new URLSearchParams(location.search); -const settings = { - singlePlay: params.get("singlePlay") === "1", -}; - -function initKeyboardBridge() { - if (initKeyboardBridge._done) return; - initKeyboardBridge._done = true; - let buffer = ""; - document.addEventListener("keydown", (e) => { - if (e.key === "Backspace") { - e.preventDefault(); - buffer = buffer.slice(0, -1); - InputModule.updateLocalDisplay(buffer); - } else if (e.key === "Enter") { - e.preventDefault(); - const line = buffer; - buffer = ""; - InputModule.submitLocal(line); - InputModule.updateLocalDisplay(""); - } else if (e.key === "Escape") { - buffer = ""; - InputModule.clear(); - } else if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - buffer += e.key; - InputModule.updateLocalDisplay(buffer); - } - }); -} - -const game = { - launchProjectile(word, targetEnemy, isHit, username) { - projectiles.push(new Projectile(word, targetEnemy, isHit, username)); - }, - - onProjectileHit(projectile, enemy) { - const dead = enemy.hitWord(projectile.word); - - if (dead) { - score += enemy.isBoss ? CONFIG.game.scoreBoss : enemy.isHeavy ? CONFIG.game.scoreHeavy : CONFIG.game.scoreSimple; - document.getElementById("score").textContent = score; - if (projectile.username) { - if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 }; - playerStats[projectile.username].kills++; - } - this.destroyEnemy(enemy); - } else { - const color = enemy.isBoss ? "#ff4444" : CONFIG.colors.heavy; - spawnParticles(enemy.x, enemy.y, color, S.particleArmorBreak); - triggerShake(S.shakeHit, S.shakeHitDuration); - } - }, - - onProjectileMiss(projectile) { - spawnParticles(projectile.targetX, projectile.targetY, CONFIG.colors.miss, S.particleMiss); - triggerShake(S.shakeMiss, S.shakeMissDuration); - if (projectile.username) { - if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 }; - playerStats[projectile.username].misses++; - } - }, - - destroyEnemy(enemy) { - const idx = enemies.indexOf(enemy); - if (idx > -1) { - const color = enemy.isBoss ? "#ff4444" : enemy.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; - const count = enemy.isBoss - ? S.particleDestroyHeavy * 2 - : enemy.isHeavy - ? S.particleDestroyHeavy - : S.particleDestroySimple; - spawnParticles(enemy.x, enemy.y, color, count); - enemies.splice(idx, 1); - } - }, -}; - -function spawnEnemy() { - enemies.push(new Enemy()); -} - -function spawnBoss() { - enemies.push(new Enemy(true)); -} - -function getEnemiesPerWave() { - return CONFIG.game.enemiesPerWaveBase + (wave - 1) * CONFIG.game.enemiesPerWaveInc; -} - -function takeDamage() { - lives--; - document.getElementById("lives").textContent = "♡".repeat(CONFIG.game.startLives - lives) + "♥".repeat(lives); - triggerShake(S.shakeDamage, S.shakeDamageDuration); - document.getElementById("game-container").classList.add("glitch"); - setTimeout( - () => document.getElementById("game-container").classList.remove("glitch"), - S.glitchDuration, - ); - - if (lives <= 0) { - gameOver(); - } -} - -let gameOverTimeout = null; - -function renderPlayerStats() { - const container = document.getElementById("player-stats"); - container.innerHTML = ""; - - const names = Object.keys(playerStats); - if (names.length === 0) return; - - const byKills = names.slice().sort((a, b) => playerStats[b].kills - playerStats[a].kills); - const top3 = byKills.slice(0, 3); - - const sectionTitle = document.createElement("div"); - sectionTitle.className = "stats-section-title"; - sectionTitle.textContent = "УБИЙЦЫ"; - container.appendChild(sectionTitle); - - top3.forEach((name, i) => { - const row = document.createElement("div"); - row.className = "stats-row" + (i === 0 ? " stats-top1" : i === 1 ? " stats-top2" : " stats-top3"); - const crown = i === 0 ? '👑 ' : ""; - const place = i === 0 ? "1" : i === 1 ? "2" : "3"; - row.innerHTML = `#${place} ${crown}${name} ${playerStats[name].kills}`; - container.appendChild(row); - }); - - let maxMisses = 0; - let mazilaName = ""; - for (const name of names) { - if (playerStats[name].misses > maxMisses) { - maxMisses = playerStats[name].misses; - mazilaName = name; - } - } - - if (mazilaName && maxMisses > 0) { - const mazila = document.createElement("div"); - mazila.className = "stats-mazila"; - mazila.innerHTML = `Мазила: ${mazilaName} (${maxMisses})`; - container.appendChild(mazila); - } -} - -function gameOver() { - gameState = "gameover"; - document.getElementById("final-score").textContent = score; - renderPlayerStats(); - document.getElementById("gameover-screen").classList.remove("hidden"); - document.getElementById("gameover-subtitle").classList.add("hidden"); - - if (gameOverTimeout) clearTimeout(gameOverTimeout); - gameOverTimeout = setTimeout(() => { - gameOverTimeout = null; - document.getElementById("gameover-screen").classList.add("hidden"); - document.getElementById("ui-overlay").classList.add("hidden"); - document.getElementById("input-chat").classList.add("hidden"); - gameLoopStarted = false; - if (!settings.singlePlay) { - recalc(); - runIntro(); - } else { - document.body.classList.add("hidden"); - } - }, CONFIG.game.gameOverDelay); -} - -function startGame() { - if (gameOverTimeout) { - clearTimeout(gameOverTimeout); - gameOverTimeout = null; - } - gameState = "playing"; - score = CONFIG.game.startScore; - wave = CONFIG.game.startWave; - lives = CONFIG.game.startLives; - enemies = []; - particles = []; - projectiles = []; - playerStats = {}; - spawnTimer = 0; - waveEnemiesLeft = getEnemiesPerWave(); - waveTimer = 0; - wavePauseActive = false; - InputModule.clear(); - - document.getElementById("score").textContent = score; - document.getElementById("wave").textContent = wave; - document.getElementById("lives").textContent = "♡".repeat(CONFIG.game.startLives - lives) + "♥".repeat(lives); - document.getElementById("start-screen").classList.add("hidden"); - document.getElementById("gameover-screen").classList.add("hidden"); - document.getElementById("ui-overlay").classList.remove("hidden"); - document.getElementById("input-chat").classList.remove("hidden"); - - if (!gameLoopStarted) { - gameLoopStarted = true; - loop(); - } -} - -function update() { - if (gameState !== "playing") return; - - if (wavePauseActive) { - wavePauseTimer -= frameFactor; - if (wavePauseTimer <= 0) { - wavePauseActive = false; - waveEnemiesLeft = getEnemiesPerWave(); - if (wave % CONFIG.game.bossEveryNthWave === 0) { - spawnBoss(); - } - } - } else if (waveEnemiesLeft > 0) { - spawnTimer += frameFactor; - const spawnInterval = CONFIG.game.waveInterval / getEnemiesPerWave(); - if (spawnTimer >= spawnInterval) { - spawnEnemy(); - waveEnemiesLeft--; - spawnTimer = 0; - } - } - - waveTimer += frameFactor; - if (waveTimer >= CONFIG.game.waveInterval && !wavePauseActive && waveEnemiesLeft <= 0) { - wave++; - waveTimer = 0; - document.getElementById("wave").textContent = wave; - wavePauseActive = true; - wavePauseTimer = 120; - wavePauseText = wave % CONFIG.game.bossEveryNthWave === 0 - ? `BOSS INCOMING` - : `WAVE ${wave} INCOMING`; - } - - for (let i = enemies.length - 1; i >= 0; i--) { - const e = enemies[i]; - e.update(); - - if (e.y > H - S.removeZone) { - takeDamage(); - enemies.splice(i, 1); - } - } - - for (let i = particles.length - 1; i >= 0; i--) { - particles[i].update(); - if (particles[i].life <= 0) particles.splice(i, 1); - } - - for (let i = projectiles.length - 1; i >= 0; i--) { - const alive = projectiles[i].update(); - if (!alive) { - projectiles.splice(i, 1); - } - } - - if (shakeTimer > 0) { - shakeTimer -= frameFactor; - shakeAmount *= 0.9; - } -} - -function draw() { - ctx.clearRect(0, 0, W, H); - - ctx.save(); - if (shakeTimer > 0) { - ctx.translate((Math.random() - 0.5) * shakeAmount, (Math.random() - 0.5) * shakeAmount); - } - - drawGrid(); - - for (let p of projectiles) p.draw(); - for (let p of particles) p.draw(); - const sortedEnemies = enemies.slice().sort((a, b) => a.speed - b.speed); - for (let e of sortedEnemies) e.draw(); - - if (wavePauseActive) { - const alpha = wavePauseTimer > 30 ? 1 : wavePauseTimer / 30; - ctx.save(); - ctx.globalAlpha = alpha; - ctx.font = `bold ${Math.round(36 * H / 1000)}px "Courier New", monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = wavePauseText === "BOSS INCOMING" ? "#ff4444" : CONFIG.colors.primary; - ctx.shadowColor = ctx.fillStyle; - ctx.shadowBlur = 20; - ctx.fillText(wavePauseText, W / 2, S.inputAreaHeight + 40); - ctx.restore(); - } - - ctx.save(); - ctx.fillStyle = CONFIG.colors.primary; - ctx.shadowColor = CONFIG.colors.primary; - ctx.shadowBlur = S.playerGlow; - ctx.beginPath(); - ctx.arc(W / 2, H - S.playerY, S.playerRadius, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - ctx.restore(); -} - -function loop(timestamp) { - calcDt(timestamp); - update(); - draw(); - if (gameLoopStarted) { - requestAnimationFrame(loop); - } -} diff --git a/js/index.js b/js/index.js deleted file mode 100644 index b8dc084..0000000 --- a/js/index.js +++ /dev/null @@ -1,43 +0,0 @@ -const input = document.getElementById("channel-input"); -const copyBtn = document.getElementById("copy-btn"); -const openBtn = document.getElementById("open-btn"); -const linkEl = document.getElementById("game-link"); -const singlePlayEl = document.getElementById("opt-single-play"); - -function buildUrl() { - const name = input.value.trim().toLowerCase() || "Ku6ep_XBOCTuK"; - const single = singlePlayEl.checked ? "1" : "0"; - return `${location.origin}${location.pathname.replace(/\/[^/]*$/, "/")}game?channel=${encodeURIComponent(name)}&singlePlay=${single}`; -} - -function updateLink() { - linkEl.textContent = buildUrl(); - linkEl.className = ""; -} - -function copyLink() { - navigator.clipboard.writeText(buildUrl()).then(() => { - linkEl.textContent = "СКОПИРОВАНО!"; - linkEl.className = "copied"; - setTimeout(updateLink, 1500); - }); -} - -function openLink() { - window.open(buildUrl(), "_blank"); -} - -input.addEventListener("input", updateLink); -singlePlayEl.addEventListener("change", updateLink); -input.addEventListener("keydown", (e) => { - if (e.key === "Enter") copyLink(); -}); - -copyBtn.addEventListener("click", copyLink); -openBtn.addEventListener("click", openLink); - -updateLink(); - -document.querySelector(".hint-toggle").addEventListener("click", () => { - document.querySelector(".settings-hints").classList.toggle("hidden"); -}); diff --git a/js/input.js b/js/input.js deleted file mode 100644 index 6b33172..0000000 --- a/js/input.js +++ /dev/null @@ -1,113 +0,0 @@ -const InputModule = { - currentLine: "", - localBuffer: "", - - submitLine(text, username) { - const line = text.trim().toLowerCase().split(/\s+/)[0]; - if (!line) return; - if (introPlaying) return; - - if (line === CONFIG.commands.play) { - if (gameState === "start" || gameState === "gameover") { - startGame(); - } - return; - } - - if (gameState !== "playing") return; - - this.currentLine = line; - this.updateChatDisplay(line, username); - - const enemy = this.findEnemy(line); - - this.animateShoot(line, enemy, username); - }, - - submitLocal(text) { - const line = text.trim().toLowerCase().split(/\s+/)[0]; - if (!line) return; - if (introPlaying) return; - - if (line === CONFIG.commands.play) { - if (gameState === "start" || gameState === "gameover") { - startGame(); - } - return; - } - - if (gameState !== "playing") return; - - this.currentLine = line; - this.updateLocalDisplay(line); - - const enemy = this.findEnemy(line); - const username = channelName; - - this.animateShoot(line, enemy, username); - }, - - animateShoot(line, enemy, username) { - const localEl = document.getElementById("typed-text"); - const chatEl = document.getElementById("chat-text"); - - const isLocal = username === channelName; - const el = isLocal ? localEl : chatEl; - - el.classList.add("shooting"); - - setTimeout(() => { - el.classList.remove("shooting"); - this.currentLine = ""; - if (isLocal) { - this.updateLocalDisplay(""); - this.localBuffer = ""; - } else { - this.updateChatDisplay("", ""); - } - - if (enemy) { - game.launchProjectile(line, enemy, true, username); - } else { - game.launchProjectile(line, null, false, username); - } - }, S.inputShootDelay); - }, - - findEnemy(word) { - for (let e of enemies) { - if (e.isBoss) { - const layer = e.layers[e.currentLayer]; - if (layer && layer.includes(word) && !e.killedInLayer.has(word)) { - return e; - } - } else { - if (e.text === word) { - return e; - } - } - } - return null; - }, - - updateLocalDisplay(text) { - const el = document.getElementById("typed-text"); - el.innerHTML = text; - }, - - updateChatDisplay(text, username) { - const el = document.getElementById("chat-text"); - if (text) { - el.innerHTML = `${username}: ${text}`; - } else { - el.innerHTML = ""; - } - }, - - clear() { - this.currentLine = ""; - this.localBuffer = ""; - this.updateLocalDisplay(""); - this.updateChatDisplay("", ""); - }, -}; diff --git a/js/intro.js b/js/intro.js deleted file mode 100644 index a411854..0000000 --- a/js/intro.js +++ /dev/null @@ -1,156 +0,0 @@ -function runIntro() { - introPlaying = true; - const I = CONFIG.intro; - const ih = H / 1000; - const IS = { - fontSize: Math.round(I.fontSize * ih), - glow: Math.round(I.glow * ih), - trailGlow: Math.round(I.trailGlow * ih), - projectileGlow: Math.round(I.projectileGlow * ih), - projectileRadius: Math.round(I.projectileRadius * ih), - trailRect: Math.round(I.trailRect * ih), - subtitleFont: Math.round(I.subtitleFont * ih), - subtitleGlow: Math.round(I.subtitleGlow * ih), - subtitleYOffset: Math.round(I.subtitleYOffset * ih), - }; - const TEXT_Y = Math.round(H * I.textYR); - ctx.save(); - ctx.font = `bold ${IS.fontSize}px "Courier New", monospace`; - const totalW = ctx.measureText("SHOOTING WORLD").width; - const worW = ctx.measureText("SHOOTING WOR").width; - const lW = ctx.measureText("L").width; - const dW = ctx.measureText("D").width; - ctx.restore(); - - const cx = W / 2; - const lTargetX = cx - totalW / 2 + worW + lW / 2; - const lTargetY = TEXT_Y; - const shooterX = cx; - const shooterY = H - S.shooterOffset; - - let frame = 0; - let textAlpha = 0; - let p1 = []; - let shots = []; - - function drawPartialWorld(alpha, dSlide) { - ctx.save(); - ctx.font = `bold ${IS.fontSize}px "Courier New", monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - ctx.globalAlpha = alpha; - ctx.fillStyle = CONFIG.colors.primary; - ctx.shadowColor = CONFIG.colors.primaryGlow; - ctx.shadowBlur = IS.glow; - ctx.fillText("SHOOTING WOR", cx - totalW / 2 + worW / 2, TEXT_Y); - - const lFade = Math.max(0, 1 - dSlide); - if (lFade > 0) { - ctx.globalAlpha = alpha * lFade; - ctx.fillText("L", cx - totalW / 2 + worW + lW / 2, TEXT_Y); - } - - const dOffset = lW * dSlide; - ctx.globalAlpha = alpha; - ctx.fillText("D", cx - totalW / 2 + worW + lW + dW / 2 - dOffset, TEXT_Y); - - ctx.restore(); - } - - function drawShotProjectile(s) { - const x = shooterX + (lTargetX - shooterX) * s.progress; - const y = shooterY + (lTargetY - shooterY) * s.progress; - ctx.save(); - - for (let t of s.trail) { - ctx.globalAlpha = t.life * I.trailAlpha; - ctx.fillStyle = CONFIG.colors.primary; - ctx.shadowColor = CONFIG.colors.primary; - ctx.shadowBlur = IS.trailGlow; - ctx.fillRect(t.x - IS.trailRect / 2, t.y - IS.trailRect / 2, IS.trailRect, IS.trailRect); - } - - if (!s.done) { - ctx.globalAlpha = 1; - ctx.fillStyle = CONFIG.colors.primary; - ctx.shadowColor = CONFIG.colors.primary; - ctx.shadowBlur = IS.projectileGlow; - ctx.beginPath(); - ctx.arc(x, y, IS.projectileRadius, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } - - function introLoop(timestamp) { - calcDt(timestamp); - const prevFrame = Math.floor(frame); - frame += frameFactor; - const curFrame = Math.floor(frame); - - if (frame <= I.fadeInFrames) { - textAlpha = Math.min(1, frame / I.fadeInFrames); - } - - if (prevFrame < I.shot1Frame && curFrame >= I.shot1Frame) shots.push({ progress: 0, trail: [], done: false }); - if (prevFrame < I.shot2Frame && curFrame >= I.shot2Frame) shots.push({ progress: 0, trail: [], done: false }); - if (prevFrame < I.shot3Frame && curFrame >= I.shot3Frame) shots.push({ progress: 0, trail: [], done: false }); - - for (let s of shots) { - if (s.done) continue; - s.progress = Math.min(1, s.progress + I.shotSpeed * frameFactor); - - const x = shooterX + (lTargetX - shooterX) * s.progress; - const y = shooterY + (lTargetY - shooterY) * s.progress; - - s.trail.push({ x, y, life: 1 }); - if (s.trail.length > I.trailMax) s.trail.shift(); - for (let t of s.trail) t.life -= I.trailDecay * frameFactor; - s.trail = s.trail.filter((t) => t.life > 0); - - if (s.progress >= 1) { - s.done = true; - for (let i = 0; i < I.particleCountPerHit; i++) { - p1.push(new Particle(lTargetX, lTargetY, CONFIG.colors.primary)); - } - } - } - - let dSlide = 0; - if (frame >= I.slideStartFrame) { - dSlide = Math.min(1, (frame - I.slideStartFrame) / I.slideDuration); - } - - for (let p of p1) p.update(); - p1 = p1.filter((p) => p.life > 0); - - ctx.clearRect(0, 0, W, H); - drawGrid(); - - drawPartialWorld(textAlpha, dSlide); - for (let s of shots) drawShotProjectile(s); - for (let p of p1) p.draw(); - - if (frame < I.totalFrames) { - requestAnimationFrame(introLoop); - } else { - p1 = []; - introPlaying = false; - ctx.clearRect(0, 0, W, H); - drawGrid(); - drawPartialWorld(1, 1); - ctx.save(); - ctx.font = `${IS.subtitleFont}px "Courier New", monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = CONFIG.colors.primaryDim; - ctx.shadowColor = CONFIG.colors.primary; - ctx.shadowBlur = IS.subtitleGlow; - ctx.fillText(CONFIG.texts.startSubtitle, cx, TEXT_Y + IS.subtitleYOffset); - ctx.restore(); - } - } - - setTimeout(introLoop, I.startDelay); -} diff --git a/js/main.js b/js/main.js deleted file mode 100644 index b5104c7..0000000 --- a/js/main.js +++ /dev/null @@ -1,32 +0,0 @@ -(function initUI() { - document.getElementById("start-subtitle").textContent = CONFIG.texts.startSubtitle; - document.getElementById("gameover-title").textContent = CONFIG.texts.gameOverTitle; - document.getElementById("gameover-subtitle").textContent = CONFIG.texts.gameOverSubtitle; - - document.querySelector("#ui-overlay .ui-text:nth-child(1)").innerHTML = - `${CONFIG.texts.scoreLabel} 0`; - document.querySelector("#ui-overlay .ui-text:nth-child(2)").innerHTML = - `${CONFIG.texts.waveLabel} 1`; - document.querySelector("#ui-overlay .ui-text:nth-child(3)").innerHTML = - `${CONFIG.texts.livesLabel} ${"♥".repeat(CONFIG.game.startLives)}`; - - document.querySelector("#gameover-screen .screen-subtitle").innerHTML = - `${CONFIG.texts.finalScoreLabel} 0`; -})(); - -const gameParams = new URLSearchParams(location.search); -const channel = gameParams.get("channel") || "ku6ep_xboctuk"; -const channelName = channel; -const client = new tmi.Client({ channels: [channel] }); -client.connect(); - -client.on("message", (channel, tags, message, self) => { - const username = tags["display-name"] || tags.username || "anonymous"; - InputModule.submitLine(message, username); -}); - -initKeyboardBridge(); - -window.addEventListener("resize", recalc); -recalc(); -runIntro(); diff --git a/js/particle.js b/js/particle.js deleted file mode 100644 index 57976e9..0000000 --- a/js/particle.js +++ /dev/null @@ -1,28 +0,0 @@ -class Particle { - constructor(x, y, color) { - this.x = x; - this.y = y; - this.vx = (Math.random() - 0.5) * S.particleVel; - this.vy = (Math.random() - 0.5) * S.particleVel; - this.life = 1; - this.decay = S.particleDecayBase + Math.random() * S.particleDecayRange; - this.color = color; - this.size = S.particleSizeMin + Math.random() * (S.particleSizeMax - S.particleSizeMin); - } - update() { - this.x += this.vx * frameFactor; - this.y += this.vy * frameFactor; - this.life -= this.decay * frameFactor; - this.vx *= S.particleDamping; - this.vy *= S.particleDamping; - } - draw() { - ctx.save(); - ctx.globalAlpha = this.life; - ctx.fillStyle = this.color; - ctx.shadowColor = this.color; - ctx.shadowBlur = S.particleGlow; - ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size); - ctx.restore(); - } -} diff --git a/js/projectile.js b/js/projectile.js deleted file mode 100644 index fd4c1dc..0000000 --- a/js/projectile.js +++ /dev/null @@ -1,121 +0,0 @@ -class Projectile { - constructor(word, targetEnemy, isHit, username) { - this.word = word; - this.isHit = isHit; - this.username = username; - this.x = W / 2; - this.y = H - S.shooterOffset; - this.speed = S.projectileSpeed; - this.life = 1; - this.trail = []; - this.hit = false; - - if (isHit && targetEnemy) { - this.target = targetEnemy; - this.targetX = targetEnemy.x; - this.targetY = targetEnemy.y; - const dx = targetEnemy.x - this.x; - const dy = targetEnemy.y - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); - this.vx = (dx / dist) * this.speed; - this.vy = (dy / dist) * this.speed; - } else { - this.target = null; - this.targetX = S.missTargetPadX + Math.random() * (W - 2 * S.missTargetPadX); - this.targetY = S.missTargetPadY + Math.random() * (H * S.missTargetYRange); - const dx = this.targetX - this.x; - const dy = this.targetY - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); - this.vx = (dx / dist) * this.speed; - this.vy = (dy / dist) * this.speed; - } - } - - update() { - this.trail.push({ x: this.x, y: this.y, life: 1 }); - if (this.trail.length > S.projectileTrailLength) this.trail.shift(); - - for (let t of this.trail) { - t.life -= S.projectileTrailDecay * frameFactor; - } - this.trail = this.trail.filter((t) => t.life > 0); - - this.x += this.vx * frameFactor; - this.y += this.vy * frameFactor; - - if (this.isHit && this.target && !this.hit) { - const dx = this.target.x - this.x; - const dy = this.target.y - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist > 0) { - this.vx = (dx / dist) * this.speed; - this.vy = (dy / dist) * this.speed; - } - - if (dist < S.projectileHitRadius) { - this.hit = true; - game.onProjectileHit(this, this.target); - return false; - } - - if (!enemies.includes(this.target)) { - return false; - } - } - - if (!this.isHit && !this.hit) { - const dx = this.targetX - this.x; - const dy = this.targetY - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist < S.projectileMissRadius) { - this.hit = true; - game.onProjectileMiss(this); - return false; - } - } - - const off = S.shooterOffset; - if (this.x < -off || this.x > W + off || this.y < -off || this.y > H + off) { - return false; - } - - return true; - } - - draw() { - ctx.save(); - - const color = this.isHit ? CONFIG.colors.primary : CONFIG.colors.miss; - const glowColor = this.isHit ? CONFIG.colors.primary : CONFIG.colors.miss; - - for (let i = 0; i < this.trail.length; i++) { - const t = this.trail[i]; - ctx.globalAlpha = t.life * S.projectileTrailAlpha; - ctx.fillStyle = color; - ctx.shadowColor = glowColor; - ctx.shadowBlur = S.trailGlow; - const size = S.trailSizeBase + i * S.trailSizeInc; - ctx.fillRect(t.x - size / 2, t.y - size / 2, size, size); - } - - ctx.globalAlpha = 1; - ctx.font = `bold ${S.projectileFont}px "Courier New", monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = color; - ctx.shadowColor = glowColor; - ctx.shadowBlur = S.projectileGlow; - ctx.fillText(this.word, this.x, this.y); - - ctx.strokeStyle = color; - ctx.lineWidth = 1; - ctx.globalAlpha = S.projectileCircleAlpha; - ctx.beginPath(); - ctx.arc(this.x, this.y, S.projectileCircleRadius, 0, Math.PI * 2); - ctx.stroke(); - - ctx.restore(); - } -} diff --git a/js/tmi.min.js b/js/tmi.min.js deleted file mode 100644 index cfe8d15..0000000 --- a/js/tmi.min.js +++ /dev/null @@ -1 +0,0 @@ -!function s(o,i,r){function a(t,e){if(!i[t]){if(!o[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(c)return c(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=i[t]={exports:{}},o[t][0].call(n.exports,function(e){return a(o[t][1][e]||e)},n,n.exports,s,o,i,r)}return i[t].exports}for(var c="function"==typeof require&&require,e=0;ee.length)&&(t=e.length);for(var n=0,s=new Array(t);n: ").concat(h)),V.hasOwn(t.tags,"username")||(t.tags.username=J),t.tags["message-type"]="whisper";J=V.channel(t.tags.username);this.emits(["whisper","message"],[[J,t.tags,h,!1]]);break;case"PRIVMSG":t.tags.username=t.prefix.split("!")[0],"jtv"===t.tags.username?(c=V.username(h.split(" ")[0]),u=h.includes("auto"),h.includes("hosting you for")?(a=V.extractNumber(h),this.emit("hosted",m,c,a,u)):h.includes("hosting you")&&this.emit("hosted",m,c,0,u)):(a=V.get(this.opts.options.messagesLogLevel,"info"),c=V.actionMessage(h),t.tags["message-type"]=c?"action":"chat",h=c?c[1]:h,V.hasOwn(t.tags,"bits")?this.emit("cheer",m,t.tags,h):(V.hasOwn(t.tags,"msg-id")?"highlighted-message"===t.tags["msg-id"]?(u=t.tags["msg-id"],this.emit("redeem",m,t.tags.username,u,t.tags,h)):"skip-subs-mode-message"===t.tags["msg-id"]&&(l=t.tags["msg-id"],this.emit("redeem",m,t.tags.username,l,t.tags,h)):V.hasOwn(t.tags,"custom-reward-id")&&(l=t.tags["custom-reward-id"],this.emit("redeem",m,t.tags.username,l,t.tags,h)),c?(this.log[a]("[".concat(m,"] *<").concat(t.tags.username,">: ").concat(h)),this.emits(["action","message"],[[m,t.tags,h,!1]])):(this.log[a]("[".concat(m,"] <").concat(t.tags.username,">: ").concat(h)),this.emits(["chat","message"],[[m,t.tags,h,!1]]))));break;default:this.log.warn("Could not parse message:\n".concat(JSON.stringify(t,null,4)))}}},n.prototype.connect=function(){var s=this;return new Promise(function(t,n){s.server=V.get(s.opts.connection.server,"irc-ws.chat.twitch.tv"),s.port=V.get(s.opts.connection.port,80),s.secure&&(s.port=443),443===s.port&&(s.secure=!0),s.reconnectTimer=s.reconnectTimer*s.reconnectDecay,s.reconnectTimer>=s.maxReconnectInterval&&(s.reconnectTimer=s.maxReconnectInterval),s._openConnection(),s.once("_promiseConnect",function(e){e?n(e):t([s.server,~~s.port])})})},n.prototype._openConnection=function(){var e="".concat(this.secure?"wss":"ws","://").concat(this.server,":").concat(this.port,"/"),t={};"agent"in this.opts.connection&&(t.agent=this.opts.connection.agent),this.ws=new r(e,"irc",t),this.ws.onmessage=this._onMessage.bind(this),this.ws.onerror=this._onError.bind(this),this.ws.onclose=this._onClose.bind(this),this.ws.onopen=this._onOpen.bind(this)},n.prototype._onOpen=function(){var n=this;this._isConnected()&&(this.log.info("Connecting to ".concat(this.server," on port ").concat(this.port,"..")),this.emit("connecting",this.server,~~this.port),this.username=V.get(this.opts.identity.username,V.justinfan()),this._getToken().then(function(e){var t=V.password(e);n.log.info("Sending authentication to server.."),n.emit("logon");e="twitch.tv/tags twitch.tv/commands";n._skipMembership||(e+=" twitch.tv/membership"),n.ws.send("CAP REQ :"+e),t?n.ws.send("PASS ".concat(t)):V.isJustinfan(n.username)&&n.ws.send("PASS SCHMOOPIIE"),n.ws.send("NICK ".concat(n.username))}).catch(function(e){n.emits(["_promiseConnect","disconnected"],[[e],["Could not get a token."]])}))},n.prototype._getToken=function(){var e,t=this.opts.identity.password;return"function"==typeof t?(e=t())instanceof Promise?e:Promise.resolve(e):Promise.resolve(t)},n.prototype._onMessage=function(e){var t=this;e.data.trim().split("\r\n").forEach(function(e){e=q.msg(e);e&&t.handleMessage(e)})},n.prototype._onError=function(){var t=this;this.moderators={},this.userstate={},this.globaluserstate={},clearInterval(this.pingLoop),clearTimeout(this.pingTimeout),clearTimeout(this._updateEmotesetsTimer),this.reason=null===this.ws?"Connection closed.":"Unable to connect.",this.emits(["_promiseConnect","disconnected"],[[this.reason]]),this.reconnect&&this.reconnections===this.maxReconnectAttempts&&(this.emit("maxreconnect"),this.log.error("Maximum reconnection attempts reached.")),this.reconnect&&!this.reconnecting&&this.reconnections<=this.maxReconnectAttempts-1&&(this.reconnecting=!0,this.reconnections=this.reconnections+1,this.log.error("Reconnecting in ".concat(Math.round(this.reconnectTimer/1e3)," seconds..")),this.emit("reconnect"),setTimeout(function(){t.reconnecting=!1,t.connect().catch(function(e){return t.log.error(e)})},this.reconnectTimer)),this.ws=null},n.prototype._onClose=function(){var t=this;this.moderators={},this.userstate={},this.globaluserstate={},clearInterval(this.pingLoop),clearTimeout(this.pingTimeout),clearTimeout(this._updateEmotesetsTimer),this.wasCloseCalled?(this.wasCloseCalled=!1,this.reason="Connection closed.",this.log.info(this.reason),this.emits(["_promiseConnect","_promiseDisconnect","disconnected"],[[this.reason],[null],[this.reason]])):(this.emits(["_promiseConnect","disconnected"],[[this.reason]]),this.reconnect&&this.reconnections===this.maxReconnectAttempts&&(this.emit("maxreconnect"),this.log.error("Maximum reconnection attempts reached.")),this.reconnect&&!this.reconnecting&&this.reconnections<=this.maxReconnectAttempts-1&&(this.reconnecting=!0,this.reconnections=this.reconnections+1,this.log.error("Could not connect to server. Reconnecting in ".concat(Math.round(this.reconnectTimer/1e3)," seconds..")),this.emit("reconnect"),setTimeout(function(){t.reconnecting=!1,t.connect().catch(function(e){return t.log.error(e)})},this.reconnectTimer))),this.ws=null},n.prototype._getPromiseDelay=function(){return this.currentLatency<=600?600:this.currentLatency+100},n.prototype._sendCommand=function(s,o,i,r){var a=this;return new Promise(function(e,t){if(!a._isConnected())return t("Not connected to server.");var n;null!==s&&"number"!=typeof s||(null===s&&(s=a._getPromiseDelay()),V.promiseDelay(s).then(function(){return t("No response from Twitch.")})),null!==o?(n=V.channel(o),a.log.info("[".concat(n,"] Executing command: ").concat(i)),a.ws.send("PRIVMSG ".concat(n," :").concat(i))):(a.log.info("Executing command: ".concat(i)),a.ws.send(i)),"function"==typeof r?r(e,t):e()})},n.prototype._sendMessage=function(c,u,l,m){var h=this;return new Promise(function(e,t){if(!h._isConnected())return t("Not connected to server.");if(V.isJustinfan(h.getUsername()))return t("Cannot send anonymous messages.");var n,s=V.channel(u);h.userstate[s]||(h.userstate[s]={}),500<=l.length&&(n=V.splitLine(l,500),l=n[0],setTimeout(function(){h._sendMessage(c,u,n[1],function(){})},350)),h.ws.send("PRIVMSG ".concat(s," :").concat(l));var o={};Object.keys(h.emotesets).forEach(function(e){return h.emotesets[e].forEach(function(e){return(V.isRegex(e.code)?q.emoteRegex:q.emoteString)(l,e.code,e.id,o)})});var i=Object.assign(h.userstate[s],q.emotes({emotes:q.transformEmotes(o)||null})),r=V.get(h.opts.options.messagesLogLevel,"info"),a=V.actionMessage(l);a?(i["message-type"]="action",h.log[r]("[".concat(s,"] *<").concat(h.getUsername(),">: ").concat(a[1])),h.emits(["action","message"],[[s,i,a[1],!0]])):(i["message-type"]="chat",h.log[r]("[".concat(s,"] <").concat(h.getUsername(),">: ").concat(l)),h.emits(["chat","message"],[[s,i,l,!0]])),"function"==typeof m?m(e,t):e()})},n.prototype._updateEmoteset=function(s){var t,o=this,e=void 0!==s;e&&(s===this.emotes?e=!1:this.emotes=s),this._skipUpdatingEmotesets?e&&this.emit("emotesets",s,{}):(t=function(){0n&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace()),this},o.prototype.once=function(e,t){if(!c(t))throw TypeError("listener must be a function");var n=!1;if(this._events.hasOwnProperty(e)&&"_"===e.charAt(0)){var s,o=1,i=e;for(s in this._events)this._events.hasOwnProperty(s)&&s.startsWith(i)&&o++;e+=o}function r(){"_"!==e.charAt(0)||isNaN(e.substr(e.length-1))||(e=e.substring(0,e.length-1)),this.removeListener(e,r),n||(n=!0,t.apply(this,arguments))}return r.listener=t,this.on(e,r),this},o.prototype.removeListener=function(e,t){var n,s,o,i;if(!c(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(o=(n=this._events[e]).length,s=-1,n===t||c(n.listener)&&n.listener===t){if(delete this._events[e],this._events.hasOwnProperty(e+"2")&&"_"===e.charAt(0)){var r,a=e;for(r in this._events)this._events.hasOwnProperty(r)&&r.startsWith(a)&&(isNaN(parseInt(r.substr(r.length-1)))||(this._events[e+parseInt(r.substr(r.length-1)-1)]=this._events[r],delete this._events[r]));this._events[e]=this._events[e+"1"],delete this._events[e+"1"]}this._events.removeListener&&this.emit("removeListener",e,t)}else if(u(n)){for(i=o;0n?(t.command=e.slice(n),t):null;for(t.command=e.slice(n,s),n=s+1;32===e.charCodeAt(n);)n++;for(;n").replace(/\\"\\;/g,'"').replace(/\\'\\;/g,"'")},unescapeIRC:function(e){return e&&"string"==typeof e&&e.includes("\\")?e.replace(i,function(e,t){return t in a?a[t]:t}):e},escapeIRC:function(e){return e&&"string"==typeof e?e.replace(r,function(e,t){return t in c?"\\".concat(c[t]):t}):e},addWord:function(e,t){return e.length?e+" "+t:e+t},splitLine:function(e,t){var n=e.substring(0,t).lastIndexOf(" ");return[e.substring(0,n=-1===n?t-1:n),e.substring(n+1)]},extractNumber:function(e){for(var t=e.split(" "),n=0;n=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hmans/id@0.0.1': + resolution: {integrity: sha512-2BxHxZziST8F0P5Dt+UuttF2lwCaKK7QFR814PwEp708l6dm86pjWJAFr7BKyAGu27mRpYcghvCAUAucNdsCdw==} + + '@hmans/queue@0.0.1': + resolution: {integrity: sha512-Mm8oQRFRoiEYQ3QD9CD14DTaShd21nzNrUFyBhFxy+TduKf2PdMRfHHx7lDvEpbODGNlj+e8mcWGj/YjWmC3Bw==} + + '@miniplex/bucket@2.0.0': + resolution: {integrity: sha512-jdAW0vG0tLel0dD0lyQqkQ4g4G9CscbVtenu35jB48fnVK3zmfFmQKIxXHsUvFs4KAN44UESAgaVOroTetFSRw==} + + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + + '@types/tmi.js@1.8.6': + resolution: {integrity: sha512-LVzNK7AxTMyh9qHLanAQR1o0I9XzfbIcXk85cx85igmCJHHO1Sm71sdhQ8Mj1WmRGzynPIoCXx6mVaFynWbsQw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@webgpu/types@0.1.72': + resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==} + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventery@0.0.4: + resolution: {integrity: sha512-rQbXJG8WX9z2cm3IJadkSUZLWFXuwGMIJ40oWyB23DhVb1e92T7Jx3qF0UwtgHI7D4VZzVswbZ7MXfW88cFF4Q==} + + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + + miniplex@2.0.0: + resolution: {integrity: sha512-pJlxmlPf5Qyx12amgOCyRE6Lzw28ct2G0lF9xn7/xudLtA/xDOUnCIU2xOxCk8GkjePYctcNpjmFshJp/Ht66A==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + parse-svg-path@0.2.0: + resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==} + + pixi.js@8.20.1: + resolution: {integrity: sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==} + + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + + tmi.js@1.8.5: + resolution: {integrity: sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==} + engines: {node: '>=10.0.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hmans/id@0.0.1': {} + + '@hmans/queue@0.0.1': {} + + '@miniplex/bucket@2.0.0': + dependencies: + eventery: 0.0.4 + + '@pixi/colord@2.9.6': {} + + '@types/earcut@3.0.0': {} + + '@types/tmi.js@1.8.6': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@webgpu/types@0.1.72': {} + + '@xmldom/xmldom@0.8.15': {} + + earcut@3.2.3: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + eventemitter3@5.0.4: {} + + eventery@0.0.4: {} + + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + + ismobilejs@1.1.1: {} + + js-binary-schema-parser@2.0.3: {} + + miniplex@2.0.0: + dependencies: + '@hmans/id': 0.0.1 + '@hmans/queue': 0.0.1 + '@miniplex/bucket': 2.0.0 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + parse-svg-path@0.2.0: {} + + pixi.js@8.20.1: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.72 + '@xmldom/xmldom': 0.8.15 + earcut: 3.2.3 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.2.0 + tiny-lru: 11.4.7 + + tiny-lru@11.4.7: {} + + tmi.js@1.8.5: + dependencies: + node-fetch: 2.7.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + tr46@0.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + ws@8.21.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/app/GameApp.ts b/src/app/GameApp.ts new file mode 100644 index 0000000..a175d55 --- /dev/null +++ b/src/app/GameApp.ts @@ -0,0 +1,124 @@ +import { gameConfig } from '../config/gameConfig' +import { defaultWordDictionary } from '../config/words' +import { EventBus } from '../core/eventBus' +import { GameClock } from '../core/GameClock' +import { CanvasTextMeasurer } from '../core/CanvasTextMeasurer' +import { MetricsStore } from '../core/metrics' +import { createGameWorld } from '../ecs/world' +import { Simulation } from '../game/Simulation' +import { GameStore } from '../game/GameStore' +import type { GameEvents } from '../game/events' +import { InputRouter } from '../input/InputRouter' +import { LocalKeyboardInputSource } from '../input/sources/LocalKeyboardInputSource' +import { TwitchChatInputSource } from '../input/sources/TwitchChatInputSource' +import { Renderer } from '../render/Renderer' +import { HudController } from '../ui/HudController' +import { InputFeedbackController } from '../ui/InputFeedbackController' +import { OverlayScaler } from '../ui/OverlayScaler' +import { ScreensController } from '../ui/ScreensController' + +export type GameSettings = { + channel: string + singlePlay: boolean +} + +/** + * Точка сборки приложения: связывает симуляцию, рендер, ввод и DOM-UI. + * Логика тут только оркестрационная — фазы игры, таймеры экранов, такт. + */ +export async function bootstrapGame(canvas: HTMLCanvasElement): Promise { + const params = new URLSearchParams(window.location.search) + const settings: GameSettings = { + channel: params.get('channel') || 'ku6ep_xboctuk', + singlePlay: params.get('singlePlay') === '1', + } + + const bus = new EventBus() + const metricsStore = new MetricsStore(gameConfig) + const world = createGameWorld() + const store = new GameStore() + const clock = new GameClock() + const measurer = new CanvasTextMeasurer() + + const simulation = new Simulation({ world, store, bus, metrics: metricsStore, spawn: { words: defaultWordDictionary, measurer } }) + const renderer = await Renderer.create(canvas, metricsStore, world, bus, measurer) + + new HudController(bus) + const screens = new ScreensController(bus) + const inputFeedback = new InputFeedbackController(bus) + const scaler = new OverlayScaler() + + const localSource = new LocalKeyboardInputSource(settings.channel) + const router = new InputRouter([new TwitchChatInputSource(settings.channel), localSource], bus) + router.start() + inputFeedback.bindBuffer(localSource) + + const intro = renderer.intro + let gameOverTimer: number | null = null + + const clearGameOverTimer = (): void => { + if (gameOverTimer !== null) { + clearTimeout(gameOverTimer) + gameOverTimer = null + } + } + + const startIntro = (): void => { + intro.start() + clock.start() + } + + intro.onFinish = () => { + // В простое такт не нужен — canvas сохраняет последний кадр. + if (store.phase !== 'playing') clock.stop() + } + + bus.on('input:start', () => { + if (intro.active || store.phase === 'playing') return + clearGameOverTimer() + intro.hide() + simulation.start() + clock.start() + }) + + bus.on('input:word', ({ word, player }) => { + if (intro.active || store.phase !== 'playing') return + simulation.submitWord(word, player) + }) + + bus.on('game:over', () => { + clearGameOverTimer() + gameOverTimer = window.setTimeout(() => { + gameOverTimer = null + screens.hideAll() + if (settings.singlePlay) { + document.body.classList.add('hidden') + clock.stop() + } else { + startIntro() + } + }, gameConfig.game.gameOverDelay) + }) + + clock.add((delta) => { + if (intro.active) intro.update(delta) + if (store.phase === 'playing') simulation.update(delta) + renderer.update(delta, world, store) + renderer.render() + }) + + window.addEventListener('resize', () => { + const width = window.innerWidth + const height = window.innerHeight + metricsStore.rebuild(width, height) + renderer.resize(width, height) + scaler.apply(metricsStore.current) + if (!clock.isRunning()) renderer.render() + }) + + scaler.apply(metricsStore.current) + startIntro() + + // Отладочный хук: в OBS можно открыть консоль и посмотреть состояние мира. + ;(window as unknown as Record).__sw = { world, store, bus, metrics: metricsStore, renderer } +} diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts new file mode 100644 index 0000000..8f0d8f6 --- /dev/null +++ b/src/config/gameConfig.ts @@ -0,0 +1,169 @@ +/** + * Игровые константы. Все размеры — в "дизайн-пикселях" относительно + * базовой высоты/ширины 1000px; конкретные значения считает Metrics. + */ +export const gameConfig = { + commands: { play: '!play' }, + texts: { + startTitle: 'SHOOTING WORD', + startSubtitle: 'Введи !play чтобы играть', + gameOverTitle: 'ЧАТ, ВЫ ПРОИГРАЛИ!', + gameOverSubtitle: 'Введи !play чтобы играть', + scoreLabel: 'SCORE:', + waveLabel: 'WAVE:', + livesLabel: 'LIVES:', + finalScoreLabel: 'Final Score:', + armorLabel: 'ARMOR', + bossLabel: 'BOSS', + bossIncoming: 'BOSS INCOMING', + }, + game: { + startLives: 10, + startWave: 1, + startScore: 0, + enemiesPerWaveBase: 5, + enemiesPerWaveInc: 1, + waveInterval: 900, + heavyEnemyChanceBase: 0.25, + heavyEnemyChancePerWave: 0.02, + scoreSimple: 10, + scoreHeavy: 50, + scoreBoss: 200, + bossEveryNthWave: 10, + bossLayers: 3, + bossWordsPerLayer: 3, + gameOverDelay: 10000, + }, + speed: { + slowEnemyCrossTimeStart: 240, + slowEnemyCrossTimeCap: 30, + slowEnemyCrossTimeCapWave: 50, + fastEnemyCrossTimeStart: 120, + fastEnemyCrossTimeCap: 15, + fastEnemyCrossTimeCapWave: 50, + bossCrossTime: 60, + projectileCrossTime: 0.43, + gridSpeed: 0.5, + }, + visual: { + gridSize: 80, + enemyPaddingX: 60, + enemyAlienOffset: 16, + armorBarPad: 20, + armorBarFont: 20, + missTargetPadX: 60, + missTargetPadY: 100, + inputAreaHeight: 105, + removeZone: 60, + playerY: 32, + playerRadius: 16, + playerGlow: 30, + enemySimpleHeight: 30, + enemyHeavyHeight: 42, + enemySimpleFont: 28, + enemyHeavyFont: 30, + enemyArmorBarHeight: 6, + enemyArmorBarOffset: 11, + enemyArmorLabelOffset: 15, + enemyGlow: 10, + enemyFlashGlow: 50, + enemyLineWidth: 3, + projectileGlow: 30, + projectileHitRadius: 30, + projectileMissRadius: 30, + projectileFont: 28, + projectileCircleRadius: 30, + shakeHit: 8, + shakeMiss: 6, + shakeDamage: 16, + particleVel: 12, + particleSizeMin: 4, + particleSizeMax: 10, + particleGlow: 16, + trailSizeBase: 4, + trailSizeInc: 1, + trailGlow: 20, + shooterOffset: 50, + wavePauseFont: 36, + }, + ratio: { + missTargetYRange: 0.5, + projectileTrailLength: 8, + projectileTrailDecay: 0.15, + projectileTrailAlpha: 0.6, + projectileCircleAlpha: 0.5, + enemyPulseRate: 0.05, + enemyAlphaBase: 1.0, + enemyAlphaRange: 0.0, + enemyGlowPulse: 5, + particleDecayBase: 0.02, + particleDecayRange: 0.03, + particleDamping: 0.98, + bossFontSizeFactor: 1.2, + bossHeightFactor: 1.5, + bossWordGap: 24, + }, + counts: { + projectileTrailLength: 8, + particleHit: 15, + particleMiss: 20, + particleDestroySimple: 15, + particleDestroyHeavy: 30, + particleArmorBreak: 15, + flashDuration: 10, + shakeHitDuration: 6, + shakeMissDuration: 5, + shakeDamageDuration: 10, + wavePauseFrames: 120, + }, + timing: { + inputShootDelay: 150, + glitchDuration: 300, + }, + colors: { + primary: '#00ff41', + primaryDim: '#00cc33', + primaryGlow: 'rgba(0,255,65,0.2)', + primaryGrid: 'rgba(0,255,65,0.08)', + heavy: '#ffaa00', + heavyDim: '#cc8800', + boss: '#ff4444', + bossDim: '#cc2222', + miss: '#ff0044', + white: '#ffffff', + red: '#ff0000', + armorBarBg: 'rgba(255,0,0,0.3)', + armorBarFill: '#ffaa00', + enemyFill: '#0a0a0a', + playerBase: 'rgba(0,255,65,0.15)', + scanline: 'rgba(0,255,65,0.03)', + }, + font: '"Courier New", monospace', + intro: { + fontSize: 44, + textYR: 0.35, + subtitleYOffset: 60, + fadeInFrames: 40, + shot1Frame: 45, + shot2Frame: 70, + shot3Frame: 95, + shotSpeed: 0.045, + particleCountPerHit: 18, + slideStartFrame: 122, + slideDuration: 20, + totalFrames: 180, + startDelay: 300, + glow: 50, + trailGlow: 20, + projectileGlow: 30, + projectileRadius: 8, + trailRect: 8, + trailMax: 10, + trailDecay: 0.12, + trailAlpha: 0.5, + subtitleFont: 32, + subtitleGlow: 20, + }, +} as const + +export type GameConfig = typeof gameConfig diff --git a/src/config/words.ts b/src/config/words.ts new file mode 100644 index 0000000..da987d1 --- /dev/null +++ b/src/config/words.ts @@ -0,0 +1,72 @@ +export type WordRoll = { kind: 'simple'; word: string } | { kind: 'heavy'; words: string[] } + +/** Словарь IT-слов: простые враги — одно слово, тяжёлые — пара слов. */ +export class WordDictionary { + constructor( + private readonly simpleWords: readonly string[], + private readonly heavyWords: readonly (readonly string[])[], + ) {} + + static fromLists(simple: string[], heavy: string[][]): WordDictionary { + return new WordDictionary(simple, heavy) + } + + simple(): string { + return pick(this.simpleWords) + } + + heavyPair(): string[] { + return [...pick(this.heavyWords)] + } + + /** Слово или пара слов для врага выбранного типа. */ + roll(kind: 'simple' | 'heavy'): WordRoll { + return kind === 'simple' ? { kind: 'simple', word: this.simple() } : { kind: 'heavy', words: this.heavyPair() } + } +} + +function pick(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)] +} + +const SIMPLE_WORDS: string[] = [ + 'hack', 'code', 'data', 'node', 'link', 'byte', 'bit', 'net', 'web', 'sys', + 'run', 'log', 'bug', 'fix', 'api', 'cpu', 'ram', 'ssd', 'gpu', 'dns', + 'ip', 'url', 'tag', 'css', 'js', 'sql', 'git', 'cli', 'env', 'dev', + 'app', 'bot', 'ai', 'io', 'os', 'ui', 'ux', 'vm', 'vpn', 'lan', + 'wan', 'ftp', 'ssh', 'http', 'json', 'xml', 'yaml', 'md', 'py', 'go', + 'rust', 'java', 'php', 'cpp', 'ts', 'rb', 'kt', 'swift', 'dart', 'lua', + 'perl', 'bash', 'zsh', 'fish', 'vim', 'nano', 'emacs', 'sed', 'awk', 'grep', + 'find', 'cat', 'ls', 'cd', 'mv', 'cp', 'rm', 'mkdir', 'chmod', 'chown', + 'ping', 'curl', 'wget', 'nc', 'nmap', 'tcp', 'udp', 'ip4', 'ip6', 'mac', + 'hex', 'bin', 'dec', 'oct', 'xor', 'and', 'or', 'not', 'shl', 'shr', + 'key', 'lock', 'door', 'gate', 'wall', 'fire', 'ice', 'wind', 'storm', 'rain', + 'sun', 'moon', 'star', 'void', 'null', 'zero', 'one', 'two', 'ten', 'max', +] + +const HEAVY_WORDS: string[][] = [ + ['root', 'access'], ['data', 'breach'], ['fire', 'wall'], ['deep', 'web'], ['dark', 'net'], + ['cloud', 'base'], ['neural', 'net'], ['quantum', 'bit'], ['block', 'chain'], ['smart', 'contract'], + ['machine', 'learn'], ['deep', 'fake'], ['zero', 'day'], ['back', 'door'], ['side', 'channel'], + ['man', 'middle'], ['denial', 'service'], ['brute', 'force'], ['social', 'engineer'], ['phishing', 'attack'], + ['sql', 'inject'], ['cross', 'site'], ['buffer', 'over'], ['heap', 'spray'], ['stack', 'pivot'], + ['return', 'orient'], ['format', 'string'], ['race', 'condition'], ['time', 'check'], ['use', 'after'], + ['double', 'free'], ['null', 'pointer'], ['integer', 'over'], ['type', 'confus'], ['memory', 'leak'], + ['dead', 'lock'], ['live', 'lock'], ['starvation', 'mode'], ['priority', 'invert'], ['cache', 'miss'], + ['branch', 'mispred'], ['speculative', 'exec'], ['meltdown', 'flaw'], ['spectre', 'bug'], ['row', 'hammer'], + ['cold', 'boot'], ['evil', 'maid'], ['supply', 'chain'], ['hardware', 'troj'], ['firmware', 'root'], + ['bios', 'implant'], ['uefi', 'shell'], ['smm', 'exploit'], ['ring', 'zero'], ['kernel', 'panic'], + ['blue', 'screen'], ['kernel', 'oops'], ['seg', 'fault'], ['bus', 'error'], ['illegal', 'op'], + ['trap', 'divide'], ['trap', 'debug'], ['trap', 'nmi'], ['trap', 'break'], ['trap', 'overflow'], + ['trap', 'bound'], ['trap', 'invalid'], ['trap', 'device'], ['trap', 'double'], ['trap', 'copro'], + ['trap', 'tss'], ['trap', 'segment'], ['trap', 'stack'], ['trap', 'general'], ['trap', 'page'], + ['trap', 'x87'], ['trap', 'align'], ['trap', 'machine'], ['trap', 'simd'], ['trap', 'virtual'], + ['security', 'check'], ['stack', 'cookie'], ['aslr', 'bypass'], ['dep', 'bypass'], ['cfg', 'bypass'], + ['cet', 'bypass'], ['shadow', 'stack'], ['control', 'flow'], ['indirect', 'call'], ['jump', 'oriented'], + ['call', 'oriented'], ['data', 'oriented'], ['counterfeit', 'obj'], ['heap', 'feng'], ['house', 'spirit'], + ['house', 'lore'], ['house', 'force'], ['fast', 'bin'], ['tcache', 'poison'], ['unsorted', 'bin'], + ['large', 'bin'], ['small', 'bin'], ['mmap', 'chunk'], ['top', 'chunk'], ['wilderness', 'area'], + ['arena', 'corrupt'], ['thread', 'cache'], ['per', 'thread'], ['main', 'arena'], +] + +export const defaultWordDictionary = WordDictionary.fromLists(SIMPLE_WORDS, HEAVY_WORDS) diff --git a/src/core/CanvasTextMeasurer.ts b/src/core/CanvasTextMeasurer.ts new file mode 100644 index 0000000..982c7e3 --- /dev/null +++ b/src/core/CanvasTextMeasurer.ts @@ -0,0 +1,20 @@ +import { gameConfig } from '../config/gameConfig' + +/** Замер ширины текста для логики (расчёт ширины врага). Платформенная зависимость изолирована здесь. */ +export interface TextMeasurer { + measure(text: string, sizePx: number, bold?: boolean): number +} + +export class CanvasTextMeasurer implements TextMeasurer { + private readonly ctx: CanvasRenderingContext2D + + constructor() { + const canvas = document.createElement('canvas') + this.ctx = canvas.getContext('2d') as CanvasRenderingContext2D + } + + measure(text: string, sizePx: number, bold = false): number { + this.ctx.font = `${bold ? 'bold ' : ''}${sizePx}px ${gameConfig.font}` + return this.ctx.measureText(text).width + } +} diff --git a/src/core/GameClock.ts b/src/core/GameClock.ts new file mode 100644 index 0000000..12a593e --- /dev/null +++ b/src/core/GameClock.ts @@ -0,0 +1,45 @@ +/** Реестр обработчиков кадра; delta — прошедшие кадры, нормированные к 60 FPS (аналог старого frameFactor). */ +export class GameClock { + private subscribers = new Set<(deltaFrames: number) => void>() + private rafId = 0 + private lastTime = 0 + private running = false + + add(handler: (deltaFrames: number) => void): void { + this.subscribers.add(handler) + } + + isRunning(): boolean { + return this.running + } + + start(): void { + if (this.running) return + this.running = true + this.lastTime = 0 + this.rafId = requestAnimationFrame(this.frame) + } + + stop(): void { + this.running = false + cancelAnimationFrame(this.rafId) + } + + private frame = (timestamp: number): void => { + if (!this.running) return + let delta = 1 + if (this.lastTime && timestamp) { + delta = Math.min((timestamp - this.lastTime) / (1000 / 60), 6) + } + this.lastTime = timestamp || performance.now() + for (const handler of [...this.subscribers]) { + try { + handler(delta) + } catch (error) { + // Виджет живёт в OBS часами: сбой кадра не должен убивать цикл навсегда. + console.error('Shooting Word: ошибка кадра', error) + } + } + if (this.running) this.rafId = requestAnimationFrame(this.frame) + } +} diff --git a/src/core/color.ts b/src/core/color.ts new file mode 100644 index 0000000..ecc08a3 --- /dev/null +++ b/src/core/color.ts @@ -0,0 +1,9 @@ +/** Преобразует CSS-цвет '#rrggbb' в число для Pixi (tint/fill). */ +export function cssColorToHex(css: string): number { + const hex = css.replace('#', '') + return parseInt(hex, 16) +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} diff --git a/src/core/eventBus.ts b/src/core/eventBus.ts new file mode 100644 index 0000000..92b04b6 --- /dev/null +++ b/src/core/eventBus.ts @@ -0,0 +1,24 @@ +export type EventHandler = (payload: T) => void + +/** Минималистичная типизированная шина событий: источники ввода/логика — издатели, рендер и DOM-UI — подписчики. */ +export class EventBus> { + private handlers = new Map>>() + + on(event: K, handler: EventHandler): () => void { + let set = this.handlers.get(event) + if (!set) { + set = new Set() + this.handlers.set(event, set) + } + set.add(handler as EventHandler) + return () => set.delete(handler as EventHandler) + } + + emit(event: K, payload: Events[K]): void { + const set = this.handlers.get(event) + if (!set) return + for (const handler of [...set]) { + ;(handler as EventHandler)(payload) + } + } +} diff --git a/src/core/metrics.ts b/src/core/metrics.ts new file mode 100644 index 0000000..61529e5 --- /dev/null +++ b/src/core/metrics.ts @@ -0,0 +1,183 @@ +import type { GameConfig } from '../config/gameConfig' + +/** + * Масштабированные размеры для текущего окна. + * Раньше это был глобальный объект S, наполняемый recalc() через циклы по строковым ключам. + */ +export type Metrics = { + width: number + height: number + // геометрия сцены + gridSize: number + enemyPaddingX: number + enemyAlienOffset: number + armorBarPad: number + armorBarFont: number + missTargetPadX: number + missTargetPadY: number + missTargetYRange: number + inputAreaHeight: number + removeZone: number + shooterOffset: number + playerY: number + playerRadius: number + playerGlow: number + // враги + enemySimpleHeight: number + enemyHeavyHeight: number + enemySimpleFont: number + enemyHeavyFont: number + enemyArmorBarHeight: number + enemyArmorBarOffset: number + enemyArmorLabelOffset: number + enemyGlow: number + enemyFlashGlow: number + enemyLineWidth: number + enemyPulseRate: number + enemyAlphaBase: number + enemyAlphaRange: number + enemyGlowPulse: number + bossFontSizeFactor: number + bossHeightFactor: number + bossWordGap: number + flashDuration: number + // снаряды + projectileSpeed: number + projectileGlow: number + projectileHitRadius: number + projectileMissRadius: number + projectileFont: number + projectileCircleRadius: number + projectileCircleAlpha: number + projectileTrailLength: number + projectileTrailDecay: number + projectileTrailAlpha: number + trailSizeBase: number + trailSizeInc: number + trailGlow: number + // частицы + particleVel: number + particleSizeMin: number + particleSizeMax: number + particleGlow: number + particleDecayBase: number + particleDecayRange: number + particleDamping: number + particleHit: number + particleMiss: number + particleDestroySimple: number + particleDestroyHeavy: number + particleArmorBreak: number + // тряска / волны / ввод + shakeHit: number + shakeMiss: number + shakeDamage: number + shakeHitDuration: number + shakeMissDuration: number + shakeDamageDuration: number + wavePauseFont: number + wavePauseFrames: number + gridSpeed: number + inputShootDelayMs: number + glitchDurationMs: number +} + +export class MetricsStore { + current: Metrics + + constructor(private readonly config: GameConfig) { + this.current = computeMetrics(config, window.innerWidth, window.innerHeight) + } + + rebuild(width: number, height: number): void { + this.current = computeMetrics(this.config, width, height) + } +} + +export function computeMetrics(config: GameConfig, width: number, height: number): Metrics { + const V = config.visual + const R = config.ratio + const C = config.counts + const S = config.speed + const T = config.timing + + const vw = (value: number) => Math.round((value * width) / 1000) + const vh = (value: number) => Math.round((value * height) / 1000) + + return { + width, + height, + gridSize: vw(V.gridSize), + enemyPaddingX: vw(V.enemyPaddingX), + enemyAlienOffset: vw(V.enemyAlienOffset), + armorBarPad: vw(V.armorBarPad), + armorBarFont: vw(V.armorBarFont), + missTargetPadX: vw(V.missTargetPadX), + missTargetPadY: vh(V.missTargetPadY), + missTargetYRange: R.missTargetYRange, + inputAreaHeight: vh(V.inputAreaHeight), + removeZone: vh(V.removeZone), + shooterOffset: vh(V.shooterOffset), + playerY: vh(V.playerY), + playerRadius: vh(V.playerRadius), + playerGlow: vh(V.playerGlow), + enemySimpleHeight: vh(V.enemySimpleHeight), + enemyHeavyHeight: vh(V.enemyHeavyHeight), + enemySimpleFont: vh(V.enemySimpleFont), + enemyHeavyFont: vh(V.enemyHeavyFont), + enemyArmorBarHeight: vh(V.enemyArmorBarHeight), + enemyArmorBarOffset: vh(V.enemyArmorBarOffset), + enemyArmorLabelOffset: vh(V.enemyArmorLabelOffset), + enemyGlow: vh(V.enemyGlow), + enemyFlashGlow: vh(V.enemyFlashGlow), + enemyLineWidth: vh(V.enemyLineWidth), + enemyPulseRate: R.enemyPulseRate, + enemyAlphaBase: R.enemyAlphaBase, + enemyAlphaRange: R.enemyAlphaRange, + enemyGlowPulse: R.enemyGlowPulse, + bossFontSizeFactor: R.bossFontSizeFactor, + bossHeightFactor: R.bossHeightFactor, + bossWordGap: R.bossWordGap, + flashDuration: C.flashDuration, + projectileSpeed: height / (S.projectileCrossTime * 60), + projectileGlow: vh(V.projectileGlow), + projectileHitRadius: vh(V.projectileHitRadius), + projectileMissRadius: vh(V.projectileMissRadius), + projectileFont: vh(V.projectileFont), + projectileCircleRadius: vh(V.projectileCircleRadius), + projectileCircleAlpha: R.projectileCircleAlpha, + projectileTrailLength: R.projectileTrailLength, + projectileTrailDecay: R.projectileTrailDecay, + projectileTrailAlpha: R.projectileTrailAlpha, + trailSizeBase: vh(V.trailSizeBase), + trailSizeInc: vh(V.trailSizeInc), + trailGlow: vh(V.trailGlow), + particleVel: vh(V.particleVel), + particleSizeMin: vh(V.particleSizeMin), + particleSizeMax: vh(V.particleSizeMax), + particleGlow: vh(V.particleGlow), + particleDecayBase: R.particleDecayBase, + particleDecayRange: R.particleDecayRange, + particleDamping: R.particleDamping, + particleHit: C.particleHit, + particleMiss: C.particleMiss, + particleDestroySimple: C.particleDestroySimple, + particleDestroyHeavy: C.particleDestroyHeavy, + particleArmorBreak: C.particleArmorBreak, + shakeHit: vh(V.shakeHit), + shakeMiss: vh(V.shakeMiss), + shakeDamage: vh(V.shakeDamage), + shakeHitDuration: C.shakeHitDuration, + shakeMissDuration: C.shakeMissDuration, + shakeDamageDuration: C.shakeDamageDuration, + wavePauseFont: vh(V.wavePauseFont), + wavePauseFrames: C.wavePauseFrames, + gridSpeed: (S.gridSpeed * height) / 1000, + inputShootDelayMs: T.inputShootDelay, + glitchDurationMs: T.glitchDuration, + } +} + +export function framesFromMs(ms: number): number { + return ms / (1000 / 60) +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..4fa7e0f --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,9 @@ +/** Игрок — абстракция над источником ввода (TMI, Twurple, локальная клавиатура). */ +export type Player = { + /** Стабильный идентификатор (для чата — логин, для Twurple — user id). */ + id: string + /** Отображаемое имя для статистики. */ + name: string +} + +export type InputOrigin = 'local' | 'chat' diff --git a/src/ecs/components.ts b/src/ecs/components.ts new file mode 100644 index 0000000..6c6baf1 --- /dev/null +++ b/src/ecs/components.ts @@ -0,0 +1,45 @@ +import type { Player } from '../core/types' + +export type Position = { x: number; y: number } + +/** Скорость в пикселях за кадр (60 FPS-нормированный кадр). */ +export type Velocity = { x: number; y: number } + +export type Damping = { factor: number } + +/** Затухание частиц: remaining -= decay * dt. */ +export type Lifetime = { remaining: number; decay: number } + +export type EnemyKind = 'simple' | 'heavy' | 'boss' + +/** + * Броня врага — слои слов. Отображается и уязвима только текущий слой. + * simple — 1 слой из одного слова; heavy — 2 слоя по одному слову (порядок строгий); + * boss — 3 слоя по 3 слова (внутри слоя порядок свободный). + */ +export type Armor = { + layers: string[][] + layerIndex: number + /** Убитые слова текущего слоя. */ + killed: string[] +} + +export type EnemyBox = { width: number; height: number } + +export type ProjectilePayload = { word: string } + +export type ParticleVisual = { size: number; color: string } + +export type TargetPoint = { x: number; y: number } + +export type WaveBanner = { text: string; isBoss: boolean } + +export type WaveState = { + wave: number + enemiesLeft: number + spawnTimer: number + waveTimer: number + pauseActive: boolean + pauseTimer: number + banner: WaveBanner | null +} diff --git a/src/ecs/spawn.ts b/src/ecs/spawn.ts new file mode 100644 index 0000000..e3153de --- /dev/null +++ b/src/ecs/spawn.ts @@ -0,0 +1,170 @@ +import { gameConfig } from '../config/gameConfig' +import type { WordDictionary } from '../config/words' +import type { TextMeasurer } from '../core/CanvasTextMeasurer' +import { framesFromMs, type Metrics } from '../core/metrics' +import type { Player } from '../core/types' +import type { EnemyKind, TargetPoint } from './components' +import type { GameEntity, GameWorld } from './world' + +/** Отступ текста внутри рамки врага (в старом коде — фиксированные 24px). */ +const ENEMY_BOX_PAD = 24 + +export type SpawnDependencies = { + words: WordDictionary + measurer: TextMeasurer +} + +/** Время пересечения экрана врагом (сек) с разгоном по волнам. */ +export function enemyCrossTime(kind: 'slow' | 'fast' | 'boss', wave: number): number { + const S = gameConfig.speed + if (kind === 'slow') { + const t = Math.min(wave / S.slowEnemyCrossTimeCapWave, 1) + return S.slowEnemyCrossTimeStart - (S.slowEnemyCrossTimeStart - S.slowEnemyCrossTimeCap) * t + } + if (kind === 'fast') { + const t = Math.min(wave / S.fastEnemyCrossTimeCapWave, 1) + return S.fastEnemyCrossTimeStart - (S.fastEnemyCrossTimeStart - S.fastEnemyCrossTimeCap) * t + } + return S.bossCrossTime +} + +export function crossTimeToSpeed(crossTimeSec: number, metrics: Metrics): number { + const playHeight = metrics.height - metrics.removeZone + return playHeight / (crossTimeSec * 60) +} + +function armorLayers(words: WordDictionary, kind: EnemyKind): string[][] { + if (kind === 'boss') { + return Array.from({ length: gameConfig.game.bossLayers }, () => + Array.from({ length: gameConfig.game.bossWordsPerLayer }, () => words.simple()), + ) + } + if (kind === 'heavy') { + return words.heavyPair().map((word) => [word]) + } + return [[words.simple()]] +} + +function enemyWidth( + kind: EnemyKind, + layers: string[][], + metrics: Metrics, + measurer: TextMeasurer, +): number { + if (kind === 'boss') { + const font = Math.round(metrics.enemyHeavyFont * metrics.bossFontSizeFactor) + return Math.max( + ...layers.map((layer) => { + const textWidth = layer.reduce((sum, word) => sum + measurer.measure(word, font, true), 0) + return textWidth + (layer.length - 1) * gameConfig.ratio.bossWordGap + }), + ) + } + const font = kind === 'heavy' ? metrics.enemyHeavyFont : metrics.enemySimpleFont + return Math.max(...layers.flat().map((word) => measurer.measure(word, font, true))) +} + +export function enemyHeight(kind: EnemyKind, metrics: Metrics): number { + if (kind === 'boss') return Math.round(metrics.enemyHeavyHeight * metrics.bossHeightFactor) + return kind === 'heavy' ? metrics.enemyHeavyHeight : metrics.enemySimpleHeight +} + +export function spawnEnemy( + world: GameWorld, + deps: SpawnDependencies, + kind: EnemyKind, + metrics: Metrics, + wave: number, +): GameEntity { + const layers = armorLayers(deps.words, kind) + const width = Math.round(enemyWidth(kind, layers, metrics, deps.measurer)) + ENEMY_BOX_PAD + const height = enemyHeight(kind, metrics) + const speed = crossTimeToSpeed(enemyCrossTime(kind === 'boss' ? 'boss' : kind === 'heavy' ? 'slow' : 'fast', wave), metrics) + + const minX = metrics.enemyPaddingX + width / 2 + const maxX = metrics.width - metrics.enemyPaddingX - width / 2 + const spawnX = minX + Math.random() * Math.max(0, maxX - minX) + + return world.add({ + enemyKind: kind, + enemyBox: { width, height }, + armor: { layers, layerIndex: 0, killed: [] }, + position: { x: spawnX, y: 0 }, + velocity: { x: 0, y: speed }, + }) +} + +export function missTargetPoint(metrics: Metrics): TargetPoint { + return { + x: metrics.missTargetPadX + Math.random() * (metrics.width - 2 * metrics.missTargetPadX), + y: metrics.missTargetPadY + Math.random() * metrics.height * metrics.missTargetYRange, + } +} + +export function spawnProjectile( + world: GameWorld, + word: string, + target: GameEntity | null, + player: Player, + metrics: Metrics, +): GameEntity { + const entity: GameEntity = { + projectile: { word }, + player, + position: { x: metrics.width / 2, y: metrics.height - metrics.shooterOffset }, + velocity: { x: 0, y: 0 }, + fuse: framesFromMs(metrics.inputShootDelayMs), + } + if (target) { + entity.target = target + } else { + entity.targetPoint = missTargetPoint(metrics) + } + return world.add(entity) +} + +export function spawnParticleBurst( + world: GameWorld, + metrics: Metrics, + x: number, + y: number, + color: string, + count: number, +): void { + for (let i = 0; i < count; i++) { + world.add({ + position: { x, y }, + velocity: { + x: (Math.random() - 0.5) * metrics.particleVel, + y: (Math.random() - 0.5) * metrics.particleVel, + }, + damping: { factor: metrics.particleDamping }, + lifetime: { + remaining: 1, + decay: metrics.particleDecayBase + Math.random() * metrics.particleDecayRange, + }, + particle: { + size: metrics.particleSizeMin + Math.random() * (metrics.particleSizeMax - metrics.particleSizeMin), + color, + }, + }) + } +} + +export function createWaveState(world: GameWorld, wave: number, enemiesLeft: number): GameEntity { + return world.add({ + waveState: { + wave, + enemiesLeft, + spawnTimer: 0, + waveTimer: 0, + pauseActive: false, + pauseTimer: 0, + banner: null, + }, + }) +} + +export function enemiesPerWave(wave: number): number { + return gameConfig.game.enemiesPerWaveBase + (wave - 1) * gameConfig.game.enemiesPerWaveInc +} diff --git a/src/ecs/world.ts b/src/ecs/world.ts new file mode 100644 index 0000000..6b96c26 --- /dev/null +++ b/src/ecs/world.ts @@ -0,0 +1,46 @@ +import { World } from 'miniplex' +import type { + Armor, + Damping, + EnemyBox, + EnemyKind, + Lifetime, + ParticleVisual, + Position, + ProjectilePayload, + TargetPoint, + Velocity, + WaveState, +} from './components' +import type { Player } from '../core/types' + +/** + * Сущность — набор опциональных компонентов. Наличие компонента определяется + * тем, что свойство не undefined (так работает miniplex), поэтому «нет компонента» + * означает «свойство не установлено». + */ +export type GameEntity = { + position?: Position + velocity?: Velocity + damping?: Damping + lifetime?: Lifetime + enemyKind?: EnemyKind + enemyBox?: EnemyBox + armor?: Armor + projectile?: ProjectilePayload + player?: Player + /** Цель наведения (только для снарядов-попаданий). */ + target?: GameEntity + /** Точка полёта мимо (только для снарядов-промахов). */ + targetPoint?: TargetPoint + /** Кадры до «выстрела» (задержка ввода). */ + fuse?: number + particle?: ParticleVisual + waveState?: WaveState +} + +export type GameWorld = World + +export function createGameWorld(): GameWorld { + return new World() +} diff --git a/src/entries/game.ts b/src/entries/game.ts new file mode 100644 index 0000000..58ba9a0 --- /dev/null +++ b/src/entries/game.ts @@ -0,0 +1,8 @@ +import { bootstrapGame } from '../app/GameApp' + +const canvas = document.getElementById('game-canvas') as HTMLCanvasElement | null +if (!canvas) throw new Error('Shooting Word: не найден #game-canvas') + +bootstrapGame(canvas).catch((error) => { + console.error('Shooting Word: ошибка запуска', error) +}) diff --git a/src/entries/index.ts b/src/entries/index.ts new file mode 100644 index 0000000..5225604 --- /dev/null +++ b/src/entries/index.ts @@ -0,0 +1,60 @@ +function main( + input: HTMLInputElement, + copyBtn: HTMLElement, + openBtn: HTMLElement, + linkEl: HTMLElement, + singlePlayEl: HTMLInputElement, +): void { + function buildUrl(): string { + const name = input.value.trim().toLowerCase() || 'Ku6ep_XBOCTuK' + const single = singlePlayEl.checked ? '1' : '0' + return `${location.origin}${location.pathname.replace(/\/[^/]*$/, '/')}game?channel=${encodeURIComponent(name)}&singlePlay=${single}` + } + + function updateLink(): void { + linkEl.textContent = buildUrl() + linkEl.className = '' + } + + function copyLink(): void { + navigator.clipboard + .writeText(buildUrl()) + .then(() => { + linkEl.textContent = 'СКОПИРОВАНО!' + linkEl.className = 'copied' + setTimeout(updateLink, 1500) + }) + .catch((error) => console.error('Shooting Word: не удалось скопировать ссылку', error)) + } + + function openLink(): void { + window.open(buildUrl(), '_blank') + } + + input.addEventListener('input', updateLink) + singlePlayEl.addEventListener('change', updateLink) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') copyLink() + }) + + copyBtn.addEventListener('click', copyLink) + openBtn.addEventListener('click', openLink) + + updateLink() + + document.querySelector('.hint-toggle')?.addEventListener('click', () => { + document.querySelector('.settings-hints')?.classList.toggle('hidden') + }) +} + +const input = document.getElementById('channel-input') as HTMLInputElement | null +const copyBtn = document.getElementById('copy-btn') +const openBtn = document.getElementById('open-btn') +const linkEl = document.getElementById('game-link') +const singlePlay = document.getElementById('opt-single-play') as HTMLInputElement | null + +if (!input || !copyBtn || !openBtn || !linkEl || !singlePlay) { + throw new Error('Shooting Word: страница настроек повреждена (не найдены элементы формы)') +} + +main(input, copyBtn, openBtn, linkEl, singlePlay) diff --git a/src/game/Enemy.ts b/src/game/Enemy.ts new file mode 100644 index 0000000..bd49e53 --- /dev/null +++ b/src/game/Enemy.ts @@ -0,0 +1,104 @@ +export type EnemyKind = 'simple' | 'heavy' | 'boss' + +/** + * Состояние врага: позиция, слова и урон. Ничего не знает про рендер и ввод. + * simple — одно слово; heavy — пара слов по порядку; boss — слои слов в произвольном порядке. + */ +export class Enemy { + x: number + y = 0 + readonly speed: number + readonly width: number + readonly height: number + pulse = 0 + flashTimer = 0 + + private layerIndex = 0 + /** Прогресс внутри текущего слоя для simple/heavy (слова уничтожаются по порядку). */ + private wordProgress = 0 + private readonly killedInLayer = new Set() + + constructor( + readonly kind: EnemyKind, + /** simple/heavy: единственный слой с последовательностью слов. boss: слои слов. */ + readonly layers: string[][], + spawnX: number, + speed: number, + width: number, + height: number, + private readonly flashDurationFrames: number, + ) { + this.x = spawnX + this.speed = speed + this.width = width + this.height = height + } + + /** Слой слов, который нужно уничтожить боссу (null — не босс). */ + get bossLayer(): string[] | null { + return this.kind === 'boss' ? (this.layers[this.layerIndex] ?? null) : null + } + + /** Текущее отображаемое слово (для simple/heavy). */ + get label(): string | null { + return this.kind === 'boss' ? null : (this.layers[this.layerIndex]?.[this.wordProgress] ?? null) + } + + /** Слово доступно для попадания. */ + acceptsWord(word: string): boolean { + if (this.kind === 'boss') { + const layer = this.bossLayer + return !!layer && layer.includes(word) && !this.killedInLayer.has(word) + } + return this.label === word + } + + /** Сколько слов осталось в текущем слое (для полоски брони). */ + remainingInLayer(): number { + if (this.kind === 'boss') { + const layer = this.bossLayer + return layer ? layer.length - this.killedInLayer.size : 0 + } + const words = this.layers[this.layerIndex] + return words ? words.length - this.wordProgress : 0 + } + + totalLayers(): number { + return this.layers.length + } + + currentLayerIndex(): number { + return this.layerIndex + } + + killedWordsInLayer(): ReadonlySet { + return this.killedInLayer + } + + /** Применяет попадание словом. Возвращает true, если враг уничтожен. */ + hit(word: string): boolean { + this.flashTimer = this.flashDurationFrames + if (this.kind === 'boss') { + this.killedInLayer.add(word) + const layer = this.bossLayer + if (layer && layer.every((w) => this.killedInLayer.has(w))) { + this.layerIndex++ + this.killedInLayer.clear() + return this.layerIndex >= this.layers.length + } + return false + } + const next = this.wordProgress + 1 + if (next >= (this.layers[this.layerIndex]?.length ?? 0)) { + return true + } + this.wordProgress = next + return false + } + + update(deltaFrames: number, pulseRate: number): void { + this.y += this.speed * deltaFrames + this.pulse += pulseRate * deltaFrames + if (this.flashTimer > 0) this.flashTimer -= deltaFrames + } +} diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts new file mode 100644 index 0000000..b6238f1 --- /dev/null +++ b/src/game/GameStore.ts @@ -0,0 +1,38 @@ +import { gameConfig } from '../config/gameConfig' +import { PlayerStats } from './PlayerStats' + +export type GamePhase = 'start' | 'playing' | 'gameover' + +/** + * Скалярное состояние партии (счёт, жизни, фаза). По договорённости — не сущности, + * как и в старом плане рефакторинга: это переменные партии, а не объекты мира. + */ +export class GameStore { + phase: GamePhase = 'start' + score = gameConfig.game.startScore + lives = gameConfig.game.startLives + readonly stats = new PlayerStats() + + /** Экранная тряска — тоже скаляр, но её чистит симуляция каждый кадр. */ + readonly shake = { timer: 0, amount: 0 } + + reset(): void { + this.phase = 'playing' + this.score = gameConfig.game.startScore + this.lives = gameConfig.game.startLives + this.stats.reset() + this.shake.timer = 0 + this.shake.amount = 0 + } + + triggerShake(amount: number, durationFrames: number): void { + this.shake.amount = amount + this.shake.timer = durationFrames + } + + updateShake(deltaFrames: number): void { + if (this.shake.timer <= 0) return + this.shake.timer -= deltaFrames + this.shake.amount *= Math.pow(0.9, deltaFrames) + } +} diff --git a/src/game/PlayerStats.ts b/src/game/PlayerStats.ts new file mode 100644 index 0000000..4268a8e --- /dev/null +++ b/src/game/PlayerStats.ts @@ -0,0 +1,44 @@ +import type { Player } from '../core/types' +import type { PlayerStatsSnapshot } from './events' + +/** Статистика зрителей: убийства и промахи по именам. */ +export class PlayerStats { + private readonly entries = new Map() + + reset(): void { + this.entries.clear() + } + + addKill(player: Player): void { + this.entry(player).kills++ + } + + addMiss(player: Player): void { + this.entry(player).misses++ + } + + /** Топ-3 по убийствам и главный «Мазила» по промахам. */ + snapshot(): PlayerStatsSnapshot { + const names = [...this.entries.keys()] + const byKills = names.slice().sort((a, b) => this.entries.get(b)!.kills - this.entries.get(a)!.kills) + const top = byKills.slice(0, 3).map((name) => ({ name, kills: this.entries.get(name)!.kills })) + + let mazila: PlayerStatsSnapshot['mazila'] = null + for (const name of names) { + const { misses } = this.entries.get(name)! + if (misses > 0 && (!mazila || misses > mazila.misses)) { + mazila = { name, misses } + } + } + return { top, mazila } + } + + private entry(player: Player): { kills: number; misses: number } { + let entry = this.entries.get(player.name) + if (!entry) { + entry = { kills: 0, misses: 0 } + this.entries.set(player.name, entry) + } + return entry + } +} diff --git a/src/game/Simulation.ts b/src/game/Simulation.ts new file mode 100644 index 0000000..4aad315 --- /dev/null +++ b/src/game/Simulation.ts @@ -0,0 +1,140 @@ +import { gameConfig } from '../config/gameConfig' +import { + createWaveState, + enemiesPerWave, + spawnParticleBurst, + spawnProjectile, +} from '../ecs/spawn' +import type { EnemyKind } from '../ecs/components' +import type { GameEntity } from '../ecs/world' +import type { Player } from '../core/types' +import type { SystemContext } from '../systems/context' +import { cullSystem } from '../systems/cullSystem' +import { escapeSystem } from '../systems/escapeSystem' +import { fuseSystem } from '../systems/fuseSystem' +import { homingSystem } from '../systems/homingSystem' +import { integrationSystem } from '../systems/integrationSystem' +import { lifetimeSystem } from '../systems/lifetimeSystem' +import { missSystem } from '../systems/missSystem' +import { waveSystem } from '../systems/waveSystem' + +function scoreByKind(kind: EnemyKind): number { + if (kind === 'boss') return gameConfig.game.scoreBoss + return kind === 'heavy' ? gameConfig.game.scoreHeavy : gameConfig.game.scoreSimple +} + +function colorByKind(kind: EnemyKind): string { + if (kind === 'boss') return gameConfig.colors.boss + return kind === 'heavy' ? gameConfig.colors.heavy : gameConfig.colors.primary +} + +function destroyParticleCount(kind: EnemyKind, metrics: SystemContext['metrics']['current']): number { + if (kind === 'boss') return metrics.particleDestroyHeavy * 2 + return kind === 'heavy' ? metrics.particleDestroyHeavy : metrics.particleDestroySimple +} + +/** + * Симуляция: фиксированный порядок систем за кадр + обработка урона. + * Ничего не знает про рендер и DOM — только мир, стор и шина событий. + */ +export class Simulation { + constructor(private readonly ctx: SystemContext) { + ctx.bus.on('projectile:hit', ({ target, word, player }) => this.applyHit(target, word, player)) + ctx.bus.on('projectile:miss', ({ x, y, player }) => this.applyMiss(x, y, player)) + ctx.bus.on('enemy:escaped', ({ kind }) => this.takeDamage()) + } + + /** Сброс мира и состояния партии; вызывается по команде «старт». */ + start(): void { + const { world, store, bus } = this.ctx + world.clear() + createWaveState(world, gameConfig.game.startWave, enemiesPerWave(gameConfig.game.startWave)) + store.reset() + bus.emit('game:started', {}) + } + + submitWord(word: string, player: Player): void { + if (this.ctx.store.phase !== 'playing') return + const target = this.findEnemyByWord(word) + spawnProjectile(this.ctx.world, word, target, player, this.ctx.metrics.current) + } + + update(delta: number): void { + if (this.ctx.store.phase !== 'playing') return + const ctx = this.ctx + fuseSystem(ctx, delta) + homingSystem(ctx) + missSystem(ctx) + waveSystem(ctx, delta) + integrationSystem(ctx, delta) + cullSystem(ctx) + escapeSystem(ctx) + lifetimeSystem(ctx, delta) + ctx.store.updateShake(delta) + } + + /** Первый враг, чей текущий слой содержит слово (порядок спавна — как в оригинале). */ + private findEnemyByWord(word: string): GameEntity | null { + for (const entity of this.ctx.world.with('armor')) { + const armor = entity.armor as { layers: string[][]; layerIndex: number; killed: string[] } + const layer = armor.layers[armor.layerIndex] + if (layer && layer.includes(word) && !armor.killed.includes(word)) return entity + } + return null + } + + private applyHit(target: GameEntity, word: string, player: Player): void { + const { store, bus, world, metrics } = this.ctx + if (store.phase !== 'playing') return + const armor = target.armor + if (!armor || !target.position || !target.enemyKind) return + const kind = target.enemyKind + const { x, y } = target.position + + armor.killed.push(word) + bus.emit('enemy:hit', { entity: target }) + + const layer = armor.layers[armor.layerIndex] + const layerComplete = !!layer && armor.killed.length >= layer.length + if (layerComplete) { + armor.layerIndex++ + armor.killed = [] + } + const destroyed = layerComplete && armor.layerIndex >= armor.layers.length + + if (destroyed) { + store.score += scoreByKind(kind) + store.stats.addKill(player) + bus.emit('score:changed', { score: store.score }) + bus.emit('enemy:destroyed', { entity: target, x, y, kind }) + spawnParticleBurst(world, metrics.current, x, y, colorByKind(kind), destroyParticleCount(kind, metrics.current)) + world.remove(target) + } else { + // попадание в броню: слой держится + spawnParticleBurst(world, metrics.current, x, y, colorByKind(kind), metrics.current.particleArmorBreak) + store.triggerShake(metrics.current.shakeHit, metrics.current.shakeHitDuration) + } + } + + private applyMiss(x: number, y: number, player: Player): void { + const { store, world, metrics } = this.ctx + if (store.phase !== 'playing') return + store.stats.addMiss(player) + store.triggerShake(metrics.current.shakeMiss, metrics.current.shakeMissDuration) + spawnParticleBurst(world, metrics.current, x, y, gameConfig.colors.miss, metrics.current.particleMiss) + } + + private takeDamage(): void { + const { store, bus, metrics } = this.ctx + if (store.phase !== 'playing') return + store.lives-- + bus.emit('player:damaged', { lives: store.lives }) + store.triggerShake(metrics.current.shakeDamage, metrics.current.shakeDamageDuration) + bus.emit('effect:glitch', { durationMs: metrics.current.glitchDurationMs }) + + if (store.lives <= 0) { + store.phase = 'gameover' + bus.emit('game:over', { finalScore: store.score, stats: store.stats.snapshot() }) + } + } +} diff --git a/src/game/events.ts b/src/game/events.ts new file mode 100644 index 0000000..3002499 --- /dev/null +++ b/src/game/events.ts @@ -0,0 +1,30 @@ +import type { InputOrigin, Player } from '../core/types' +import type { EnemyKind } from '../ecs/components' +import type { GameEntity } from '../ecs/world' + +/** Все события приложения. Логика издаёт, рендер и DOM-UI слушают. */ +export type GameEvents = { + // ввод + 'input:start': { origin: InputOrigin } + 'input:word': { word: string; player: Player; origin: InputOrigin } + 'input:cleared': Record + // бой + 'projectile:hit': { entity: GameEntity; target: GameEntity; word: string; player: Player } + 'projectile:miss': { x: number; y: number; player: Player } + 'enemy:hit': { entity: GameEntity } + 'enemy:destroyed': { entity: GameEntity; x: number; y: number; kind: EnemyKind } + 'enemy:escaped': { x: number; y: number; kind: EnemyKind } + // состояние игры + 'game:started': Record + 'game:over': { finalScore: number; stats: PlayerStatsSnapshot } + 'score:changed': { score: number } + 'wave:changed': { wave: number } + 'player:damaged': { lives: number } + // эффекты (не сущности) + 'effect:glitch': { durationMs: number } +} + +export type PlayerStatsSnapshot = { + top: { name: string; kills: number }[] + mazila: { name: string; misses: number } | null +} diff --git a/src/input/InputRouter.ts b/src/input/InputRouter.ts new file mode 100644 index 0000000..0297af4 --- /dev/null +++ b/src/input/InputRouter.ts @@ -0,0 +1,36 @@ +import type { EventBus } from '../core/eventBus' +import type { GameEvents } from '../game/events' +import type { InputCommand, InputSource } from './InputSource' + +/** Регистрирует источники ввода и переносит их команды на шину событий. */ +export class InputRouter { + constructor( + private readonly sources: InputSource[], + private readonly bus: EventBus, + ) {} + + start(): void { + for (const source of this.sources) { + source.onCommand((command) => this.forward(source.origin, command)) + source.start() + } + } + + stop(): void { + for (const source of this.sources) source.stop() + } + + private forward(origin: InputSource['origin'], command: InputCommand): void { + switch (command.kind) { + case 'start': + this.bus.emit('input:start', { origin }) + break + case 'word': + this.bus.emit('input:word', { word: command.word, player: command.player, origin }) + break + case 'clear': + this.bus.emit('input:cleared', {}) + break + } + } +} diff --git a/src/input/InputSource.ts b/src/input/InputSource.ts new file mode 100644 index 0000000..2ad80d5 --- /dev/null +++ b/src/input/InputSource.ts @@ -0,0 +1,32 @@ +import type { InputOrigin, Player } from '../core/types' + +/** + * Команда ввода — то, что игра понимает. Источники транслируют свои события + * (сообщения чата, награды за баллы канала, клавиатуру) в эти команды. + */ +export type InputCommand = + | { kind: 'start' } + | { kind: 'clear' } + | { kind: 'word'; word: string; player: Player } + +/** + * Порт ввода. Игра зависит только от этого интерфейса, а не от конкретной + * библиотеки: сегодня это tmi.js-чат и локальная клавиатура, завтра — + * Twurple (chat + channel point rewards) — достаточно реализовать интерфейс + * и зарегистрировать источник в InputRouter. + */ +export interface InputSource { + readonly origin: InputOrigin + start(): void + stop(): void + onCommand(handler: (command: InputCommand) => void): () => void +} + +/** Источник с набираемым текстом (локальная клавиатура). */ +export interface LocalBufferSource extends InputSource { + onBuffer(handler: (text: string) => void): () => void +} + +export function firstToken(message: string): string { + return message.trim().toLowerCase().split(/\s+/)[0] ?? '' +} diff --git a/src/input/sources/LocalKeyboardInputSource.ts b/src/input/sources/LocalKeyboardInputSource.ts new file mode 100644 index 0000000..c94ff6b --- /dev/null +++ b/src/input/sources/LocalKeyboardInputSource.ts @@ -0,0 +1,75 @@ +import { gameConfig } from '../../config/gameConfig' +import type { Player } from '../../core/types' +import type { InputCommand, LocalBufferSource } from '../InputSource' +import { firstToken } from '../InputSource' + +/** Тестовый источник без Twitch: локальная клавиатура, как в оригинале. */ +export class LocalKeyboardInputSource implements LocalBufferSource { + readonly origin = 'local' as const + private buffer = '' + private attached = false + private readonly commandHandlers = new Set<(command: InputCommand) => void>() + private readonly bufferHandlers = new Set<(text: string) => void>() + + constructor(private readonly channelName: string) {} + + start(): void { + if (this.attached) return + window.addEventListener('keydown', this.onKeyDown) + this.attached = true + } + + stop(): void { + if (!this.attached) return + window.removeEventListener('keydown', this.onKeyDown) + this.attached = false + } + + onCommand(handler: (command: InputCommand) => void): () => void { + this.commandHandlers.add(handler) + return () => this.commandHandlers.delete(handler) + } + + onBuffer(handler: (text: string) => void): () => void { + this.bufferHandlers.add(handler) + return () => this.bufferHandlers.delete(handler) + } + + private onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Backspace') { + event.preventDefault() + this.buffer = this.buffer.slice(0, -1) + this.emitBuffer() + } else if (event.key === 'Enter') { + event.preventDefault() + const line = this.buffer + this.buffer = '' + this.submit(line) + } else if (event.key === 'Escape') { + this.buffer = '' + this.emitCommand({ kind: 'clear' }) + } else if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) { + this.buffer += event.key + this.emitBuffer() + } + } + + private submit(line: string): void { + const word = firstToken(line) + if (!word) return + if (word === gameConfig.commands.play) { + this.emitCommand({ kind: 'start' }) + return + } + const player: Player = { id: 'local', name: this.channelName } + this.emitCommand({ kind: 'word', word, player }) + } + + private emitCommand(command: InputCommand): void { + for (const handler of [...this.commandHandlers]) handler(command) + } + + private emitBuffer(): void { + for (const handler of [...this.bufferHandlers]) handler(this.buffer) + } +} diff --git a/src/input/sources/TwitchChatInputSource.ts b/src/input/sources/TwitchChatInputSource.ts new file mode 100644 index 0000000..9c441cb --- /dev/null +++ b/src/input/sources/TwitchChatInputSource.ts @@ -0,0 +1,51 @@ +import * as tmi from 'tmi.js' +import { gameConfig } from '../../config/gameConfig' +import type { Player } from '../../core/types' +import type { InputCommand, InputSource } from '../InputSource' +import { firstToken } from '../InputSource' + +/** + * Twitch-чат через tmi.js. Реализует InputSource — при переходе на Twurple + * заменяется на TwurpleChatInputSource (и дополняется источником наград + * за баллы канала), без изменений в игровой логике. + */ +export class TwitchChatInputSource implements InputSource { + readonly origin = 'chat' as const + private client: tmi.Client | null = null + private readonly commandHandlers = new Set<(command: InputCommand) => void>() + + constructor(private readonly channel: string) {} + + start(): void { + if (this.client) return + const client = new tmi.Client({ channels: [this.channel] }) + client.on('message', this.onMessage) + client.connect().catch((error) => { + console.error('Shooting Word: не удалось подключиться к чату', error) + }) + this.client = client + } + + stop(): void { + if (!this.client) return + this.client.disconnect().catch(() => undefined) + this.client = null + } + + onCommand(handler: (command: InputCommand) => void): () => void { + this.commandHandlers.add(handler) + return () => this.commandHandlers.delete(handler) + } + + private onMessage = (_channel: string, tags: tmi.ChatUserstate, message: string): void => { + const word = firstToken(message) + if (!word) return + const player: Player = { + id: tags.username ?? tags['display-name'] ?? 'anonymous', + name: tags['display-name'] ?? tags.username ?? 'anonymous', + } + const command: InputCommand = + word === gameConfig.commands.play ? { kind: 'start' } : { kind: 'word', word, player } + for (const handler of [...this.commandHandlers]) handler(command) + } +} diff --git a/src/render/BannerLayer.ts b/src/render/BannerLayer.ts new file mode 100644 index 0000000..fe1f07e --- /dev/null +++ b/src/render/BannerLayer.ts @@ -0,0 +1,64 @@ +import { Container, Sprite, Text, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { Metrics } from '../core/metrics' +import type { GameWorld } from '../ecs/world' +import { tintOf } from './textures' + +/** Баннер между волнами («WAVE N INCOMING» / «BOSS INCOMING»). */ +export class BannerLayer { + private readonly text: Text + private readonly glow: Sprite + private lastKey = '' + + constructor(parent: Container, glowTexture: Texture) { + this.glow = new Sprite(glowTexture) + this.glow.anchor.set(0.5) + this.glow.blendMode = 'add' + this.text = new Text({ + text: '', + style: { + fontFamily: ['Courier New', 'monospace'], + fontSize: 36, + fontWeight: 'bold', + fill: gameConfig.colors.primary, + }, + }) + this.text.anchor.set(0.5) + this.text.visible = false + this.text.alpha = 0 + parent.addChild(this.glow) + parent.addChild(this.text) + } + + applyMetrics(metrics: Metrics): void { + this.text.style.fontSize = metrics.wavePauseFont + this.text.position.set(metrics.width / 2, metrics.inputAreaHeight + 40) + this.glow.position.set(metrics.width / 2, metrics.inputAreaHeight + 40) + } + + update(world: GameWorld, metrics: Metrics): void { + const ws = world.with('waveState').first?.waveState + const banner = ws?.banner + if (!ws || !banner || !ws.pauseActive) { + this.text.visible = false + this.glow.visible = false + return + } + + const color = banner.isBoss ? gameConfig.colors.boss : gameConfig.colors.primary + const key = `${banner.text}:${color}` + if (key !== this.lastKey) { + this.lastKey = key + this.text.text = banner.text + this.text.style.fill = color + this.glow.tint = tintOf(color) + } + this.text.visible = true + this.glow.visible = true + this.glow.width = this.text.width + metrics.wavePauseFont * 2 + this.glow.height = metrics.wavePauseFont * 4 + this.glow.alpha = 0.35 + this.glow.blendMode = 'add' + this.text.alpha = ws.pauseTimer > 30 ? 1 : ws.pauseTimer / 30 + } +} diff --git a/src/render/EnemyLayer.ts b/src/render/EnemyLayer.ts new file mode 100644 index 0000000..c431c38 --- /dev/null +++ b/src/render/EnemyLayer.ts @@ -0,0 +1,55 @@ +import { Container, Texture } from 'pixi.js' +import type { TextMeasurer } from '../core/CanvasTextMeasurer' +import type { Metrics } from '../core/metrics' +import type { GameEntity, GameWorld } from '../ecs/world' +import type { GameEvents } from '../game/events' +import type { EventBus } from '../core/eventBus' +import { EnemyView } from './EnemyView' + +/** + * Слой врагов. Медленные враги рисуются поверх быстрых (zIndex по скорости), + * как сортировка по speed в оригинале. + */ +export class EnemyLayer { + private readonly container = new Container() + private readonly views = new Map() + + constructor( + parent: Container, + world: GameWorld, + bus: EventBus, + private metrics: Metrics, + measurer: TextMeasurer, + glowTexture: Texture, + ) { + this.container.sortableChildren = true + parent.addChild(this.container) + + world.onEntityAdded.subscribe((entity) => { + if (!entity.armor) return + const view = new EnemyView(entity, this.metrics, measurer, glowTexture) + view.zIndex = -(entity.velocity?.y ?? 0) + this.views.set(entity, view) + this.container.addChild(view) + }) + world.onEntityRemoved.subscribe((entity) => { + const view = this.views.get(entity) + if (!view) return + this.views.delete(entity) + view.destroyView() + }) + bus.on('enemy:hit', ({ entity }) => { + this.views.get(entity)?.onHit(this.metrics) + }) + } + + update(delta: number, metrics: Metrics, wave: number): void { + this.metrics = metrics + for (const [entity, view] of this.views) view.sync(entity, delta, metrics, wave) + } + + applyMetrics(metrics: Metrics): void { + this.metrics = metrics + for (const view of this.views.values()) view.applyMetrics(metrics) + } +} diff --git a/src/render/EnemyView.ts b/src/render/EnemyView.ts new file mode 100644 index 0000000..e6d51dd --- /dev/null +++ b/src/render/EnemyView.ts @@ -0,0 +1,241 @@ +import { Container, Graphics, Sprite, Text, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { TextMeasurer } from '../core/CanvasTextMeasurer' +import type { Metrics } from '../core/metrics' +import type { Armor, EnemyKind } from '../ecs/components' +import type { GameEntity } from '../ecs/world' +import { tintOf } from './textures' + +function kindColor(kind: EnemyKind): string { + if (kind === 'boss') return gameConfig.colors.boss + return kind === 'heavy' ? gameConfig.colors.heavy : gameConfig.colors.primary +} + +/** + * Вью врага. Вся «визуальная» логика врага живёт здесь: пульс, вспышка при + * попадании, перерисовка слоёв брони. Логика урона — в Simulation. + */ +export class EnemyView extends Container { + private readonly kind: EnemyKind + private readonly glow: Sprite + private readonly hull = new Graphics() + private readonly marks = new Graphics() + private readonly armorBar = new Graphics() + private readonly armorLabel: Text + private wordTexts: Text[] = [] + private pulse = 0 + private flashTimer = 0 + private lastFlash = false + private lastLayerKey = '' + + constructor( + private entity: GameEntity, + private metrics: Metrics, + private readonly measurer: TextMeasurer, + glowTexture: Texture, + ) { + super() + this.kind = entity.enemyKind as EnemyKind + const color = kindColor(this.kind) + + this.glow = new Sprite(glowTexture) + this.glow.anchor.set(0.5) + this.glow.tint = tintOf(color) + this.glow.blendMode = 'add' + this.addChild(this.glow) + + this.addChild(this.hull) + this.addChild(this.marks) + + this.armorLabel = new Text({ + text: '', + style: { fontFamily: ['Courier New', 'monospace'], fontSize: metrics.armorBarFont, fill: color }, + }) + this.armorLabel.anchor.set(0.5) + this.addChild(this.armorLabel) + + this.addChild(this.armorBar) + this.rebuildWords() + this.drawHull(false) + this.drawArmorBar() + } + + /** Вспышка при попадании — визуальная реакция на событие. */ + onHit(metrics: Metrics): void { + this.flashTimer = metrics.flashDuration + } + + applyMetrics(metrics: Metrics): void { + this.metrics = metrics + this.armorLabel.style.fontSize = metrics.armorBarFont + this.lastLayerKey = '' + this.rebuildWords() + this.drawHull(this.lastFlash) + this.drawArmorBar() + } + + sync(entity: GameEntity, delta: number, metrics: Metrics, wave: number): void { + this.entity = entity + this.metrics = metrics + const position = entity.position as { x: number; y: number } + this.position.set(position.x, position.y) + + this.pulse += metrics.enemyPulseRate * delta + if (this.flashTimer > 0) this.flashTimer -= delta + const flash = this.flashTimer > 0 + + const boxWidth = (entity.enemyBox as { width: number }).width + const boxHeight = (entity.enemyBox as { height: number }).height + const glowPad = (flash ? metrics.enemyFlashGlow : metrics.enemyGlow) * 3 + this.glow.width = boxWidth + glowPad * 2 + this.glow.height = boxHeight + glowPad * 2 + this.glow.alpha = flash ? 0.9 : 0.32 + Math.sin(this.pulse * 2) * 0.12 + + if (flash !== this.lastFlash) { + this.lastFlash = flash + this.drawHull(flash) + this.drawArmorBar() + } + + const armor = entity.armor as Armor + const layerKey = `${armor.layerIndex}:${armor.killed.join(',')}` + if (layerKey !== this.lastLayerKey) { + this.lastLayerKey = layerKey + this.rebuildWords() + this.drawArmorBar() + } + + const showArmorLabel = wave <= 5 && this.kind !== 'simple' + if (showArmorLabel) { + const total = armor.layers.length + const remaining = total - armor.layerIndex + this.armorLabel.text = + this.kind === 'boss' + ? `${gameConfig.texts.bossLabel} ${remaining}/${total}` + : `${gameConfig.texts.armorLabel} ${remaining}/${total}` + this.armorLabel.y = -boxHeight / 2 - metrics.enemyArmorLabelOffset + this.armorLabel.style.fill = flash ? gameConfig.colors.white : kindColor(this.kind) + } + this.armorLabel.visible = showArmorLabel + } + + destroyView(): void { + this.destroy({ children: true }) + } + + private drawHull(flash: boolean): void { + const box = this.entity.enemyBox as { width: number; height: number } + const width = box.width + const height = box.height + const off = this.metrics.enemyAlienOffset + const color = flash ? gameConfig.colors.white : kindColor(this.kind) + const fill = flash ? gameConfig.colors.white : gameConfig.colors.enemyFill + const lineWidth = flash ? this.metrics.enemyLineWidth * 2 : this.metrics.enemyLineWidth + + const hull = this.hull.clear() + if (this.kind === 'boss') { + hull.moveTo(-width / 2 + off, -height / 2) + hull.lineTo(width / 2 - off, -height / 2) + hull.lineTo(width / 2, -height / 2 + off) + hull.lineTo(width / 2, height / 2 - off) + hull.lineTo(width / 2 - off, height / 2) + hull.lineTo(-width / 2 + off, height / 2) + hull.lineTo(-width / 2, height / 2 - off) + hull.lineTo(-width / 2, -height / 2 + off) + hull.closePath() + } else if (this.kind === 'heavy') { + hull.moveTo(-width / 2 + off, -height / 2) + hull.lineTo(width / 2 - off, -height / 2) + hull.lineTo(width / 2, 0) + hull.lineTo(width / 2 - off, height / 2) + hull.lineTo(-width / 2 + off, height / 2) + hull.lineTo(-width / 2, 0) + hull.closePath() + } else { + hull.rect(-width / 2, -height / 2, width, height) + } + hull.fill({ color: fill }).stroke({ color, width: lineWidth }) + } + + private drawArmorBar(): void { + if (this.kind === 'simple') { + this.armorBar.clear() + return + } + const armor = this.entity.armor as Armor + const box = this.entity.enemyBox as { width: number; height: number } + const barWidth = box.width - this.metrics.armorBarPad + const barHeight = this.metrics.enemyArmorBarHeight + const y = -box.height / 2 - this.metrics.enemyArmorBarOffset + const total = armor.layers.length + const remaining = total - armor.layerIndex + + this.armorBar.clear() + this.armorBar.rect(-barWidth / 2, y, barWidth, barHeight).fill({ color: gameConfig.colors.armorBarBg }) + if (remaining > 0) { + this.armorBar + .rect(-barWidth / 2, y, (barWidth * remaining) / total, barHeight) + .fill({ color: gameConfig.colors.armorBarFill }) + } + } + + private rebuildWords(): void { + for (const text of this.wordTexts) text.destroy() + this.wordTexts = [] + this.marks.clear() + + const armor = this.entity.armor as Armor + const layer = armor.layers[armor.layerIndex] + if (!layer) return + const flash = this.lastFlash + const color = flash ? gameConfig.colors.white : kindColor(this.kind) + const box = this.entity.enemyBox as { width: number; height: number } + + if (this.kind === 'boss') { + const fontSize = Math.round(this.metrics.enemyHeavyFont * this.metrics.bossFontSizeFactor) + const widths = layer.map((word) => this.measurer.measure(word, fontSize, true)) + const totalWidth = + widths.reduce((sum, w) => sum + w, 0) + (layer.length - 1) * gameConfig.ratio.bossWordGap + let cursor = -totalWidth / 2 + layer.forEach((word, i) => { + const killed = armor.killed.includes(word) + const text = new Text({ + text: word, + style: { + fontFamily: ['Courier New', 'monospace'], + fontSize, + fontWeight: 'bold', + fill: color, + }, + }) + text.anchor.set(0.5) + text.alpha = killed ? 0.3 : 1 + text.x = cursor + widths[i] / 2 + text.y = 0 + this.addChild(text) + this.wordTexts.push(text) + if (killed) { + this.marks + .moveTo(text.x - widths[i] / 2, 0) + .lineTo(text.x + widths[i] / 2, 0) + .stroke({ color, width: 2 }) + } + cursor += widths[i] + gameConfig.ratio.bossWordGap + }) + } else { + const fontSize = this.kind === 'heavy' ? this.metrics.enemyHeavyFont : this.metrics.enemySimpleFont + const text = new Text({ + text: layer[0] ?? '', + style: { + fontFamily: ['Courier New', 'monospace'], + fontSize, + fontWeight: 'bold', + fill: color, + }, + }) + text.anchor.set(0.5) + this.addChild(text) + this.wordTexts.push(text) + } + } +} diff --git a/src/render/GridLayer.ts b/src/render/GridLayer.ts new file mode 100644 index 0000000..d1c684a --- /dev/null +++ b/src/render/GridLayer.ts @@ -0,0 +1,48 @@ +import { Container, TilingSprite, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { Metrics } from '../core/metrics' + +/** + * Сетка фона одним TilingSprite: одна текстура, один quad, скролл через + * tilePosition — вместо ~40 stroke() за кадр в старом canvas-рендере. + */ +export class GridLayer { + private readonly sprite: TilingSprite + private texture: Texture | null = null + private builtGridSize = 0 + + constructor(parent: Container) { + this.sprite = new TilingSprite({ texture: Texture.WHITE, width: 1, height: 1 }) + parent.addChild(this.sprite) + } + + applyMetrics(metrics: Metrics): void { + if (metrics.gridSize !== this.builtGridSize) { + this.texture?.destroy(true) + this.texture = this.buildTexture(metrics.gridSize) + this.sprite.texture = this.texture + this.builtGridSize = metrics.gridSize + } + this.sprite.width = metrics.width + this.sprite.height = metrics.height + } + + update(delta: number, metrics: Metrics): void { + this.sprite.tilePosition.y = (this.sprite.tilePosition.y - metrics.gridSpeed * delta) % metrics.gridSize + } + + private buildTexture(gridSize: number): Texture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = gridSize + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + ctx.strokeStyle = gameConfig.colors.primaryGrid + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(gridSize - 0.5, 0) + ctx.lineTo(gridSize - 0.5, gridSize) + ctx.moveTo(0, gridSize - 0.5) + ctx.lineTo(gridSize, gridSize - 0.5) + ctx.stroke() + return Texture.from(canvas) + } +} diff --git a/src/render/IntroSequence.ts b/src/render/IntroSequence.ts new file mode 100644 index 0000000..ada34aa --- /dev/null +++ b/src/render/IntroSequence.ts @@ -0,0 +1,220 @@ +import { Container, Graphics, Sprite, Text, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { Metrics } from '../core/metrics' +import { tintOf } from './textures' + +type IntroParticle = { x: number; y: number; vx: number; vy: number; life: number; decay: number; size: number } +type IntroShot = { progress: number; done: boolean; trail: IntroParticle[]; body: Graphics; trailSprites: Sprite[] } + +/** + * Заставка «SHOOTING WORLD → SHOOTING WORD»: буква L простреливается, + * D въезжает на её место. Полностью самодостаточный визуальный блок. + */ +export class IntroSequence { + readonly container = new Container() + active = false + onFinish: (() => void) | null = null + + private readonly main: Text + private readonly lText: Text + private readonly dText: Text + private readonly subtitle: Text + private readonly glow: Sprite + private readonly particleGraphics = new Graphics() + private readonly shots: IntroShot[] = [] + private readonly particles: IntroParticle[] = [] + + private frame = 0 + private textAlpha = 0 + private layout = { totalW: 0, lX: 0, lY: 0, dX: 0, shooterX: 0, shooterY: 0 } + + constructor( + parent: Container, + private metrics: Metrics, + glowTexture: Texture, + ) { + const style = (fontSize: number, fill: string, bold: boolean) => ({ + fontFamily: ['Courier New', 'monospace'], + fontSize, + fontWeight: bold ? ('bold' as const) : ('normal' as const), + fill, + }) + + this.glow = new Sprite(glowTexture) + this.glow.anchor.set(0.5) + this.glow.tint = tintOf(gameConfig.colors.primary) + this.glow.blendMode = 'add' + + this.main = new Text({ text: 'SHOOTING WOR', style: style(44, gameConfig.colors.primary, true) }) + this.main.anchor.set(0.5) + this.lText = new Text({ text: 'L', style: style(44, gameConfig.colors.primary, true) }) + this.lText.anchor.set(0.5) + this.dText = new Text({ text: 'D', style: style(44, gameConfig.colors.primary, true) }) + this.dText.anchor.set(0.5) + this.subtitle = new Text({ text: gameConfig.texts.startSubtitle, style: style(32, gameConfig.colors.primaryDim, false) }) + this.subtitle.anchor.set(0.5) + this.subtitle.visible = false + + this.container.addChild(this.glow, this.main, this.lText, this.dText, this.particleGraphics, this.subtitle) + parent.addChild(this.container) + this.applyMetrics(metrics) + } + + applyMetrics(metrics: Metrics): void { + this.metrics = metrics + const scale = metrics.height / 1000 + const I = gameConfig.intro + + this.main.style.fontSize = Math.round(I.fontSize * scale) + this.lText.style.fontSize = Math.round(I.fontSize * scale) + this.dText.style.fontSize = Math.round(I.fontSize * scale) + this.subtitle.style.fontSize = Math.round(I.subtitleFont * scale) + + const textY = Math.round(metrics.height * I.textYR) + const cx = metrics.width / 2 + const worW = this.main.width + const lW = this.lText.width + const dW = this.dText.width + const totalW = worW + lW + dW + + this.main.position.set(cx - totalW / 2 + worW / 2, textY) + this.layout = { + totalW, + lX: cx - totalW / 2 + worW + lW / 2, + lY: textY, + dX: cx - totalW / 2 + worW + lW + dW / 2, + shooterX: cx, + shooterY: metrics.height - metrics.shooterOffset, + } + this.lText.position.set(this.layout.lX, textY) + this.dText.position.set(this.layout.dX, textY) + this.subtitle.position.set(cx, textY + Math.round(I.subtitleYOffset * scale)) + this.glow.width = this.main.width + Math.round(I.glow * scale) * 2 + this.glow.height = Math.round(I.fontSize * scale) * 3 + this.glow.position.copyFrom(this.main.position) + } + + start(): void { + this.active = true + this.frame = 0 + this.textAlpha = 0 + this.subtitle.visible = false + this.lText.alpha = 1 + this.dText.alpha = 1 + this.particles.length = 0 + for (const shot of this.shots) shot.body.destroy() + this.shots.length = 0 + this.container.visible = true + } + + hide(): void { + this.active = false + this.container.visible = false + } + + update(delta: number): void { + if (!this.active) return + const m = this.metrics + const I = gameConfig.intro + const scale = m.height / 1000 + + const prevFrame = Math.floor(this.frame) + this.frame += delta + const curFrame = Math.floor(this.frame) + + this.textAlpha = Math.min(1, this.frame / I.fadeInFrames) + this.main.alpha = this.textAlpha + + for (const shotFrame of [I.shot1Frame, I.shot2Frame, I.shot3Frame]) { + if (prevFrame < shotFrame && curFrame >= shotFrame) this.spawnShot() + } + + for (const shot of this.shots) { + if (shot.done) continue + shot.progress = Math.min(1, shot.progress + I.shotSpeed * delta) + const x = this.layout.shooterX + (this.layout.lX - this.layout.shooterX) * shot.progress + const y = this.layout.shooterY + (this.layout.lY - this.layout.shooterY) * shot.progress + shot.trail.unshift({ x, y, vx: 0, vy: 0, life: 1, decay: I.trailDecay, size: I.trailRect * scale }) + if (shot.trail.length > I.trailMax) shot.trail.pop() + for (const point of shot.trail) point.life -= I.trailDecay * delta + while (shot.trail.length && shot.trail[shot.trail.length - 1].life <= 0) shot.trail.pop() + shot.body.clear() + shot.body.circle(x, y, I.projectileRadius * scale).fill({ color: gameConfig.colors.primary }) + if (shot.progress >= 1) { + shot.done = true + shot.body.clear() + for (let i = 0; i < I.particleCountPerHit; i++) { + this.particles.push({ + x: this.layout.lX, + y: this.layout.lY, + vx: (Math.random() - 0.5) * m.particleVel, + vy: (Math.random() - 0.5) * m.particleVel, + life: 1, + decay: m.particleDecayBase + Math.random() * m.particleDecayRange, + size: m.particleSizeMin + Math.random() * (m.particleSizeMax - m.particleSizeMin), + }) + } + } + this.drawTrail(shot) + } + + const dSlide = this.frame >= I.slideStartFrame ? Math.min(1, (this.frame - I.slideStartFrame) / I.slideDuration) : 0 + this.lText.alpha = this.textAlpha * Math.max(0, 1 - dSlide) + this.dText.alpha = this.textAlpha + this.dText.x = this.layout.dX - this.lText.width * dSlide + + for (const p of this.particles) { + p.x += p.vx * delta + p.y += p.vy * delta + p.vx *= m.particleDamping + p.vy *= m.particleDamping + p.life -= p.decay * delta + } + for (let i = this.particles.length - 1; i >= 0; i--) { + if (this.particles[i].life <= 0) this.particles.splice(i, 1) + } + this.particleGraphics.clear() + for (const p of this.particles) { + this.particleGraphics + .rect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size) + .fill({ color: gameConfig.colors.primary, alpha: p.life }) + } + + if (this.frame >= I.totalFrames) { + this.active = false + this.subtitle.visible = true + this.onFinish?.() + } + } + + private spawnShot(): void { + const body = new Graphics() + const trailSprites: Sprite[] = [] + for (let i = 0; i < gameConfig.intro.trailMax; i++) { + const sprite = new Sprite(Texture.WHITE) + sprite.anchor.set(0.5) + sprite.tint = tintOf(gameConfig.colors.primary) + sprite.visible = false + this.container.addChildAt(sprite, this.container.getChildIndex(this.particleGraphics)) + trailSprites.push(sprite) + } + this.container.addChild(body) + this.shots.push({ progress: 0, done: false, trail: [], body, trailSprites }) + } + + private drawTrail(shot: IntroShot): void { + const I = gameConfig.intro + shot.trailSprites.forEach((sprite, i) => { + const point = shot.trail[i] + if (!point) { + sprite.visible = false + return + } + sprite.visible = true + sprite.position.set(point.x, point.y) + sprite.alpha = point.life * I.trailAlpha + sprite.width = point.size + sprite.height = point.size + }) + } +} diff --git a/src/render/ParticleLayer.ts b/src/render/ParticleLayer.ts new file mode 100644 index 0000000..8ba4d6c --- /dev/null +++ b/src/render/ParticleLayer.ts @@ -0,0 +1,52 @@ +import { Container, Particle, ParticleContainer, Texture } from 'pixi.js' +import type { GameEntity, GameWorld } from '../ecs/world' +import { tintOf } from './textures' + +/** Размер эталонной белой текстуры Pixi (Texture.WHITE). */ +const WHITE_TEXTURE_SIZE = 16 + +/** + * Частицы — сущности ECS, отрисовка — один ParticleContainer. + * Батч всего слоя за один draw call, без Graphics на частицу. + */ +export class ParticleLayer { + private readonly container = new ParticleContainer({ + dynamicProperties: { position: true, scale: true, rotation: false, color: true }, + }) + private readonly views = new Map() + + constructor(parent: Container, world: GameWorld) { + parent.addChild(this.container) + world.onEntityAdded.subscribe((entity) => { + if (!entity.particle) return + const particle = new Particle({ + texture: Texture.WHITE, + x: entity.position?.x ?? 0, + y: entity.position?.y ?? 0, + anchorX: 0.5, + anchorY: 0.5, + tint: tintOf(entity.particle.color), + alpha: entity.lifetime?.remaining ?? 1, + }) + this.views.set(entity, particle) + this.container.addParticle(particle) + }) + world.onEntityRemoved.subscribe((entity) => { + const particle = this.views.get(entity) + if (!particle) return + this.container.removeParticle(particle) + this.views.delete(entity) + }) + } + + update(world: GameWorld): void { + for (const [entity, particle] of this.views) { + particle.x = entity.position?.x ?? 0 + particle.y = entity.position?.y ?? 0 + particle.alpha = Math.max(0, entity.lifetime?.remaining ?? 0) + const scale = (entity.particle?.size ?? 4) / WHITE_TEXTURE_SIZE + particle.scaleX = scale + particle.scaleY = scale + } + } +} diff --git a/src/render/PlayerView.ts b/src/render/PlayerView.ts new file mode 100644 index 0000000..d84ded7 --- /dev/null +++ b/src/render/PlayerView.ts @@ -0,0 +1,31 @@ +import { Container, Graphics, Sprite, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { Metrics } from '../core/metrics' +import { tintOf } from './textures' + +/** Стрелок в нижней части экрана — статичная неоновая точка со свечением. */ +export class PlayerView { + private readonly glow: Sprite + private readonly body = new Graphics() + + constructor(parent: Container, glowTexture: Texture) { + this.glow = new Sprite(glowTexture) + this.glow.anchor.set(0.5) + this.glow.tint = tintOf(gameConfig.colors.primary) + this.glow.blendMode = 'add' + this.glow.alpha = 0.5 + parent.addChild(this.glow) + parent.addChild(this.body) + } + + applyMetrics(metrics: Metrics): void { + this.body.clear() + this.body + .circle(0, 0, metrics.playerRadius) + .fill({ color: gameConfig.colors.primary }) + this.glow.width = metrics.playerGlow * 4 + this.glow.height = metrics.playerGlow * 4 + this.body.position.set(metrics.width / 2, metrics.height - metrics.playerY) + this.glow.position.set(metrics.width / 2, metrics.height - metrics.playerY) + } +} diff --git a/src/render/ProjectileLayer.ts b/src/render/ProjectileLayer.ts new file mode 100644 index 0000000..4865f20 --- /dev/null +++ b/src/render/ProjectileLayer.ts @@ -0,0 +1,41 @@ +import { Container, Texture } from 'pixi.js' +import type { Metrics } from '../core/metrics' +import type { GameEntity, GameWorld } from '../ecs/world' +import { ProjectileView } from './ProjectileView' + +/** Слой снарядов: жизненный цикл вью подписан на добавление/удаление сущностей мира. */ +export class ProjectileLayer { + private readonly container = new Container() + private readonly views = new Map() + + constructor( + parent: Container, + world: GameWorld, + private metrics: Metrics, + glowTexture: Texture, + ) { + parent.addChild(this.container) + world.onEntityAdded.subscribe((entity) => { + if (!entity.projectile) return + const view = new ProjectileView(entity, this.metrics, glowTexture) + this.views.set(entity, view) + this.container.addChild(view) + }) + world.onEntityRemoved.subscribe((entity) => { + const view = this.views.get(entity) + if (!view) return + this.views.delete(entity) + view.destroyView() + }) + } + + update(delta: number, metrics: Metrics): void { + this.metrics = metrics + for (const [entity, view] of this.views) view.sync(entity, delta, metrics) + } + + applyMetrics(metrics: Metrics): void { + this.metrics = metrics + for (const view of this.views.values()) view.applyMetrics(metrics) + } +} diff --git a/src/render/ProjectileView.ts b/src/render/ProjectileView.ts new file mode 100644 index 0000000..a97764e --- /dev/null +++ b/src/render/ProjectileView.ts @@ -0,0 +1,102 @@ +import { Container, Graphics, Sprite, Text, Texture } from 'pixi.js' +import { gameConfig } from '../config/gameConfig' +import type { Metrics } from '../core/metrics' +import type { GameEntity } from '../ecs/world' +import { tintOf } from './textures' + +type TrailPoint = { x: number; y: number; life: number } + +/** Вью снаряда: слово, окружность, свечение и хвост из квадратов (как в оригинале). */ +export class ProjectileView extends Container { + private readonly glow: Sprite + private readonly body: Graphics + private readonly wordLabel: Text + private readonly trailSprites: Sprite[] = [] + private readonly cssColor: string + private trail: TrailPoint[] = [] + + constructor( + entity: GameEntity, + metrics: Metrics, + glowTexture: Texture, + ) { + super() + const isHit = !!entity.target + this.cssColor = isHit ? gameConfig.colors.primary : gameConfig.colors.miss + const tint = tintOf(this.cssColor) + + for (let i = 0; i < metrics.projectileTrailLength; i++) { + const sprite = new Sprite(Texture.WHITE) + sprite.anchor.set(0.5) + sprite.tint = tint + sprite.visible = false + this.addChild(sprite) + this.trailSprites.push(sprite) + } + + this.glow = new Sprite(glowTexture) + this.glow.anchor.set(0.5) + this.glow.tint = tint + this.glow.blendMode = 'add' + this.glow.alpha = 0.4 + this.addChild(this.glow) + + this.body = new Graphics() + this.addChild(this.body) + + this.wordLabel = new Text({ + text: entity.projectile?.word ?? '', + style: { + fontFamily: ['Courier New', 'monospace'], + fontSize: metrics.projectileFont, + fontWeight: 'bold', + fill: this.cssColor, + }, + }) + this.wordLabel.anchor.set(0.5) + this.addChild(this.wordLabel) + + this.applyMetrics(metrics) + } + + applyMetrics(metrics: Metrics): void { + this.body.clear() + this.body + .circle(0, 0, metrics.projectileCircleRadius) + .stroke({ color: this.cssColor, width: 1, alpha: metrics.projectileCircleAlpha }) + this.glow.width = metrics.projectileGlow * 4 + this.glow.height = metrics.projectileGlow * 4 + } + + sync(entity: GameEntity, delta: number, metrics: Metrics): void { + const position = entity.position as { x: number; y: number } + this.position.set(position.x, position.y) + + const velocity = entity.velocity + if (velocity && (velocity.x !== 0 || velocity.y !== 0)) { + this.trail.unshift({ x: position.x, y: position.y, life: 1 }) + if (this.trail.length > metrics.projectileTrailLength) this.trail.pop() + } + for (const point of this.trail) point.life -= metrics.projectileTrailDecay * delta + this.trail = this.trail.filter((point) => point.life > 0) + + for (let i = 0; i < this.trailSprites.length; i++) { + const sprite = this.trailSprites[i] + const point = this.trail[i] + if (!point) { + sprite.visible = false + continue + } + sprite.visible = true + sprite.position.set(point.x - position.x, point.y - position.y) + sprite.alpha = point.life * metrics.projectileTrailAlpha + const size = metrics.trailSizeBase + i * metrics.trailSizeInc + sprite.width = size + sprite.height = size + } + } + + destroyView(): void { + this.destroy({ children: true }) + } +} diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts new file mode 100644 index 0000000..5d4e667 --- /dev/null +++ b/src/render/Renderer.ts @@ -0,0 +1,114 @@ +import { Application, Container } from 'pixi.js' +import type { TextMeasurer } from '../core/CanvasTextMeasurer' +import type { MetricsStore } from '../core/metrics' +import type { GameWorld } from '../ecs/world' +import type { GameStore } from '../game/GameStore' +import type { EventBus } from '../core/eventBus' +import type { GameEvents } from '../game/events' +import { BannerLayer } from './BannerLayer' +import { EnemyLayer } from './EnemyLayer' +import { GridLayer } from './GridLayer' +import { IntroSequence } from './IntroSequence' +import { ParticleLayer } from './ParticleLayer' +import { PlayerView } from './PlayerView' +import { ProjectileLayer } from './ProjectileLayer' +import { createGlowTexture } from './textures' + +/** + * Pixi-рендер: инициализация приложения, слои в порядке отрисовки, + * тряска экрана и рендер по внешнему такту (GameClock), а не через свой ticker. + */ +export class Renderer { + readonly app = new Application() + readonly intro: IntroSequence + + private readonly root = new Container() + private readonly grid: GridLayer + private readonly particles: ParticleLayer + private readonly projectiles: ProjectileLayer + private readonly enemies: EnemyLayer + private readonly player: PlayerView + private readonly banner: BannerLayer + + private constructor( + private readonly metricsStore: MetricsStore, + world: GameWorld, + bus: EventBus, + measurer: TextMeasurer, + ) { + const metrics = metricsStore.current + const glowTexture = createGlowTexture() + + this.app.stage.addChild(this.root) + this.grid = new GridLayer(this.root) + this.particles = new ParticleLayer(this.root, world) + this.projectiles = new ProjectileLayer(this.root, world, metrics, glowTexture) + this.enemies = new EnemyLayer(this.root, world, bus, metrics, measurer, glowTexture) + this.player = new PlayerView(this.root, glowTexture) + this.banner = new BannerLayer(this.root, glowTexture) + this.intro = new IntroSequence(this.root, metrics, glowTexture) + + this.grid.applyMetrics(metrics) + this.player.applyMetrics(metrics) + this.banner.applyMetrics(metrics) + this.intro.applyMetrics(metrics) + } + + static async create( + canvas: HTMLCanvasElement, + metricsStore: MetricsStore, + world: GameWorld, + bus: EventBus, + measurer: TextMeasurer, + ): Promise { + const metrics = metricsStore.current + const renderer = new Renderer(metricsStore, world, bus, measurer) + await renderer.app.init({ + canvas, + width: metrics.width, + height: metrics.height, + backgroundColor: 0x000000, + antialias: true, + resolution: Math.min(window.devicePixelRatio || 1, 2), + autoDensity: true, + powerPreference: 'high-performance', + }) + // Рендерим вручную из GameClock — pixi-ticker не нужен. + renderer.app.ticker.stop() + return renderer + } + + update(delta: number, world: GameWorld, store: GameStore): void { + const metrics = this.metricsStore.current + this.grid.update(delta, metrics) + this.particles.update(world) + this.projectiles.update(delta, metrics) + const wave = world.with('waveState').first?.waveState?.wave ?? 1 + this.enemies.update(delta, metrics, wave) + this.banner.update(world, metrics) + + if (store.shake.timer > 0) { + this.root.position.set( + (Math.random() - 0.5) * store.shake.amount, + (Math.random() - 0.5) * store.shake.amount, + ) + } else { + this.root.position.set(0, 0) + } + } + + render(): void { + this.app.render() + } + + resize(width: number, height: number): void { + this.app.renderer.resize(width, height) + const metrics = this.metricsStore.current + this.grid.applyMetrics(metrics) + this.player.applyMetrics(metrics) + this.banner.applyMetrics(metrics) + this.projectiles.applyMetrics(metrics) + this.enemies.applyMetrics(metrics) + this.intro.applyMetrics(metrics) + } +} diff --git a/src/render/textures.ts b/src/render/textures.ts new file mode 100644 index 0000000..961abf6 --- /dev/null +++ b/src/render/textures.ts @@ -0,0 +1,31 @@ +import { Texture } from 'pixi.js' +import { cssColorToHex } from '../core/color' + +/** + * Радиальный градиент вместо canvas shadowBlur: свечение рисуется спрайтом + * с аддитивным блендингом, что на порядки дешевле попиксельного blur. + */ +export function createGlowTexture(size = 128): Texture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = size + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + const half = size / 2 + const gradient = ctx.createRadialGradient(half, half, 0, half, half, half) + gradient.addColorStop(0, 'rgba(255,255,255,0.85)') + gradient.addColorStop(0.35, 'rgba(255,255,255,0.3)') + gradient.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = gradient + ctx.fillRect(0, 0, size, size) + return Texture.from(canvas) +} + +const tintCache = new Map() + +export function tintOf(cssColor: string): number { + let tint = tintCache.get(cssColor) + if (tint === undefined) { + tint = cssColorToHex(cssColor) + tintCache.set(cssColor, tint) + } + return tint +} diff --git a/src/systems/context.ts b/src/systems/context.ts new file mode 100644 index 0000000..471a977 --- /dev/null +++ b/src/systems/context.ts @@ -0,0 +1,20 @@ +import type { EventBus } from '../core/eventBus' +import type { MetricsStore } from '../core/metrics' +import type { SpawnDependencies } from '../ecs/spawn' +import type { GameEntity, GameWorld } from '../ecs/world' +import type { GameEvents } from '../game/events' +import type { GameStore } from '../game/GameStore' + +/** Общий контекст, который получают все системы за кадр. */ +export type SystemContext = { + world: GameWorld + store: GameStore + bus: EventBus + metrics: MetricsStore + spawn: SpawnDependencies +} + +/** Сущности, запланированные к удалению внутри системы, удаляются после обхода запроса. */ +export function removeAll(world: GameWorld, entities: GameEntity[]): void { + for (const entity of entities) world.remove(entity) +} diff --git a/src/systems/cullSystem.ts b/src/systems/cullSystem.ts new file mode 100644 index 0000000..1cdcb23 --- /dev/null +++ b/src/systems/cullSystem.ts @@ -0,0 +1,15 @@ +import { removeAll, type SystemContext } from './context' + +/** Снаряды, улетевшие за экран. */ +export function cullSystem(ctx: SystemContext): void { + const metrics = ctx.metrics.current + const margin = metrics.shooterOffset + const dead = [] + for (const entity of ctx.world.with('projectile', 'position')) { + const { x, y } = entity.position as { x: number; y: number } + if (x < -margin || x > metrics.width + margin || y < -margin || y > metrics.height + margin) { + dead.push(entity) + } + } + removeAll(ctx.world, dead) +} diff --git a/src/systems/escapeSystem.ts b/src/systems/escapeSystem.ts new file mode 100644 index 0000000..f6cc1c2 --- /dev/null +++ b/src/systems/escapeSystem.ts @@ -0,0 +1,22 @@ +import type { GameEntity } from '../ecs/world' +import { removeAll, type SystemContext } from './context' + +/** Враги, дошедшие до нижней зоны, убывают из мира и наносят урон (через событие). */ +export function escapeSystem(ctx: SystemContext): void { + const metrics = ctx.metrics.current + const limit = metrics.height - metrics.removeZone + const escaped: GameEntity[] = [] + + for (const entity of ctx.world.with('armor', 'position')) { + if ((entity.position as { y: number }).y > limit) escaped.push(entity) + } + + for (const entity of escaped) { + ctx.bus.emit('enemy:escaped', { + x: (entity.position as { x: number }).x, + y: (entity.position as { y: number }).y, + kind: entity.enemyKind as 'simple' | 'heavy' | 'boss', + }) + } + removeAll(ctx.world, escaped) +} diff --git a/src/systems/fuseSystem.ts b/src/systems/fuseSystem.ts new file mode 100644 index 0000000..342a0dd --- /dev/null +++ b/src/systems/fuseSystem.ts @@ -0,0 +1,29 @@ +import type { GameEntity } from '../ecs/world' +import type { SystemContext } from './context' + +/** + * Задержка выстрела: снаряд существует сразу (для отклика UI), но стоит на месте + * у стрелка, пока не истечёт fuse. По истечении задаются начальные скорости. + */ +export function fuseSystem(ctx: SystemContext, delta: number): void { + const armed: GameEntity[] = [] + for (const entity of ctx.world.with('projectile', 'fuse')) { + const fuse = (entity.fuse ?? 0) - delta + if (fuse <= 0) armed.push(entity) + else entity.fuse = fuse + } + + for (const entity of armed) { + ctx.world.removeComponent(entity, 'fuse') + const { position, velocity } = entity + if (!position || !velocity) continue + const dest = entity.targetPoint ?? entity.target?.position + if (!dest) continue + const dx = dest.x - position.x + const dy = dest.y - position.y + const dist = Math.hypot(dx, dy) || 1 + const speed = ctx.metrics.current.projectileSpeed + velocity.x = (dx / dist) * speed + velocity.y = (dy / dist) * speed + } +} diff --git a/src/systems/homingSystem.ts b/src/systems/homingSystem.ts new file mode 100644 index 0000000..7b44d2e --- /dev/null +++ b/src/systems/homingSystem.ts @@ -0,0 +1,38 @@ +import type { GameEntity } from '../ecs/world' +import type { Player } from '../core/types' +import { removeAll, type SystemContext } from './context' + +type HitInfo = { entity: GameEntity; target: GameEntity; word: string; player: Player } + +/** Наведение: снаряд доворачивает к цели, при касании издаёт событие попадания. */ +export function homingSystem(ctx: SystemContext): void { + const metrics = ctx.metrics.current + const dead: GameEntity[] = [] + const hits: HitInfo[] = [] + + for (const entity of ctx.world.with('projectile', 'target', 'position', 'velocity')) { + const target = entity.target as GameEntity + const position = entity.position as { x: number; y: number } + const velocity = entity.velocity as { x: number; y: number } + + if (!ctx.world.has(target)) { + dead.push(entity) + continue + } + + const dx = (target.position as { x: number; y: number }).x - position.x + const dy = (target.position as { x: number; y: number }).y - position.y + const dist = Math.hypot(dx, dy) + if (dist > 0) { + velocity.x = (dx / dist) * metrics.projectileSpeed + velocity.y = (dy / dist) * metrics.projectileSpeed + } + if (dist < metrics.projectileHitRadius) { + hits.push({ entity, target, word: entity.projectile?.word ?? '', player: entity.player as Player }) + dead.push(entity) + } + } + + removeAll(ctx.world, dead) + for (const hit of hits) ctx.bus.emit('projectile:hit', hit) +} diff --git a/src/systems/integrationSystem.ts b/src/systems/integrationSystem.ts new file mode 100644 index 0000000..279285e --- /dev/null +++ b/src/systems/integrationSystem.ts @@ -0,0 +1,17 @@ +import type { SystemContext } from './context' + +/** Интеграция движения для всех сущностей с позицией и скоростью (враги, снаряды, частицы). */ +export function integrationSystem(ctx: SystemContext, delta: number): void { + for (const entity of ctx.world.with('position', 'velocity')) { + const position = entity.position as { x: number; y: number } + const velocity = entity.velocity as { x: number; y: number } + position.x += velocity.x * delta + position.y += velocity.y * delta + const damping = entity.damping + if (damping) { + const k = Math.pow(damping.factor, delta) + velocity.x *= k + velocity.y *= k + } + } +} diff --git a/src/systems/lifetimeSystem.ts b/src/systems/lifetimeSystem.ts new file mode 100644 index 0000000..8253ffe --- /dev/null +++ b/src/systems/lifetimeSystem.ts @@ -0,0 +1,13 @@ +import type { GameEntity } from '../ecs/world' +import { removeAll, type SystemContext } from './context' + +/** Частицы: затухание жизни, удаление отживших. */ +export function lifetimeSystem(ctx: SystemContext, delta: number): void { + const dead: GameEntity[] = [] + for (const entity of ctx.world.with('lifetime')) { + const lifetime = entity.lifetime as { remaining: number; decay: number } + lifetime.remaining -= lifetime.decay * delta + if (lifetime.remaining <= 0) dead.push(entity) + } + removeAll(ctx.world, dead) +} diff --git a/src/systems/missSystem.ts b/src/systems/missSystem.ts new file mode 100644 index 0000000..8baffd3 --- /dev/null +++ b/src/systems/missSystem.ts @@ -0,0 +1,23 @@ +import type { GameEntity } from '../ecs/world' +import type { Player } from '../core/types' +import { removeAll, type SystemContext } from './context' + +/** Промахи: снаряд без цели долетает до точки и рассыпается. */ +export function missSystem(ctx: SystemContext): void { + const metrics = ctx.metrics.current + const dead: GameEntity[] = [] + const misses: { x: number; y: number; player: Player }[] = [] + + for (const entity of ctx.world.with('projectile', 'targetPoint', 'position', 'velocity')) { + const point = entity.targetPoint as { x: number; y: number } + const position = entity.position as { x: number; y: number } + const dist = Math.hypot(point.x - position.x, point.y - position.y) + if (dist < metrics.projectileMissRadius) { + misses.push({ x: point.x, y: point.y, player: entity.player as Player }) + dead.push(entity) + } + } + + removeAll(ctx.world, dead) + for (const miss of misses) ctx.bus.emit('projectile:miss', miss) +} diff --git a/src/systems/waveSystem.ts b/src/systems/waveSystem.ts new file mode 100644 index 0000000..f88c010 --- /dev/null +++ b/src/systems/waveSystem.ts @@ -0,0 +1,49 @@ +import { gameConfig } from '../config/gameConfig' +import { enemiesPerWave, spawnEnemy } from '../ecs/spawn' +import type { SystemContext } from './context' + +/** Волны: таймеры спавна, пауза между волнами, боссы. */ +export function waveSystem(ctx: SystemContext, delta: number): void { + const ws = ctx.world.with('waveState').first?.waveState + if (!ws) return + const cfg = gameConfig.game + const metrics = ctx.metrics.current + + if (ws.pauseActive) { + ws.pauseTimer -= delta + if (ws.pauseTimer <= 0) { + ws.pauseActive = false + ws.banner = null + ws.enemiesLeft = enemiesPerWave(ws.wave) + if (ws.wave % cfg.bossEveryNthWave === 0) { + spawnEnemy(ctx.world, ctx.spawn, 'boss', metrics, ws.wave) + } + } + return + } + + if (ws.enemiesLeft > 0) { + ws.spawnTimer += delta + const spawnInterval = cfg.waveInterval / enemiesPerWave(ws.wave) + if (ws.spawnTimer >= spawnInterval) { + ws.spawnTimer = 0 + ws.enemiesLeft-- + const heavyChance = cfg.heavyEnemyChanceBase + ws.wave * cfg.heavyEnemyChancePerWave + spawnEnemy(ctx.world, ctx.spawn, Math.random() < heavyChance ? 'heavy' : 'simple', metrics, ws.wave) + } + } + + ws.waveTimer += delta + if (ws.waveTimer >= cfg.waveInterval && ws.enemiesLeft <= 0) { + ws.wave++ + ws.waveTimer = 0 + ws.pauseActive = true + ws.pauseTimer = metrics.wavePauseFrames + const isBoss = ws.wave % cfg.bossEveryNthWave === 0 + ws.banner = { + text: isBoss ? gameConfig.texts.bossIncoming : `WAVE ${ws.wave} INCOMING`, + isBoss, + } + ctx.bus.emit('wave:changed', { wave: ws.wave }) + } +} diff --git a/src/ui/HudController.ts b/src/ui/HudController.ts new file mode 100644 index 0000000..2de557e --- /dev/null +++ b/src/ui/HudController.ts @@ -0,0 +1,36 @@ +import { gameConfig } from '../config/gameConfig' +import type { EventBus } from '../core/eventBus' +import type { GameEvents } from '../game/events' +import { requiredElement } from './dom' + +/** HUD: счёт, волна, жизни. */ +export class HudController { + private readonly scoreEl = requiredElement('score') + private readonly waveEl = requiredElement('wave') + private readonly livesEl = requiredElement('lives') + + constructor(bus: EventBus) { + bus.on('game:started', () => this.reset()) + bus.on('score:changed', ({ score }) => { + this.scoreEl.textContent = String(score) + }) + bus.on('wave:changed', ({ wave }) => { + this.waveEl.textContent = String(wave) + }) + bus.on('player:damaged', ({ lives }) => { + this.livesEl.textContent = formatLives(lives) + }) + } + + reset(): void { + this.scoreEl.textContent = String(gameConfig.game.startScore) + this.waveEl.textContent = String(gameConfig.game.startWave) + this.livesEl.textContent = formatLives(gameConfig.game.startLives) + } +} + +function formatLives(lives: number): string { + const clamped = Math.max(0, lives) + const lost = gameConfig.game.startLives - clamped + return '♡'.repeat(Math.max(0, lost)) + '♥'.repeat(clamped) +} diff --git a/src/ui/InputFeedbackController.ts b/src/ui/InputFeedbackController.ts new file mode 100644 index 0000000..43c24fc --- /dev/null +++ b/src/ui/InputFeedbackController.ts @@ -0,0 +1,61 @@ +import { gameConfig } from '../config/gameConfig' +import type { EventBus } from '../core/eventBus' +import type { GameEvents } from '../game/events' +import { requiredElement } from './dom' + +/** Отображение набираемого текста и «выстрелившего» слова (локальный ввод и чат). */ +export class InputFeedbackController { + private readonly typedEl = requiredElement('typed-text') + private readonly chatEl = requiredElement('chat-text') + private readonly clearTimers = new Map() + + constructor(bus: EventBus) { + bus.on('game:started', () => this.clearAll()) + bus.on('input:cleared', () => this.clearAll()) + bus.on('input:word', ({ word, player, origin }) => { + const el = origin === 'local' ? this.typedEl : this.chatEl + if (origin === 'local') { + el.textContent = word + } else { + el.replaceChildren() + const name = document.createElement('span') + name.style.opacity = '0.6' + name.textContent = `${player.name}: ` + el.appendChild(name) + el.appendChild(document.createTextNode(word)) + } + this.animate(el) + }) + } + + /** Подписка на набор текста локальной клавиатуры (буфер ещё не отправлен). */ + bindBuffer(source: { onBuffer(handler: (text: string) => void): () => void }): void { + source.onBuffer((text) => { + this.typedEl.textContent = text + }) + } + + private animate(el: HTMLElement): void { + el.classList.add('shooting') + const existing = this.clearTimers.get(el) + if (existing !== undefined) clearTimeout(existing) + this.clearTimers.set( + el, + window.setTimeout(() => { + el.classList.remove('shooting') + el.replaceChildren() + this.clearTimers.delete(el) + }, gameConfig.timing.inputShootDelay), + ) + } + + private clearAll(): void { + for (const el of [this.typedEl, this.chatEl]) { + const timer = this.clearTimers.get(el) + if (timer !== undefined) clearTimeout(timer) + this.clearTimers.delete(el) + el.classList.remove('shooting') + el.replaceChildren() + } + } +} diff --git a/src/ui/OverlayScaler.ts b/src/ui/OverlayScaler.ts new file mode 100644 index 0000000..543ca5e --- /dev/null +++ b/src/ui/OverlayScaler.ts @@ -0,0 +1,76 @@ +import type { Metrics } from '../core/metrics' + +/** + * Масштабирование DOM-оверлея под размер окна (перенос applyOverlayScale). + * Значения — «дизайн-пиксели» от базовой высоты/ширины 1000. + */ +export class OverlayScaler { + private readonly title = document.querySelector('.screen-title') + private readonly subtitles = document.querySelectorAll('.screen-subtitle') + private readonly overlay = document.getElementById('ui-overlay') + private readonly typed = document.getElementById('typed-text') + private readonly chatText = document.getElementById('chat-text') + private readonly channelForm = document.getElementById('channel-form') + private readonly gameoverTitle = document.getElementById('gameover-title') + private readonly gameoverSubtitles = document.querySelectorAll('#gameover-screen .screen-subtitle') + private readonly statsTitles = document.querySelectorAll('.stats-section-title') + private readonly statsTop1 = document.querySelectorAll('.stats-top1') + private readonly statsTop2 = document.querySelectorAll('.stats-top2') + private readonly statsTop3 = document.querySelectorAll('.stats-top3') + private readonly statsRows = document.querySelectorAll( + '.stats-row:not(.stats-top1):not(.stats-top2):not(.stats-top3)', + ) + private readonly mazila = document.querySelectorAll('.stats-mazila') + private readonly crowns = document.querySelectorAll('.stats-crown') + + apply(metrics: Metrics): void { + const { width, height } = metrics + const vw = (value: number) => Math.round((value * width) / 1000) + 'px' + const vh = (value: number) => Math.round((value * height) / 1000) + 'px' + + if (this.title) this.title.style.fontSize = vh(40) + for (const el of this.subtitles) el.style.fontSize = vh(18) + + if (this.overlay) { + this.overlay.style.padding = `${vh(12)} ${vw(16)}` + this.overlay.style.fontSize = vh(28) + } + if (this.typed) { + this.typed.style.fontSize = vh(20) + this.typed.style.minHeight = vh(28) + } + if (this.chatText) { + this.chatText.style.fontSize = vh(16) + this.chatText.style.minHeight = vh(28) + } + if (this.channelForm) { + const formTitle = this.channelForm.querySelector('.screen-title') + if (formTitle) formTitle.style.fontSize = vh(80) + const input = this.channelForm.querySelector('input') + if (input) { + input.style.fontSize = vh(22) + input.style.padding = `${vh(12)} ${vw(20)}` + input.style.width = vw(300) + } + const button = this.channelForm.querySelector('button') + if (button) { + button.style.fontSize = vh(18) + button.style.padding = `${vh(10)} ${vw(40)}` + button.style.minWidth = vw(260) + } + const link = document.getElementById('game-link') + if (link) link.style.fontSize = vh(16) + const note = this.channelForm.querySelector('.obs-note') + if (note) note.style.fontSize = vh(16) + } + if (this.gameoverTitle) this.gameoverTitle.style.fontSize = vh(64) + for (const el of this.gameoverSubtitles) el.style.fontSize = vh(28) + for (const el of this.statsTitles) el.style.fontSize = vh(28) + for (const el of this.statsTop1) el.style.fontSize = vh(48) + for (const el of this.statsTop2) el.style.fontSize = vh(36) + for (const el of this.statsTop3) el.style.fontSize = vh(28) + for (const el of this.statsRows) el.style.fontSize = vh(24) + for (const el of this.mazila) el.style.fontSize = vh(28) + for (const el of this.crowns) el.style.fontSize = vh(56) + } +} diff --git a/src/ui/PlayerStatsView.ts b/src/ui/PlayerStatsView.ts new file mode 100644 index 0000000..50e6f43 --- /dev/null +++ b/src/ui/PlayerStatsView.ts @@ -0,0 +1,68 @@ +import type { PlayerStatsSnapshot } from '../game/events' + +/** + * Таблица зрителей: топ-3 убийц и «Мазила». Имена вставляются через textContent, + * а не innerHTML — ник нельзя доверять как разметку. + */ +export function renderPlayerStats(container: HTMLElement, stats: PlayerStatsSnapshot): void { + container.replaceChildren() + if (stats.top.length === 0) return + + const title = document.createElement('div') + title.className = 'stats-section-title' + title.textContent = 'УБИЙЦЫ' + container.appendChild(title) + + stats.top.forEach((entry, index) => { + const row = document.createElement('div') + row.className = 'stats-row ' + (index === 0 ? 'stats-top1' : index === 1 ? 'stats-top2' : 'stats-top3') + + const place = document.createElement('span') + place.className = 'stats-place' + place.textContent = `#${index + 1}` + row.appendChild(place) + row.appendChild(document.createTextNode(' ')) + + if (index === 0) { + const crown = document.createElement('span') + crown.className = 'stats-crown' + crown.textContent = '👑' + row.appendChild(crown) + row.appendChild(document.createTextNode(' ')) + } + + const name = document.createElement('span') + name.className = 'stats-name' + name.textContent = entry.name + row.appendChild(name) + + const kills = document.createElement('span') + kills.className = 'stats-kills' + kills.textContent = ` ${entry.kills}` + row.appendChild(kills) + + container.appendChild(row) + }) + + if (stats.mazila) { + const mazila = document.createElement('div') + mazila.className = 'stats-mazila' + + const label = document.createElement('span') + label.className = 'mazila-title' + label.textContent = 'Мазила:' + mazila.appendChild(label) + + const name = document.createElement('span') + name.className = 'mazila-name' + name.textContent = ` ${stats.mazila.name} ` + mazila.appendChild(name) + + const count = document.createElement('span') + count.className = 'mazila-count' + count.textContent = `(${stats.mazila.misses})` + mazila.appendChild(count) + + container.appendChild(mazila) + } +} diff --git a/src/ui/ScreensController.ts b/src/ui/ScreensController.ts new file mode 100644 index 0000000..8f9e7bb --- /dev/null +++ b/src/ui/ScreensController.ts @@ -0,0 +1,58 @@ +import { gameConfig } from '../config/gameConfig' +import type { EventBus } from '../core/eventBus' +import type { GameEvents } from '../game/events' +import { requiredElement } from './dom' +import { renderPlayerStats } from './PlayerStatsView' + +/** Экраны и оверлеи: старт, поражение, glitch-эффект. */ +export class ScreensController { + private readonly container = requiredElement('game-container') + private readonly startScreen = requiredElement('start-screen') + private readonly gameoverScreen = requiredElement('gameover-screen') + private readonly gameoverSubtitle = requiredElement('gameover-subtitle') + private readonly finalScore = requiredElement('final-score') + private readonly uiOverlay = requiredElement('ui-overlay') + private readonly inputChat = requiredElement('input-chat') + private readonly playerStats = requiredElement('player-stats') + private glitchTimer: number | null = null + + constructor(bus: EventBus) { + requiredElement('start-subtitle').textContent = gameConfig.texts.startSubtitle + requiredElement('gameover-title').textContent = gameConfig.texts.gameOverTitle + this.gameoverSubtitle.textContent = gameConfig.texts.gameOverSubtitle + + bus.on('game:started', () => this.showGame()) + bus.on('game:over', ({ finalScore, stats }) => this.showGameOver(finalScore, stats)) + bus.on('effect:glitch', ({ durationMs }) => this.glitch(durationMs)) + } + + showGame(): void { + this.startScreen.classList.add('hidden') + this.gameoverScreen.classList.add('hidden') + this.uiOverlay.classList.remove('hidden') + this.inputChat.classList.remove('hidden') + } + + showGameOver(finalScore: number, stats: import('../game/events').PlayerStatsSnapshot): void { + this.finalScore.textContent = String(finalScore) + renderPlayerStats(this.playerStats, stats) + this.gameoverScreen.classList.remove('hidden') + this.gameoverSubtitle.classList.add('hidden') + } + + /** Скрывается после паузы с таблицей результатов (таймер ведёт GameApp). */ + hideAll(): void { + this.gameoverScreen.classList.add('hidden') + this.uiOverlay.classList.add('hidden') + this.inputChat.classList.add('hidden') + } + + private glitch(durationMs: number): void { + this.container.classList.add('glitch') + if (this.glitchTimer !== null) clearTimeout(this.glitchTimer) + this.glitchTimer = window.setTimeout(() => { + this.container.classList.remove('glitch') + this.glitchTimer = null + }, durationMs) + } +} diff --git a/src/ui/dom.ts b/src/ui/dom.ts new file mode 100644 index 0000000..9ddbfc1 --- /dev/null +++ b/src/ui/dom.ts @@ -0,0 +1,5 @@ +export function requiredElement(id: string): T { + const element = document.getElementById(id) + if (!element) throw new Error(`Shooting Word: не найден элемент #${id}`) + return element as T +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..44bf88d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +}