feat: move to typescript, miniplex ecs
This commit is contained in:
@@ -15,17 +15,17 @@ Shooting Word — это browser-source виджет для стримов, пр
|
||||
## Типы врагов
|
||||
|
||||
| Тип | Описание | Очки |
|
||||
| ----------- | --------------------------------------------- | ---- |
|
||||
| ----------- | ------------------------------------------------------ | ---- |
|
||||
| **Обычный** | Одно слово (hack, code, data, git...) | 10 |
|
||||
| **Тяжёлый** | Два слова подряд (fire wall, null pointer...) | 50 |
|
||||
| **Тяжёлый** | Два слова-слоя подряд (fire → wall, null → pointer...) | 50 |
|
||||
| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 |
|
||||
|
||||
Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
|
||||
Броня отображается и пробивается только по текущему (верхнему) слою. Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Волны** — количество врагов и их скорость растут с каждой волной
|
||||
- **Боссы** — multi-phase враги со слоями защиты
|
||||
- **Боссы** — multi-layer враги со слоями защиты
|
||||
- **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов
|
||||
- **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне
|
||||
- **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot)
|
||||
@@ -33,10 +33,20 @@ Shooting Word — это browser-source виджет для стримов, пр
|
||||
|
||||
## Установка
|
||||
|
||||
### Сборка из исходников
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # typecheck + сборка в dist/
|
||||
pnpm serve # локальный сервер на dist/
|
||||
```
|
||||
|
||||
Для разработки: `pnpm dev` — esbuild watch, пересобирает dist при изменениях в src/.
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
1. Открой [страницу настройки](http://ku6epxboctuk.is-a.dev/shooting-word-html/)
|
||||
2. Введи имя.twitch канала
|
||||
1. Открой [страницу настройки](https://xboctuk.duckdns.org/shooting-word/)
|
||||
2. Введи имя twitch канала
|
||||
3. Нажми **СКОПИРОВАТЬ ССЫЛКУ**
|
||||
4. В OBS: Sources → Browser → вставь ссылку
|
||||
5. Рекомендуемая высота — не менее 800px
|
||||
@@ -44,7 +54,7 @@ Shooting Word — это browser-source виджет для стримов, пр
|
||||
### Прямая ссылка
|
||||
|
||||
```txt
|
||||
http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА
|
||||
https://xboctuk.duckdns.org/shooting-word/game.html?channel=ИМЯ_КАНАЛА
|
||||
```
|
||||
|
||||
### Параметры URL
|
||||
@@ -61,29 +71,34 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН
|
||||
|
||||
## Технологии
|
||||
|
||||
- Vanilla JavaScript (ES6+), без фреймворков и сборщиков
|
||||
- Canvas 2D для рендеринга
|
||||
- TypeScript (strict), сборка esbuild
|
||||
- [PixiJS v8](https://pixijs.com/) для рендера (WebGL/WebGPU)
|
||||
- [miniplex](https://github.com/hmans/miniplex) — ECS-ядро
|
||||
- [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC
|
||||
- Один HTML-файл + CSS + JS модули
|
||||
- Хостится на GitHub Pages
|
||||
|
||||
Архитектура описана в [ecs-refactoring.md](ecs-refactoring.md): ввод отделён интерфейсом
|
||||
`InputSource` (переход на Twurple и запуск через баллы канала = новый источник без правок
|
||||
игры), логика отделена от рендера, сущности и системы — на miniplex.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```txt
|
||||
├── index.html # Страница настройки (выбор канала)
|
||||
├── game.html # Игровой экран
|
||||
├── style.css # Стили ( cyberpunk-тема: чёрный фон, зелёный неон)
|
||||
├── js/
|
||||
│ ├── config.js # Конфигурация и словарь слов
|
||||
│ ├── game.js # Основной игровой цикл
|
||||
│ ├── enemy.js # Класс врага (обычный / тяжёлый / босс)
|
||||
│ ├── projectile.js # Снаряды
|
||||
│ ├── particle.js # Система частиц
|
||||
│ ├── input.js # Обработка ввода (чат + локальный)
|
||||
│ ├── intro.js # Заставка
|
||||
│ ├── utils.js # Утилиты
|
||||
│ ├── main.js # Точка входа
|
||||
│ └── tmi.min.js # Twitch Messaging Interface
|
||||
├── style.css # Стили (cyberpunk-тема: чёрный фон, зелёный неон)
|
||||
├── build.js # esbuild-сборка двух бандлов + копирование статики в dist/
|
||||
├── src/
|
||||
│ ├── entries/ # Точки входа: game.ts (игра), index.ts (настройки)
|
||||
│ ├── app/ # GameApp — сборка приложения, фазы, такт
|
||||
│ ├── config/ # gameConfig.ts (константы), words.ts (словарь слов)
|
||||
│ ├── core/ # Шина событий, игровой цикл, метрики, измеритель текста
|
||||
│ ├── ecs/ # Компоненты, мир miniplex, спавн-функции
|
||||
│ ├── systems/ # Системы: волны, fuse, наведение, интеграция, побег, lifetime
|
||||
│ ├── game/ # Simulation (урон/оркестрация), GameStore, события, статистика
|
||||
│ ├── input/ # InputSource-интерфейс, роутер, источники: tmi.js + клавиатура
|
||||
│ ├── render/ # Pixi: сетка, частицы, враги, снаряды, баннер, интро
|
||||
│ └── ui/ # DOM: HUD, экраны, статистика, фидбек ввода, скейлинг
|
||||
├── img/
|
||||
│ └── sprite.svg # Иконки (GitHub, Twitch)
|
||||
└── LICENSE # MIT
|
||||
@@ -91,11 +106,10 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН
|
||||
|
||||
## Добавление своих слов
|
||||
|
||||
Отредактируй `js/config.js`:
|
||||
Отредактируй `src/config/words.ts`:
|
||||
|
||||
WordGenerator.simpleWords - простые враги из одного слова
|
||||
|
||||
WordGenerator.heavyWords - бронированные враги - два слова
|
||||
- `SIMPLE_WORDS` — простые враги из одного слова
|
||||
- `HEAVY_WORDS` — бронированные враги: пары слов, каждое слово — отдельный слой брони
|
||||
|
||||
## Лицензия
|
||||
|
||||
@@ -103,10 +117,9 @@ MIT
|
||||
|
||||
## Связь
|
||||
|
||||
- [GitHub Issues](https://github.com/Ku6epXBOCTuK/shooting-word-html/issues) — баги и идеи
|
||||
- [Twitch](https://www.twitch.tv/ku6ep_xboctuk) — смотри игру в действии
|
||||
- [Обратная связь - telegram chat](https://t.me/Ku6epXBOCTuK_chat) — баги и идеи
|
||||
- [Twitch](https://www.twitch.tv/ku6epxboctuk) — смотри игру в действии
|
||||
|
||||
## Похожие проекты
|
||||
|
||||
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget).
|
||||
Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva
|
||||
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта более крупного проекта multi-widget
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { build, context } from 'esbuild'
|
||||
import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const dist = join(root, 'dist')
|
||||
const watch = process.argv.includes('--watch')
|
||||
|
||||
const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json']
|
||||
const jsDir = 'js'
|
||||
const imgFiles = ['sprite.svg']
|
||||
|
||||
function listFiles(dir) {
|
||||
@@ -22,14 +23,33 @@ for (const file of rootFiles) {
|
||||
copyFileSync(join(root, file), join(dist, file))
|
||||
}
|
||||
|
||||
cpSync(join(root, jsDir), join(dist, jsDir), { recursive: true })
|
||||
|
||||
mkdirSync(join(dist, 'img'))
|
||||
for (const file of imgFiles) {
|
||||
copyFileSync(join(root, 'img', file), join(dist, 'img', file))
|
||||
}
|
||||
|
||||
console.log('Собрано в dist:')
|
||||
for (const file of listFiles(dist)) {
|
||||
console.log(' ' + relative(dist, file))
|
||||
const options = {
|
||||
entryPoints: {
|
||||
game: join(root, 'src/entries/game.ts'),
|
||||
index: join(root, 'src/entries/index.ts'),
|
||||
},
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
format: 'iife',
|
||||
target: 'es2020',
|
||||
logLevel: 'info',
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
const contexts = await Promise.all([context(options)])
|
||||
await Promise.all(contexts.map((c) => c.watch()))
|
||||
console.log('Слежу за изменениями src/...')
|
||||
} else {
|
||||
await build(options)
|
||||
console.log('Собрано в dist:')
|
||||
for (const file of listFiles(dist)) {
|
||||
console.log(' ' + relative(dist, file))
|
||||
}
|
||||
}
|
||||
|
||||
+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. Система волн — считает таймеры, решает когда спавнить нового врага и какого типа, создаёт сущность в контейнере.
|
||||
2. Система ввода — получает слово, ищет лучшего врага (точное совпадение > более позднее слово > частичное), создаёт снаряд с ссылкой на цель.
|
||||
3. Система движения — для всех сущностей с позицией и скоростью: позиция += скорость.
|
||||
4. Система наведения — для снарядов с целью: вычисляет расстояние, если рядом — помечает попадание, если цель исчезла — удаляет снаряд, иначе двигает к цели.
|
||||
5. Система урона — для снарядов с пометкой попадания: проверяет броню (если есть и не ноль — снимает), проверяет босс-данные (считает убитые слова в слое, если слой пройден — переключает), если враг умер — удаляет и вызывает callback.
|
||||
6. Система рендера — берёт все враги, сортирует по высоте, рисует форму/слово/планку брони. Отдельно рисует снаряды. Отрисовка HUD отдельно от ECS.
|
||||
7. Система очистки — удаляет из контейнера все сущности с пометкой "мёртвый".
|
||||
Сущность — объект с опциональными компонентами; «есть компонент» = свойство не `undefined`.
|
||||
|
||||
## Что остаётся за пределами ECS
|
||||
| Компонент | Смысл |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `position`, `velocity`, `damping` | Движение (враги, снаряды, частицы — одна система интеграции) |
|
||||
| `enemyKind`, `enemyBox` | Тип (simple/heavy/boss) и габариты рамки |
|
||||
| `armor` | Слои слов: текущий слой отображается и уязвим только он |
|
||||
| `projectile`, `player`, `target` / `targetPoint`, `fuse` | Снаряд: слово, кто выстрелил, цель (или точка промаха), задержка выстрела |
|
||||
| `lifetime`, `particle` | Частицы — полноценные сущности, а не особый массив |
|
||||
| `waveState` | Синглтон-сущность с состоянием волны и баннером |
|
||||
|
||||
Волны, ввод, рендер HUD, очистка — это системы, но не entity. Жизни/счёт/номер волны — простые переменные, не сущности. Частицы — не сущности, рисуются как визуальный эффект по времени от взрыва.
|
||||
Броня единая для всех типов: simple — 1 слой из 1 слова, heavy — 2 слоя по 1 слову
|
||||
(порядок строгий), boss — 3 слоя по 3 слова (внутри слоя порядок свободный).
|
||||
Урон снимает только текущий слой; после каждого попадания вью перерисовывается по диффу
|
||||
`layerIndex:killed`.
|
||||
|
||||
## Порядок миграции
|
||||
## Системы (порядок за кадр, src/systems)
|
||||
|
||||
1. Контейнер сущностей — замена глобальных массивов.
|
||||
2. Система движения — самая простая, проверка что всё работает.
|
||||
3. Система наведения и урона — core gameplay.
|
||||
4. Система волн — спавн.
|
||||
5. Система ввода — поиск цели + создание снаряда.
|
||||
6. Система рендера — вся отрисовка.
|
||||
7. Остальное — очистка, game loop, удаление старых классов.
|
||||
1. `fuseSystem` — снаряд стоит у стрелка, пока не истечёт задержка ввода (150 мс), потом получает скорость.
|
||||
2. `homingSystem` — наведение на цель, касание → событие `projectile:hit`; цель исчезла — снаряд удаляется.
|
||||
3. `missSystem` — промах долетает до точки → `projectile:miss`.
|
||||
4. `waveSystem` — таймеры волны, спавн, пауза, боссы; пишет в синглтон `waveState`.
|
||||
5. `integrationSystem` — единое движение для всех сущностей с position+velocity (и damping).
|
||||
6. `cullSystem` — снаряды за экраном.
|
||||
7. `escapeSystem` — враг дошёл до низа → `enemy:escaped` → урон.
|
||||
8. `lifetimeSystem` — затухание частиц.
|
||||
|
||||
`Simulation` (src/game) задаёт порядок систем, принимает команды ввода (поиск цели по слову),
|
||||
обрабатывает попадания/промахи/урон и держит скаляры партии (счёт, жизни, фаза, тряска)
|
||||
в `GameStore` — по договорённости это не сущности.
|
||||
|
||||
## Ввод (src/input) — отдельный интерфейс
|
||||
|
||||
Игра зависит только от порта `InputSource`, а не от библиотеки:
|
||||
|
||||
```ts
|
||||
interface InputSource {
|
||||
readonly origin: "local" | "chat";
|
||||
start(): void;
|
||||
stop(): void;
|
||||
onCommand(handler: (command: InputCommand) => void): () => void;
|
||||
}
|
||||
|
||||
type InputCommand =
|
||||
| { kind: "start" } // !play, награда за баллы канала и т.п.
|
||||
| { kind: "clear" }
|
||||
| { kind: "word"; word: string; player: Player };
|
||||
```
|
||||
|
||||
Сейчас реализованы `TwitchChatInputSource` (tmi.js, парсит `!play` и первое слово сообщения)
|
||||
и `LocalKeyboardInputSource` (тест без Twitch). Будущий переход на Twurple и запуск через
|
||||
баллы канала = новый источник, который эмитит `{ kind: 'start' }` на событие награды;
|
||||
`InputRouter` просто регистрирует его рядом с остальными.
|
||||
|
||||
## Рендер (src/render)
|
||||
|
||||
- `GridLayer` — сетка одним `TilingSprite` (вместо ~40 `stroke()` за кадр).
|
||||
- `ParticleLayer` — все частицы в одном `ParticleContainer` (один draw call, пул объектов).
|
||||
- Свечение — заранее запечённая радиальная текстура с аддитивным блендингом
|
||||
вместо попиксельного `shadowBlur`.
|
||||
- Вью врагов/снарядов создаются и уничтожаются по сигналам мира
|
||||
(`world.onEntityAdded/onEntityRemoved`), каждый кадр только синхронизируют позицию/альфу.
|
||||
- Тряска экрана — смещение корневого контейнера, состояние в `GameStore`.
|
||||
- Цикл: внешний `GameClock` (rAF, delta в 60-кадрах), pixi-ticker остановлен; в простое
|
||||
(интро закончилось, партия не идёт) рендер вообще не тикает — canvas хранит последний кадр.
|
||||
Сбой одного кадра не убивает цикл.
|
||||
|
||||
## События (src/game/events.ts)
|
||||
|
||||
Типизированная шина `EventBus`: ввод, бой (`projectile:hit/miss`, `enemy:*`) и состояние
|
||||
партии (`game:started/over`, `score/wave/lives`, `effect:glitch`). Рендер и DOM-UI — только
|
||||
подписчики.
|
||||
|
||||
## DOM-UI (src/ui)
|
||||
|
||||
HUD, экраны старта/поражения, таблица зрителей (имена вставляются через `textContent` —
|
||||
ник из чата не должен стать разметкой), фидбек ввода, масштабирование оверлея под окно.
|
||||
|
||||
## Отладка
|
||||
|
||||
В консоли браузера доступен хук `window.__sw` — мир, стор, шина, метрики и рендерер
|
||||
(`__sw.world.with('armor').entities`, `__sw.store.score` и т.д.).
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shooting word</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="js/tmi.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
@@ -38,14 +37,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/config.js"></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>
|
||||
<script src="game.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -66,6 +66,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/index.js"></script>
|
||||
<script src="index.js" defer></script>
|
||||
</body>
|
||||
</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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.",
|
||||
"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",
|
||||
"devEngines": {
|
||||
@@ -13,5 +16,15 @@
|
||||
"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:
|
||||
|
||||
.: {}
|
||||
.:
|
||||
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