Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec01e1ce56 | ||
|
|
0928eba0e3 | ||
|
|
9a7d793d7e | ||
|
|
8a5a27f3b6 | ||
|
|
2edba57952 | ||
|
|
b1c8ee08ff | ||
|
|
b282f1064a | ||
|
|
b97322dcfa | ||
|
|
c2de374aea | ||
|
|
5489c0f8d5 |
@@ -1,13 +1,112 @@
|
||||
# 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).
|
||||
Пока что ожидается перенос на sveltekit + miniplex ecs и возможно pixi.js\konva
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# TODO
|
||||
|
||||
- [ ] Переделать процесс работы с инпутами — прятать локальные, если не используется
|
||||
- [ ] Переделать процесс работы с инпутами — прятать локальные, если не используется, прятать чат, если не используется
|
||||
|
||||
@@ -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, удаление старых классов.
|
||||
@@ -14,12 +14,12 @@
|
||||
<div id="ui-overlay" class="hidden">
|
||||
<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">LIVES: <span id="lives">3</span></span>
|
||||
<span class="ui-text">LIVES: <span id="lives">♥♥♥♥♥♥♥♥♥♥</span></span>
|
||||
</div>
|
||||
|
||||
<div id="input-display">
|
||||
<div id="input-local">
|
||||
<div id="typed-text"></div>
|
||||
<div id="typed-text">просто пиши !play и другие слова</div>
|
||||
</div>
|
||||
<div id="input-chat" class="hidden">
|
||||
<div id="chat-text"></div>
|
||||
|
||||
@@ -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
@@ -13,13 +13,44 @@
|
||||
<div class="screen-subtitle">Введите имя канала для игры</div>
|
||||
<input type="text" id="channel-input" placeholder="Ku6ep_XBOCTuK" autofocus />
|
||||
<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>
|
||||
<button id="copy-btn">СКОПИРОВАТЬ ССЫЛКУ</button>
|
||||
<div id="game-link"></div>
|
||||
<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>
|
||||
|
||||
+248
-51
@@ -15,23 +15,28 @@ const CONFIG = {
|
||||
startLives: 10,
|
||||
startWave: 1,
|
||||
startScore: 0,
|
||||
spawnIntervalBase: 240,
|
||||
spawnIntervalMin: 60,
|
||||
spawnIntervalDecayPerWave: 12,
|
||||
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: {
|
||||
simpleEnemyCrossTime: 60,
|
||||
simpleEnemyCrossTimeRand: 120,
|
||||
simpleEnemyCrossTimeDecay: 4000,
|
||||
heavyEnemyCrossTime: 120,
|
||||
heavyEnemyCrossTimeRand: 240,
|
||||
projectileCrossTime: 1.3,
|
||||
slowEnemyCrossTimeStart: 240,
|
||||
slowEnemyCrossTimeCap: 30,
|
||||
slowEnemyCrossTimeCapWave: 50,
|
||||
fastEnemyCrossTimeStart: 120,
|
||||
fastEnemyCrossTimeCap: 15,
|
||||
fastEnemyCrossTimeCapWave: 50,
|
||||
bossCrossTime: 60,
|
||||
projectileCrossTime: 0.43,
|
||||
gridSpeed: 0.5,
|
||||
},
|
||||
visual: {
|
||||
@@ -81,8 +86,8 @@ const CONFIG = {
|
||||
projectileTrailAlpha: 0.6,
|
||||
projectileCircleAlpha: 0.5,
|
||||
enemyPulseRate: 0.05,
|
||||
enemyAlphaBase: 0.7,
|
||||
enemyAlphaRange: 0.3,
|
||||
enemyAlphaBase: 1.0,
|
||||
enemyAlphaRange: 0.0,
|
||||
enemyGlowPulse: 5,
|
||||
particleDecayBase: 0.02,
|
||||
particleDecayRange: 0.03,
|
||||
@@ -114,6 +119,8 @@ const CONFIG = {
|
||||
primaryGrid: "rgba(0,255,65,0.08)",
|
||||
heavy: "#ffaa00",
|
||||
heavyDim: "#cc8800",
|
||||
boss: "#ff4444",
|
||||
bossDim: "#cc2222",
|
||||
miss: "#ff0044",
|
||||
white: "#ffffff",
|
||||
red: "#ff0000",
|
||||
@@ -151,48 +158,238 @@ const CONFIG = {
|
||||
|
||||
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",
|
||||
"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"],
|
||||
["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) {
|
||||
|
||||
+171
-29
@@ -1,5 +1,22 @@
|
||||
class Enemy {
|
||||
constructor() {
|
||||
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);
|
||||
@@ -14,16 +31,16 @@ class Enemy {
|
||||
this.text = wordData;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
@@ -32,15 +49,30 @@ class Enemy {
|
||||
|
||||
_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 = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`;
|
||||
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;
|
||||
@@ -52,7 +84,24 @@ class Enemy {
|
||||
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.flashTimer = S.flashDuration;
|
||||
if (this.currentWordIndex >= this.words.length) {
|
||||
@@ -63,9 +112,9 @@ class Enemy {
|
||||
}
|
||||
|
||||
update() {
|
||||
this.y += this.speed;
|
||||
this.pulse += 0.05;
|
||||
if (this.flashTimer > 0) this.flashTimer--;
|
||||
this.y += this.speed * frameFactor;
|
||||
this.pulse += 0.05 * frameFactor;
|
||||
if (this.flashTimer > 0) this.flashTimer -= frameFactor;
|
||||
}
|
||||
|
||||
draw() {
|
||||
@@ -73,22 +122,105 @@ class Enemy {
|
||||
const flash = this.flashTimer > 0;
|
||||
|
||||
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 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.strokeStyle = flash
|
||||
? CONFIG.colors.white
|
||||
: this.isHeavy
|
||||
? `rgba(255,170,0,${alpha})`
|
||||
: `rgba(0,255,65,${alpha})`;
|
||||
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.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;
|
||||
ctx.beginPath();
|
||||
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, this.y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = flash ? "#ffffff" : "#0a0a0a";
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
const barW = boxW - S.armorBarPad;
|
||||
@@ -124,17 +258,25 @@ class Enemy {
|
||||
this.y - boxH / 2 - S.enemyArmorLabelOffset,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ctx.strokeRect(this.x - boxW / 2, this.y - boxH / 2, boxW, boxH);
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`;
|
||||
ctx.font = `${fontWeight}${fontSize}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.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();
|
||||
}
|
||||
|
||||
+70
-36
@@ -10,8 +10,11 @@ let enemies = [];
|
||||
let particles = [];
|
||||
let projectiles = [];
|
||||
let spawnTimer = 0;
|
||||
let spawnInterval = CONFIG.game.spawnIntervalBase;
|
||||
let waveEnemiesLeft = 0;
|
||||
let waveTimer = 0;
|
||||
let wavePauseTimer = 0;
|
||||
let wavePauseText = "";
|
||||
let wavePauseActive = false;
|
||||
let shakeTimer = 0;
|
||||
let shakeAmount = 0;
|
||||
let bgOffset = 0;
|
||||
@@ -22,7 +25,7 @@ let playerStats = {};
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const settings = {
|
||||
autoRestart: params.get("autoRestart") !== "0",
|
||||
singlePlay: params.get("singlePlay") === "1",
|
||||
};
|
||||
|
||||
function initKeyboardBridge() {
|
||||
@@ -39,6 +42,7 @@ function initKeyboardBridge() {
|
||||
const line = buffer;
|
||||
buffer = "";
|
||||
InputModule.submitLocal(line);
|
||||
InputModule.updateLocalDisplay("");
|
||||
} else if (e.key === "Escape") {
|
||||
buffer = "";
|
||||
InputModule.clear();
|
||||
@@ -55,10 +59,10 @@ const game = {
|
||||
},
|
||||
|
||||
onProjectileHit(projectile, enemy) {
|
||||
const dead = enemy.nextWord();
|
||||
const dead = enemy.hitWord(projectile.word);
|
||||
|
||||
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;
|
||||
if (projectile.username) {
|
||||
if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 };
|
||||
@@ -66,7 +70,8 @@ const game = {
|
||||
}
|
||||
this.destroyEnemy(enemy);
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
@@ -83,8 +88,10 @@ const game = {
|
||||
destroyEnemy(enemy) {
|
||||
const idx = enemies.indexOf(enemy);
|
||||
if (idx > -1) {
|
||||
const color = enemy.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary;
|
||||
const count = enemy.isHeavy
|
||||
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);
|
||||
@@ -97,9 +104,17 @@ 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 = 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(
|
||||
@@ -132,8 +147,9 @@ function renderPlayerStats() {
|
||||
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> <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);
|
||||
});
|
||||
|
||||
@@ -168,7 +184,7 @@ function gameOver() {
|
||||
document.getElementById("ui-overlay").classList.add("hidden");
|
||||
document.getElementById("input-chat").classList.add("hidden");
|
||||
gameLoopStarted = false;
|
||||
if (settings.autoRestart) {
|
||||
if (!settings.singlePlay) {
|
||||
recalc();
|
||||
runIntro();
|
||||
} else {
|
||||
@@ -190,14 +206,15 @@ function startGame() {
|
||||
particles = [];
|
||||
projectiles = [];
|
||||
playerStats = {};
|
||||
spawnTimer = CONFIG.game.spawnIntervalBase;
|
||||
spawnInterval = CONFIG.game.spawnIntervalBase;
|
||||
spawnTimer = 0;
|
||||
waveEnemiesLeft = getEnemiesPerWave();
|
||||
waveTimer = 0;
|
||||
wavePauseActive = false;
|
||||
InputModule.clear();
|
||||
|
||||
document.getElementById("score").textContent = score;
|
||||
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("gameover-screen").classList.add("hidden");
|
||||
document.getElementById("ui-overlay").classList.remove("hidden");
|
||||
@@ -212,25 +229,35 @@ function startGame() {
|
||||
function update() {
|
||||
if (gameState !== "playing") return;
|
||||
|
||||
spawnTimer++;
|
||||
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;
|
||||
spawnInterval = Math.max(
|
||||
CONFIG.game.spawnIntervalMin,
|
||||
CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
waveTimer++;
|
||||
if (waveTimer >= CONFIG.game.waveInterval) {
|
||||
waveTimer += frameFactor;
|
||||
if (waveTimer >= CONFIG.game.waveInterval && !wavePauseActive && waveEnemiesLeft <= 0) {
|
||||
wave++;
|
||||
waveTimer = 0;
|
||||
document.getElementById("wave").textContent = wave;
|
||||
spawnInterval = Math.max(
|
||||
CONFIG.game.spawnIntervalMin,
|
||||
CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave,
|
||||
);
|
||||
wavePauseActive = true;
|
||||
wavePauseTimer = 120;
|
||||
wavePauseText = wave % CONFIG.game.bossEveryNthWave === 0
|
||||
? `BOSS INCOMING`
|
||||
: `WAVE ${wave} INCOMING`;
|
||||
}
|
||||
|
||||
for (let i = enemies.length - 1; i >= 0; i--) {
|
||||
@@ -256,7 +283,7 @@ function update() {
|
||||
}
|
||||
|
||||
if (shakeTimer > 0) {
|
||||
shakeTimer--;
|
||||
shakeTimer -= frameFactor;
|
||||
shakeAmount *= 0.9;
|
||||
}
|
||||
}
|
||||
@@ -273,18 +300,24 @@ function draw() {
|
||||
|
||||
for (let p of projectiles) 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.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.shadowColor = CONFIG.colors.primary;
|
||||
ctx.shadowBlur = S.playerGlow;
|
||||
@@ -296,7 +329,8 @@ function draw() {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function loop() {
|
||||
function loop(timestamp) {
|
||||
calcDt(timestamp);
|
||||
update();
|
||||
draw();
|
||||
if (gameLoopStarted) {
|
||||
|
||||
+30
-6
@@ -1,19 +1,43 @@
|
||||
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 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() {
|
||||
const name = input.value.trim().toLowerCase() || "Ku6ep_XBOCTuK";
|
||||
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(() => {
|
||||
navigator.clipboard.writeText(buildUrl()).then(() => {
|
||||
linkEl.textContent = "СКОПИРОВАНО!";
|
||||
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) => {
|
||||
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");
|
||||
});
|
||||
|
||||
+9
-2
@@ -3,7 +3,7 @@ const InputModule = {
|
||||
localBuffer: "",
|
||||
|
||||
submitLine(text, username) {
|
||||
const line = text.trim().toLowerCase();
|
||||
const line = text.trim().toLowerCase().split(/\s+/)[0];
|
||||
if (!line) return;
|
||||
if (introPlaying) return;
|
||||
|
||||
@@ -25,7 +25,7 @@ const InputModule = {
|
||||
},
|
||||
|
||||
submitLocal(text) {
|
||||
const line = text.trim().toLowerCase();
|
||||
const line = text.trim().toLowerCase().split(/\s+/)[0];
|
||||
if (!line) return;
|
||||
if (introPlaying) return;
|
||||
|
||||
@@ -76,10 +76,17 @@ const InputModule = {
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
+10
-7
@@ -83,27 +83,30 @@ function runIntro() {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function introLoop() {
|
||||
frame++;
|
||||
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 (frame === I.shot1Frame) shots.push({ progress: 0, trail: [], done: false });
|
||||
if (frame === I.shot2Frame) shots.push({ progress: 0, trail: [], done: false });
|
||||
if (frame === I.shot3Frame) shots.push({ progress: 0, trail: [], done: false });
|
||||
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);
|
||||
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;
|
||||
for (let t of s.trail) t.life -= I.trailDecay * frameFactor;
|
||||
s.trail = s.trail.filter((t) => t.life > 0);
|
||||
|
||||
if (s.progress >= 1) {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
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">3</span>`;
|
||||
`${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>`;
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ class Particle {
|
||||
this.size = S.particleSizeMin + Math.random() * (S.particleSizeMax - S.particleSizeMin);
|
||||
}
|
||||
update() {
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.life -= this.decay;
|
||||
this.x += this.vx * frameFactor;
|
||||
this.y += this.vy * frameFactor;
|
||||
this.life -= this.decay * frameFactor;
|
||||
this.vx *= S.particleDamping;
|
||||
this.vy *= S.particleDamping;
|
||||
}
|
||||
|
||||
+8
-3
@@ -36,18 +36,23 @@ class Projectile {
|
||||
if (this.trail.length > S.projectileTrailLength) this.trail.shift();
|
||||
|
||||
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.x += this.vx;
|
||||
this.y += this.vy;
|
||||
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);
|
||||
|
||||
+141
-58
@@ -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 = {};
|
||||
|
||||
function recalc() {
|
||||
@@ -16,48 +46,85 @@ function recalc() {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
const SP = CONFIG.speed;
|
||||
const playH = H - S.removeZone;
|
||||
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;
|
||||
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"]) {
|
||||
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"]) {
|
||||
for (const k of [
|
||||
"missTargetYRange",
|
||||
"projectileTrailLength",
|
||||
"projectileTrailDecay",
|
||||
"projectileTrailAlpha",
|
||||
"projectileCircleAlpha",
|
||||
"enemyPulseRate",
|
||||
"enemyAlphaBase",
|
||||
"enemyAlphaRange",
|
||||
"enemyGlowPulse",
|
||||
"particleDecayBase",
|
||||
"particleDecayRange",
|
||||
"particleDamping",
|
||||
]) {
|
||||
S[k] = R[k];
|
||||
}
|
||||
|
||||
@@ -69,58 +136,74 @@ function recalc() {
|
||||
|
||||
function applyOverlayScale(W, H) {
|
||||
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");
|
||||
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");
|
||||
if (overlay) {
|
||||
overlay.style.padding = Math.round(12 * H / 1000) + "px " + Math.round(16 * W / 1000) + "px";
|
||||
overlay.style.fontSize = Math.round(14 * 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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
if (note) note.style.fontSize = Math.round((16 * H) / 1000) + "px";
|
||||
}
|
||||
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() {
|
||||
ctx.strokeStyle = CONFIG.colors.primaryGrid;
|
||||
ctx.lineWidth = 1;
|
||||
const gridSize = S.gridSize;
|
||||
bgOffset = (bgOffset + S.gridSpeed) % gridSize;
|
||||
bgOffset = (bgOffset + S.gridSpeed * frameFactor) % gridSize;
|
||||
for (let x = 0; x <= W; x += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cleanUrls": true,
|
||||
"cleanUrls": false,
|
||||
"rewrites": [
|
||||
{ "source": "/game", "destination": "/game.html" }
|
||||
{ "source": "/game", "destination": "/game.html" },
|
||||
{ "source": "/", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
@@ -61,10 +61,16 @@ body {
|
||||
}
|
||||
.ui-text {
|
||||
color: #00ff41;
|
||||
font-size: 14px;
|
||||
font-size: 28px;
|
||||
text-shadow: 0 0 8px #00ff41;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.ui-text:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
.ui-text:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
#input-display {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -118,32 +124,33 @@ body {
|
||||
}
|
||||
.screen-title {
|
||||
color: #00ff41;
|
||||
font-size: 32px;
|
||||
font-size: 64px;
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 0 20px #00ff41;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
.screen-subtitle {
|
||||
color: #00cc33;
|
||||
font-size: 14px;
|
||||
font-size: 28px;
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#player-stats {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.stats-section-title {
|
||||
color: #00ff41;
|
||||
font-size: 16px;
|
||||
font-size: 28px;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 10px #00ff41;
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
margin-bottom: 4px;
|
||||
@@ -151,47 +158,56 @@ body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stats-top1 {
|
||||
font-size: 18px;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #00ff41;
|
||||
border-left-width: 4px;
|
||||
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 {
|
||||
font-size: 15px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #00dd33;
|
||||
opacity: 0.9;
|
||||
text-shadow: 0 0 10px #00dd33;
|
||||
}
|
||||
.stats-top3 {
|
||||
font-size: 13px;
|
||||
font-size: 36px;
|
||||
color: #00bb22;
|
||||
text-shadow: 0 0 8px #00bb22;
|
||||
}
|
||||
.stats-place {
|
||||
min-width: 28px;
|
||||
}
|
||||
.stats-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
margin-left: 8px;
|
||||
margin-right: 10vw;
|
||||
}
|
||||
.stats-kills {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
.stats-mazila {
|
||||
margin-top: 14px;
|
||||
font-size: 14px;
|
||||
color: #ff6644;
|
||||
opacity: 0.85;
|
||||
margin-top: 40px;
|
||||
font-size: 28px;
|
||||
color: #ff4444;
|
||||
text-shadow:
|
||||
0 0 12px #ff4444,
|
||||
0 0 24px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
.mazila-title {
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 8px #ff6644;
|
||||
color: #ff6666;
|
||||
text-shadow: 0 0 16px #ff6666;
|
||||
}
|
||||
.mazila-count {
|
||||
opacity: 0.6;
|
||||
color: #cc2222;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
@@ -244,7 +260,7 @@ body {
|
||||
opacity: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#channel-form input {
|
||||
#channel-form input[type="text"] {
|
||||
background: transparent;
|
||||
border: 2px solid #00ff41;
|
||||
color: #00ff41;
|
||||
@@ -256,7 +272,15 @@ body {
|
||||
text-shadow: 0 0 12px #00ff41;
|
||||
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);
|
||||
}
|
||||
#settings-panel {
|
||||
@@ -265,9 +289,13 @@ body {
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.settings-label {
|
||||
color: #00cc33;
|
||||
font-size: 14px;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -278,13 +306,52 @@ body {
|
||||
.settings-label:hover {
|
||||
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"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
accent-color: #00ff41;
|
||||
cursor: pointer;
|
||||
margin-right: 12px;
|
||||
}
|
||||
#channel-form button {
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: transparent;
|
||||
border: 2px solid #00ff41;
|
||||
color: #00ff41;
|
||||
@@ -296,7 +363,7 @@ body {
|
||||
transition: all 0.15s;
|
||||
min-width: 260px;
|
||||
}
|
||||
#channel-form button:hover {
|
||||
.btn-primary:hover {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
box-shadow: 0 0 30px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
@@ -312,10 +379,38 @@ body {
|
||||
text-shadow: 0 0 12px #00ff41;
|
||||
}
|
||||
#channel-form .obs-note {
|
||||
margin-top: 24px;
|
||||
margin-top: 32px;
|
||||
color: #00ff41;
|
||||
font-size: 16px;
|
||||
font-size: 28px;
|
||||
opacity: 0.8;
|
||||
line-height: 1.6;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user