feat: move to typescript, miniplex ecs

This commit is contained in:
2026-09-05 12:07:22 +05:00
parent 63f9885e46
commit f6596f765e
69 changed files with 3932 additions and 1838 deletions
+50 -37
View File
@@ -14,18 +14,18 @@ Shooting Word — это browser-source виджет для стримов, пр
## Типы врагов ## Типы врагов
| Тип | Описание | Очки | | Тип | Описание | Очки |
| ----------- | --------------------------------------------- | ---- | | ----------- | ------------------------------------------------------ | ---- |
| **Обычный** | Одно слово (hack, code, data, git...) | 10 | | **Обычный** | Одно слово (hack, code, data, git...) | 10 |
| **Тяжёлый** | Два слова подряд (fire wall, null pointer...) | 50 | | **Тяжёлый** | Два слова-слоя подряд (fire wall, null pointer...) | 50 |
| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 | | **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 |
Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала. Броня отображается и пробивается только по текущему (верхнему) слою. Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
## Возможности ## Возможности
- **Волны** — количество врагов и их скорость растут с каждой волной - **Волны** — количество врагов и их скорость растут с каждой волной
- **Боссы** — multi-phase враги со слоями защиты - **Боссы** — multi-layer враги со слоями защиты
- **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов - **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов
- **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне - **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне
- **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot) - **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot)
@@ -33,10 +33,20 @@ Shooting Word — это browser-source виджет для стримов, пр
## Установка ## Установка
### Сборка из исходников
```bash
pnpm install
pnpm build # typecheck + сборка в dist/
pnpm serve # локальный сервер на dist/
```
Для разработки: `pnpm dev` — esbuild watch, пересобирает dist при изменениях в src/.
### Быстрый старт ### Быстрый старт
1. Открой [страницу настройки](http://ku6epxboctuk.is-a.dev/shooting-word-html/) 1. Открой [страницу настройки](https://xboctuk.duckdns.org/shooting-word/)
2. Введи имя.twitch канала 2. Введи имя twitch канала
3. Нажми **СКОПИРОВАТЬ ССЫЛКУ** 3. Нажми **СКОПИРОВАТЬ ССЫЛКУ**
4. В OBS: Sources → Browser → вставь ссылку 4. В OBS: Sources → Browser → вставь ссылку
5. Рекомендуемая высота — не менее 800px 5. Рекомендуемая высота — не менее 800px
@@ -44,7 +54,7 @@ Shooting Word — это browser-source виджет для стримов, пр
### Прямая ссылка ### Прямая ссылка
```txt ```txt
http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА https://xboctuk.duckdns.org/shooting-word/game.html?channel=ИМЯ_КАНАЛА
``` ```
### Параметры URL ### Параметры URL
@@ -61,41 +71,45 @@ http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАН
## Технологии ## Технологии
- Vanilla JavaScript (ES6+), без фреймворков и сборщиков - TypeScript (strict), сборка esbuild
- Canvas 2D для рендеринга - [PixiJS v8](https://pixijs.com/) для рендера (WebGL/WebGPU)
- [miniplex](https://github.com/hmans/miniplex) — ECS-ядро
- [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC - [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC
- Один HTML-файл + CSS + JS модули
- Хостится на GitHub Pages - Хостится на GitHub Pages
Архитектура описана в [ecs-refactoring.md](ecs-refactoring.md): ввод отделён интерфейсом
`InputSource` (переход на Twurple и запуск через баллы канала = новый источник без правок
игры), логика отделена от рендера, сущности и системы — на miniplex.
## Структура проекта ## Структура проекта
```txt ```txt
├── index.html # Страница настройки (выбор канала) ├── index.html # Страница настройки (выбор канала)
├── game.html # Игровой экран ├── game.html # Игровой экран
├── style.css # Стили ( cyberpunk-тема: чёрный фон, зелёный неон) ├── style.css # Стили (cyberpunk-тема: чёрный фон, зелёный неон)
├── js/ ├── build.js # esbuild-сборка двух бандлов + копирование статики в dist/
│ ├── config.js # Конфигурация и словарь слов ├── src/
│ ├── game.js # Основной игровой цикл │ ├── entries/ # Точки входа: game.ts (игра), index.ts (настройки)
│ ├── enemy.js # Класс врага (обычный / тяжёлый / босс) │ ├── app/ # GameApp — сборка приложения, фазы, такт
│ ├── projectile.js # Снаряды │ ├── config/ # gameConfig.ts (константы), words.ts (словарь слов)
│ ├── particle.js # Система частиц │ ├── core/ # Шина событий, игровой цикл, метрики, измеритель текста
│ ├── input.js # Обработка ввода (чат + локальный) │ ├── ecs/ # Компоненты, мир miniplex, спавн-функции
│ ├── intro.js # Заставка │ ├── systems/ # Системы: волны, fuse, наведение, интеграция, побег, lifetime
│ ├── utils.js # Утилиты │ ├── game/ # Simulation (урон/оркестрация), GameStore, события, статистика
│ ├── main.js # Точка входа │ ├── input/ # InputSource-интерфейс, роутер, источники: tmi.js + клавиатура
── tmi.min.js # Twitch Messaging Interface ── render/ # Pixi: сетка, частицы, враги, снаряды, баннер, интро
│ └── ui/ # DOM: HUD, экраны, статистика, фидбек ввода, скейлинг
├── img/ ├── img/
│ └── sprite.svg # Иконки (GitHub, Twitch) │ └── sprite.svg # Иконки (GitHub, Twitch)
└── LICENSE # MIT └── LICENSE # MIT
``` ```
## Добавление своих слов ## Добавление своих слов
Отредактируй `js/config.js`: Отредактируй `src/config/words.ts`:
WordGenerator.simpleWords - простые враги из одного слова - `SIMPLE_WORDS` простые враги из одного слова
- `HEAVY_WORDS` — бронированные враги: пары слов, каждое слово — отдельный слой брони
WordGenerator.heavyWords - бронированные враги - два слова
## Лицензия ## Лицензия
@@ -103,10 +117,9 @@ MIT
## Связь ## Связь
- [GitHub Issues](https://github.com/Ku6epXBOCTuK/shooting-word-html/issues) — баги и идеи - [Обратная связь - telegram chat](https://t.me/Ku6epXBOCTuK_chat) — баги и идеи
- [Twitch](https://www.twitch.tv/ku6ep_xboctuk) — смотри игру в действии - [Twitch](https://www.twitch.tv/ku6epxboctuk) — смотри игру в действии
## Похожие проекты ## Похожие проекты
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget). Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта более крупного проекта multi-widget
Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva
+26 -6
View File
@@ -1,12 +1,13 @@
import { build, context } from 'esbuild'
import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs' import { cpSync, copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import { dirname, join, relative } from 'node:path' import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const root = dirname(fileURLToPath(import.meta.url)) const root = dirname(fileURLToPath(import.meta.url))
const dist = join(root, 'dist') const dist = join(root, 'dist')
const watch = process.argv.includes('--watch')
const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json'] const rootFiles = ['index.html', 'game.html', 'style.css', 'serve.json']
const jsDir = 'js'
const imgFiles = ['sprite.svg'] const imgFiles = ['sprite.svg']
function listFiles(dir) { function listFiles(dir) {
@@ -22,14 +23,33 @@ for (const file of rootFiles) {
copyFileSync(join(root, file), join(dist, file)) copyFileSync(join(root, file), join(dist, file))
} }
cpSync(join(root, jsDir), join(dist, jsDir), { recursive: true })
mkdirSync(join(dist, 'img')) mkdirSync(join(dist, 'img'))
for (const file of imgFiles) { for (const file of imgFiles) {
copyFileSync(join(root, 'img', file), join(dist, 'img', file)) copyFileSync(join(root, 'img', file), join(dist, 'img', file))
} }
console.log('Собрано в dist:') const options = {
for (const file of listFiles(dist)) { entryPoints: {
console.log(' ' + relative(dist, file)) 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
View File
@@ -1,33 +1,98 @@
# ECS рефакторинг — план # Архитектура (ECS на miniplex)
## Суть Проект переписан на TypeScript + PixiJS с ECS-ядром на [miniplex](https://github.com/hmans/miniplex).
Этот документ — описание того, как всё устроено сейчас.
Вместо классов врагов/снарядов с методами update() и draw() — единый контейнер сущностей, где каждая сущность это набор компонентов (позиция, скорость, тип, слово и т.д.). Логика вынесена в отдельные функции-системы, которые каждую итерацию проходят по нужным подмножествам сущностей и обновляют их. ## Слои
## Компоненты ```txt
ввод (InputSource) → шина событий → симуляция (мир + системы) → шина событий → рендер (Pixi) и DOM-UI
```
Позиция и скорость — общие для всех движущихся сущностей. Тип врага и данные слова — только для врагов. Ссылка на цель — только для снарядов. Броня и босс-данные — для усиленных и босс-врагов. Пустой маркер — чтобы система знала, что сущность является снарядом. Каждый слой знает только соседей через интерфейсы: логика не знает про Pixi и DOM,
рендер не содержит игровой логики, ввод не знает про игру — только команды.
## Системы (по порядку за кадр) ## Сущности и компоненты (src/ecs)
1. Система волн — считает таймеры, решает когда спавнить нового врага и какого типа, создаёт сущность в контейнере. Сущность — объект с опциональными компонентами; «есть компонент» = свойство не `undefined`.
2. Система ввода — получает слово, ищет лучшего врага (точное совпадение > более позднее слово > частичное), создаёт снаряд с ссылкой на цель.
3. Система движения — для всех сущностей с позицией и скоростью: позиция += скорость.
4. Система наведения — для снарядов с целью: вычисляет расстояние, если рядом — помечает попадание, если цель исчезла — удаляет снаряд, иначе двигает к цели.
5. Система урона — для снарядов с пометкой попадания: проверяет броню (если есть и не ноль — снимает), проверяет босс-данные (считает убитые слова в слое, если слой пройден — переключает), если враг умер — удаляет и вызывает callback.
6. Система рендера — берёт все враги, сортирует по высоте, рисует форму/слово/планку брони. Отдельно рисует снаряды. Отрисовка HUD отдельно от ECS.
7. Система очистки — удаляет из контейнера все сущности с пометкой "мёртвый".
## Что остаётся за пределами ECS | Компонент | Смысл |
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
| `position`, `velocity`, `damping` | Движение (враги, снаряды, частицы — одна система интеграции) |
| `enemyKind`, `enemyBox` | Тип (simple/heavy/boss) и габариты рамки |
| `armor` | Слои слов: текущий слой отображается и уязвим только он |
| `projectile`, `player`, `target` / `targetPoint`, `fuse` | Снаряд: слово, кто выстрелил, цель (или точка промаха), задержка выстрела |
| `lifetime`, `particle` | Частицы — полноценные сущности, а не особый массив |
| `waveState` | Синглтон-сущность с состоянием волны и баннером |
Волны, ввод, рендер HUD, очистка — это системы, но не entity. Жизни/счёт/номер волны — простые переменные, не сущности. Частицы — не сущности, рисуются как визуальный эффект по времени от взрыва. Броня единая для всех типов: simple — 1 слой из 1 слова, heavy — 2 слоя по 1 слову
(порядок строгий), boss — 3 слоя по 3 слова (внутри слоя порядок свободный).
Урон снимает только текущий слой; после каждого попадания вью перерисовывается по диффу
`layerIndex:killed`.
## Порядок миграции ## Системы (порядок за кадр, src/systems)
1. Контейнер сущностей — замена глобальных массивов. 1. `fuseSystem` — снаряд стоит у стрелка, пока не истечёт задержка ввода (150 мс), потом получает скорость.
2. Система движения — самая простая, проверка что всё работает. 2. `homingSystem` — наведение на цель, касание → событие `projectile:hit`; цель исчезла — снаряд удаляется.
3. Система наведения и урона — core gameplay. 3. `missSystem` — промах долетает до точки → `projectile:miss`.
4. Система волн — спавн. 4. `waveSystem` — таймеры волны, спавн, пауза, боссы; пишет в синглтон `waveState`.
5. Система ввода — поиск цели + создание снаряда. 5. `integrationSystem` — единое движение для всех сущностей с position+velocity (и damping).
6. Система рендера — вся отрисовка. 6. `cullSystem` — снаряды за экраном.
7. Остальное — очистка, game loop, удаление старых классов. 7. `escapeSystem` — враг дошёл до низа → `enemy:escaped` → урон.
8. `lifetimeSystem` — затухание частиц.
`Simulation` (src/game) задаёт порядок систем, принимает команды ввода (поиск цели по слову),
обрабатывает попадания/промахи/урон и держит скаляры партии (счёт, жизни, фаза, тряска)
в `GameStore` — по договорённости это не сущности.
## Ввод (src/input) — отдельный интерфейс
Игра зависит только от порта `InputSource`, а не от библиотеки:
```ts
interface InputSource {
readonly origin: "local" | "chat";
start(): void;
stop(): void;
onCommand(handler: (command: InputCommand) => void): () => void;
}
type InputCommand =
| { kind: "start" } // !play, награда за баллы канала и т.п.
| { kind: "clear" }
| { kind: "word"; word: string; player: Player };
```
Сейчас реализованы `TwitchChatInputSource` (tmi.js, парсит `!play` и первое слово сообщения)
и `LocalKeyboardInputSource` (тест без Twitch). Будущий переход на Twurple и запуск через
баллы канала = новый источник, который эмитит `{ kind: 'start' }` на событие награды;
`InputRouter` просто регистрирует его рядом с остальными.
## Рендер (src/render)
- `GridLayer` — сетка одним `TilingSprite` (вместо ~40 `stroke()` за кадр).
- `ParticleLayer` — все частицы в одном `ParticleContainer` (один draw call, пул объектов).
- Свечение — заранее запечённая радиальная текстура с аддитивным блендингом
вместо попиксельного `shadowBlur`.
- Вью врагов/снарядов создаются и уничтожаются по сигналам мира
(`world.onEntityAdded/onEntityRemoved`), каждый кадр только синхронизируют позицию/альфу.
- Тряска экрана — смещение корневого контейнера, состояние в `GameStore`.
- Цикл: внешний `GameClock` (rAF, delta в 60-кадрах), pixi-ticker остановлен; в простое
(интро закончилось, партия не идёт) рендер вообще не тикает — canvas хранит последний кадр.
Сбой одного кадра не убивает цикл.
## События (src/game/events.ts)
Типизированная шина `EventBus`: ввод, бой (`projectile:hit/miss`, `enemy:*`) и состояние
партии (`game:started/over`, `score/wave/lives`, `effect:glitch`). Рендер и DOM-UI — только
подписчики.
## DOM-UI (src/ui)
HUD, экраны старта/поражения, таблица зрителей (имена вставляются через `textContent`
ник из чата не должен стать разметкой), фидбек ввода, масштабирование оверлея под окно.
## Отладка
В консоли браузера доступен хук `window.__sw` — мир, стор, шина, метрики и рендерер
(`__sw.world.with('armor').entities`, `__sw.store.score` и т.д.).
+1 -10
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shooting word</title> <title>Shooting word</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<script src="js/tmi.min.js"></script>
</head> </head>
<body> <body>
<div id="game-container"> <div id="game-container">
@@ -38,14 +37,6 @@
</div> </div>
</div> </div>
<script src="js/config.js"></script> <script src="game.js" defer></script>
<script src="js/utils.js"></script>
<script src="js/input.js"></script>
<script src="js/particle.js"></script>
<script src="js/enemy.js"></script>
<script src="js/projectile.js"></script>
<script src="js/game.js"></script>
<script src="js/intro.js"></script>
<script src="js/main.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -66,6 +66,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="js/index.js"></script> <script src="index.js" defer></script>
</body> </body>
</html> </html>
-402
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
-28
View File
@@ -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();
}
}
-121
View File
@@ -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();
}
}
-1
View File
File diff suppressed because one or more lines are too long
-230
View File
@@ -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
View File
@@ -1,9 +1,12 @@
{ {
"name": "shooting-word", "name": "shooting-word",
"version": "0.1.0", "version": "0.2.0",
"description": "Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.", "description": "Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.",
"scripts": { "scripts": {
"build": "node build.js" "typecheck": "tsc --noEmit",
"build": "tsc --noEmit && node build.js",
"dev": "node build.js --watch",
"serve": "pnpm dlx serve dist -l 8080"
}, },
"license": "MIT", "license": "MIT",
"devEngines": { "devEngines": {
@@ -13,5 +16,15 @@
"onFail": "download" "onFail": "download"
} }
}, },
"type": "module" "type": "module",
"dependencies": {
"miniplex": "^2.0.0",
"pixi.js": "^8.20.1",
"tmi.js": "^1.8.5"
},
"devDependencies": {
"@types/tmi.js": "^1.8.6",
"esbuild": "^0.28.2",
"typescript": "^7.0.2"
}
} }
+670 -1
View File
@@ -205,4 +205,673 @@ settings:
importers: importers:
.: {} .:
dependencies:
miniplex:
specifier: ^2.0.0
version: 2.0.0
pixi.js:
specifier: ^8.20.1
version: 8.20.1
tmi.js:
specifier: ^1.8.5
version: 1.8.5
devDependencies:
'@types/tmi.js':
specifier: ^1.8.6
version: 1.8.6
esbuild:
specifier: ^0.28.2
version: 0.28.2
typescript:
specifier: ^7.0.2
version: 7.0.2
packages:
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@hmans/id@0.0.1':
resolution: {integrity: sha512-2BxHxZziST8F0P5Dt+UuttF2lwCaKK7QFR814PwEp708l6dm86pjWJAFr7BKyAGu27mRpYcghvCAUAucNdsCdw==}
'@hmans/queue@0.0.1':
resolution: {integrity: sha512-Mm8oQRFRoiEYQ3QD9CD14DTaShd21nzNrUFyBhFxy+TduKf2PdMRfHHx7lDvEpbODGNlj+e8mcWGj/YjWmC3Bw==}
'@miniplex/bucket@2.0.0':
resolution: {integrity: sha512-jdAW0vG0tLel0dD0lyQqkQ4g4G9CscbVtenu35jB48fnVK3zmfFmQKIxXHsUvFs4KAN44UESAgaVOroTetFSRw==}
'@pixi/colord@2.9.6':
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==}
'@types/earcut@3.0.0':
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
'@types/tmi.js@1.8.6':
resolution: {integrity: sha512-LVzNK7AxTMyh9qHLanAQR1o0I9XzfbIcXk85cx85igmCJHHO1Sm71sdhQ8Mj1WmRGzynPIoCXx6mVaFynWbsQw==}
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [aix]
'@typescript/typescript-darwin-arm64@7.0.2':
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/typescript-darwin-x64@7.0.2':
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/typescript-freebsd-arm64@7.0.2':
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [freebsd]
'@typescript/typescript-freebsd-x64@7.0.2':
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [freebsd]
'@typescript/typescript-linux-arm64@7.0.2':
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/typescript-linux-arm@7.0.2':
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/typescript-linux-loong64@7.0.2':
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
engines: {node: '>=16.20.0'}
cpu: [loong64]
os: [linux]
'@typescript/typescript-linux-mips64el@7.0.2':
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
engines: {node: '>=16.20.0'}
cpu: [mips64el]
os: [linux]
'@typescript/typescript-linux-ppc64@7.0.2':
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [linux]
'@typescript/typescript-linux-riscv64@7.0.2':
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
engines: {node: '>=16.20.0'}
cpu: [riscv64]
os: [linux]
'@typescript/typescript-linux-s390x@7.0.2':
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
engines: {node: '>=16.20.0'}
cpu: [s390x]
os: [linux]
'@typescript/typescript-linux-x64@7.0.2':
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/typescript-netbsd-arm64@7.0.2':
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [netbsd]
'@typescript/typescript-netbsd-x64@7.0.2':
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [netbsd]
'@typescript/typescript-openbsd-arm64@7.0.2':
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [openbsd]
'@typescript/typescript-openbsd-x64@7.0.2':
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [openbsd]
'@typescript/typescript-sunos-x64@7.0.2':
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [sunos]
'@typescript/typescript-win32-arm64@7.0.2':
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/typescript-win32-x64@7.0.2':
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@webgpu/types@0.1.72':
resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
earcut@3.2.3:
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventery@0.0.4:
resolution: {integrity: sha512-rQbXJG8WX9z2cm3IJadkSUZLWFXuwGMIJ40oWyB23DhVb1e92T7Jx3qF0UwtgHI7D4VZzVswbZ7MXfW88cFF4Q==}
gifuct-js@2.1.2:
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==}
ismobilejs@1.1.1:
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==}
js-binary-schema-parser@2.0.3:
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
miniplex@2.0.0:
resolution: {integrity: sha512-pJlxmlPf5Qyx12amgOCyRE6Lzw28ct2G0lF9xn7/xudLtA/xDOUnCIU2xOxCk8GkjePYctcNpjmFshJp/Ht66A==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
parse-svg-path@0.2.0:
resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==}
pixi.js@8.20.1:
resolution: {integrity: sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==}
tiny-lru@11.4.7:
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==}
engines: {node: '>=12'}
tmi.js@1.8.5:
resolution: {integrity: sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==}
engines: {node: '>=10.0.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@hmans/id@0.0.1': {}
'@hmans/queue@0.0.1': {}
'@miniplex/bucket@2.0.0':
dependencies:
eventery: 0.0.4
'@pixi/colord@2.9.6': {}
'@types/earcut@3.0.0': {}
'@types/tmi.js@1.8.6': {}
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
'@typescript/typescript-darwin-arm64@7.0.2':
optional: true
'@typescript/typescript-darwin-x64@7.0.2':
optional: true
'@typescript/typescript-freebsd-arm64@7.0.2':
optional: true
'@typescript/typescript-freebsd-x64@7.0.2':
optional: true
'@typescript/typescript-linux-arm64@7.0.2':
optional: true
'@typescript/typescript-linux-arm@7.0.2':
optional: true
'@typescript/typescript-linux-loong64@7.0.2':
optional: true
'@typescript/typescript-linux-mips64el@7.0.2':
optional: true
'@typescript/typescript-linux-ppc64@7.0.2':
optional: true
'@typescript/typescript-linux-riscv64@7.0.2':
optional: true
'@typescript/typescript-linux-s390x@7.0.2':
optional: true
'@typescript/typescript-linux-x64@7.0.2':
optional: true
'@typescript/typescript-netbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-netbsd-x64@7.0.2':
optional: true
'@typescript/typescript-openbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-openbsd-x64@7.0.2':
optional: true
'@typescript/typescript-sunos-x64@7.0.2':
optional: true
'@typescript/typescript-win32-arm64@7.0.2':
optional: true
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@webgpu/types@0.1.72': {}
'@xmldom/xmldom@0.8.15': {}
earcut@3.2.3: {}
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
eventemitter3@5.0.4: {}
eventery@0.0.4: {}
gifuct-js@2.1.2:
dependencies:
js-binary-schema-parser: 2.0.3
ismobilejs@1.1.1: {}
js-binary-schema-parser@2.0.3: {}
miniplex@2.0.0:
dependencies:
'@hmans/id': 0.0.1
'@hmans/queue': 0.0.1
'@miniplex/bucket': 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
parse-svg-path@0.2.0: {}
pixi.js@8.20.1:
dependencies:
'@pixi/colord': 2.9.6
'@types/earcut': 3.0.0
'@webgpu/types': 0.1.72
'@xmldom/xmldom': 0.8.15
earcut: 3.2.3
eventemitter3: 5.0.4
gifuct-js: 2.1.2
ismobilejs: 1.1.1
parse-svg-path: 0.2.0
tiny-lru: 11.4.7
tiny-lru@11.4.7: {}
tmi.js@1.8.5:
dependencies:
node-fetch: 2.7.0
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
tr46@0.0.3: {}
typescript@7.0.2:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2
'@typescript/typescript-darwin-arm64': 7.0.2
'@typescript/typescript-darwin-x64': 7.0.2
'@typescript/typescript-freebsd-arm64': 7.0.2
'@typescript/typescript-freebsd-x64': 7.0.2
'@typescript/typescript-linux-arm': 7.0.2
'@typescript/typescript-linux-arm64': 7.0.2
'@typescript/typescript-linux-loong64': 7.0.2
'@typescript/typescript-linux-mips64el': 7.0.2
'@typescript/typescript-linux-ppc64': 7.0.2
'@typescript/typescript-linux-riscv64': 7.0.2
'@typescript/typescript-linux-s390x': 7.0.2
'@typescript/typescript-linux-x64': 7.0.2
'@typescript/typescript-netbsd-arm64': 7.0.2
'@typescript/typescript-netbsd-x64': 7.0.2
'@typescript/typescript-openbsd-arm64': 7.0.2
'@typescript/typescript-openbsd-x64': 7.0.2
'@typescript/typescript-sunos-x64': 7.0.2
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
ws@8.21.3: {}
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+124
View File
@@ -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 }
}
+169
View File
@@ -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
+72
View File
@@ -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)
+20
View File
@@ -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
}
}
+45
View File
@@ -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)
}
}
+9
View File
@@ -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))
}
+24
View File
@@ -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)
}
}
}
+183
View File
@@ -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)
}
+9
View File
@@ -0,0 +1,9 @@
/** Игрок — абстракция над источником ввода (TMI, Twurple, локальная клавиатура). */
export type Player = {
/** Стабильный идентификатор (для чата — логин, для Twurple — user id). */
id: string
/** Отображаемое имя для статистики. */
name: string
}
export type InputOrigin = 'local' | 'chat'
+45
View File
@@ -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
}
+170
View File
@@ -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
}
+46
View File
@@ -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>()
}
+8
View File
@@ -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)
})
+60
View File
@@ -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)
+104
View File
@@ -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
}
}
+38
View File
@@ -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)
}
}
+44
View File
@@ -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
}
}
+140
View File
@@ -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() })
}
}
}
+30
View File
@@ -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
}
+36
View File
@@ -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
}
}
}
+32
View File
@@ -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)
}
}
+64
View File
@@ -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
}
}
+55
View File
@@ -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)
}
}
+241
View File
@@ -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)
}
}
}
+48
View File
@@ -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)
}
}
+220
View File
@@ -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
})
}
}
+52
View File
@@ -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
}
}
}
+31
View File
@@ -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)
}
}
+41
View File
@@ -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)
}
}
+102
View File
@@ -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 })
}
}
+114
View File
@@ -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)
}
}
+31
View File
@@ -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
}
+20
View File
@@ -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)
}
+15
View File
@@ -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)
}
+22
View File
@@ -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)
}
+29
View File
@@ -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
}
}
+38
View File
@@ -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)
}
+17
View File
@@ -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
}
}
}
+13
View File
@@ -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)
}
+23
View File
@@ -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)
}
+49
View File
@@ -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 })
}
}
+36
View File
@@ -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)
}
+61
View File
@@ -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()
}
}
}
+76
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
}
+5
View File
@@ -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
}
+15
View File
@@ -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"]
}