Compare commits

...
10 Commits
Author SHA1 Message Date
Ku6epXBOCTuK ec01e1ce56 fix: add frame factor calculations
Deploy to GitHub Pages / deploy (push) Failing after 1m2s
2026-06-26 22:23:47 +05:00
Ku6epXBOCTuK 0928eba0e3 fix: update input flow 2026-06-25 19:55:00 +05:00
Ku6epXBOCTuK 9a7d793d7e docs: update readme.md 2026-06-25 19:54:55 +05:00
Ku6epXBOCTuK 8a5a27f3b6 fix: update settings 2026-06-25 19:21:09 +05:00
Ku6epXBOCTuK 2edba57952 feat: update index page 2026-06-25 12:43:38 +05:00
Ku6epXBOCTuK b1c8ee08ff feat: update hud 2026-06-25 12:15:20 +05:00
Ku6epXBOCTuK b282f1064a feat: update player stats 2026-06-25 11:55:04 +05:00
Ku6epXBOCTuK b97322dcfa docs: add ecs refactoring plan 2026-06-25 09:51:38 +05:00
Ku6epXBOCTuK c2de374aea feat: add fill for enemy bodies 2026-06-25 09:16:29 +05:00
Ku6epXBOCTuK 5489c0f8d5 feat: update waves, enemies 2026-06-25 09:05:44 +05:00
18 changed files with 1021 additions and 259 deletions
+104 -5
View File
@@ -1,13 +1,112 @@
# Shooting Word # Shooting Word
Twitch-чат игра, где зрители пишут слова, чтобы стрелять по врагам. **Twitch-интерактивная игра, где зрители чата стреляют по врагам, печатая слова.**
## Использование Shooting Word — это browser-source виджет для стримов, превращающий Twitch-чат в аркадный шутер. Зрители пишут слова в чате, а на экране сначала появляется слово, потом снаряд, и если слово совпадает с врагом — враг уничтожается. Простая механика, высокая вовлечённость, нулевая настройка.
Открой [http://ku6epxboctuk.is-a.dev/shooting-word-html/](http://ku6epxboctuk.is-a.dev/shooting-word-html/), введи имя канала, скопируй ссылку и вставь в OBS как браузер-источник. Чат пишет слова — игрок стреляет. ## Как это работает
Либо сразу открыть `http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА`. 1. Стример добавляет виджет в OBS как **Browser Source** или открывает у себя в браузере.
2. Зрители пишут `!play` в чат, чтобы начать игру.
3. На экране появляются враги с подписанными словами.
4. Зрители печатают совпавшее слово — снаряд летит в цель и уничтожает врага
5. Игра идёт по волнам, сложность растёт, каждые 10 волн — босс.
## Развитие ## Типы врагов
| Тип | Описание | Очки |
| ----------- | --------------------------------------------- | ---- |
| **Обычный** | Одно слово (hack, code, data, git...) | 10 |
| **Тяжёлый** | Два слова подряд (fire wall, null pointer...) | 50 |
| **Босс** | 3 слоя по 3 слова, появляется каждые 10 волн | 200 |
Слова — IT/хакерская тематика: названия языков, протоколов, уязвимостей, команд терминала.
## Возможности
- **Волны** — количество врагов и их скорость растут с каждой волной
- **Боссы** — multi-phase враги со слоями защиты
- **Статистика** — таблица лидеров по уничтоженным врагам и «Мазила» за много промахов
- **Визуальные эффекты** — частицы, трейлы, screen shake, glitch при уроне
- **Режим single play** — скрытие виджета после одной партии (для автоматизации через Streamer.bot)
- **Локальный ввод** — можно тестировать без Twitch, просто печатая в браузере
## Установка
### Быстрый старт
1. Открой [страницу настройки](http://ku6epxboctuk.is-a.dev/shooting-word-html/)
2. Введи имя.twitch канала
3. Нажми **СКОПИРОВАТЬ ССЫЛКУ**
4. В OBS: Sources → Browser → вставь ссылку
5. Рекомендуемая высота — не менее 800px
### Прямая ссылка
```txt
http://ku6epxboctuk.is-a.dev/shooting-word-html/game.html?channel=ИМЯ_КАНАЛА
```
### Параметры URL
| Параметр | Описание |
| -------------- | ----------------------------------------------------- |
| `channel` | Имя канала Twitch (без @) |
| `singlePlay=1` | После game over виджет скрывается (для автоматизации) |
## Автоматизация с Streamer.bot
Включи «Отключать источник, когда он не виден» в настройках Browser Source в OBS. Через Streamer.bot скрывай/показывай источник по расписанию или триггерам — игра будет запускаться заново при каждом появлении. Триггер можно настроить на команду или награду за баллы канала.
Полноценной автоматизации без перехода на twitch auth невозможна
## Технологии
- Vanilla JavaScript (ES6+), без фреймворков и сборщиков
- Canvas 2D для рендеринга
- [TMI.js](https://github.com/tmijs/tmi.js) для подключения к Twitch IRC
- Один HTML-файл + CSS + JS модули
- Хостится на GitHub Pages
## Структура проекта
```txt
├── index.html # Страница настройки (выбор канала)
├── game.html # Игровой экран
├── style.css # Стили ( cyberpunk-тема: чёрный фон, зелёный неон)
├── js/
│ ├── config.js # Конфигурация и словарь слов
│ ├── game.js # Основной игровой цикл
│ ├── enemy.js # Класс врага (обычный / тяжёлый / босс)
│ ├── projectile.js # Снаряды
│ ├── particle.js # Система частиц
│ ├── input.js # Обработка ввода (чат + локальный)
│ ├── intro.js # Заставка
│ ├── utils.js # Утилиты
│ ├── main.js # Точка входа
│ └── tmi.min.js # Twitch Messaging Interface
├── img/
│ └── sprite.svg # Иконки (GitHub, Twitch)
└── LICENSE # MIT
```
## Добавление своих слов
Отредактируй `js/config.js`:
WordGenerator.simpleWords - простые враги из одного слова
WordGenerator.heavyWords - бронированные враги - два слова
## Лицензия
MIT
## Связь
- [GitHub Issues](https://github.com/Ku6epXBOCTuK/shooting-word-html/issues) — баги и идеи
- [Twitch](https://www.twitch.tv/ku6ep_xboctuk) — смотри игру в действии
## Похожие проекты
Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget). Этот репозиторий не будет активно развиваться. В дальнейшем функционал станет частью проекта [multi-widget](https://github.com/Ku6epXBOCTuK/multi-widget).
Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva
+1 -1
View File
@@ -1,3 +1,3 @@
# TODO # TODO
- [ ] Переделать процесс работы с инпутами — прятать локальные, если не используется - [ ] Переделать процесс работы с инпутами — прятать локальные, если не используется, прятать чат, если не используется
+33
View File
@@ -0,0 +1,33 @@
# ECS рефакторинг — план
## Суть
Вместо классов врагов/снарядов с методами update() и draw() — единый контейнер сущностей, где каждая сущность это набор компонентов (позиция, скорость, тип, слово и т.д.). Логика вынесена в отдельные функции-системы, которые каждую итерацию проходят по нужным подмножествам сущностей и обновляют их.
## Компоненты
Позиция и скорость — общие для всех движущихся сущностей. Тип врага и данные слова — только для врагов. Ссылка на цель — только для снарядов. Броня и босс-данные — для усиленных и босс-врагов. Пустой маркер — чтобы система знала, что сущность является снарядом.
## Системы (по порядку за кадр)
1. Система волн — считает таймеры, решает когда спавнить нового врага и какого типа, создаёт сущность в контейнере.
2. Система ввода — получает слово, ищет лучшего врага (точное совпадение > более позднее слово > частичное), создаёт снаряд с ссылкой на цель.
3. Система движения — для всех сущностей с позицией и скоростью: позиция += скорость.
4. Система наведения — для снарядов с целью: вычисляет расстояние, если рядом — помечает попадание, если цель исчезла — удаляет снаряд, иначе двигает к цели.
5. Система урона — для снарядов с пометкой попадания: проверяет броню (если есть и не ноль — снимает), проверяет босс-данные (считает убитые слова в слое, если слой пройден — переключает), если враг умер — удаляет и вызывает callback.
6. Система рендера — берёт все враги, сортирует по высоте, рисует форму/слово/планку брони. Отдельно рисует снаряды. Отрисовка HUD отдельно от ECS.
7. Система очистки — удаляет из контейнера все сущности с пометкой "мёртвый".
## Что остаётся за пределами ECS
Волны, ввод, рендер HUD, очистка — это системы, но не entity. Жизни/счёт/номер волны — простые переменные, не сущности. Частицы — не сущности, рисуются как визуальный эффект по времени от взрыва.
## Порядок миграции
1. Контейнер сущностей — замена глобальных массивов.
2. Система движения — самая простая, проверка что всё работает.
3. Система наведения и урона — core gameplay.
4. Система волн — спавн.
5. Система ввода — поиск цели + создание снаряда.
6. Система рендера — вся отрисовка.
7. Остальное — очистка, game loop, удаление старых классов.
+2 -2
View File
@@ -14,12 +14,12 @@
<div id="ui-overlay" class="hidden"> <div id="ui-overlay" class="hidden">
<span class="ui-text">SCORE: <span id="score">0</span></span> <span class="ui-text">SCORE: <span id="score">0</span></span>
<span class="ui-text">WAVE: <span id="wave">1</span></span> <span class="ui-text">WAVE: <span id="wave">1</span></span>
<span class="ui-text">LIVES: <span id="lives">3</span></span> <span class="ui-text">LIVES: <span id="lives">♥♥♥♥♥♥♥♥♥♥</span></span>
</div> </div>
<div id="input-display"> <div id="input-display">
<div id="input-local"> <div id="input-local">
<div id="typed-text"></div> <div id="typed-text">просто пиши !play и другие слова</div>
</div> </div>
<div id="input-chat" class="hidden"> <div id="input-chat" class="hidden">
<div id="chat-text"></div> <div id="chat-text"></div>
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-github" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</symbol>
<symbol id="icon-twitch" viewBox="0 0 24 24">
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+35 -4
View File
@@ -13,13 +13,44 @@
<div class="screen-subtitle">Введите имя канала для игры</div> <div class="screen-subtitle">Введите имя канала для игры</div>
<input type="text" id="channel-input" placeholder="Ku6ep_XBOCTuK" autofocus /> <input type="text" id="channel-input" placeholder="Ku6ep_XBOCTuK" autofocus />
<div id="settings-panel"> <div id="settings-panel">
<label class="settings-label"><input type="checkbox" id="opt-auto-restart" checked> Автоперезапуск после поражения</label> <div class="settings-row">
<label class="settings-label">
<input type="checkbox" id="opt-single-play" /> Скрывать игру после поражения
</label>
<button type="button" class="hint-toggle">?</button>
</div>
<div class="settings-hints hidden">
<div class="settings-hint">С включенной галочкой игра скроется после одной партии.</div>
<div class="settings-hint">
В OBS для этого источника нужно включить «Отключать источник, когда он не виден»
</div>
<div class="settings-hint">Чтобы запустить игру заново, скройте и снова покажите источник.</div>
<div class="settings-hint">Можно автоматизировать через Streamer.bot.</div>
</div>
</div>
<div class="btn-row">
<button id="open-btn" class="btn-primary">ОТКРЫТЬ В БРАУЗЕРЕ</button>
<button id="copy-btn" class="btn-primary">СКОПИРОВАТЬ ССЫЛКУ</button>
</div> </div>
<button id="copy-btn">СКОПИРОВАТЬ ССЫЛКУ</button>
<div id="game-link"></div> <div id="game-link"></div>
<div class="obs-note"> <div class="obs-note">
Вставь ссылку в OBS как источник браузера<br /> Открой ссылку в браузере<br />
Желательно высоту побольше Или вставь ссылку в OBS как источник браузера<br />
Желательно высоту побольше<br />
</div>
<div class="links-row">
<a href="https://github.com/Ku6epXBOCTuK/shooting-word-html" target="_blank" class="page-link">
<svg class="link-icon"><use href="img/sprite.svg#icon-github" /></svg>
GitHub
</a>
<a href="https://github.com/Ku6epXBOCTuK/shooting-word-html/issues" target="_blank" class="page-link">
<svg class="link-icon"><use href="img/sprite.svg#icon-github" /></svg>
Увидели ошибку? Пиши сюда
</a>
<a href="https://www.twitch.tv/ku6ep_xboctuk" target="_blank" class="page-link">
<svg class="link-icon"><use href="img/sprite.svg#icon-twitch" /></svg>
Twitch
</a>
</div> </div>
</div> </div>
</div> </div>
+248 -51
View File
@@ -15,23 +15,28 @@ const CONFIG = {
startLives: 10, startLives: 10,
startWave: 1, startWave: 1,
startScore: 0, startScore: 0,
spawnIntervalBase: 240, enemiesPerWaveBase: 5,
spawnIntervalMin: 60, enemiesPerWaveInc: 1,
spawnIntervalDecayPerWave: 12,
waveInterval: 900, waveInterval: 900,
heavyEnemyChanceBase: 0.25, heavyEnemyChanceBase: 0.25,
heavyEnemyChancePerWave: 0.02, heavyEnemyChancePerWave: 0.02,
scoreSimple: 10, scoreSimple: 10,
scoreHeavy: 50, scoreHeavy: 50,
scoreBoss: 200,
bossEveryNthWave: 10,
bossLayers: 3,
bossWordsPerLayer: 3,
gameOverDelay: 10000, gameOverDelay: 10000,
}, },
speed: { speed: {
simpleEnemyCrossTime: 60, slowEnemyCrossTimeStart: 240,
simpleEnemyCrossTimeRand: 120, slowEnemyCrossTimeCap: 30,
simpleEnemyCrossTimeDecay: 4000, slowEnemyCrossTimeCapWave: 50,
heavyEnemyCrossTime: 120, fastEnemyCrossTimeStart: 120,
heavyEnemyCrossTimeRand: 240, fastEnemyCrossTimeCap: 15,
projectileCrossTime: 1.3, fastEnemyCrossTimeCapWave: 50,
bossCrossTime: 60,
projectileCrossTime: 0.43,
gridSpeed: 0.5, gridSpeed: 0.5,
}, },
visual: { visual: {
@@ -81,8 +86,8 @@ const CONFIG = {
projectileTrailAlpha: 0.6, projectileTrailAlpha: 0.6,
projectileCircleAlpha: 0.5, projectileCircleAlpha: 0.5,
enemyPulseRate: 0.05, enemyPulseRate: 0.05,
enemyAlphaBase: 0.7, enemyAlphaBase: 1.0,
enemyAlphaRange: 0.3, enemyAlphaRange: 0.0,
enemyGlowPulse: 5, enemyGlowPulse: 5,
particleDecayBase: 0.02, particleDecayBase: 0.02,
particleDecayRange: 0.03, particleDecayRange: 0.03,
@@ -114,6 +119,8 @@ const CONFIG = {
primaryGrid: "rgba(0,255,65,0.08)", primaryGrid: "rgba(0,255,65,0.08)",
heavy: "#ffaa00", heavy: "#ffaa00",
heavyDim: "#cc8800", heavyDim: "#cc8800",
boss: "#ff4444",
bossDim: "#cc2222",
miss: "#ff0044", miss: "#ff0044",
white: "#ffffff", white: "#ffffff",
red: "#ff0000", red: "#ff0000",
@@ -151,48 +158,238 @@ const CONFIG = {
const WordGenerator = { const WordGenerator = {
simpleWords: [ simpleWords: [
"hack", "code", "data", "node", "link", "byte", "bit", "net", "web", "sys", "hack",
"run", "log", "bug", "fix", "api", "cpu", "ram", "ssd", "gpu", "dns", "code",
"ip", "url", "tag", "css", "js", "sql", "git", "cli", "env", "dev", "data",
"app", "bot", "ai", "io", "os", "ui", "ux", "vm", "vpn", "lan", "node",
"wan", "ftp", "ssh", "http", "json", "xml", "yaml", "md", "py", "go", "link",
"rust", "java", "php", "cpp", "ts", "rb", "kt", "swift", "dart", "lua", "byte",
"perl", "bash", "zsh", "fish", "vim", "nano", "emacs", "sed", "awk", "grep", "bit",
"find", "cat", "ls", "cd", "mv", "cp", "rm", "mkdir", "chmod", "chown", "net",
"ping", "curl", "wget", "nc", "nmap", "tcp", "udp", "ip4", "ip6", "mac", "web",
"hex", "bin", "dec", "oct", "xor", "and", "or", "not", "shl", "shr", "sys",
"key", "lock", "door", "gate", "wall", "fire", "ice", "wind", "storm", "rain", "run",
"sun", "moon", "star", "void", "null", "zero", "one", "two", "ten", "max", "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: [ heavyWords: [
["root", "access"], ["data", "breach"], ["fire", "wall"], ["deep", "web"], ["root", "access"],
["dark", "net"], ["cloud", "base"], ["neural", "net"], ["quantum", "bit"], ["data", "breach"],
["block", "chain"], ["smart", "contract"], ["machine", "learn"], ["deep", "fake"], ["fire", "wall"],
["zero", "day"], ["back", "door"], ["side", "channel"], ["man", "middle"], ["deep", "web"],
["denial", "service"], ["brute", "force"], ["social", "engineer"], ["phishing", "attack"], ["dark", "net"],
["sql", "inject"], ["cross", "site"], ["buffer", "over"], ["heap", "spray"], ["cloud", "base"],
["stack", "pivot"], ["return", "orient"], ["format", "string"], ["race", "condition"], ["neural", "net"],
["time", "check"], ["use", "after"], ["double", "free"], ["null", "pointer"], ["quantum", "bit"],
["integer", "over"], ["type", "confus"], ["memory", "leak"], ["dead", "lock"], ["block", "chain"],
["live", "lock"], ["starvation", "mode"], ["priority", "invert"], ["cache", "miss"], ["smart", "contract"],
["branch", "mispred"], ["speculative", "exec"], ["meltdown", "flaw"], ["spectre", "bug"], ["machine", "learn"],
["row", "hammer"], ["cold", "boot"], ["evil", "maid"], ["supply", "chain"], ["deep", "fake"],
["hardware", "troj"], ["firmware", "root"], ["bios", "implant"], ["uefi", "shell"], ["zero", "day"],
["smm", "exploit"], ["ring", "zero"], ["kernel", "panic"], ["blue", "screen"], ["back", "door"],
["kernel", "oops"], ["seg", "fault"], ["bus", "error"], ["illegal", "op"], ["side", "channel"],
["trap", "divide"], ["trap", "debug"], ["trap", "nmi"], ["trap", "break"], ["man", "middle"],
["trap", "overflow"], ["trap", "bound"], ["trap", "invalid"], ["trap", "device"], ["denial", "service"],
["trap", "double"], ["trap", "copro"], ["trap", "tss"], ["trap", "segment"], ["brute", "force"],
["trap", "stack"], ["trap", "general"], ["trap", "page"], ["trap", "x87"], ["social", "engineer"],
["trap", "align"], ["trap", "machine"], ["trap", "simd"], ["trap", "virtual"], ["phishing", "attack"],
["security", "check"], ["stack", "cookie"], ["aslr", "bypass"], ["dep", "bypass"], ["sql", "inject"],
["cfg", "bypass"], ["cet", "bypass"], ["shadow", "stack"], ["control", "flow"], ["cross", "site"],
["indirect", "call"], ["jump", "oriented"], ["call", "oriented"], ["data", "oriented"], ["buffer", "over"],
["counterfeit", "obj"], ["use", "after"], ["heap", "feng"], ["house", "spirit"], ["heap", "spray"],
["house", "lore"], ["house", "force"], ["fast", "bin"], ["tcache", "poison"], ["stack", "pivot"],
["unsorted", "bin"], ["large", "bin"], ["small", "bin"], ["mmap", "chunk"], ["return", "orient"],
["top", "chunk"], ["wilderness", "area"], ["arena", "corrupt"], ["thread", "cache"], ["format", "string"],
["per", "thread"], ["main", "arena"], ["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) { generateWord(isHeavy) {
+186 -44
View File
@@ -1,29 +1,46 @@
class Enemy { class Enemy {
constructor() { constructor(boss) {
const isHeavy = Math.random() < CONFIG.game.heavyEnemyChanceBase + wave * CONFIG.game.heavyEnemyChancePerWave; this.isBoss = !!boss;
this.isHeavy = isHeavy; this.isHeavy = false;
const wordData = WordGenerator.generateWord(isHeavy);
if (isHeavy) { if (this.isBoss) {
this.words = wordData; this.layers = [];
this.currentWordIndex = 0; this.currentLayer = 0;
this.text = this.words[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 { } else {
this.words = [wordData]; const isHeavy = Math.random() < CONFIG.game.heavyEnemyChanceBase + wave * CONFIG.game.heavyEnemyChancePerWave;
this.currentWordIndex = 0; this.isHeavy = isHeavy;
this.text = wordData; 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.y = 0;
this.speed = isHeavy
? S.heavyEnemySpeedBase + Math.random() * S.heavyEnemySpeedRandom
: Math.min(
S.simpleEnemySpeedBase +
Math.random() * S.simpleEnemySpeedRandom +
wave * S.simpleEnemySpeedPerWave,
S.simpleEnemyMaxSpeed,
);
this.height = isHeavy ? S.enemyHeavyHeight : S.enemySimpleHeight;
this.pulse = 0; this.pulse = 0;
this.flashTimer = 0; this.flashTimer = 0;
this.width = 0; this.width = 0;
@@ -32,14 +49,29 @@ class Enemy {
_calculateSpawnX() { _calculateSpawnX() {
const padding = S.enemyPaddingX; 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.save();
ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`; ctx.font = `${fontWeight}${fontSize}px 'Courier New', monospace`;
let maxTextWidth = 0; let maxTextWidth = 0;
for (let word of this.words) { if (this.isBoss) {
const w = ctx.measureText(word).width; for (const layer of this.layers) {
if (w > maxTextWidth) maxTextWidth = w; 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(); ctx.restore();
@@ -52,7 +84,24 @@ class Enemy {
return minX + Math.random() * (maxX - minX); return minX + Math.random() * (maxX - minX);
} }
nextWord() { 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.currentWordIndex++;
this.flashTimer = S.flashDuration; this.flashTimer = S.flashDuration;
if (this.currentWordIndex >= this.words.length) { if (this.currentWordIndex >= this.words.length) {
@@ -63,9 +112,9 @@ class Enemy {
} }
update() { update() {
this.y += this.speed; this.y += this.speed * frameFactor;
this.pulse += 0.05; this.pulse += 0.05 * frameFactor;
if (this.flashTimer > 0) this.flashTimer--; if (this.flashTimer > 0) this.flashTimer -= frameFactor;
} }
draw() { draw() {
@@ -73,22 +122,105 @@ class Enemy {
const flash = this.flashTimer > 0; const flash = this.flashTimer > 0;
ctx.save(); ctx.save();
ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`;
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 boxW = this.width;
const boxH = this.height; const boxH = this.height;
ctx.shadowColor = flash ? CONFIG.colors.white : this.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; 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; ctx.shadowBlur = flash ? S.enemyFlashGlow : S.enemyGlow + Math.sin(this.pulse * 2) * S.enemyGlowPulse;
ctx.strokeStyle = flash const rgb = this.isBoss ? "255,68,68" : this.isHeavy ? "255,170,0" : "0,255,65";
? CONFIG.colors.white ctx.strokeStyle = flash ? CONFIG.colors.white : `rgba(${rgb},${alpha})`;
: this.isHeavy
? `rgba(255,170,0,${alpha})`
: `rgba(0,255,65,${alpha})`;
ctx.lineWidth = flash ? S.enemyLineWidth * 2 : S.enemyLineWidth; ctx.lineWidth = flash ? S.enemyLineWidth * 2 : S.enemyLineWidth;
if (this.isHeavy) { 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; const off = S.enemyAlienOffset;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(this.x - boxW / 2 + off, this.y - boxH / 2); ctx.moveTo(this.x - boxW / 2 + off, this.y - boxH / 2);
@@ -98,6 +230,8 @@ class Enemy {
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.lineTo(this.x - boxW / 2, this.y);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = flash ? "#ffffff" : "#0a0a0a";
ctx.fill();
ctx.stroke(); ctx.stroke();
const barW = boxW - S.armorBarPad; const barW = boxW - S.armorBarPad;
@@ -124,18 +258,26 @@ class Enemy {
this.y - boxH / 2 - S.enemyArmorLabelOffset, 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 { } 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.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.shadowBlur = 0;
ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = flash ? CONFIG.colors.white : this.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary;
ctx.fillText(this.text, this.x, this.y);
ctx.restore(); ctx.restore();
} }
+75 -41
View File
@@ -10,8 +10,11 @@ let enemies = [];
let particles = []; let particles = [];
let projectiles = []; let projectiles = [];
let spawnTimer = 0; let spawnTimer = 0;
let spawnInterval = CONFIG.game.spawnIntervalBase; let waveEnemiesLeft = 0;
let waveTimer = 0; let waveTimer = 0;
let wavePauseTimer = 0;
let wavePauseText = "";
let wavePauseActive = false;
let shakeTimer = 0; let shakeTimer = 0;
let shakeAmount = 0; let shakeAmount = 0;
let bgOffset = 0; let bgOffset = 0;
@@ -22,7 +25,7 @@ let playerStats = {};
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const settings = { const settings = {
autoRestart: params.get("autoRestart") !== "0", singlePlay: params.get("singlePlay") === "1",
}; };
function initKeyboardBridge() { function initKeyboardBridge() {
@@ -39,6 +42,7 @@ function initKeyboardBridge() {
const line = buffer; const line = buffer;
buffer = ""; buffer = "";
InputModule.submitLocal(line); InputModule.submitLocal(line);
InputModule.updateLocalDisplay("");
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
buffer = ""; buffer = "";
InputModule.clear(); InputModule.clear();
@@ -55,10 +59,10 @@ const game = {
}, },
onProjectileHit(projectile, enemy) { onProjectileHit(projectile, enemy) {
const dead = enemy.nextWord(); const dead = enemy.hitWord(projectile.word);
if (dead) { if (dead) {
score += enemy.isHeavy ? CONFIG.game.scoreHeavy : CONFIG.game.scoreSimple; score += enemy.isBoss ? CONFIG.game.scoreBoss : enemy.isHeavy ? CONFIG.game.scoreHeavy : CONFIG.game.scoreSimple;
document.getElementById("score").textContent = score; document.getElementById("score").textContent = score;
if (projectile.username) { if (projectile.username) {
if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 }; if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 };
@@ -66,7 +70,8 @@ const game = {
} }
this.destroyEnemy(enemy); this.destroyEnemy(enemy);
} else { } else {
spawnParticles(enemy.x, enemy.y, CONFIG.colors.heavy, S.particleArmorBreak); const color = enemy.isBoss ? "#ff4444" : CONFIG.colors.heavy;
spawnParticles(enemy.x, enemy.y, color, S.particleArmorBreak);
triggerShake(S.shakeHit, S.shakeHitDuration); triggerShake(S.shakeHit, S.shakeHitDuration);
} }
}, },
@@ -83,10 +88,12 @@ const game = {
destroyEnemy(enemy) { destroyEnemy(enemy) {
const idx = enemies.indexOf(enemy); const idx = enemies.indexOf(enemy);
if (idx > -1) { if (idx > -1) {
const color = enemy.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; const color = enemy.isBoss ? "#ff4444" : enemy.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary;
const count = enemy.isHeavy const count = enemy.isBoss
? S.particleDestroyHeavy ? S.particleDestroyHeavy * 2
: S.particleDestroySimple; : enemy.isHeavy
? S.particleDestroyHeavy
: S.particleDestroySimple;
spawnParticles(enemy.x, enemy.y, color, count); spawnParticles(enemy.x, enemy.y, color, count);
enemies.splice(idx, 1); enemies.splice(idx, 1);
} }
@@ -97,9 +104,17 @@ function spawnEnemy() {
enemies.push(new Enemy()); enemies.push(new Enemy());
} }
function spawnBoss() {
enemies.push(new Enemy(true));
}
function getEnemiesPerWave() {
return CONFIG.game.enemiesPerWaveBase + (wave - 1) * CONFIG.game.enemiesPerWaveInc;
}
function takeDamage() { function takeDamage() {
lives--; lives--;
document.getElementById("lives").textContent = lives; document.getElementById("lives").textContent = "♡".repeat(CONFIG.game.startLives - lives) + "♥".repeat(lives);
triggerShake(S.shakeDamage, S.shakeDamageDuration); triggerShake(S.shakeDamage, S.shakeDamageDuration);
document.getElementById("game-container").classList.add("glitch"); document.getElementById("game-container").classList.add("glitch");
setTimeout( setTimeout(
@@ -132,8 +147,9 @@ function renderPlayerStats() {
top3.forEach((name, i) => { top3.forEach((name, i) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "stats-row" + (i === 0 ? " stats-top1" : i === 1 ? " stats-top2" : " stats-top3"); 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"; const place = i === 0 ? "1" : i === 1 ? "2" : "3";
row.innerHTML = `<span class="stats-place">#${place}</span> <span class="stats-name">${name}</span> <span class="stats-kills">${playerStats[name].kills}</span>`; 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); container.appendChild(row);
}); });
@@ -168,7 +184,7 @@ function gameOver() {
document.getElementById("ui-overlay").classList.add("hidden"); document.getElementById("ui-overlay").classList.add("hidden");
document.getElementById("input-chat").classList.add("hidden"); document.getElementById("input-chat").classList.add("hidden");
gameLoopStarted = false; gameLoopStarted = false;
if (settings.autoRestart) { if (!settings.singlePlay) {
recalc(); recalc();
runIntro(); runIntro();
} else { } else {
@@ -190,14 +206,15 @@ function startGame() {
particles = []; particles = [];
projectiles = []; projectiles = [];
playerStats = {}; playerStats = {};
spawnTimer = CONFIG.game.spawnIntervalBase; spawnTimer = 0;
spawnInterval = CONFIG.game.spawnIntervalBase; waveEnemiesLeft = getEnemiesPerWave();
waveTimer = 0; waveTimer = 0;
wavePauseActive = false;
InputModule.clear(); InputModule.clear();
document.getElementById("score").textContent = score; document.getElementById("score").textContent = score;
document.getElementById("wave").textContent = wave; document.getElementById("wave").textContent = wave;
document.getElementById("lives").textContent = lives; document.getElementById("lives").textContent = "♡".repeat(CONFIG.game.startLives - lives) + "♥".repeat(lives);
document.getElementById("start-screen").classList.add("hidden"); document.getElementById("start-screen").classList.add("hidden");
document.getElementById("gameover-screen").classList.add("hidden"); document.getElementById("gameover-screen").classList.add("hidden");
document.getElementById("ui-overlay").classList.remove("hidden"); document.getElementById("ui-overlay").classList.remove("hidden");
@@ -212,25 +229,35 @@ function startGame() {
function update() { function update() {
if (gameState !== "playing") return; if (gameState !== "playing") return;
spawnTimer++; if (wavePauseActive) {
if (spawnTimer >= spawnInterval) { wavePauseTimer -= frameFactor;
spawnEnemy(); if (wavePauseTimer <= 0) {
spawnTimer = 0; wavePauseActive = false;
spawnInterval = Math.max( waveEnemiesLeft = getEnemiesPerWave();
CONFIG.game.spawnIntervalMin, if (wave % CONFIG.game.bossEveryNthWave === 0) {
CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave, spawnBoss();
); }
}
} else if (waveEnemiesLeft > 0) {
spawnTimer += frameFactor;
const spawnInterval = CONFIG.game.waveInterval / getEnemiesPerWave();
if (spawnTimer >= spawnInterval) {
spawnEnemy();
waveEnemiesLeft--;
spawnTimer = 0;
}
} }
waveTimer++; waveTimer += frameFactor;
if (waveTimer >= CONFIG.game.waveInterval) { if (waveTimer >= CONFIG.game.waveInterval && !wavePauseActive && waveEnemiesLeft <= 0) {
wave++; wave++;
waveTimer = 0; waveTimer = 0;
document.getElementById("wave").textContent = wave; document.getElementById("wave").textContent = wave;
spawnInterval = Math.max( wavePauseActive = true;
CONFIG.game.spawnIntervalMin, wavePauseTimer = 120;
CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave, wavePauseText = wave % CONFIG.game.bossEveryNthWave === 0
); ? `BOSS INCOMING`
: `WAVE ${wave} INCOMING`;
} }
for (let i = enemies.length - 1; i >= 0; i--) { for (let i = enemies.length - 1; i >= 0; i--) {
@@ -256,7 +283,7 @@ function update() {
} }
if (shakeTimer > 0) { if (shakeTimer > 0) {
shakeTimer--; shakeTimer -= frameFactor;
shakeAmount *= 0.9; shakeAmount *= 0.9;
} }
} }
@@ -273,18 +300,24 @@ function draw() {
for (let p of projectiles) p.draw(); for (let p of projectiles) p.draw();
for (let p of particles) p.draw(); for (let p of particles) p.draw();
for (let e of enemies) e.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.save();
ctx.fillStyle = CONFIG.colors.playerBase;
ctx.fillRect(0, H - S.inputAreaHeight, W, S.inputAreaHeight);
ctx.strokeStyle = CONFIG.colors.primary;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - S.inputAreaHeight);
ctx.lineTo(W, H - S.inputAreaHeight);
ctx.stroke();
ctx.fillStyle = CONFIG.colors.primary; ctx.fillStyle = CONFIG.colors.primary;
ctx.shadowColor = CONFIG.colors.primary; ctx.shadowColor = CONFIG.colors.primary;
ctx.shadowBlur = S.playerGlow; ctx.shadowBlur = S.playerGlow;
@@ -296,7 +329,8 @@ function draw() {
ctx.restore(); ctx.restore();
} }
function loop() { function loop(timestamp) {
calcDt(timestamp);
update(); update();
draw(); draw();
if (gameLoopStarted) { if (gameLoopStarted) {
+30 -6
View File
@@ -1,19 +1,43 @@
const input = document.getElementById("channel-input"); const input = document.getElementById("channel-input");
const copyBtn = document.getElementById("copy-btn"); const copyBtn = document.getElementById("copy-btn");
const openBtn = document.getElementById("open-btn");
const linkEl = document.getElementById("game-link"); const linkEl = document.getElementById("game-link");
const autoRestartEl = document.getElementById("opt-auto-restart"); 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() { function copyLink() {
const name = input.value.trim().toLowerCase() || "Ku6ep_XBOCTuK"; navigator.clipboard.writeText(buildUrl()).then(() => {
const autoRestart = autoRestartEl.checked ? "1" : "0";
const url = `${location.origin}${location.pathname.replace(/\/[^/]*$/, "/")}game.html?channel=${encodeURIComponent(name)}&autoRestart=${autoRestart}`;
navigator.clipboard.writeText(url).then(() => {
linkEl.textContent = "СКОПИРОВАНО!"; linkEl.textContent = "СКОПИРОВАНО!";
linkEl.className = "copied"; linkEl.className = "copied";
setTimeout(updateLink, 1500);
}); });
} }
copyBtn.addEventListener("click", copyLink); function openLink() {
window.open(buildUrl(), "_blank");
}
input.addEventListener("input", updateLink);
singlePlayEl.addEventListener("change", updateLink);
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if (e.key === "Enter") copyLink(); 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");
});
+11 -4
View File
@@ -3,7 +3,7 @@ const InputModule = {
localBuffer: "", localBuffer: "",
submitLine(text, username) { submitLine(text, username) {
const line = text.trim().toLowerCase(); const line = text.trim().toLowerCase().split(/\s+/)[0];
if (!line) return; if (!line) return;
if (introPlaying) return; if (introPlaying) return;
@@ -25,7 +25,7 @@ const InputModule = {
}, },
submitLocal(text) { submitLocal(text) {
const line = text.trim().toLowerCase(); const line = text.trim().toLowerCase().split(/\s+/)[0];
if (!line) return; if (!line) return;
if (introPlaying) return; if (introPlaying) return;
@@ -76,8 +76,15 @@ const InputModule = {
findEnemy(word) { findEnemy(word) {
for (let e of enemies) { for (let e of enemies) {
if (e.text === word) { if (e.isBoss) {
return e; 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; return null;
+10 -7
View File
@@ -83,27 +83,30 @@ function runIntro() {
ctx.restore(); ctx.restore();
} }
function introLoop() { function introLoop(timestamp) {
frame++; calcDt(timestamp);
const prevFrame = Math.floor(frame);
frame += frameFactor;
const curFrame = Math.floor(frame);
if (frame <= I.fadeInFrames) { if (frame <= I.fadeInFrames) {
textAlpha = Math.min(1, frame / I.fadeInFrames); textAlpha = Math.min(1, frame / I.fadeInFrames);
} }
if (frame === I.shot1Frame) shots.push({ progress: 0, trail: [], done: false }); if (prevFrame < I.shot1Frame && curFrame >= I.shot1Frame) shots.push({ progress: 0, trail: [], done: false });
if (frame === I.shot2Frame) shots.push({ progress: 0, trail: [], done: false }); if (prevFrame < I.shot2Frame && curFrame >= I.shot2Frame) shots.push({ progress: 0, trail: [], done: false });
if (frame === I.shot3Frame) 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) { for (let s of shots) {
if (s.done) continue; if (s.done) continue;
s.progress = Math.min(1, s.progress + I.shotSpeed); s.progress = Math.min(1, s.progress + I.shotSpeed * frameFactor);
const x = shooterX + (lTargetX - shooterX) * s.progress; const x = shooterX + (lTargetX - shooterX) * s.progress;
const y = shooterY + (lTargetY - shooterY) * s.progress; const y = shooterY + (lTargetY - shooterY) * s.progress;
s.trail.push({ x, y, life: 1 }); s.trail.push({ x, y, life: 1 });
if (s.trail.length > I.trailMax) s.trail.shift(); if (s.trail.length > I.trailMax) s.trail.shift();
for (let t of s.trail) t.life -= I.trailDecay; for (let t of s.trail) t.life -= I.trailDecay * frameFactor;
s.trail = s.trail.filter((t) => t.life > 0); s.trail = s.trail.filter((t) => t.life > 0);
if (s.progress >= 1) { if (s.progress >= 1) {
+1 -1
View File
@@ -8,7 +8,7 @@
document.querySelector("#ui-overlay .ui-text:nth-child(2)").innerHTML = document.querySelector("#ui-overlay .ui-text:nth-child(2)").innerHTML =
`${CONFIG.texts.waveLabel} <span id="wave">1</span>`; `${CONFIG.texts.waveLabel} <span id="wave">1</span>`;
document.querySelector("#ui-overlay .ui-text:nth-child(3)").innerHTML = document.querySelector("#ui-overlay .ui-text:nth-child(3)").innerHTML =
`${CONFIG.texts.livesLabel} <span id="lives">3</span>`; `${CONFIG.texts.livesLabel} <span id="lives">${"♥".repeat(CONFIG.game.startLives)}</span>`;
document.querySelector("#gameover-screen .screen-subtitle").innerHTML = document.querySelector("#gameover-screen .screen-subtitle").innerHTML =
`${CONFIG.texts.finalScoreLabel} <span id="final-score">0</span>`; `${CONFIG.texts.finalScoreLabel} <span id="final-score">0</span>`;
+3 -3
View File
@@ -10,9 +10,9 @@ class Particle {
this.size = S.particleSizeMin + Math.random() * (S.particleSizeMax - S.particleSizeMin); this.size = S.particleSizeMin + Math.random() * (S.particleSizeMax - S.particleSizeMin);
} }
update() { update() {
this.x += this.vx; this.x += this.vx * frameFactor;
this.y += this.vy; this.y += this.vy * frameFactor;
this.life -= this.decay; this.life -= this.decay * frameFactor;
this.vx *= S.particleDamping; this.vx *= S.particleDamping;
this.vy *= S.particleDamping; this.vy *= S.particleDamping;
} }
+8 -3
View File
@@ -36,18 +36,23 @@ class Projectile {
if (this.trail.length > S.projectileTrailLength) this.trail.shift(); if (this.trail.length > S.projectileTrailLength) this.trail.shift();
for (let t of this.trail) { for (let t of this.trail) {
t.life -= S.projectileTrailDecay; t.life -= S.projectileTrailDecay * frameFactor;
} }
this.trail = this.trail.filter((t) => t.life > 0); this.trail = this.trail.filter((t) => t.life > 0);
this.x += this.vx; this.x += this.vx * frameFactor;
this.y += this.vy; this.y += this.vy * frameFactor;
if (this.isHit && this.target && !this.hit) { if (this.isHit && this.target && !this.hit) {
const dx = this.target.x - this.x; const dx = this.target.x - this.x;
const dy = this.target.y - this.y; const dy = this.target.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy); 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) { if (dist < S.projectileHitRadius) {
this.hit = true; this.hit = true;
game.onProjectileHit(this, this.target); game.onProjectileHit(this, this.target);
+141 -58
View File
@@ -1,3 +1,33 @@
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 = {}; let S = {};
function recalc() { function recalc() {
@@ -16,48 +46,85 @@ function recalc() {
const R = CONFIG.visualRatio; const R = CONFIG.visualRatio;
const T = CONFIG.timing; const T = CONFIG.timing;
S.gridSize = Math.round(V.gridSize * W / 1000); S.gridSize = Math.round((V.gridSize * W) / 1000);
S.enemyPaddingX = Math.round(V.enemyPaddingX * W / 1000); S.enemyPaddingX = Math.round((V.enemyPaddingX * W) / 1000);
S.enemyAlienOffset = Math.round(V.enemyAlienOffset * W / 1000); S.enemyAlienOffset = Math.round((V.enemyAlienOffset * W) / 1000);
S.armorBarPad = Math.round(V.armorBarPad * W / 1000); S.armorBarPad = Math.round((V.armorBarPad * W) / 1000);
S.armorBarFont = Math.round(V.armorBarFont * W / 1000); S.armorBarFont = Math.round((V.armorBarFont * W) / 1000);
S.missTargetPadX = Math.round(V.missTargetPadX * W / 1000); S.missTargetPadX = Math.round((V.missTargetPadX * W) / 1000);
S.missTargetPadY = Math.round(V.missTargetPadY * H / 1000); S.missTargetPadY = Math.round((V.missTargetPadY * H) / 1000);
S.inputAreaHeight = Math.round(V.inputAreaHeight * H / 1000); S.inputAreaHeight = Math.round((V.inputAreaHeight * H) / 1000);
S.removeZone = Math.round(V.removeZone * H / 1000); S.removeZone = Math.round((V.removeZone * H) / 1000);
for (const k of ["playerY","playerRadius","playerGlow","enemySimpleHeight","enemyHeavyHeight", for (const k of [
"enemySimpleFont","enemyHeavyFont","enemyArmorBarHeight","enemyArmorBarOffset", "playerY",
"enemyArmorLabelOffset","enemyGlow","enemyFlashGlow","enemyLineWidth", "playerRadius",
"projectileGlow","projectileHitRadius","projectileMissRadius","projectileFont", "playerGlow",
"projectileCircleRadius","shakeHit","shakeMiss","shakeDamage","particleVel", "enemySimpleHeight",
"particleSizeMin","particleSizeMax","particleGlow","trailSizeBase","trailSizeInc", "enemyHeavyHeight",
"trailGlow","shooterOffset"]) { "enemySimpleFont",
S[k] = Math.round(V[k] * H / 1000); "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);
} }
const SP = CONFIG.speed; S.projectileSpeed = H / (CONFIG.speed.projectileCrossTime * 60);
const playH = H - S.removeZone; S.gridSpeed = (CONFIG.speed.gridSpeed * H) / 1000;
S.simpleEnemySpeedBase = playH / (SP.simpleEnemyCrossTime * 60);
S.simpleEnemySpeedRandom = playH / (SP.simpleEnemyCrossTimeRand * 60);
S.simpleEnemySpeedPerWave = playH / (SP.simpleEnemyCrossTimeDecay * 60);
S.simpleEnemyMaxSpeed = playH / (15 * 60);
S.heavyEnemySpeedBase = playH / (SP.heavyEnemyCrossTime * 60);
S.heavyEnemySpeedRandom = playH / (SP.heavyEnemyCrossTimeRand * 60);
S.projectileSpeed = H / (SP.projectileCrossTime * 60);
S.gridSpeed = SP.gridSpeed * H / 1000;
for (const k of ["projectileTrailLength","particleHit","particleMiss", for (const k of [
"particleDestroySimple","particleDestroyHeavy","particleArmorBreak", "projectileTrailLength",
"flashDuration","shakeHitDuration","shakeMissDuration","shakeDamageDuration", "particleHit",
"trailMaxLength","trailDecay","trailAlpha"]) { "particleMiss",
"particleDestroySimple",
"particleDestroyHeavy",
"particleArmorBreak",
"flashDuration",
"shakeHitDuration",
"shakeMissDuration",
"shakeDamageDuration",
"trailMaxLength",
"trailDecay",
"trailAlpha",
]) {
S[k] = C[k]; S[k] = C[k];
} }
for (const k of ["missTargetYRange","projectileTrailLength","projectileTrailDecay", for (const k of [
"projectileTrailAlpha","projectileCircleAlpha","enemyPulseRate","enemyAlphaBase", "missTargetYRange",
"enemyAlphaRange","enemyGlowPulse","particleDecayBase","particleDecayRange", "projectileTrailLength",
"particleDamping"]) { "projectileTrailDecay",
"projectileTrailAlpha",
"projectileCircleAlpha",
"enemyPulseRate",
"enemyAlphaBase",
"enemyAlphaRange",
"enemyGlowPulse",
"particleDecayBase",
"particleDecayRange",
"particleDamping",
]) {
S[k] = R[k]; S[k] = R[k];
} }
@@ -69,58 +136,74 @@ function recalc() {
function applyOverlayScale(W, H) { function applyOverlayScale(W, H) {
const title = document.querySelector(".screen-title"); const title = document.querySelector(".screen-title");
if (title) title.style.fontSize = Math.round(40 * H / 1000) + "px"; if (title) title.style.fontSize = Math.round((40 * H) / 1000) + "px";
const subs = document.querySelectorAll(".screen-subtitle"); const subs = document.querySelectorAll(".screen-subtitle");
for (let el of subs) el.style.fontSize = Math.round(18 * H / 1000) + "px"; for (let el of subs) el.style.fontSize = Math.round((18 * H) / 1000) + "px";
const overlay = document.getElementById("ui-overlay"); const overlay = document.getElementById("ui-overlay");
if (overlay) { if (overlay) {
overlay.style.padding = Math.round(12 * H / 1000) + "px " + Math.round(16 * W / 1000) + "px"; overlay.style.padding = Math.round((12 * H) / 1000) + "px " + Math.round((16 * W) / 1000) + "px";
overlay.style.fontSize = Math.round(14 * H / 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 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"); const typed = document.getElementById("typed-text");
if (typed) { if (typed) {
typed.style.fontSize = Math.round(20 * H / 1000) + "px"; typed.style.fontSize = Math.round((20 * H) / 1000) + "px";
typed.style.minHeight = Math.round(28 * H / 1000) + "px"; typed.style.minHeight = Math.round((28 * H) / 1000) + "px";
} }
const chatText = document.getElementById("chat-text"); const chatText = document.getElementById("chat-text");
if (chatText) { if (chatText) {
chatText.style.fontSize = Math.round(16 * H / 1000) + "px"; chatText.style.fontSize = Math.round((16 * H) / 1000) + "px";
chatText.style.minHeight = Math.round(28 * H / 1000) + "px"; chatText.style.minHeight = Math.round((28 * H) / 1000) + "px";
} }
const chForm = document.getElementById("channel-form"); const chForm = document.getElementById("channel-form");
if (chForm) { if (chForm) {
chForm.querySelector(".screen-title").style.fontSize = Math.round(80 * H / 1000) + "px"; chForm.querySelector(".screen-title").style.fontSize = Math.round((80 * H) / 1000) + "px";
const inp = chForm.querySelector("input"); const inp = chForm.querySelector("input");
if (inp) { if (inp) {
inp.style.fontSize = Math.round(22 * H / 1000) + "px"; 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.padding = Math.round((12 * H) / 1000) + "px " + Math.round((20 * W) / 1000) + "px";
inp.style.width = Math.round(300 * W / 1000) + "px"; inp.style.width = Math.round((300 * W) / 1000) + "px";
} }
const btn = chForm.querySelector("button"); const btn = chForm.querySelector("button");
if (btn) { if (btn) {
btn.style.fontSize = Math.round(18 * H / 1000) + "px"; 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.padding = Math.round((10 * H) / 1000) + "px " + Math.round((40 * W) / 1000) + "px";
btn.style.minWidth = Math.round(260 * W / 1000) + "px"; btn.style.minWidth = Math.round((260 * W) / 1000) + "px";
} }
const link = document.getElementById("game-link"); const link = document.getElementById("game-link");
if (link) link.style.fontSize = Math.round(16 * H / 1000) + "px"; if (link) link.style.fontSize = Math.round((16 * H) / 1000) + "px";
const note = chForm.querySelector(".obs-note"); const note = chForm.querySelector(".obs-note");
if (note) note.style.fontSize = Math.round(16 * H / 1000) + "px"; if (note) note.style.fontSize = Math.round((16 * H) / 1000) + "px";
} }
const goTitle = document.getElementById("gameover-title"); const goTitle = document.getElementById("gameover-title");
if (goTitle) goTitle.style.fontSize = Math.round(32 * H / 1000) + "px"; 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() { function drawGrid() {
ctx.strokeStyle = CONFIG.colors.primaryGrid; ctx.strokeStyle = CONFIG.colors.primaryGrid;
ctx.lineWidth = 1; ctx.lineWidth = 1;
const gridSize = S.gridSize; const gridSize = S.gridSize;
bgOffset = (bgOffset + S.gridSpeed) % gridSize; bgOffset = (bgOffset + S.gridSpeed * frameFactor) % gridSize;
for (let x = 0; x <= W; x += gridSize) { for (let x = 0; x <= W; x += gridSize) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, 0); ctx.moveTo(x, 0);
+3 -2
View File
@@ -1,6 +1,7 @@
{ {
"cleanUrls": true, "cleanUrls": false,
"rewrites": [ "rewrites": [
{ "source": "/game", "destination": "/game.html" } { "source": "/game", "destination": "/game.html" },
{ "source": "/", "destination": "/index.html" }
] ]
} }
+121 -26
View File
@@ -61,10 +61,16 @@ body {
} }
.ui-text { .ui-text {
color: #00ff41; color: #00ff41;
font-size: 14px; font-size: 28px;
text-shadow: 0 0 8px #00ff41; text-shadow: 0 0 8px #00ff41;
letter-spacing: 1px; letter-spacing: 1px;
} }
.ui-text:nth-child(2) {
text-align: center;
}
.ui-text:nth-child(3) {
text-align: right;
}
#input-display { #input-display {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -118,32 +124,33 @@ body {
} }
.screen-title { .screen-title {
color: #00ff41; color: #00ff41;
font-size: 32px; font-size: 64px;
margin-bottom: 12px; margin-bottom: 12px;
text-shadow: 0 0 20px #00ff41; text-shadow: 0 0 20px #00ff41;
letter-spacing: 4px; letter-spacing: 4px;
} }
.screen-subtitle { .screen-subtitle {
color: #00cc33; color: #00cc33;
font-size: 14px; font-size: 28px;
margin-bottom: 30px; margin-bottom: 30px;
opacity: 0.8; opacity: 0.8;
} }
#player-stats { #player-stats {
margin-bottom: 20px; margin-bottom: 20px;
display: flex;
flex-direction: column;
align-items: center;
width: 80%; width: 80%;
max-width: 400px;
} }
.stats-section-title { .stats-section-title {
color: #00ff41; color: #00ff41;
font-size: 16px; font-size: 28px;
letter-spacing: 3px; letter-spacing: 3px;
margin-bottom: 10px; margin-bottom: 10px;
text-shadow: 0 0 10px #00ff41; text-shadow: 0 0 10px #00ff41;
} }
.stats-row { .stats-row {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 4px 12px; padding: 4px 12px;
margin-bottom: 4px; margin-bottom: 4px;
@@ -151,47 +158,56 @@ body {
opacity: 0.8; opacity: 0.8;
} }
.stats-top1 { .stats-top1 {
font-size: 18px; font-size: 48px;
font-weight: bold; font-weight: bold;
color: #00ff41; color: #00ff41;
border-left-width: 4px; border-left-width: 4px;
opacity: 1; opacity: 1;
text-shadow: 0 0 12px #00ff41; text-shadow: 0 0 16px #00ff41;
}
.stats-crown {
font-size: 1.2em;
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.7));
} }
.stats-top2 { .stats-top2 {
font-size: 15px; font-size: 40px;
font-weight: 600; font-weight: 600;
color: #00dd33; color: #00dd33;
opacity: 0.9; opacity: 0.9;
text-shadow: 0 0 10px #00dd33;
} }
.stats-top3 { .stats-top3 {
font-size: 13px; font-size: 36px;
color: #00bb22; color: #00bb22;
text-shadow: 0 0 8px #00bb22;
} }
.stats-place { .stats-place {
min-width: 28px; min-width: 28px;
} }
.stats-name { .stats-name {
flex: 1;
text-align: left; text-align: left;
margin-left: 8px; margin-left: 8px;
margin-right: 10vw;
} }
.stats-kills { .stats-kills {
min-width: 24px; min-width: 24px;
text-align: right; text-align: right;
} }
.stats-mazila { .stats-mazila {
margin-top: 14px; margin-top: 40px;
font-size: 14px; font-size: 28px;
color: #ff6644; color: #ff4444;
opacity: 0.85; text-shadow:
0 0 12px #ff4444,
0 0 24px rgba(255, 68, 68, 0.4);
} }
.mazila-title { .mazila-title {
font-weight: bold; font-weight: bold;
text-shadow: 0 0 8px #ff6644; color: #ff6666;
text-shadow: 0 0 16px #ff6666;
} }
.mazila-count { .mazila-count {
opacity: 0.6; color: #cc2222;
} }
.hidden { .hidden {
display: none !important; display: none !important;
@@ -244,7 +260,7 @@ body {
opacity: 1; opacity: 1;
margin-bottom: 8px; margin-bottom: 8px;
} }
#channel-form input { #channel-form input[type="text"] {
background: transparent; background: transparent;
border: 2px solid #00ff41; border: 2px solid #00ff41;
color: #00ff41; color: #00ff41;
@@ -256,7 +272,15 @@ body {
text-shadow: 0 0 12px #00ff41; text-shadow: 0 0 12px #00ff41;
box-shadow: 0 0 20px rgba(0, 255, 65, 0.2); box-shadow: 0 0 20px rgba(0, 255, 65, 0.2);
} }
#channel-form input:focus { #channel-form input[type="text"]:focus {
box-shadow: 0 0 30px rgba(0, 255, 65, 0.4);
}
#channel-form input[type="checkbox"] {
border: none;
box-shadow: none;
padding: 0;
}
#channel-form input:not([type="checkbox"]):focus {
box-shadow: 0 0 30px rgba(0, 255, 65, 0.4); box-shadow: 0 0 30px rgba(0, 255, 65, 0.4);
} }
#settings-panel { #settings-panel {
@@ -265,9 +289,13 @@ body {
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.settings-row {
display: flex;
align-items: center;
}
.settings-label { .settings-label {
color: #00cc33; color: #00cc33;
font-size: 14px; font-size: 28px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -278,13 +306,52 @@ body {
.settings-label:hover { .settings-label:hover {
opacity: 1; opacity: 1;
} }
.hint-toggle {
background: transparent;
border: 2px solid #00aa33;
color: #00aa33;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
text-shadow: 0 0 6px #00aa33;
transition: all 0.15s;
margin-left: 8px;
}
.hint-toggle:hover {
border-color: #00ff41;
color: #00ff41;
text-shadow: 0 0 10px #00ff41;
}
.settings-hints {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
margin-top: 12px;
}
.settings-hint {
color: #00aa33;
font-size: 18px;
text-shadow: none;
line-height: 1.5;
max-width: 500px;
text-align: center;
}
.settings-label input[type="checkbox"] { .settings-label input[type="checkbox"] {
width: 16px; width: 28px;
height: 16px; height: 28px;
accent-color: #00ff41; accent-color: #00ff41;
cursor: pointer; cursor: pointer;
margin-right: 12px;
} }
#channel-form button { .btn-row {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn-primary {
background: transparent; background: transparent;
border: 2px solid #00ff41; border: 2px solid #00ff41;
color: #00ff41; color: #00ff41;
@@ -296,7 +363,7 @@ body {
transition: all 0.15s; transition: all 0.15s;
min-width: 260px; min-width: 260px;
} }
#channel-form button:hover { .btn-primary:hover {
background: rgba(0, 255, 65, 0.1); background: rgba(0, 255, 65, 0.1);
box-shadow: 0 0 30px rgba(0, 255, 65, 0.4); box-shadow: 0 0 30px rgba(0, 255, 65, 0.4);
} }
@@ -312,10 +379,38 @@ body {
text-shadow: 0 0 12px #00ff41; text-shadow: 0 0 12px #00ff41;
} }
#channel-form .obs-note { #channel-form .obs-note {
margin-top: 24px; margin-top: 32px;
color: #00ff41; color: #00ff41;
font-size: 16px; font-size: 28px;
opacity: 0.8; opacity: 0.8;
line-height: 1.6; line-height: 1.6;
text-shadow: 0 0 6px #00ff41; text-shadow: 0 0 6px #00ff41;
} }
.links-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px 40px;
margin-top: 16px;
}
.page-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #00ff41;
font-size: 22px;
text-decoration: none;
text-shadow: 0 0 10px #00ff41;
opacity: 0.85;
transition: opacity 0.15s;
}
.link-icon {
width: 24px;
height: 24px;
fill: currentColor;
flex-shrink: 0;
}
.page-link:hover {
opacity: 1;
text-shadow: 0 0 16px #00ff41;
}