feat: move to typescript, miniplex ecs
This commit is contained in:
@@ -15,17 +15,17 @@ Shooting Word — это browser-source виджет для стримов, пр
|
|||||||
## Типы врагов
|
## Типы врагов
|
||||||
|
|
||||||
| Тип | Описание | Очки |
|
| Тип | Описание | Очки |
|
||||||
| ----------- | --------------------------------------------- | ---- |
|
| ----------- | ------------------------------------------------------ | ---- |
|
||||||
| **Обычный** | Одно слово (hack, code, data, git...) | 10 |
|
| **Обычный** | Одно слово (hack, code, data, git...) | 10 |
|
||||||
| **Тяжёлый** | Два слова подряд (fire wall, null pointer...) | 50 |
|
| **Тяжёлый** | Два слова-слоя подряд (fire → wall, null → pointer...) | 50 |
|
||||||
| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 |
|
| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 |
|
||||||
|
|
||||||
Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
|
Броня отображается и пробивается только по текущему (верхнему) слою. Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- **Волны** — количество врагов и их скорость растут с каждой волной
|
- **Волны** — количество врагов и их скорость растут с каждой волной
|
||||||
- **Боссы** — multi-phase враги со слоями защиты
|
- **Боссы** — multi-layer враги со слоями защиты
|
||||||
- **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов
|
- **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов
|
||||||
- **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне
|
- **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне
|
||||||
- **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot)
|
- **Режим 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/)
|
1. Открой [страницу настройки](https://xboctuk.duckdns.org/shooting-word/)
|
||||||
2. Введи имя.twitch канала
|
2. Введи имя twitch канала
|
||||||
3. Нажми **СКОПИРОВАТЬ ССЫЛКУ**
|
3. Нажми **СКОПИРОВАТЬ ССЫЛКУ**
|
||||||
4. В OBS: Sources → Browser → вставь ссылку
|
4. В OBS: Sources → Browser → вставь ссылку
|
||||||
5. Рекомендуемая высота — не менее 800px
|
5. Рекомендуемая высота — не менее 800px
|
||||||
@@ -44,7 +54,7 @@ Shooting Word — это browser-source виджет для стримов, пр
|
|||||||
### Прямая ссылка
|
### Прямая ссылка
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА
|
https://xboctuk.duckdns.org/shooting-word/game.html?channel=ИМЯ_КАНАЛА
|
||||||
```
|
```
|
||||||
|
|
||||||
### Параметры URL
|
### Параметры URL
|
||||||
@@ -61,29 +71,34 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН
|
|||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- Vanilla JavaScript (ES6+), без фреймворков и сборщиков
|
- TypeScript (strict), сборка esbuild
|
||||||
- Canvas 2D для рендеринга
|
- [PixiJS v8](https://pixijs.com/) для рендера (WebGL/WebGPU)
|
||||||
|
- [miniplex](https://github.com/hmans/miniplex) — ECS-ядро
|
||||||
- [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC
|
- [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC
|
||||||
- Один HTML-файл + CSS + JS модули
|
|
||||||
- Хостится на GitHub Pages
|
- Хостится на GitHub Pages
|
||||||
|
|
||||||
|
Архитектура описана в [ecs-refactoring.md](ecs-refactoring.md): ввод отделён интерфейсом
|
||||||
|
`InputSource` (переход на Twurple и запуск через баллы канала = новый источник без правок
|
||||||
|
игры), логика отделена от рендера, сущности и системы — на miniplex.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
├── index.html # Страница настройки (выбор канала)
|
├── index.html # Страница настройки (выбор канала)
|
||||||
├── game.html # Игровой экран
|
├── game.html # Игровой экран
|
||||||
├── style.css # Стили (cyberpunk-тема: чёрный фон, зелёный неон)
|
├── style.css # Стили (cyberpunk-тема: чёрный фон, зелёный неон)
|
||||||
├── js/
|
├── build.js # esbuild-сборка двух бандлов + копирование статики в dist/
|
||||||
│ ├── config.js # Конфигурация и словарь слов
|
├── src/
|
||||||
│ ├── game.js # Основной игровой цикл
|
│ ├── entries/ # Точки входа: game.ts (игра), index.ts (настройки)
|
||||||
│ ├── enemy.js # Класс врага (обычный / тяжёлый / босс)
|
│ ├── app/ # GameApp — сборка приложения, фазы, такт
|
||||||
│ ├── projectile.js # Снаряды
|
│ ├── config/ # gameConfig.ts (константы), words.ts (словарь слов)
|
||||||
│ ├── particle.js # Система частиц
|
│ ├── core/ # Шина событий, игровой цикл, метрики, измеритель текста
|
||||||
│ ├── input.js # Обработка ввода (чат + локальный)
|
│ ├── ecs/ # Компоненты, мир miniplex, спавн-функции
|
||||||
│ ├── intro.js # Заставка
|
│ ├── systems/ # Системы: волны, fuse, наведение, интеграция, побег, lifetime
|
||||||
│ ├── utils.js # Утилиты
|
│ ├── game/ # Simulation (урон/оркестрация), GameStore, события, статистика
|
||||||
│ ├── main.js # Точка входа
|
│ ├── input/ # InputSource-интерфейс, роутер, источники: tmi.js + клавиатура
|
||||||
│ └── tmi.min.js # Twitch Messaging Interface
|
│ ├── render/ # Pixi: сетка, частицы, враги, снаряды, баннер, интро
|
||||||
|
│ └── ui/ # DOM: HUD, экраны, статистика, фидбек ввода, скейлинг
|
||||||
├── img/
|
├── img/
|
||||||
│ └── sprite.svg # Иконки (GitHub, Twitch)
|
│ └── sprite.svg # Иконки (GitHub, Twitch)
|
||||||
└── LICENSE # MIT
|
└── LICENSE # MIT
|
||||||
@@ -91,11 +106,10 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН
|
|||||||
|
|
||||||
## Добавление своих слов
|
## Добавление своих слов
|
||||||
|
|
||||||
Отредактируй `js/config.js`:
|
Отредактируй `src/config/words.ts`:
|
||||||
|
|
||||||
WordGenerator.simpleWords - простые враги из одного слова
|
- `SIMPLE_WORDS` — простые враги из одного слова
|
||||||
|
- `HEAVY_WORDS` — бронированные враги: пары слов, каждое слово — отдельный слой брони
|
||||||
WordGenerator.heavyWords - бронированные враги - два слова
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
@@ -103,10 +117,9 @@ MIT
|
|||||||
|
|
||||||
## Связь
|
## Связь
|
||||||
|
|
||||||
- [GitHub Issues](https://github.com/Ku6epXBOCTuK/shooting-word-html/issues) — баги и идеи
|
- [Обратная связь - telegram chat](https://t.me/Ku6epXBOCTuK_chat) — баги и идеи
|
||||||
- [Twitch](https://www.twitch.tv/ku6ep_xboctuk) — смотри игру в действии
|
- [Twitch](https://www.twitch.tv/ku6epxboctuk) — смотри игру в действии
|
||||||
|
|
||||||
## Похожие проекты
|
## Похожие проекты
|
||||||
|
|
||||||
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget).
|
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта более крупного проекта multi-widget
|
||||||
Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { build, context } from 'esbuild'
|
||||||
import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||||
import { dirname, join, relative } from 'node:path'
|
import { dirname, join, relative } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const root = dirname(fileURLToPath(import.meta.url))
|
const root = dirname(fileURLToPath(import.meta.url))
|
||||||
const dist = join(root, 'dist')
|
const dist = join(root, 'dist')
|
||||||
|
const watch = process.argv.includes('--watch')
|
||||||
|
|
||||||
const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json']
|
const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json']
|
||||||
const jsDir = 'js'
|
|
||||||
const imgFiles = ['sprite.svg']
|
const imgFiles = ['sprite.svg']
|
||||||
|
|
||||||
function listFiles(dir) {
|
function listFiles(dir) {
|
||||||
@@ -22,14 +23,33 @@ for (const file of rootFiles) {
|
|||||||
copyFileSync(join(root, file), join(dist, file))
|
copyFileSync(join(root, file), join(dist, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
cpSync(join(root, jsDir), join(dist, jsDir), { recursive: true })
|
|
||||||
|
|
||||||
mkdirSync(join(dist, 'img'))
|
mkdirSync(join(dist, 'img'))
|
||||||
for (const file of imgFiles) {
|
for (const file of imgFiles) {
|
||||||
copyFileSync(join(root, 'img', file), join(dist, 'img', file))
|
copyFileSync(join(root, 'img', file), join(dist, 'img', 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:')
|
console.log('Собрано в dist:')
|
||||||
for (const file of listFiles(dist)) {
|
for (const file of listFiles(dist)) {
|
||||||
console.log(' ' + relative(dist, file))
|
console.log(' ' + relative(dist, file))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+88
-23
@@ -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. Система волн — считает таймеры, решает когда спавнить нового врага и какого типа, создаёт сущность в контейнере.
|
Сущность — объект с опциональными компонентами; «есть компонент» = свойство не `undefined`.
|
||||||
2. Система ввода — получает слово, ищет лучшего врага (точное совпадение > более позднее слово > частичное), создаёт снаряд с ссылкой на цель.
|
|
||||||
3. Система движения — для всех сущностей с позицией и скоростью: позиция += скорость.
|
|
||||||
4. Система наведения — для снарядов с целью: вычисляет расстояние, если рядом — помечает попадание, если цель исчезла — удаляет снаряд, иначе двигает к цели.
|
|
||||||
5. Система урона — для снарядов с пометкой попадания: проверяет броню (если есть и не ноль — снимает), проверяет босс-данные (считает убитые слова в слое, если слой пройден — переключает), если враг умер — удаляет и вызывает callback.
|
|
||||||
6. Система рендера — берёт все враги, сортирует по высоте, рисует форму/слово/планку брони. Отдельно рисует снаряды. Отрисовка HUD отдельно от ECS.
|
|
||||||
7. Система очистки — удаляет из контейнера все сущности с пометкой "мёртвый".
|
|
||||||
|
|
||||||
## Что остаётся за пределами 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. Контейнер сущностей — замена глобальных массивов.
|
1. `fuseSystem` — снаряд стоит у стрелка, пока не истечёт задержка ввода (150 мс), потом получает скорость.
|
||||||
2. Система движения — самая простая, проверка что всё работает.
|
2. `homingSystem` — наведение на цель, касание → событие `projectile:hit`; цель исчезла — снаряд удаляется.
|
||||||
3. Система наведения и урона — core gameplay.
|
3. `missSystem` — промах долетает до точки → `projectile:miss`.
|
||||||
4. Система волн — спавн.
|
4. `waveSystem` — таймеры волны, спавн, пауза, боссы; пишет в синглтон `waveState`.
|
||||||
5. Система ввода — поиск цели + создание снаряда.
|
5. `integrationSystem` — единое движение для всех сущностей с position+velocity (и damping).
|
||||||
6. Система рендера — вся отрисовка.
|
6. `cullSystem` — снаряды за экраном.
|
||||||
7. Остальное — очистка, game loop, удаление старых классов.
|
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` и т.д.).
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Shooting word</title>
|
<title>Shooting word</title>
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
<script src="js/tmi.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="game-container">
|
<div id="game-container">
|
||||||
@@ -38,14 +37,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/config.js"></script>
|
<script src="game.js" defer></script>
|
||||||
<script src="js/utils.js"></script>
|
|
||||||
<script src="js/input.js"></script>
|
|
||||||
<script src="js/particle.js"></script>
|
|
||||||
<script src="js/enemy.js"></script>
|
|
||||||
<script src="js/projectile.js"></script>
|
|
||||||
<script src="js/game.js"></script>
|
|
||||||
<script src="js/intro.js"></script>
|
|
||||||
<script src="js/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -66,6 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="js/index.js"></script>
|
<script src="index.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
-402
@@ -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)];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-292
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-339
@@ -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 ? '<span class="stats-crown">👑</span> ' : "";
|
|
||||||
const place = i === 0 ? "1" : i === 1 ? "2" : "3";
|
|
||||||
row.innerHTML = `<span class="stats-place">#${place}</span> ${crown}<span class="stats-name">${name}</span> <span class="stats-kills">${playerStats[name].kills}</span>`;
|
|
||||||
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 = `<span class="mazila-title">Мазила:</span> ${mazilaName} <span class="mazila-count">(${maxMisses})</span>`;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-43
@@ -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");
|
|
||||||
});
|
|
||||||
-113
@@ -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 = `<span style="opacity:0.6">${username}:</span> ${text}`;
|
|
||||||
} else {
|
|
||||||
el.innerHTML = "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.currentLine = "";
|
|
||||||
this.localBuffer = "";
|
|
||||||
this.updateLocalDisplay("");
|
|
||||||
this.updateChatDisplay("", "");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-156
@@ -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);
|
|
||||||
}
|
|
||||||
-32
@@ -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} <span id="score">0</span>`;
|
|
||||||
document.querySelector("#ui-overlay .ui-text:nth-child(2)").innerHTML =
|
|
||||||
`${CONFIG.texts.waveLabel} <span id="wave">1</span>`;
|
|
||||||
document.querySelector("#ui-overlay .ui-text:nth-child(3)").innerHTML =
|
|
||||||
`${CONFIG.texts.livesLabel} <span id="lives">${"♥".repeat(CONFIG.game.startLives)}</span>`;
|
|
||||||
|
|
||||||
document.querySelector("#gameover-screen .screen-subtitle").innerHTML =
|
|
||||||
`${CONFIG.texts.finalScoreLabel} <span id="final-score">0</span>`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
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();
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-230
@@ -1,230 +0,0 @@
|
|||||||
let frameFactor = 1;
|
|
||||||
let _lastTime = 0;
|
|
||||||
|
|
||||||
function calcDt(timestamp) {
|
|
||||||
if (!_lastTime || !timestamp) {
|
|
||||||
frameFactor = 1;
|
|
||||||
} else {
|
|
||||||
frameFactor = Math.min((timestamp - _lastTime) / (1000 / 60), 6);
|
|
||||||
}
|
|
||||||
_lastTime = timestamp || performance.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function crossTimeToSpeed(crossTimeSec) {
|
|
||||||
const playH = H - S.removeZone;
|
|
||||||
return playH / (crossTimeSec * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnemyCrossTime(type) {
|
|
||||||
const SP = CONFIG.speed;
|
|
||||||
if (type === "slow") {
|
|
||||||
const t = Math.min(wave / SP.slowEnemyCrossTimeCapWave, 1);
|
|
||||||
return SP.slowEnemyCrossTimeStart - (SP.slowEnemyCrossTimeStart - SP.slowEnemyCrossTimeCap) * t;
|
|
||||||
}
|
|
||||||
if (type === "fast") {
|
|
||||||
const t = Math.min(wave / SP.fastEnemyCrossTimeCapWave, 1);
|
|
||||||
return SP.fastEnemyCrossTimeStart - (SP.fastEnemyCrossTimeStart - SP.fastEnemyCrossTimeCap) * t;
|
|
||||||
}
|
|
||||||
return SP.bossCrossTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
let S = {};
|
|
||||||
|
|
||||||
function recalc() {
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
W = window.innerWidth;
|
|
||||||
H = window.innerHeight;
|
|
||||||
canvas.width = W * dpr;
|
|
||||||
canvas.height = H * dpr;
|
|
||||||
canvas.style.width = W + "px";
|
|
||||||
canvas.style.height = H + "px";
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
S = {};
|
|
||||||
|
|
||||||
const V = CONFIG.visual;
|
|
||||||
const C = CONFIG.counts;
|
|
||||||
const R = CONFIG.visualRatio;
|
|
||||||
const T = CONFIG.timing;
|
|
||||||
|
|
||||||
S.gridSize = Math.round((V.gridSize * W) / 1000);
|
|
||||||
S.enemyPaddingX = Math.round((V.enemyPaddingX * W) / 1000);
|
|
||||||
S.enemyAlienOffset = Math.round((V.enemyAlienOffset * W) / 1000);
|
|
||||||
S.armorBarPad = Math.round((V.armorBarPad * W) / 1000);
|
|
||||||
S.armorBarFont = Math.round((V.armorBarFont * W) / 1000);
|
|
||||||
S.missTargetPadX = Math.round((V.missTargetPadX * W) / 1000);
|
|
||||||
S.missTargetPadY = Math.round((V.missTargetPadY * H) / 1000);
|
|
||||||
S.inputAreaHeight = Math.round((V.inputAreaHeight * H) / 1000);
|
|
||||||
S.removeZone = Math.round((V.removeZone * H) / 1000);
|
|
||||||
|
|
||||||
for (const k of [
|
|
||||||
"playerY",
|
|
||||||
"playerRadius",
|
|
||||||
"playerGlow",
|
|
||||||
"enemySimpleHeight",
|
|
||||||
"enemyHeavyHeight",
|
|
||||||
"enemySimpleFont",
|
|
||||||
"enemyHeavyFont",
|
|
||||||
"enemyArmorBarHeight",
|
|
||||||
"enemyArmorBarOffset",
|
|
||||||
"enemyArmorLabelOffset",
|
|
||||||
"enemyGlow",
|
|
||||||
"enemyFlashGlow",
|
|
||||||
"enemyLineWidth",
|
|
||||||
"projectileGlow",
|
|
||||||
"projectileHitRadius",
|
|
||||||
"projectileMissRadius",
|
|
||||||
"projectileFont",
|
|
||||||
"projectileCircleRadius",
|
|
||||||
"shakeHit",
|
|
||||||
"shakeMiss",
|
|
||||||
"shakeDamage",
|
|
||||||
"particleVel",
|
|
||||||
"particleSizeMin",
|
|
||||||
"particleSizeMax",
|
|
||||||
"particleGlow",
|
|
||||||
"trailSizeBase",
|
|
||||||
"trailSizeInc",
|
|
||||||
"trailGlow",
|
|
||||||
"shooterOffset",
|
|
||||||
]) {
|
|
||||||
S[k] = Math.round((V[k] * H) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
S.projectileSpeed = H / (CONFIG.speed.projectileCrossTime * 60);
|
|
||||||
S.gridSpeed = (CONFIG.speed.gridSpeed * H) / 1000;
|
|
||||||
|
|
||||||
for (const k of [
|
|
||||||
"projectileTrailLength",
|
|
||||||
"particleHit",
|
|
||||||
"particleMiss",
|
|
||||||
"particleDestroySimple",
|
|
||||||
"particleDestroyHeavy",
|
|
||||||
"particleArmorBreak",
|
|
||||||
"flashDuration",
|
|
||||||
"shakeHitDuration",
|
|
||||||
"shakeMissDuration",
|
|
||||||
"shakeDamageDuration",
|
|
||||||
"trailMaxLength",
|
|
||||||
"trailDecay",
|
|
||||||
"trailAlpha",
|
|
||||||
]) {
|
|
||||||
S[k] = C[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const k of [
|
|
||||||
"missTargetYRange",
|
|
||||||
"projectileTrailLength",
|
|
||||||
"projectileTrailDecay",
|
|
||||||
"projectileTrailAlpha",
|
|
||||||
"projectileCircleAlpha",
|
|
||||||
"enemyPulseRate",
|
|
||||||
"enemyAlphaBase",
|
|
||||||
"enemyAlphaRange",
|
|
||||||
"enemyGlowPulse",
|
|
||||||
"particleDecayBase",
|
|
||||||
"particleDecayRange",
|
|
||||||
"particleDamping",
|
|
||||||
]) {
|
|
||||||
S[k] = R[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
S.inputShootDelay = T.inputShootDelay;
|
|
||||||
S.glitchDuration = T.glitchDuration;
|
|
||||||
|
|
||||||
applyOverlayScale(W, H);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyOverlayScale(W, H) {
|
|
||||||
const title = document.querySelector(".screen-title");
|
|
||||||
if (title) title.style.fontSize = Math.round((40 * H) / 1000) + "px";
|
|
||||||
const subs = document.querySelectorAll(".screen-subtitle");
|
|
||||||
for (let el of subs) el.style.fontSize = Math.round((18 * H) / 1000) + "px";
|
|
||||||
|
|
||||||
const overlay = document.getElementById("ui-overlay");
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.padding = Math.round((12 * H) / 1000) + "px " + Math.round((16 * W) / 1000) + "px";
|
|
||||||
overlay.style.fontSize = Math.round((28 * H) / 1000) + "px";
|
|
||||||
}
|
|
||||||
// const inputDisplay = document.getElementById("input-display");
|
|
||||||
// if (inputDisplay) {
|
|
||||||
// inputDisplay.style.padding = Math.round(14 * H / 1000) + "px " + Math.round(16 * W / 1000) + "px";
|
|
||||||
// }
|
|
||||||
const typed = document.getElementById("typed-text");
|
|
||||||
if (typed) {
|
|
||||||
typed.style.fontSize = Math.round((20 * H) / 1000) + "px";
|
|
||||||
typed.style.minHeight = Math.round((28 * H) / 1000) + "px";
|
|
||||||
}
|
|
||||||
const chatText = document.getElementById("chat-text");
|
|
||||||
if (chatText) {
|
|
||||||
chatText.style.fontSize = Math.round((16 * H) / 1000) + "px";
|
|
||||||
chatText.style.minHeight = Math.round((28 * H) / 1000) + "px";
|
|
||||||
}
|
|
||||||
const chForm = document.getElementById("channel-form");
|
|
||||||
if (chForm) {
|
|
||||||
chForm.querySelector(".screen-title").style.fontSize = Math.round((80 * H) / 1000) + "px";
|
|
||||||
const inp = chForm.querySelector("input");
|
|
||||||
if (inp) {
|
|
||||||
inp.style.fontSize = Math.round((22 * H) / 1000) + "px";
|
|
||||||
inp.style.padding = Math.round((12 * H) / 1000) + "px " + Math.round((20 * W) / 1000) + "px";
|
|
||||||
inp.style.width = Math.round((300 * W) / 1000) + "px";
|
|
||||||
}
|
|
||||||
const btn = chForm.querySelector("button");
|
|
||||||
if (btn) {
|
|
||||||
btn.style.fontSize = Math.round((18 * H) / 1000) + "px";
|
|
||||||
btn.style.padding = Math.round((10 * H) / 1000) + "px " + Math.round((40 * W) / 1000) + "px";
|
|
||||||
btn.style.minWidth = Math.round((260 * W) / 1000) + "px";
|
|
||||||
}
|
|
||||||
const link = document.getElementById("game-link");
|
|
||||||
if (link) link.style.fontSize = Math.round((16 * H) / 1000) + "px";
|
|
||||||
const note = chForm.querySelector(".obs-note");
|
|
||||||
if (note) note.style.fontSize = Math.round((16 * H) / 1000) + "px";
|
|
||||||
}
|
|
||||||
const goTitle = document.getElementById("gameover-title");
|
|
||||||
if (goTitle) goTitle.style.fontSize = Math.round((64 * H) / 1000) + "px";
|
|
||||||
const goSubs = document.querySelectorAll("#gameover-screen .screen-subtitle");
|
|
||||||
for (let el of goSubs) el.style.fontSize = Math.round((28 * H) / 1000) + "px";
|
|
||||||
const statsTitle = document.querySelectorAll(".stats-section-title");
|
|
||||||
for (let el of statsTitle) el.style.fontSize = Math.round((28 * H) / 1000) + "px";
|
|
||||||
const statsTop1 = document.querySelectorAll(".stats-top1");
|
|
||||||
for (let el of statsTop1) el.style.fontSize = Math.round((48 * H) / 1000) + "px";
|
|
||||||
const statsTop2 = document.querySelectorAll(".stats-top2");
|
|
||||||
for (let el of statsTop2) el.style.fontSize = Math.round((36 * H) / 1000) + "px";
|
|
||||||
const statsTop3 = document.querySelectorAll(".stats-top3");
|
|
||||||
for (let el of statsTop3) el.style.fontSize = Math.round((28 * H) / 1000) + "px";
|
|
||||||
const statsRow = document.querySelectorAll(".stats-row:not(.stats-top1):not(.stats-top2):not(.stats-top3)");
|
|
||||||
for (let el of statsRow) el.style.fontSize = Math.round((24 * H) / 1000) + "px";
|
|
||||||
const mazila = document.querySelectorAll(".stats-mazila");
|
|
||||||
for (let el of mazila) el.style.fontSize = Math.round((28 * H) / 1000) + "px";
|
|
||||||
const crown = document.querySelectorAll(".stats-crown");
|
|
||||||
for (let el of crown) el.style.fontSize = Math.round((56 * H) / 1000) + "px";
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGrid() {
|
|
||||||
ctx.strokeStyle = CONFIG.colors.primaryGrid;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const gridSize = S.gridSize;
|
|
||||||
bgOffset = (bgOffset + S.gridSpeed * frameFactor) % gridSize;
|
|
||||||
for (let x = 0; x <= W; x += gridSize) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, H);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = bgOffset; y <= H; y += gridSize) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(W, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnParticles(x, y, color, count) {
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
particles.push(new Particle(x, y, color));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerShake(amount, duration) {
|
|
||||||
shakeAmount = amount;
|
|
||||||
shakeTimer = duration;
|
|
||||||
}
|
|
||||||
+16
-3
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "shooting-word",
|
"name": "shooting-word",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.",
|
"description": "Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "tsc --noEmit && node build.js",
|
||||||
|
"dev": "node build.js --watch",
|
||||||
|
"serve": "pnpm dlx serve dist -l 8080"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
@@ -13,5 +16,15 @@
|
|||||||
"onFail": "download"
|
"onFail": "download"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"miniplex": "^2.0.0",
|
||||||
|
"pixi.js": "^8.20.1",
|
||||||
|
"tmi.js": "^1.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/tmi.js": "^1.8.6",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+670
-1
@@ -205,4 +205,673 @@ settings:
|
|||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.: {}
|
.:
|
||||||
|
dependencies:
|
||||||
|
miniplex:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
|
pixi.js:
|
||||||
|
specifier: ^8.20.1
|
||||||
|
version: 8.20.1
|
||||||
|
tmi.js:
|
||||||
|
specifier: ^1.8.5
|
||||||
|
version: 1.8.5
|
||||||
|
devDependencies:
|
||||||
|
'@types/tmi.js':
|
||||||
|
specifier: ^1.8.6
|
||||||
|
version: 1.8.6
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.28.2
|
||||||
|
version: 0.28.2
|
||||||
|
typescript:
|
||||||
|
specifier: ^7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.28.2':
|
||||||
|
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||||
|
engines: {node: '>=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: {}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -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<void> {
|
||||||
|
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<GameEvents>()
|
||||||
|
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<string, unknown>).__sw = { world, store, bus, metrics: metricsStore, renderer }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<T>(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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type EventHandler<T> = (payload: T) => void
|
||||||
|
|
||||||
|
/** Минималистичная типизированная шина событий: источники ввода/логика — издатели, рендер и DOM-UI — подписчики. */
|
||||||
|
export class EventBus<Events extends Record<string, unknown>> {
|
||||||
|
private handlers = new Map<keyof Events, Set<EventHandler<never>>>()
|
||||||
|
|
||||||
|
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
|
||||||
|
let set = this.handlers.get(event)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.handlers.set(event, set)
|
||||||
|
}
|
||||||
|
set.add(handler as EventHandler<never>)
|
||||||
|
return () => set.delete(handler as EventHandler<never>)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
|
||||||
|
const set = this.handlers.get(event)
|
||||||
|
if (!set) return
|
||||||
|
for (const handler of [...set]) {
|
||||||
|
;(handler as EventHandler<Events[K]>)(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Игрок — абстракция над источником ввода (TMI, Twurple, локальная клавиатура). */
|
||||||
|
export type Player = {
|
||||||
|
/** Стабильный идентификатор (для чата — логин, для Twurple — user id). */
|
||||||
|
id: string
|
||||||
|
/** Отображаемое имя для статистики. */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputOrigin = 'local' | 'chat'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<GameEntity>
|
||||||
|
|
||||||
|
export function createGameWorld(): GameWorld {
|
||||||
|
return new World<GameEntity>()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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<string>()
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Player } from '../core/types'
|
||||||
|
import type { PlayerStatsSnapshot } from './events'
|
||||||
|
|
||||||
|
/** Статистика зрителей: убийства и промахи по именам. */
|
||||||
|
export class PlayerStats {
|
||||||
|
private readonly entries = new Map<string, { kills: number; misses: number }>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, never>
|
||||||
|
// бой
|
||||||
|
'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<string, never>
|
||||||
|
'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
|
||||||
|
}
|
||||||
@@ -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<GameEvents>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] ?? ''
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEntity, EnemyView>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
parent: Container,
|
||||||
|
world: GameWorld,
|
||||||
|
bus: EventBus<GameEvents>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEntity, Particle>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEntity, ProjectileView>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEvents>,
|
||||||
|
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<GameEvents>,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
): Promise<Renderer> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, number>()
|
||||||
|
|
||||||
|
export function tintOf(cssColor: string): number {
|
||||||
|
let tint = tintCache.get(cssColor)
|
||||||
|
if (tint === undefined) {
|
||||||
|
tint = cssColorToHex(cssColor)
|
||||||
|
tintCache.set(cssColor, tint)
|
||||||
|
}
|
||||||
|
return tint
|
||||||
|
}
|
||||||
@@ -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<GameEvents>
|
||||||
|
metrics: MetricsStore
|
||||||
|
spawn: SpawnDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сущности, запланированные к удалению внутри системы, удаляются после обхода запроса. */
|
||||||
|
export function removeAll(world: GameWorld, entities: GameEntity[]): void {
|
||||||
|
for (const entity of entities) world.remove(entity)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEvents>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<HTMLElement, number>()
|
||||||
|
|
||||||
|
constructor(bus: EventBus<GameEvents>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Metrics } from '../core/metrics'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Масштабирование DOM-оверлея под размер окна (перенос applyOverlayScale).
|
||||||
|
* Значения — «дизайн-пиксели» от базовой высоты/ширины 1000.
|
||||||
|
*/
|
||||||
|
export class OverlayScaler {
|
||||||
|
private readonly title = document.querySelector<HTMLElement>('.screen-title')
|
||||||
|
private readonly subtitles = document.querySelectorAll<HTMLElement>('.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<HTMLElement>('#gameover-screen .screen-subtitle')
|
||||||
|
private readonly statsTitles = document.querySelectorAll<HTMLElement>('.stats-section-title')
|
||||||
|
private readonly statsTop1 = document.querySelectorAll<HTMLElement>('.stats-top1')
|
||||||
|
private readonly statsTop2 = document.querySelectorAll<HTMLElement>('.stats-top2')
|
||||||
|
private readonly statsTop3 = document.querySelectorAll<HTMLElement>('.stats-top3')
|
||||||
|
private readonly statsRows = document.querySelectorAll<HTMLElement>(
|
||||||
|
'.stats-row:not(.stats-top1):not(.stats-top2):not(.stats-top3)',
|
||||||
|
)
|
||||||
|
private readonly mazila = document.querySelectorAll<HTMLElement>('.stats-mazila')
|
||||||
|
private readonly crowns = document.querySelectorAll<HTMLElement>('.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<HTMLElement>('.screen-title')
|
||||||
|
if (formTitle) formTitle.style.fontSize = vh(80)
|
||||||
|
const input = this.channelForm.querySelector<HTMLInputElement>('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<HTMLButtonElement>('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<HTMLElement>('.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEvents>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function requiredElement<T extends HTMLElement>(id: string): T {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
if (!element) throw new Error(`Shooting Word: не найден элемент #${id}`)
|
||||||
|
return element as T
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user