Initial commit: Arcanum TD — medieval fantasy tower defense

Vite + React + PixiJS + TypeScript. Features:
- 4-level campaign (King's Road → Obsidian Keep)
- Isometric 2.5D grid with ley-line mechanics
- ECS architecture (entities, components, systems)
- 4 tower types, hero spellcaster, 10+ enemy types
- Lich King boss with 3-phase AI
- Meta-progression: essence, rune unlocks
- Full UI redesign with fantasy design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mareli
2026-04-19 12:31:49 +03:00
commit 7a62067af1
91 changed files with 11832 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+349
View File
@@ -0,0 +1,349 @@
# Arcanum: Wardens of the Realm
**Tower Defense в средневеково-магической стилистике. Веб. План на реализацию (исполнитель — Sonnet).**
---
## 0. Концепция
Игрок — последний Архимаг угасающего королевства. Он защищает цитадель от вторжения Тёмного Ковенанта, расставляя на древних каменных башнях **рунические печати**, через которые течёт магия стихий. По карте идут **лей-линии** — потоки маны, усиливающие башни рядом. Сам Архимаг — подвижный герой с четырьмя заклинаниями.
**Три столпа геймплея:**
1. **Рунный синергизм** — у каждой башни 1 основная + 2 слота вторичных рун; руны комбинируются в уникальные эффекты.
2. **Лей-линии** — «магический рельеф» карты, создающий задачу оптимального размещения.
3. **Архимаг** — активный игровой элемент поверх пассивной экономики TD; позиционирование и таймингы заклинаний решают исходы волн.
**Мета-слой (roguelike-lite):** между забегами открываются новые руны в **Томе Рун** за эссенцию, выпадающую с боссов.
---
## 1. Технологический стек
| Слой | Технология | Зачем |
|---|---|---|
| Build | **Vite + TypeScript** | Быстрый HMR, строгая типизация |
| Рендеринг игры | **PixiJS v8** (WebGPU-first) | 2D/2.5D, шейдеры, сотни юнитов на экране |
| UI / меню / HUD | **React 18** | Декларативный UI поверх canvas |
| Стили | **TailwindCSS + shadcn/ui** | Быстрая сборка красивых меню |
| Состояние | **Zustand** | Лёгкий стор, подписки из Pixi и React |
| Анимации UI | **GSAP** | Переходы, ink-wipe, rune-glow |
| Аудио | **Howler.js** | Кроссбраузерный звук, пулы |
| Многопоток | **Web Workers** | A* pathfinding вне основного потока |
| Шрифты | **Cinzel** (заголовки), **EB Garamond** (текст), **MedievalSharp** (акценты) | Средневековая типографика |
| Тесты | **Vitest** + **Playwright** (smoke) | Юнит + e2e одного уровня |
| Деплой | **Vercel** / **Netlify** | Статический билд |
**Почему не Phaser/Three.js:** Phaser диктует архитектуру и «чувствуется как движок»; Three избыточен для 2.5D. PixiJS даёт чистый рендерер — больше свободы для собственной ECS и визуальных эффектов.
---
## 2. Визуальное направление («красивый веб»)
- **Стиль:** изометрическая 2.5D-проекция, hand-painted look (опорно — вайб *Bastion* / *Octopath HD-2D*).
- **Палитра:** глубокая полночь (#0E1220) + обожжённое золото (#C9A14A) + уголь (#E8702A) + морозный циан (#6ECBD5) + пергамент (#E9DCC0).
- **Динамический свет:** каждая башня излучает цветной свет (огонь пульсирует, мороз мерцает, арканум медленно вращается).
- **Постэффекты:** bloom на магических снарядах, лёгкая хроматическая аберрация на боссовых ударах, цветокоррекция по биому.
- **Частицы:** угольки, снег, руны-светлячки вокруг лей-линий, дым от смерти врагов.
- **UI-каркас:** пергамент + золотая филигрань (CSS `border-image` + SVG), кнопки с **rune-glow** на hover, иконки — нарисованные от руки.
- **Переходы:** чернильное клякса-вайп между меню и игрой.
- **Превью размещения:** призрачная башня + пульсирующий круг радиуса с рунным узором на земле.
---
## 3. Контент MVP
### 3.1 Башни (6 архетипов, по 3 тира)
| Башня | Урон | Особенность |
|---|---|---|
| Archers' Loft | физ | дальняя, точная, single-target |
| Pyromancer Spire | огонь | AoE + burn DoT |
| Cryomancer Sanctum | лёд | slow → freeze → shatter-комбо |
| Stormcaller Obelisk | молния | chain-lightning по 3-5 целям |
| Chapel of Light | поддержка | хилит стены, баффает соседние башни |
| Bone Totem | призыв | поднимает скелета из трупа врага |
### 3.2 Руны-модификаторы (12, открываются мета-прогрессией)
Pierce, Chain, Seek, Burn, Frost, Shock, Vampire, Echo, Curse, Weight, Fracture, Hex. Каждая даёт измеримый эффект + визуальный маркер на башне.
### 3.3 Враги (9)
Goblin Scout (быстрый), Orc Brute (танк), Warg Rider (swarm), Wraith (фазовый — только магия), Cursed Troll (регенит), Siege Golem (броня), Necromancer (воскрешает соседей), Dragon (летающий босс), Lich-King (финальный босс, 3 фазы).
### 3.4 Герой — **The Archmage**
- Ходит по карте по клику.
- 4 активных заклинания: **Fireball** (AoE-урон), **Blizzard** (AoE-slow), **Blink** (телепорт), **Time Warp** (глобальный slow 5 сек).
- Получает XP с каждой волны, улучшает заклинания по дереву навыков.
### 3.5 Экономика
- **Gold** — с убийств → башни/апгрейды.
- **Mana** — регенерит сам, бонус от лей-линий → заклинания героя.
- **Essence** — падает с боссов, не тратится внутри забега → Том Рун между забегами.
### 3.6 Волны
20 волн на карту, каждая 5-я — элитная, каждая 10-я — босс. Все волны описаны декларативно (TS-конфиги): состав, тайминги, паттерн спавна.
### 3.7 Карты MVP (3 + босс-арена)
1. **King's Road** — открытые поля, один путь (туториальная).
2. **Whispering Woods** — ветвление, враги делятся.
3. **Frostfall Pass** — горный проход, узкие точки, снежная погода.
4. **Obsidian Keep** — финальный босс, 3 фазы.
---
## 4. Архитектура
### 4.1 Слои
```
┌─────────────────────────────────────────┐
│ React UI (меню, HUD, модалки) │
└────────────────┬────────────────────────┘
│ hooks (useGameStore)
┌────────────────▼────────────────────────┐
│ Zustand — общий стор (game, meta, set.) │
└────────────────┬────────────────────────┘
│ подписки
┌────────────────▼────────────────────────┐
│ Game Core — ECS-lite (Entities + Systems)│
└────────────────┬────────────────────────┘
│ рендер-команды
┌────────────────▼────────────────────────┐
│ PixiJS — слои, спрайты, шейдеры, частицы│
└─────────────────────────────────────────┘
```
### 4.2 ECS-lite
Сущности — объекты с `id`. Компоненты — чистые дата-классы. Системы — функции `update(dt, world)`.
**Компоненты (пример):**
```ts
TransformComponent { x, y, rotation }
HealthComponent { current, max, armor, magicResist }
MovementComponent { speed, path, pathIndex }
TargetingComponent { range, mode: 'first'|'last'|'strongest'|'weakest' }
AttackComponent { damage, type, cooldown, projectile, runes: RuneId[] }
StatusEffectsComp { effects: Effect[] }
RenderComponent { sprite, layer, anim }
TeamComponent { team: 'player'|'enemy' }
LootComponent { gold, essence }
```
**Системы (порядок выполнения за тик):**
`InputSystem → WaveSystem → SpawnSystem → MovementSystem → TargetingSystem → AttackSystem → ProjectileSystem → StatusEffectSystem → CollisionSystem → DeathSystem → EconomySystem → RenderSystem`
### 4.3 Data-driven контент
Все башни/враги/волны/руны — типизированные TS-конфиги (единый источник правды, дружелюбен к балансу).
```ts
// src/data/towers.ts
export const TOWERS: Record<TowerId, TowerDef> = {
archers_loft: {
id: 'archers_loft',
name: "Archers' Loft",
tiers: [
{ cost: 50, damage: 8, range: 180, cooldown: 0.8 },
{ cost: 75, damage: 14, range: 200, cooldown: 0.7 },
{ cost: 120, damage: 22, range: 220, cooldown: 0.6 },
],
projectile: 'arrow',
damageType: 'physical',
sprite: 'towers/archers_loft',
},
// …
}
```
### 4.4 Структура проекта
```
tower-def/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── public/
│ └── assets/ { sprites/, audio/, fonts/, shaders/ }
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── ui/ # React
│ │ ├── screens/ { MainMenu, CampaignMap, GameHUD, TomeOfRunes, Settings, GameOver }
│ │ ├── hud/ { WaveIndicator, GoldBar, ManaBar, TowerShop, TowerInfoPanel, SpellBar }
│ │ └── components/ # shadcn wrappers
│ ├── game/
│ │ ├── core/ # GameEngine, EntityManager, EventBus, Time
│ │ ├── components/ # ECS data-классы
│ │ ├── systems/ # ECS логика
│ │ ├── entities/ { towers/, enemies/, projectiles/, hero/ }
│ │ ├── rendering/ { PixiRoot, layers, shaders/, particles/, camera }
│ │ ├── map/ { GridMap, Pathfinding, LeyLines, TilePalette }
│ │ ├── combat/ { DamageResolver, RuneEffects, Synergies }
│ │ ├── economy/
│ │ ├── audio/ # Howler-обёртка
│ │ └── input/ # pointer, keyboard, pan/zoom
│ ├── state/ # Zustand сторы
│ │ ├── gameStore.ts
│ │ ├── metaStore.ts
│ │ ├── settingsStore.ts
│ │ └── selectors.ts
│ ├── data/ # контент (TS)
│ │ ├── towers.ts
│ │ ├── enemies.ts
│ │ ├── runes.ts
│ │ ├── waves/
│ │ └── levels/
│ ├── workers/
│ │ └── pathfinding.worker.ts
│ ├── lib/ { save, rng, math }
│ └── styles/globals.css
└── tests/ { unit/, e2e/ }
```
### 4.5 Ключевые архитектурные решения
- **Фиксированный tick (60 Hz)** + интерполяция при рендере → детерминированная симуляция.
- **Object pooling** для снарядов, частиц, всплывающих цифр — без GC-stutter.
- **Pathfinding в воркере** — карта блокирует тайлы, воркер пересчитывает путь, возвращает массив точек.
- **Seeded RNG** (`mulberry32`) — воспроизводимые забеги, детерминированные тесты.
- **Save/load**: `localStorage` + версия схемы + миграции.
- **Event Bus** для одиночных событий (смерть, конец волны, босс-триггер); тонкая обвязка над `EventTarget`.
---
## 5. План разработки по фазам
Каждая фаза заканчивается **играбельным билдом**. Sonnet прогоняет `npm run build` и проходит ключевой сценарий вручную перед тем, как двигаться дальше.
### Фаза 0 — Bootstrap (день 1)
- Vite + TS + React + Tailwind + shadcn/ui.
- Монтирование PixiJS-канваса внутри React.
- Скелет Zustand-сторов.
- Главное меню → пустой экран игры.
- **Готово:** кликабельное меню, чёрный канвас с FPS-оверлеем.
### Фаза 1 — Сетка и путь (дни 2-3)
- `GridMap` с типами тайлов (buildable / path / blocked / ley-line).
- Изометрический рендер + параллакс-фон.
- A* в воркере.
- Визуализация пути (debug-overlay).
- **Готово:** видна сетка, клики меняют тайлы, путь рисуется.
### Фаза 2 — Первый враг + первая башня (дни 4-6)
- ECS-ядро (Entity, Component, System).
- Один враг идёт по пути.
- Башня «Archer»: размещение, таргет, снаряд, урон.
- Health-bar, death-анимация.
- **Готово:** вертикальный срез — башня убивает гоблина.
### Фаза 3 — Экономика и волны (дни 7-8)
- Gold с убийств, покупка башен.
- `WaveSystem` из конфигов, countdown между волнами.
- Условия победы/поражения (HP нексуса).
- Базовый HUD.
- **Готово:** полная петля на 5 волн.
### Фаза 4 — Ростер башен (дни 9-11)
- Все 6 архетипов с уникальным поведением.
- Типы снарядов (arrow/fireball/icicle/chain/heal/summon).
- 3 тира улучшений.
- Превью радиуса.
### Фаза 5 — Ростер врагов + эффекты (дни 12-14)
- Все 9 типов врагов.
- Фреймворк статус-эффектов (burn/freeze/shock/curse).
- Типы урона и резистов.
- Рунные слоты + первые 6 рун.
### Фаза 6 — Архимаг (дни 15-17)
- Подвижный герой.
- 4 заклинания с кулдаунами и маной.
- UI таргетинга.
- Дерево прокачки.
### Фаза 7 — Карты + мета (дни 18-20)
- Экран кампании.
- 3 уровня MVP с разными биомами.
- Том Рун (мета-экран).
- Save/load с версионированием.
### Фаза 8 — Босс + глубина контента (дни 21-23)
- Боссы с фазами.
- Финальный уровень «Obsidian Keep».
- Минимальные cutscene-кадры (парящие иллюстрации + текст).
### Фаза 9 — Juice и полировка (дни 24-27)
- Частицы для всех заклинаний.
- Шейдеры (fire, frost, arcane glow).
- Camera shake, hit-flashes, damage-numbers.
- Амбиент + музыкальные треки (3-4) + sfx.
- GSAP-анимации меню.
- Экран настроек (аудио, разрешение, доступность).
### Фаза 10 — Баланс и релиз (дни 28-30)
- Прогон всех волн, балансировка.
- Режимы сложности (Normal / Heroic / Nightmare).
- Performance-проход (pooling, batching).
- Билд и деплой.
**Итого:** ~30 дней разработки для одного разработчика (Sonnet) при полной занятости. Приоритет при нехватке времени: фазы 0-3 (playable core) > 4-5 (контент) > 9 (полировка) > 6-8 (герой/мета) — их можно урезать.
---
## 6. Определения «готово» (Definition of Done)
- ✅ TypeScript **strict**, zero `any` в прод-коде.
-`npm run build` проходит без warnings.
- ✅ 60 FPS на MacBook M1 при 80 врагах на экране.
- ✅ Первый paint < 2 сек, бандл < 2 МБ (gzip).
- ✅ Поддержка Chrome/Firefox/Safari последних 2 версий.
- ✅ Работает на тач-устройствах (iPad-первого класса).
- ✅ Клавиатурная навигация по меню.
- ✅ Минимум 1 юнит-тест для: `DamageResolver`, `Pathfinding`, `WaveSystem`, `SaveSystem`.
---
## 7. Риски и как их смять
| Риск | Смягчение |
|---|---|
| Перфоманс падает при 200+ врагах | Object pooling + sprite batching + culling по камере |
| Pathfinding блокирует рендер | Вынос в Web Worker с самого начала (фаза 1) |
| Переусложнение рун → нечитаемость | Лимит: 2 слота рун + визуальный маркер на башне + tooltip с итоговой формулой |
| React ре-рендерится слишком часто | Подписки Zustand с селекторами; HUD обновляется только по событиям волны/золота |
| Контент-бутылочное горло (арт) | MVP-арт: placeholder-спрайты (Kenney assets) → заменить в фазе 9; геймплей не ждёт графики |
| Звук блокируется автоплеем | Инициализация Howler по первому клику пользователя |
---
## 8. Что не входит в MVP (осознанно отложено)
- Мультиплеер / кооп.
- Редактор уровней.
- Локализация (только EN + RU-интерфейс, тексты в отдельном словаре для будущей i18n).
- Сторимод с развёрнутыми кат-сценами.
- Мобильная UI-оптимизация за пределами «работает на планшете».
---
## 9. Что от Sonnet ожидается между фазами
1. Обновить `PROGRESS.md` (создать в фазе 0) — одна строчка на фазу с датой.
2. Фиксировать ключевые решения в `DECISIONS.md` (ADR-стиль, 5-10 строк).
3. Запустить билд и smoke-прогон сценария фазы. Если что-то сломалось — **не двигаться дальше**.
4. Не вводить новые зависимости без явной причины (каждый npm-пакет добавляется с комментарием «зачем»).
---
## 10. Первый шаг для Sonnet
```bash
cd g:/Dev/Tower_Def
npm create vite@latest . -- --template react-ts
npm i pixi.js@8 zustand howler gsap
npm i -D tailwindcss postcss autoprefixer @types/howler vitest
npx tailwindcss init -p
```
Дальше — раздел 5, фаза 0.
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arcanum: Wardens of the Realm</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4396
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "tower-def",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"gsap": "^3.15.0",
"howler": "^2.2.4",
"pixi.js": "^8.18.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/howler": "^2.2.12",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"vitest": "^4.1.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+24
View File
@@ -0,0 +1,24 @@
import { useGameStore } from '@/state/gameStore'
import { MainMenu } from '@/ui/screens/MainMenu'
import { CampaignMap } from '@/ui/screens/CampaignMap'
import { GameScreen } from '@/ui/screens/GameScreen'
import { TomeOfRunes } from '@/ui/screens/TomeOfRunes'
import { Settings } from '@/ui/screens/Settings'
import { GameOver } from '@/ui/screens/GameOver'
export function App() {
const screen = useGameStore((s) => s.screen)
return (
<div className="w-full h-full relative overflow-hidden">
{screen === 'menu' && <MainMenu />}
{screen === 'campaign' && <CampaignMap />}
{screen === 'game' && <GameScreen />}
{screen === 'tome' && <TomeOfRunes />}
{screen === 'settings' && <Settings />}
{screen === 'gameover' && <GameOver />}
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+28
View File
@@ -0,0 +1,28 @@
export interface LevelDef {
id: string
name: string
cols: number
rows: number
layout: string[]
spawn: { x: number; y: number }
nexus: { x: number; y: number }
maxWaves: number
music: string
}
export { KINGS_ROAD } from './levels/kings_road'
export { WHISPERING_WOODS } from './levels/whispering_woods'
export { FROSTFALL_PASS } from './levels/frostfall_pass'
export { OBSIDIAN_KEEP } from './levels/obsidian_keep'
import { KINGS_ROAD } from './levels/kings_road'
import { WHISPERING_WOODS } from './levels/whispering_woods'
import { FROSTFALL_PASS } from './levels/frostfall_pass'
import { OBSIDIAN_KEEP } from './levels/obsidian_keep'
export const LEVEL_MAP: Record<string, LevelDef> = {
kings_road: KINGS_ROAD,
whispering_woods: WHISPERING_WOODS,
frostfall_pass: FROSTFALL_PASS,
obsidian_keep: OBSIDIAN_KEEP,
}
+28
View File
@@ -0,0 +1,28 @@
import type { LevelDef } from '../levels'
// P=path S=spawn N=nexus L=ley_line .=buildable
// Path: S(0,5)→right→(4,5)→up→(4,1)→right→(8,1)→down→(8,8)→right→(11,8)→up→(11,3)→right→(14,3)→down→(14,5)→N(15,5)
const layout = [
'....LL.LL.......',
'....PPPPP.......',
'.LL.P...P...LL..',
'....P...P..PPPP.',
'....P...P..P..P.',
'SPPPP...P..P..PN',
'........P..P....',
'........P..P....',
'........PPPP....',
'....LL......LL..',
]
export const FROSTFALL_PASS: LevelDef = {
id: 'frostfall_pass',
name: 'Frostfall Pass',
cols: 16,
rows: 10,
layout,
spawn: { x: 0, y: 5 },
nexus: { x: 15, y: 5 },
maxWaves: 20,
music: 'theme_frost',
}
+27
View File
@@ -0,0 +1,27 @@
import type { LevelDef } from '../levels'
// P=path S=spawn N=nexus L=ley_line .=buildable X=blocked
const layout = [
'SSPP.....L....',
'..PP.....L....',
'..PPPPPP.L....',
'......PP.L....',
'...L..PPLL....',
'...L..PP......',
'...L..PPPP....',
'...LLLLLLP....',
'.........PP...',
'..........PPNN',
]
export const KINGS_ROAD: LevelDef = {
id: 'kings_road',
name: "King's Road",
cols: 14,
rows: 10,
layout,
spawn: { x: 0, y: 0 },
nexus: { x: 12, y: 9 },
maxWaves: 20,
music: 'theme_plains',
}
+28
View File
@@ -0,0 +1,28 @@
import type { LevelDef } from '../levels'
// P=path S=spawn N=nexus L=ley_line .=buildable X=blocked
// Path: S(0,4)→(4,4)→(4,1)→(9,1)→(9,6)→(12,6)→(12,9)→N(13,9)
const layout = [
'XX....X...X.XX',
'....PPPPPP....',
'.X..P....P.LL.',
'....P....P....',
'SPPPP....P....',
'.X.......P.LL.',
'.X...LL..PPPP.',
'.X...L...L..P.',
'.....LL..L..P.',
'XX..........PN',
]
export const OBSIDIAN_KEEP: LevelDef = {
id: 'obsidian_keep',
name: 'Obsidian Keep',
cols: 14,
rows: 10,
layout,
spawn: { x: 0, y: 4 },
nexus: { x: 13, y: 9 },
maxWaves: 10,
music: 'theme_boss',
}
+28
View File
@@ -0,0 +1,28 @@
import type { LevelDef } from '../levels'
// P=path S=spawn N=nexus L=ley_line .=buildable
// Path: (0,5)→right→(3,5)→up→(3,2)→right→(7,2)→down→(7,7)→right→(9,7)→up→(9,4)→right→(13,4)→down→N(13,8)
const layout = [
'...LL.....LL..',
'...LL.....LL..',
'...PPPPP......',
'...P...P...LL.',
'...P...P.PPPPP',
'SPPP...P.P...P',
'.LL....P.P...P',
'.LL....PPPLL.P',
'..........LL.N',
'..............',
]
export const WHISPERING_WOODS: LevelDef = {
id: 'whispering_woods',
name: 'Whispering Woods',
cols: 14,
rows: 10,
layout,
spawn: { x: 0, y: 5 },
nexus: { x: 13, y: 8 },
maxWaves: 20,
music: 'theme_forest',
}
+58
View File
@@ -0,0 +1,58 @@
export interface TierDef {
damage: number
cooldown: number
range: number
cost: number
}
export interface TowerDef {
id: string
nameRu: string
icon: string
description: string
damageType: 'physical' | 'magic'
tiers: TierDef[]
}
export const TOWER_DEFS: Record<string, TowerDef> = {
archer: {
id: 'archer', nameRu: 'Лучник', icon: '🏹',
description: 'Физ. урон по одной цели',
damageType: 'physical',
tiers: [
{ damage: 8, cooldown: 0.8, range: 180, cost: 50 },
{ damage: 14, cooldown: 0.65, range: 200, cost: 60 },
{ damage: 22, cooldown: 0.5, range: 220, cost: 90 },
],
},
pyromancer: {
id: 'pyromancer', nameRu: 'Пиромант', icon: '🔥',
description: 'AoE огонь + поджог',
damageType: 'magic',
tiers: [
{ damage: 12, cooldown: 1.6, range: 150, cost: 80 },
{ damage: 20, cooldown: 1.3, range: 165, cost: 65 },
{ damage: 32, cooldown: 1.0, range: 180, cost: 100 },
],
},
cryomancer: {
id: 'cryomancer', nameRu: 'Криомант', icon: '❄',
description: 'Маг. урон + замедление',
damageType: 'magic',
tiers: [
{ damage: 6, cooldown: 1.1, range: 160, cost: 90 },
{ damage: 10, cooldown: 0.9, range: 175, cost: 70 },
{ damage: 16, cooldown: 0.7, range: 190, cost: 110 },
],
},
stormcaller: {
id: 'stormcaller', nameRu: 'Повелитель бурь', icon: '⚡',
description: 'Цепная молния по 3 целям',
damageType: 'magic',
tiers: [
{ damage: 18, cooldown: 2.0, range: 200, cost: 110 },
{ damage: 28, cooldown: 1.6, range: 220, cost: 80 },
{ damage: 42, cooldown: 1.2, range: 240, cost: 120 },
],
},
}
+94
View File
@@ -0,0 +1,94 @@
export type EnemyType = 'goblin' | 'orc' | 'warg' | 'wraith' | 'troll' | 'golem' | 'necromancer' | 'dragon' | 'lich_king'
export interface WaveDef {
enemies: EnemyType[]
interval: number
}
function rep(type: EnemyType, n: number): EnemyType[] {
return Array(n).fill(type)
}
export const WAVE_DEFS: WaveDef[] = [
// Wave 1 — tutorial
{ enemies: rep('goblin', 6), interval: 1.2 },
// Wave 2
{ enemies: [...rep('goblin', 5), ...rep('warg', 3)], interval: 1.1 },
// Wave 3
{ enemies: [...rep('goblin', 6), ...rep('orc', 2)], interval: 1.1 },
// Wave 4
{ enemies: [...rep('warg', 5), ...rep('goblin', 4)], interval: 1.0 },
// Wave 5 — первые призраки
{ enemies: [...rep('goblin', 4), ...rep('orc', 2), ...rep('wraith', 3)], interval: 1.0 },
// Wave 6
{ enemies: [...rep('wraith', 4), ...rep('goblin', 5), ...rep('orc', 2)], interval: 0.9 },
// Wave 7 — первый тролль
{ enemies: [...rep('troll', 2), ...rep('orc', 3), ...rep('warg', 4), ...rep('wraith', 2)], interval: 0.9 },
// Wave 8 — первые големы
{ enemies: [...rep('golem', 2), ...rep('orc', 4), ...rep('wraith', 3)], interval: 0.85 },
// Wave 9
{ enemies: [...rep('troll', 2), ...rep('golem', 2), ...rep('goblin', 4), ...rep('wraith', 3)], interval: 0.85 },
// Wave 10 — БОСС: Некромант + элита
{ enemies: ['necromancer', ...rep('golem', 2), ...rep('troll', 2), ...rep('orc', 4), ...rep('wraith', 4)], interval: 0.8 },
// Wave 11
{ enemies: [...rep('golem', 3), ...rep('wraith', 5), ...rep('troll', 2)], interval: 0.75 },
// Wave 12
{ enemies: [...rep('necromancer', 1), ...rep('golem', 3), ...rep('orc', 5)], interval: 0.75 },
// Wave 13
{ enemies: [...rep('troll', 4), ...rep('wraith', 5), ...rep('goblin', 6)], interval: 0.7 },
// Wave 14
{ enemies: [...rep('golem', 4), ...rep('orc', 4), ...rep('wraith', 4), ...rep('warg', 4)], interval: 0.7 },
// Wave 15 — Дракон + второй Некромант + большая орда
{ enemies: ['dragon', 'necromancer', ...rep('troll', 3), ...rep('golem', 2), ...rep('wraith', 5), ...rep('orc', 4)], interval: 0.65 },
// Wave 16
{ enemies: [...rep('golem', 4), ...rep('wraith', 6), ...rep('troll', 3), ...rep('goblin', 5)], interval: 0.6 },
// Wave 17 — два дракона
{ enemies: ['dragon', ...rep('necromancer', 1), ...rep('golem', 4), ...rep('wraith', 6)], interval: 0.6 },
// Wave 18
{ enemies: [...rep('troll', 5), ...rep('golem', 5), ...rep('orc', 6), ...rep('warg', 6)], interval: 0.55 },
// Wave 19 — финальная орда
{ enemies: ['dragon', 'dragon', ...rep('golem', 4), ...rep('necromancer', 2), ...rep('wraith', 8)], interval: 0.55 },
// Wave 20 — ФИНАЛ
{ enemies: ['dragon', 'dragon', ...rep('golem', 5), ...rep('troll', 4), ...rep('necromancer', 2), ...rep('wraith', 6), ...rep('orc', 6)], interval: 0.5 },
]
export const OBSIDIAN_KEEP_WAVES: WaveDef[] = [
// Wave 1 — heavy vanguard
{ enemies: [...rep('golem', 3), ...rep('troll', 2), ...rep('wraith', 4)], interval: 0.9 },
// Wave 2 — wraith + necromancer rush
{ enemies: [...rep('wraith', 6), 'necromancer', ...rep('orc', 3)], interval: 0.8 },
// Wave 3 — first dragon
{ enemies: ['dragon', ...rep('golem', 3), ...rep('troll', 3), ...rep('wraith', 4)], interval: 0.75 },
// Wave 4 — overwhelming horde
{ enemies: [...rep('golem', 4), ...rep('necromancer', 2), ...rep('wraith', 6), ...rep('orc', 4)], interval: 0.7 },
// Wave 5 — mid-boss checkpoint
{ enemies: ['dragon', 'necromancer', ...rep('golem', 4), ...rep('wraith', 6), ...rep('troll', 3)], interval: 0.65 },
// Wave 6 — twin dragons
{ enemies: ['dragon', 'dragon', ...rep('necromancer', 2), ...rep('golem', 4), ...rep('wraith', 5)], interval: 0.6 },
// Wave 7 — undead swarm
{ enemies: [...rep('wraith', 10), ...rep('troll', 4), ...rep('golem', 4), 'necromancer'], interval: 0.55 },
// Wave 8 — triple dragon assault
{ enemies: ['dragon', 'dragon', 'dragon', ...rep('golem', 5), ...rep('necromancer', 2), ...rep('wraith', 6)], interval: 0.55 },
// Wave 9 — pre-boss wave (everything)
{ enemies: ['dragon', 'dragon', ...rep('necromancer', 3), ...rep('golem', 5), ...rep('troll', 4), ...rep('wraith', 8)], interval: 0.5 },
// Wave 10 — LICH KING FINAL BOSS
{ enemies: ['lich_king', ...rep('wraith', 6), ...rep('golem', 3), ...rep('necromancer', 2)], interval: 0.8 },
]
export function getWaveDef(waveIndex: number, levelId?: string): WaveDef {
if (levelId === 'obsidian_keep') {
const defs = OBSIDIAN_KEEP_WAVES
if (waveIndex <= defs.length) return defs[waveIndex - 1]
return defs[defs.length - 1]
}
if (waveIndex <= WAVE_DEFS.length) return WAVE_DEFS[waveIndex - 1]
const base = WAVE_DEFS[WAVE_DEFS.length - 1]
const extra = waveIndex - WAVE_DEFS.length
const enemies: EnemyType[] = []
for (const e of base.enemies) {
enemies.push(e)
if (extra >= 2) enemies.push(e)
if (extra >= 4 && Math.random() < 0.5) enemies.push(e)
}
return { enemies, interval: Math.max(0.45, base.interval - extra * 0.04) }
}
+627
View File
@@ -0,0 +1,627 @@
import { Container, Graphics } from 'pixi.js'
import { GridMap, TILE_SIZE, ISO_HALF_W, ISO_HALF_H } from './map/GridMap'
import { findPath, terminatePathWorker } from './map/Pathfinding'
import { MapRenderer } from './rendering/MapRenderer'
import { Camera } from './rendering/camera'
import { layers, getSize } from './rendering/PixiRoot'
import type { LevelDef } from '@/data/levels'
import { EntityManager } from './core/EntityManager'
import { movementSystem } from './systems/MovementSystem'
import { targetingSystem } from './systems/TargetingSystem'
import { attackSystem } from './systems/AttackSystem'
import { projectileSystem } from './systems/ProjectileSystem'
import { renderSystem } from './systems/RenderSystem'
import { deathSystem } from './systems/DeathSystem'
import { statusEffectSystem } from './systems/StatusEffectSystem'
import { heroSystem, heroAddXp, getHeroComp } from './systems/HeroSystem'
import { necromancerSystem } from './systems/NecromancerSystem'
import { lichKingSystem } from './systems/LichKingSystem'
import { createWaveState, startWave, waveSystem, type WaveState } from './systems/WaveSystem'
import { createArcherTower, ARCHER_COST } from './entities/towers/archer'
import { createPyromancer, PYROMANCER_COST } from './entities/towers/pyromancer'
import { createCryomancer, CRYO_COST } from './entities/towers/cryomancer'
import { createStormcaller, STORM_COST } from './entities/towers/stormcaller'
import { createArchmage } from './entities/hero/archmage'
import { eventBus } from './core/EventBus'
import { useGameStore } from '@/state/gameStore'
import { setWorldContainer, getWorldContainer } from './rendering/WorldContext'
import { TOWER_DEFS } from '@/data/towerDefs'
import type { TowerComp, AttackComp, TargetingComp, HealthComp } from './components'
import type { StatusEffectsComp } from './components/StatusEffect'
import { applyStatus } from './components/StatusEffect'
import { resolveDamage } from './combat/DamageResolver'
import { refreshHp } from './entities/enemies/updateHpBar'
import type { HeroComp } from './components/Hero'
export type PlacementMode = 'none' | 'archer' | 'pyromancer' | 'cryomancer' | 'stormcaller'
const _TOWER_GHOST_COLORS: Record<string, number> = {
archer: 0xccaa44,
pyromancer: 0xff6622,
cryomancer: 0x44ccee,
stormcaller: 0xaa88ff,
}
const LEY_LINE_DAMAGE_MULT = 1.20
const LEY_LINE_RANGE_MULT = 1.12
export interface TowerInfo {
towerId: string
nameRu: string
icon: string
tier: number
maxTier: number
damage: number
cooldown: number
range: number
totalCost: number
upgradeCost: number | null
sellValue: number
leyLineBuff: boolean
}
export class LevelScene {
readonly map: GridMap
readonly entities = new EntityManager()
private renderer: MapRenderer
private camera: Camera
private worldRoot: Container
private hoverGfx = new Graphics()
private selectionGfx = new Graphics()
private _destroyed = false
private _path: { x: number; y: number }[] = []
private _screenPath: { x: number; y: number }[] = []
private _detachCamera: (() => void) | null = null
private _waveState: WaveState = createWaveState()
private _placementMode: PlacementMode = 'none'
private _spellMode: string | null = null
private _maxWaves = 0
constructor(def: LevelDef) {
this.map = new GridMap(def.cols, def.rows)
this.map.loadFromLayout(def.layout)
this._maxWaves = useGameStore.getState().maxWaves
this.worldRoot = new Container()
this.worldRoot.sortableChildren = true
layers!.terrain.addChild(this.worldRoot)
setWorldContainer(this.worldRoot)
this._drawBackground()
this.renderer = new MapRenderer()
this.worldRoot.addChild(this.renderer.container)
this.worldRoot.addChild(this.hoverGfx)
this.worldRoot.addChild(this.selectionGfx)
const raw = getSize()
const width = raw.width > 0 ? raw.width : window.innerWidth
const height = raw.height > 0 ? raw.height : window.innerHeight
const mapW = this.map.isoMapWidth()
const mapH = this.map.isoMapHeight()
this.camera = new Camera(this.worldRoot, {
minX: -mapW, maxX: width,
minY: -mapH, maxY: height,
minZoom: 0.5, maxZoom: 2.5,
})
this.worldRoot.x = Math.max(ISO_HALF_W, (width - mapW) / 2)
this.worldRoot.y = Math.max(ISO_HALF_H, (height - mapH) / 2) + ISO_HALF_H
this.camera.x = this.worldRoot.x
this.camera.y = this.worldRoot.y
this.renderer.load(this.map)
this._calcPath(def.spawn, def.nexus)
this._setupEventListeners()
this._spawnHero(def)
}
update(dt: number): void {
if (this._destroyed) return
const store = useGameStore.getState()
this.camera.update(dt)
heroSystem(this.entities, dt)
if (store.phase === 'combat') {
statusEffectSystem(this.entities, dt)
movementSystem(this.entities, dt)
necromancerSystem(this.entities, dt)
lichKingSystem(this.entities, this._screenPath, dt)
targetingSystem(this.entities)
attackSystem(this.entities, dt)
projectileSystem(this.entities, dt)
renderSystem(this.entities)
deathSystem(this.entities, this.map)
waveSystem(this._waveState, this.entities, this.map, this._screenPath, dt)
} else if (store.phase === 'build' && this._waveState.countdown > 0) {
this._waveState.countdown -= dt
if (this._waveState.countdown <= 0 && store.wave < this._maxWaves) {
this._waveState.countdown = 0
this.startWave()
}
}
}
startWave(): void {
const store = useGameStore.getState()
if (store.wave >= this._maxWaves) return
store.setSelectedTowerTile(null)
const nextWave = store.wave + 1
store.setWave(nextWave)
store.setPhase('combat')
startWave(this._waveState, nextWave)
}
setPlacementMode(mode: PlacementMode): void {
this._placementMode = mode
this._spellMode = null
if (mode !== 'none') {
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
}
}
setSpellMode(spellId: string | null): void {
this._spellMode = spellId
if (spellId !== null) {
this._placementMode = 'none'
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
}
}
getSpellMode(): string | null { return this._spellMode }
getWaveState(): WaveState { return this._waveState }
getHeroComp(): HeroComp | null { return getHeroComp(this.entities) }
getTowerInfo(tx: number, ty: number): TowerInfo | null {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return null
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return null
const tc = entity.towerComp as TowerComp
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
const def = TOWER_DEFS[tc.towerId]
return {
towerId: tc.towerId,
nameRu: def?.nameRu ?? tc.towerId,
icon: def?.icon ?? '🏰',
tier: tc.tier,
maxTier: def?.tiers.length ?? 1,
damage: atk?.damage ?? 0,
cooldown: atk?.cooldown ?? 0,
range: tgt?.range ?? 0,
totalCost: tc.totalCost,
upgradeCost: def && tc.tier < def.tiers.length ? def.tiers[tc.tier].cost : null,
sellValue: Math.floor(tc.totalCost * 0.6),
leyLineBuff: tc.leyLineBuff,
}
}
upgradeTower(tx: number, ty: number): boolean {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return false
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return false
const tc = entity.towerComp as TowerComp
const def = TOWER_DEFS[tc.towerId]
if (!def || tc.tier >= def.tiers.length) return false
const nextTier = def.tiers[tc.tier]
if (!useGameStore.getState().spendGold(nextTier.cost)) return false
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
if (atk) { atk.damage = nextTier.damage; atk.cooldown = nextTier.cooldown }
if (tgt) { tgt.range = nextTier.range }
tc.tier++; tc.totalCost += nextTier.cost
if (tc.leyLineBuff) {
if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT)
if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT)
}
return true
}
sellTower(tx: number, ty: number): void {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return
const tc = entity.towerComp as TowerComp
useGameStore.getState().addGold(Math.floor(tc.totalCost * 0.6))
useGameStore.getState().setSelectedTowerTile(null)
entity.tags.add('dead')
this.selectionGfx.clear()
}
castSpell(spellId: string, worldX: number, worldY: number): boolean {
const heroComp = getHeroComp(this.entities)
if (!heroComp) return false
const spell = heroComp.spells.find((s) => s.id === spellId)
if (!spell || spell.timer > 0) return false
if (!useGameStore.getState().spendMana(spell.manaCost)) return false
spell.timer = spell.cooldown
switch (spellId) {
case 'fireball': this._castFireball(worldX, worldY, spell.damage, spell.radius); break
case 'blizzard': this._castBlizzard(worldX, worldY, spell.radius); break
case 'blink': this._castBlink(worldX, worldY); break
case 'timewarp': this._castTimeWarp(); break
}
this._spellMode = null
return true
}
private _castFireball(cx: number, cy: number, damage: number, radius: number): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const et = enemy.transform as { x: number; y: number } | undefined
if (!et) continue
if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue
const hp = enemy.health as HealthComp
if (hp) {
const dmg = resolveDamage(damage, 'magic', hp)
hp.current -= dmg
refreshHp(enemy)
if (hp.current <= 0) {
enemy.tags.add('dead')
eventBus.emit('enemy:died', { id: enemy.id, gold: (enemy.loot as { gold: number } | undefined)?.gold ?? 0, essence: 0 })
}
}
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'burn', duration: 4, strength: 6 })
}
this._fxCircle(cx, cy, radius, 0xff4400, 0xff8800)
}
private _castBlizzard(cx: number, cy: number, radius: number): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const et = enemy.transform as { x: number; y: number } | undefined
if (!et) continue
if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'slow', duration: 4, strength: 0.7 })
}
this._fxCircle(cx, cy, radius, 0x6ecbd5, 0xaaeeff)
}
private _castBlink(wx: number, wy: number): void {
const heroes = this.entities.withTag('hero')
if (heroes.length === 0) return
const hero = heroes[0]
const t = hero.transform as { x: number; y: number }
const h = hero.hero as HeroComp
const oldX = t.x; const oldY = t.y
t.x = wx; t.y = wy
h.targetX = null; h.targetY = null
this._fxBlink(oldX, oldY, wx, wy)
}
private _castTimeWarp(): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'slow', duration: 5, strength: 0.65 })
}
this._fxTimeWarp()
}
private _fxCircle(cx: number, cy: number, r: number, fill: number, stroke: number): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
gfx.circle(cx, cy, r)
gfx.fill({ color: fill, alpha: 0.3 })
gfx.circle(cx, cy, r)
gfx.stroke({ color: stroke, width: 3, alpha: 0.8 })
world.addChild(gfx)
let life = 0.5
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.5)
gfx.scale.set(1 + (0.5 - life) * 0.4)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
private _fxBlink(x1: number, y1: number, x2: number, y2: number): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
gfx.circle(x1, y1, 20)
gfx.fill({ color: 0x9966ff, alpha: 0.6 })
gfx.circle(x2, y2, 20)
gfx.fill({ color: 0xcc99ff, alpha: 0.8 })
world.addChild(gfx)
let life = 0.4
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.4)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
private _fxTimeWarp(): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
// Full-screen tint ring
for (let i = 0; i < 4; i++) {
gfx.circle(0, 0, 200 + i * 120)
gfx.stroke({ color: 0x00ccff, width: 3 - i * 0.5, alpha: 0.4 - i * 0.08 })
}
world.addChild(gfx)
let life = 0.6
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.6)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
attachInput(canvas: HTMLElement): void {
this._detachCamera = this.camera.attachPointerEvents(canvas)
canvas.addEventListener('pointermove', this._onPointerMove)
canvas.addEventListener('pointerdown', this._onPointerDown)
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
}
detachInput(canvas: HTMLElement): void {
this._detachCamera?.()
canvas.removeEventListener('pointermove', this._onPointerMove)
canvas.removeEventListener('pointerdown', this._onPointerDown)
}
private _onPointerMove = (e: PointerEvent): void => {
if (this._destroyed) return
const canvas = e.currentTarget as HTMLElement
const rect = canvas.getBoundingClientRect()
const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top)
const { x: tx, y: ty } = this.map.screenToTile(world.x, world.y)
this._drawHover(tx, ty)
}
private _onPointerDown = (e: PointerEvent): void => {
if (this._destroyed) return
if (e.button === 2) {
// Right click: cancel modes
this._placementMode = 'none'
this._spellMode = null
return
}
if (e.button !== 0) return
const canvas = e.currentTarget as HTMLElement
const rect = canvas.getBoundingClientRect()
const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top)
const wx = world.x; const wy = world.y
const { x: tx, y: ty } = this.map.screenToTile(wx, wy)
if (this._placementMode !== 'none') {
this._placeTower(tx, ty, this._placementMode)
return
}
if (this._spellMode !== null) {
const spell = getHeroComp(this.entities)?.spells.find((s) => s.id === this._spellMode)
if (spell?.targetMode === 'point') {
this.castSpell(this._spellMode, wx, wy)
}
return
}
// Tower selection or hero movement
const tile = this.map.get(tx, ty)
if (tile?.towerEntityId) {
useGameStore.getState().setSelectedTowerTile({ x: tx, y: ty })
this._drawSelection(tx, ty)
} else {
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
// Move hero to clicked world position
this._moveHero(wx, wy)
}
}
private _moveHero(wx: number, wy: number): void {
const heroes = this.entities.withTag('hero')
if (heroes.length === 0) return
const h = heroes[0].hero as HeroComp
if (h) { h.targetX = wx; h.targetY = wy }
}
private _hasAdjacentLeyLine(tx: number, ty: number): boolean {
const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]]
for (const [dx, dy] of dirs) {
if (this.map.get(tx + dx, ty + dy)?.type === 'ley_line') return true
}
return false
}
private _applyLeyLineBuff(entity: ReturnType<typeof this.entities.create>): void {
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT)
if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT)
const tc = entity.towerComp as TowerComp
if (tc) tc.leyLineBuff = true
// Blue glow dot under tower
const r = entity.render as import('./components').RenderComp | undefined
if (r?.container) {
const gfx = new Graphics()
gfx.circle(0, 0, ISO_HALF_W * 0.85)
gfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.7 })
gfx.circle(0, 0, 5)
gfx.fill({ color: 0x44bbff, alpha: 0.8 })
r.container.addChildAt(gfx, 0)
}
}
private _placeTower(tx: number, ty: number, mode: PlacementMode): void {
if (!this.map.isBuildable(tx, ty)) return
const store = useGameStore.getState()
const costs: Record<PlacementMode, number> = {
none: 0, archer: ARCHER_COST, pyromancer: PYROMANCER_COST,
cryomancer: CRYO_COST, stormcaller: STORM_COST,
}
if (!store.spendGold(costs[mode])) return
const tile = this.map.get(tx, ty)!
const entity = this.entities.create()
const { x: wxp, y: wyp } = this.map.tileCenter(tx, ty)
switch (mode) {
case 'archer': createArcherTower(entity, tx, ty, wxp, wyp); break
case 'pyromancer': createPyromancer(entity, tx, ty, wxp, wyp); break
case 'cryomancer': createCryomancer(entity, tx, ty, wxp, wyp); break
case 'stormcaller': createStormcaller(entity, tx, ty, wxp, wyp); break
}
if (this._hasAdjacentLeyLine(tx, ty) || tile.type === 'ley_line') {
this._applyLeyLineBuff(entity)
} else {
const tc = entity.towerComp as TowerComp
if (tc) tc.leyLineBuff = false
}
tile.towerEntityId = entity.id
this._placementMode = 'none'
}
private _drawHover(tx: number, ty: number): void {
this.hoverGfx.clear()
if (!this.map.get(tx, ty)) return
const { x: cx, y: cy } = this.map.tileCenter(tx, ty)
const hw = ISO_HALF_W, hh = ISO_HALF_H
const inMode = this._placementMode !== 'none' || this._spellMode !== null
const canBuild = this._placementMode !== 'none' && this.map.isBuildable(tx, ty)
const color = canBuild ? 0x44ff88 : inMode ? 0xff4444 : 0xffffff
// Diamond outline
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.stroke({ color, width: 2, alpha: 0.7 })
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.fill({ color, alpha: 0.1 })
if (canBuild && this._placementMode !== 'none') {
const def = TOWER_DEFS[this._placementMode]
const range = def?.tiers[0].range ?? 120
const hasLey = this._hasAdjacentLeyLine(tx, ty)
const boostedRange = hasLey ? Math.round(range * 1.12) : range
const rangeColor = _TOWER_GHOST_COLORS[this._placementMode] ?? 0xffd700
this.hoverGfx.circle(cx, cy, boostedRange)
this.hoverGfx.fill({ color: rangeColor, alpha: 0.06 })
this.hoverGfx.circle(cx, cy, boostedRange)
this.hoverGfx.stroke({ color: rangeColor, width: 1.5, alpha: 0.4 })
// Ghost tower diamond
this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy])
this.hoverGfx.fill({ color: rangeColor, alpha: 0.25 })
this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy])
this.hoverGfx.stroke({ color: rangeColor, width: 2, alpha: 0.7 })
if (hasLey) {
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.9 })
}
}
}
private _drawSelection(tx: number, ty: number): void {
this.selectionGfx.clear()
const { x: cx, y: cy } = this.map.tileCenter(tx, ty)
const hw = ISO_HALF_W, hh = ISO_HALF_H
this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.selectionGfx.stroke({ color: 0xffd700, width: 2.5, alpha: 0.95 })
this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.selectionGfx.fill({ color: 0xffd700, alpha: 0.12 })
}
private _drawBackground(): void {
const raw = getSize()
const width = raw.width > 0 ? raw.width : window.innerWidth
const height = raw.height > 0 ? raw.height : window.innerHeight
const bg = new Graphics()
bg.rect(-200, -200, width + 400, height + 400)
bg.fill({ color: 0x0a1a0a })
const step = TILE_SIZE * 4
for (let x = 0; x < width + 400; x += step) {
bg.moveTo(x - 200, -200); bg.lineTo(x - 200, height + 200)
bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 })
}
for (let y = 0; y < height + 400; y += step) {
bg.moveTo(-200, y - 200); bg.lineTo(width + 200, y - 200)
bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 })
}
layers!.bg.addChild(bg)
}
private _spawnHero(def: LevelDef): void {
const startTileX = Math.min(def.cols - 2, 10)
const startTileY = 1
const { x: sx, y: sy } = this.map.tileCenter(startTileX, startTileY)
const entity = this.entities.create()
createArchmage(entity, sx, sy)
}
private async _calcPath(
spawn: { x: number; y: number },
nexus: { x: number; y: number },
): Promise<void> {
const path = await findPath(this.map, spawn, nexus)
if (path) {
this._path = path
this._screenPath = path.map((p) => this.map.tileCenter(p.x, p.y))
this.renderer.showPath(path)
}
}
private _setupEventListeners(): void {
eventBus.on<{ id: number; gold: number; essence: number }>('enemy:died', (data) => {
if (this._destroyed) return
useGameStore.getState().addGold(data.gold)
heroAddXp(this.entities, 5)
})
eventBus.on<{ id: number; damage: number }>('enemy:reached_end', () => {
if (this._destroyed) return
const store = useGameStore.getState()
store.damageNexus(1)
this.camera.shake(10, 0.4)
if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') }
})
eventBus.on<{ amount: number }>('lich:nexus_damage', (data) => {
if (this._destroyed) return
const store = useGameStore.getState()
store.damageNexus(data.amount)
this.camera.shake(8, 0.5)
if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') }
})
eventBus.on<{ waveIndex: number }>('wave:end', (ev) => {
if (this._destroyed) return
const store = useGameStore.getState()
store.setPhase('build')
store.addMana(20)
heroAddXp(this.entities, 50)
if (ev.waveIndex >= this._maxWaves) { store.setWon(true); store.setScreen('gameover') }
})
}
getPath(): { x: number; y: number }[] { return this._path }
getScreenPath(): { x: number; y: number }[] { return this._screenPath }
destroy(): void {
this._destroyed = true
setWorldContainer(null)
this._detachCamera?.()
this.renderer.destroy()
this.worldRoot.destroy({ children: true })
this.entities.clear()
terminatePathWorker()
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { DamageType, HealthComp } from '@/game/components'
export function resolveDamage(
raw: number,
type: DamageType,
target: HealthComp,
): number {
if (type === 'true') return Math.max(1, raw)
if (type === 'physical') {
if (target.physicalImmune) return 0
const reduction = target.armor / (target.armor + 20)
return Math.max(1, Math.round(raw * (1 - reduction)))
}
// magic
const reduction = target.magicResist / (target.magicResist + 20)
return Math.max(1, Math.round(raw * (1 - reduction)))
}
+73
View File
@@ -0,0 +1,73 @@
export interface SpellDef {
id: string
nameRu: string
icon: string
manaCost: number
cooldown: number
timer: number
damage: number
radius: number
targetMode: 'point' | 'global'
hotkey: string
}
export interface HeroComp {
targetX: number | null
targetY: number | null
speed: number
xp: number
level: number
spells: SpellDef[]
bobTime: number
}
export const HERO_SPELLS: Omit<SpellDef, 'timer'>[] = [
{
id: 'fireball',
nameRu: 'Огненный шар',
icon: '🔥',
manaCost: 25,
cooldown: 8,
damage: 40,
radius: 70,
targetMode: 'point',
hotkey: 'Q',
},
{
id: 'blizzard',
nameRu: 'Буран',
icon: '❄',
manaCost: 30,
cooldown: 12,
damage: 0,
radius: 90,
targetMode: 'point',
hotkey: 'W',
},
{
id: 'blink',
nameRu: 'Мерцание',
icon: '✦',
manaCost: 20,
cooldown: 5,
damage: 0,
radius: 0,
targetMode: 'point',
hotkey: 'E',
},
{
id: 'timewarp',
nameRu: 'Замедление',
icon: '⌛',
manaCost: 45,
cooldown: 25,
damage: 0,
radius: 0,
targetMode: 'global',
hotkey: 'R',
},
]
export function makeSpells(): SpellDef[] {
return HERO_SPELLS.map((s) => ({ ...s, timer: 0 }))
}
+32
View File
@@ -0,0 +1,32 @@
export type StatusEffectType = 'burn' | 'slow' | 'freeze'
export interface StatusEffect {
type: StatusEffectType
duration: number // remaining seconds
strength: number // burn: dmg/sec | slow/freeze: 0..1 multiplier reduction
tickTimer: number // for burn DoT interval
}
export interface StatusEffectsComp {
effects: StatusEffect[]
}
export function getSlowMultiplier(comp: StatusEffectsComp): number {
let mult = 1
for (const e of comp.effects) {
if (e.type === 'slow' || e.type === 'freeze') {
mult = Math.min(mult, 1 - e.strength)
}
}
return Math.max(0.1, mult)
}
export function applyStatus(comp: StatusEffectsComp, effect: Omit<StatusEffect, 'tickTimer'>): void {
const existing = comp.effects.find((e) => e.type === effect.type)
if (existing) {
existing.duration = Math.max(existing.duration, effect.duration)
existing.strength = Math.max(existing.strength, effect.strength)
} else {
comp.effects.push({ ...effect, tickTimer: 0 })
}
}
+73
View File
@@ -0,0 +1,73 @@
export type DamageType = 'physical' | 'magic' | 'true'
export type TargetMode = 'first' | 'last' | 'closest' | 'strongest'
export type Team = 'player' | 'enemy'
export interface TransformComp {
x: number
y: number
rotation: number
}
export interface HealthComp {
current: number
max: number
armor: number
magicResist: number
physicalImmune?: boolean
regenRate?: number
}
export interface MovementComp {
speed: number
path: { x: number; y: number }[]
pathIndex: number
distanceTravelled: number
}
export interface TargetingComp {
range: number
mode: TargetMode
targetId: number | null
}
export interface AttackComp {
damage: number
damageType: DamageType
cooldown: number
timer: number
projectileType: string
}
export interface RenderComp {
container: import('pixi.js').Container
hpBar: import('pixi.js').Graphics | null
label: import('pixi.js').Text | null
}
export interface TeamComp {
team: Team
}
export interface LootComp {
gold: number
essence: number
}
export interface ProjectileComp {
speed: number
targetId: number
damage: number
damageType: DamageType
ownerId: number
hitRadius: number
projectileType: string
}
export interface TowerComp {
tileX: number
tileY: number
tier: number
towerId: string
totalCost: number
leyLineBuff: boolean
}
+46
View File
@@ -0,0 +1,46 @@
let _nextId = 1
export interface Entity {
id: number
tags: Set<string>
[key: string]: unknown
}
export class EntityManager {
private entities = new Map<number, Entity>()
create(tags: string[] = []): Entity {
const entity: Entity = { id: _nextId++, tags: new Set(tags) }
this.entities.set(entity.id, entity)
return entity
}
get(id: number): Entity | undefined {
return this.entities.get(id)
}
destroy(id: number): void {
this.entities.delete(id)
}
withTag(tag: string): Entity[] {
const result: Entity[] = []
for (const e of this.entities.values()) {
if (e.tags.has(tag)) result.push(e)
}
return result
}
all(): Entity[] {
return [...this.entities.values()]
}
clear(): void {
this.entities.clear()
_nextId = 1
}
get count() {
return this.entities.size
}
}
+36
View File
@@ -0,0 +1,36 @@
type Handler<T = unknown> = (data: T) => void
class EventBus {
private listeners = new Map<string, Set<Handler>>()
on<T>(event: string, handler: Handler<T>): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
this.listeners.get(event)!.add(handler as Handler)
return () => this.off(event, handler)
}
off<T>(event: string, handler: Handler<T>): void {
this.listeners.get(event)?.delete(handler as Handler)
}
emit<T>(event: string, data?: T): void {
this.listeners.get(event)?.forEach((h) => h(data as unknown))
}
clear(): void {
this.listeners.clear()
}
}
export const eventBus = new EventBus()
export type GameEvent =
| { type: 'enemy:died'; id: number; gold: number; essence: number }
| { type: 'enemy:reached_end'; id: number; damage: number }
| { type: 'tower:placed'; tileX: number; tileY: number }
| { type: 'wave:start'; waveIndex: number }
| { type: 'wave:end'; waveIndex: number }
| { type: 'game:over'; won: boolean }
| { type: 'gold:change'; amount: number }
| { type: 'mana:change'; amount: number }
| { type: 'nexus:damage'; hp: number; maxHp: number }
+114
View File
@@ -0,0 +1,114 @@
import { Application, Text, TextStyle } from 'pixi.js'
import { Time } from './Time'
import { EntityManager } from './EntityManager'
import { eventBus } from './EventBus'
import { getApp, layers } from '@/game/rendering/PixiRoot'
import { LevelScene } from '@/game/LevelScene'
import type { LevelDef } from '@/data/levels'
export class GameEngine {
private readonly _app: Application
readonly entities = new EntityManager()
private _running = false
private _lastTime = 0
private _frameCount = 0
private _fpsTime = 0
private _fpsText: Text | null = null
private _showFps = false
private _scene: LevelScene | null = null
private _canvas: HTMLElement | null = null
constructor(app: Application) {
this._app = app
}
loadLevel(def: LevelDef, canvas: HTMLElement): void {
this._scene?.destroy()
this._canvas = canvas
this._scene = new LevelScene(def)
this._scene.attachInput(canvas)
}
getScene(): LevelScene | null {
return this._scene
}
start(): void {
this._running = true
this._lastTime = performance.now()
this._fpsTime = performance.now()
this._app.ticker.add(this._tick, this)
this._setupFpsCounter()
}
stop(): void {
this._running = false
this._app.ticker.remove(this._tick, this)
}
setShowFps(show: boolean): void {
this._showFps = show
if (this._fpsText) this._fpsText.visible = show
}
private _tick(): void {
if (!this._running) return
const now = performance.now()
Time.delta = Math.min((now - this._lastTime) / 1000, 0.05)
Time.elapsed += Time.delta
this._lastTime = now
this._scene?.update(Time.delta)
this._updateFps(now)
}
private _setupFpsCounter(): void {
const style = new TextStyle({
fill: 'rgba(201, 161, 74, 0.7)',
fontSize: 12,
fontFamily: 'monospace',
})
this._fpsText = new Text({ text: 'FPS: --', style })
this._fpsText.visible = this._showFps
this._fpsText.x = 8
this._fpsText.y = 8
this._fpsText.zIndex = 9999
layers?.ui.addChild(this._fpsText)
}
private _updateFps(now: number): void {
this._frameCount++
if (now - this._fpsTime >= 500) {
const fps = Math.round((this._frameCount * 1000) / (now - this._fpsTime))
if (this._fpsText) this._fpsText.text = `FPS: ${fps}`
this._frameCount = 0
this._fpsTime = now
}
}
destroy(): void {
if (this._canvas && this._scene) {
this._scene.detachInput(this._canvas)
}
this._scene?.destroy()
this._scene = null
this.stop()
this.entities.clear()
eventBus.clear()
this._fpsText?.destroy()
this._fpsText = null
}
}
let _engine: GameEngine | null = null
export function createEngine(): GameEngine {
_engine?.destroy()
_engine = new GameEngine(getApp())
return _engine
}
export function getEngine(): GameEngine | null {
return _engine
}
+9
View File
@@ -0,0 +1,9 @@
export const Time = {
delta: 0,
elapsed: 0,
scale: 1,
get scaledDelta() {
return Time.delta * Time.scale
},
}
+156
View File
@@ -0,0 +1,156 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 54
const HP_H = 6
const HP_Y = -60
export function createDragon(
entity: Entity,
flyPath: { x: number; y: number }[],
sx: number,
sy: number,
): void {
const container = new Container()
// Flying shadow on ground (large, blurry-ish)
const groundShadow = new Graphics()
groundShadow.ellipse(0, 6, 36, 14)
groundShadow.fill({ color: 0x000000, alpha: 0.28 })
// Wing glow/aura
const glow = new Graphics()
glow.ellipse(0, -20, 42, 18)
glow.fill({ color: 0x220000, alpha: 0.35 })
// Left wing
const wings = new Graphics()
wings.poly([-10, -24, -44, -48, -36, -18, -10, -14])
wings.fill({ color: 0x660000 })
wings.stroke({ color: 0x882222, width: 1.5 })
// Wing membrane veins left
wings.moveTo(-10, -24); wings.lineTo(-44, -48)
wings.stroke({ color: 0x993333, width: 1, alpha: 0.7 })
wings.moveTo(-10, -24); wings.lineTo(-38, -30)
wings.stroke({ color: 0x993333, width: 0.8, alpha: 0.5 })
wings.moveTo(-10, -20); wings.lineTo(-34, -20)
wings.stroke({ color: 0x993333, width: 0.6, alpha: 0.4 })
// Right wing
wings.poly([10, -24, 44, -48, 36, -18, 10, -14])
wings.fill({ color: 0x660000 })
wings.stroke({ color: 0x882222, width: 1.5 })
wings.moveTo(10, -24); wings.lineTo(44, -48)
wings.stroke({ color: 0x993333, width: 1, alpha: 0.7 })
wings.moveTo(10, -24); wings.lineTo(38, -30)
wings.stroke({ color: 0x993333, width: 0.8, alpha: 0.5 })
wings.moveTo(10, -20); wings.lineTo(34, -20)
wings.stroke({ color: 0x993333, width: 0.6, alpha: 0.4 })
// Tail
const tail = new Graphics()
tail.poly([0, -10, -6, 4, -2, 16, 4, 20, 6, 10, 2, 0])
tail.fill({ color: 0x881111 })
tail.stroke({ color: 0xcc2222, width: 1 })
// Main body — elongated
const body = new Graphics()
body.ellipse(0, -24, 16, 24)
body.fill({ color: 0x881111 })
body.ellipse(0, -24, 16, 24)
body.stroke({ color: 0xcc2222, width: 2 })
// Scales row down body
for (let i = -3; i <= 3; i++) {
body.circle(i * 4, -24 + i * 5, 3.5)
body.fill({ color: 0x660000, alpha: 0.65 })
}
// Belly lighter strip
body.ellipse(0, -22, 8, 18)
body.fill({ color: 0xaa2222, alpha: 0.5 })
// Neck
const neck = new Graphics()
neck.poly([-6, -44, 6, -44, 4, -54, -4, -54])
neck.fill({ color: 0x881111 })
// Head
const head = new Graphics()
head.ellipse(0, -58, 12, 10)
head.fill({ color: 0x881111 })
head.stroke({ color: 0xcc2222, width: 1.5 })
// Snout
head.ellipse(0, -52, 8, 6)
head.fill({ color: 0x771111 })
// Nostrils
head.circle(-3, -51, 1.5)
head.fill({ color: 0x440000 })
head.circle(3, -51, 1.5)
head.fill({ color: 0x440000 })
// Eyes — large glowing
head.circle(-5, -60, 4)
head.fill({ color: 0xff6600 })
head.circle(-5, -60, 2.5)
head.fill({ color: 0xffaa00 })
head.circle(-5, -60, 1.2)
head.fill({ color: 0xffffff, alpha: 0.8 })
head.circle(5, -60, 4)
head.fill({ color: 0xff6600 })
head.circle(5, -60, 2.5)
head.fill({ color: 0xffaa00 })
head.circle(5, -60, 1.2)
head.fill({ color: 0xffffff, alpha: 0.8 })
// Horns
head.poly([-4, -64, -8, -76, -1, -64])
head.fill({ color: 0x554400 })
head.poly([4, -64, 8, -76, 1, -64])
head.fill({ color: 0x554400 })
// Crest spines
head.poly([0, -68, -3, -72, 3, -72])
head.fill({ color: 0xaa2222, alpha: 0.8 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x330000 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0xff2200 })
container.addChild(groundShadow, glow, wings, tail, body, neck, head, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 50
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 450, max: 450, armor: 3, magicResist: 3 }
const movement: MovementComp = {
speed: 70,
path: [...flyPath],
pathIndex: 0,
distanceTravelled: 0,
}
const render: RenderComp = { container, hpBar, label: null }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 50, essence: 3 }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects })
entity.tags.add('enemy')
entity.tags.add('dragon')
entity.tags.add('flying')
entity.tags.add('unit')
}
export function updateDragonHpBar(entity: Entity): void {
const hp = entity.health as HealthComp
const r = entity.render as RenderComp
if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return
const bar = r.hpBar as Graphics
const pct = Math.max(0, hp.current / hp.max)
bar.clear()
bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
bar.fill({ color: pct > 0.5 ? 0xff2200 : 0xff8800 })
}
+100
View File
@@ -0,0 +1,100 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type {
TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp,
} from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const R = 7 // body half-size reference
const HP_W = 32
const HP_H = 4
export function createGoblin(
entity: Entity,
path: { x: number; y: number }[],
startX: number,
startY: number,
): void {
const container = new Container()
const body = new Graphics()
// Ground shadow (slightly below feet)
body.ellipse(0, 4, 10, 4)
body.fill({ color: 0x000000, alpha: 0.35 })
// Legs
body.rect(-5, -4, 4, 10)
body.fill({ color: 0x336622 })
body.rect(2, -4, 4, 10)
body.fill({ color: 0x336622 })
// Torso
body.ellipse(0, -12, 7, 8)
body.fill({ color: 0x44aa44 })
body.ellipse(0, -12, 7, 8)
body.stroke({ color: 0x228822, width: 1 })
// Head
body.circle(0, -22, 7)
body.fill({ color: 0x55cc55 })
body.circle(0, -22, 7)
body.stroke({ color: 0x228822, width: 1 })
// Ears (small triangles on sides of head)
body.poly([-7, -25, -12, -22, -7, -19])
body.fill({ color: 0x44aa44 })
body.poly([7, -25, 12, -22, 7, -19])
body.fill({ color: 0x44aa44 })
// Eyes
body.circle(-3, -23, 2)
body.fill({ color: 0x111111 })
body.circle(3, -23, 2)
body.fill({ color: 0x111111 })
// Eye glint
body.circle(-2, -24, 0.8)
body.fill({ color: 0xff2222 })
body.circle(4, -24, 0.8)
body.fill({ color: 0xff2222 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, -33, HP_W, HP_H)
hpBg.fill({ color: 0x330000 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, -33, HP_W, HP_H)
hpBar.fill({ color: 0x44ff44 })
container.addChild(body, hpBg, hpBar)
container.x = startX
container.y = startY
container.zIndex = 5
getWorldContainer().addChild(container)
const transform: TransformComp = { x: startX, y: startY, rotation: 0 }
const health: HealthComp = { current: 40, max: 40, armor: 0, magicResist: 0 }
const movement: MovementComp = { speed: 80, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 5, essence: 0 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy')
entity.tags.add('unit')
entity.tags.add('goblin')
}
export function updateGoblinHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
const color = pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444
r.hpBar.rect(-HP_W / 2, -33, HP_W * pct, HP_H)
r.hpBar.fill({ color })
}
+121
View File
@@ -0,0 +1,121 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 46
const HP_H = 6
const HP_Y = -50
export function createGolem(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
const body = new Graphics()
// Large ground shadow
body.ellipse(0, 5, 22, 8)
body.fill({ color: 0x000000, alpha: 0.45 })
// Stone legs — massive blocks
body.rect(-14, -8, 11, 18)
body.fill({ color: 0x777777 })
body.rect(-14, -8, 11, 18)
body.stroke({ color: 0x555555, width: 1.5 })
body.rect(4, -8, 11, 18)
body.fill({ color: 0x777777 })
body.rect(4, -8, 11, 18)
body.stroke({ color: 0x555555, width: 1.5 })
// Main stone body block
body.rect(-16, -30, 32, 26)
body.fill({ color: 0x888888 })
body.rect(-16, -30, 32, 26)
body.stroke({ color: 0x555555, width: 3 })
// Stone body texture cracks
body.moveTo(-16, -18); body.lineTo(-8, -20); body.lineTo(-6, -10)
body.stroke({ color: 0x666666, width: 1, alpha: 0.7 })
body.moveTo(16, -22); body.lineTo(8, -20); body.lineTo(10, -12)
body.stroke({ color: 0x666666, width: 1, alpha: 0.7 })
body.moveTo(-10, -30); body.lineTo(-4, -24); body.lineTo(4, -28)
body.stroke({ color: 0x666666, width: 1, alpha: 0.6 })
// Stone arms
body.rect(-26, -28, 10, 20)
body.fill({ color: 0x808080 })
body.rect(-26, -28, 10, 20)
body.stroke({ color: 0x555555, width: 1.5 })
body.rect(16, -28, 10, 20)
body.fill({ color: 0x808080 })
body.rect(16, -28, 10, 20)
body.stroke({ color: 0x555555, width: 1.5 })
// Rune glow on chest
body.circle(0, -18, 8)
body.fill({ color: 0x3333cc, alpha: 0.7 })
body.circle(0, -18, 8)
body.stroke({ color: 0x6666ff, width: 2, alpha: 0.8 })
// Inner rune diamond
body.poly([0, -23, 5, -18, 0, -13, -5, -18])
body.fill({ color: 0x8888ff, alpha: 0.8 })
// Stone head — square-ish
body.rect(-12, -46, 24, 20)
body.fill({ color: 0x888888 })
body.rect(-12, -46, 24, 20)
body.stroke({ color: 0x555555, width: 2 })
// Stone head texture
body.moveTo(-12, -38); body.lineTo(12, -38)
body.stroke({ color: 0x666666, width: 1, alpha: 0.6 })
// Glowing eyes
body.rect(-9, -42, 6, 6)
body.fill({ color: 0x2222ff })
body.rect(-9, -42, 6, 6)
body.stroke({ color: 0x4444ff, width: 1 })
body.rect(3, -42, 6, 6)
body.fill({ color: 0x2222ff })
body.rect(3, -42, 6, 6)
body.stroke({ color: 0x4444ff, width: 1 })
// Eye glow inner
body.rect(-8, -41, 4, 4)
body.fill({ color: 0x8888ff, alpha: 0.9 })
body.rect(4, -41, 4, 4)
body.fill({ color: 0x8888ff, alpha: 0.9 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x222244 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0x4488ff })
container.addChild(body, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 10
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 400, max: 400, armor: 18, magicResist: 8 }
const movement: MovementComp = { speed: 22, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 30, essence: 1 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('golem')
}
export function updateGolemHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
r.hpBar.fill({ color: pct > 0.5 ? 0x4488ff : pct > 0.25 ? 0xffdd44 : 0xff4444 })
}
+203
View File
@@ -0,0 +1,203 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 80
const HP_H = 8
const HP_Y = -90
export interface LichKingComp {
phase: 1 | 2 | 3
summonTimer: number
summonCooldown: number
pulseTimer: number
pulseCooldown: number
}
export function createLichKing(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
// Massive dark aura
const aura = new Graphics()
aura.circle(0, -30, 52)
aura.fill({ color: 0x0a0014, alpha: 0.6 })
aura.circle(0, -30, 52)
aura.stroke({ color: 0x6600cc, width: 3, alpha: 0.7 })
// Ground shadow
const shadow = new Graphics()
shadow.ellipse(0, 4, 22, 8)
shadow.fill({ color: 0x000000, alpha: 0.5 })
// Tattered robe — wide, imposing
const robe = new Graphics()
robe.poly([0, 2, -22, -10, -20, -48, 0, -54, 20, -48, 22, -10])
robe.fill({ color: 0x080018 })
robe.poly([0, 2, -22, -10, -20, -48, 0, -54, 20, -48, 22, -10])
robe.stroke({ color: 0x4400aa, width: 2 })
// Robe hem tear details
robe.moveTo(-22, -10); robe.lineTo(-18, -2); robe.lineTo(-14, -8)
robe.stroke({ color: 0x330088, width: 1, alpha: 0.8 })
robe.moveTo(22, -10); robe.lineTo(18, -2); robe.lineTo(14, -8)
robe.stroke({ color: 0x330088, width: 1, alpha: 0.8 })
// Belt sigil
robe.circle(0, -28, 7)
robe.fill({ color: 0x1a0033 })
robe.circle(0, -28, 7)
robe.stroke({ color: 0xcc00ff, width: 2, alpha: 0.9 })
robe.poly([0, -23, -5, -28, 0, -33, 5, -28])
robe.fill({ color: 0xcc00ff, alpha: 0.85 })
// Pauldrons (shoulder armor)
const pauldrons = new Graphics()
pauldrons.ellipse(-18, -52, 10, 7)
pauldrons.fill({ color: 0x1a1a2e })
pauldrons.stroke({ color: 0x8800cc, width: 1.5 })
pauldrons.ellipse(18, -52, 10, 7)
pauldrons.fill({ color: 0x1a1a2e })
pauldrons.stroke({ color: 0x8800cc, width: 1.5 })
// Pauldron spikes
pauldrons.poly([-22, -54, -18, -62, -14, -54])
pauldrons.fill({ color: 0x2a0055 })
pauldrons.poly([14, -54, 18, -62, 22, -54])
pauldrons.fill({ color: 0x2a0055 })
// Ribcage visible through robe
const ribs = new Graphics()
for (let i = 0; i < 4; i++) {
const ry = -38 + i * 6
ribs.moveTo(-8, ry); ribs.lineTo(-12, ry + 2); ribs.lineTo(-8, ry + 4)
ribs.stroke({ color: 0xd4c8a0, width: 1, alpha: 0.5 })
ribs.moveTo(8, ry); ribs.lineTo(12, ry + 2); ribs.lineTo(8, ry + 4)
ribs.stroke({ color: 0xd4c8a0, width: 1, alpha: 0.5 })
}
// Skull head
const head = new Graphics()
head.circle(0, -68, 14)
head.fill({ color: 0xe8dfc0 })
head.stroke({ color: 0xb0a070, width: 1.5 })
// Cheekbones deep
head.ellipse(-8, -64, 3.5, 4.5)
head.fill({ color: 0xc8b880 })
head.ellipse(8, -64, 3.5, 4.5)
head.fill({ color: 0xc8b880 })
// Deep eye sockets
head.ellipse(-5, -71, 5, 6)
head.fill({ color: 0x0a0014 })
head.circle(-5, -71, 3.5)
head.fill({ color: 0x6600ff })
head.circle(-5, -71, 2)
head.fill({ color: 0xcc44ff, alpha: 0.95 })
head.circle(-5, -71, 0.8)
head.fill({ color: 0xffffff, alpha: 0.9 })
head.ellipse(5, -71, 5, 6)
head.fill({ color: 0x0a0014 })
head.circle(5, -71, 3.5)
head.fill({ color: 0x6600ff })
head.circle(5, -71, 2)
head.fill({ color: 0xcc44ff, alpha: 0.95 })
head.circle(5, -71, 0.8)
head.fill({ color: 0xffffff, alpha: 0.9 })
// Nasal cavity
head.poly([-2, -64, 0, -60, 2, -64])
head.fill({ color: 0x1a0030 })
// Jaw with teeth
head.poly([-10, -60, -8, -57, 8, -57, 10, -60])
head.fill({ color: 0xe0d5b0 })
for (let i = -3; i <= 3; i++) {
head.rect(i * 2.5 - 1, -59, 2, 3)
head.fill({ color: 0xc8be90 })
}
// Crown of bone
const crown = new Graphics()
crown.poly([-14, -80, -10, -80, -8, -92, -6, -80, -2, -80, 0, -94, 2, -80, 6, -80, 8, -92, 10, -80, 14, -80, 14, -76, -14, -76])
crown.fill({ color: 0x1a1a2e })
crown.stroke({ color: 0x8800cc, width: 1.5 })
// Crown gems
crown.circle(-8, -88, 3)
crown.fill({ color: 0xcc00ff, alpha: 0.9 })
crown.circle(0, -90, 3.5)
crown.fill({ color: 0xff00ff, alpha: 0.9 })
crown.circle(8, -88, 3)
crown.fill({ color: 0xcc00ff, alpha: 0.9 })
// Staff of Undeath
const staff = new Graphics()
staff.rect(16, -80, 4, 84)
staff.fill({ color: 0x1a0a0a })
staff.stroke({ color: 0x440022, width: 1 })
// Skull on staff
staff.circle(18, -84, 9)
staff.fill({ color: 0xe0d5b0 })
staff.ellipse(15, -87, 3, 4)
staff.fill({ color: 0x0a0014 })
staff.ellipse(21, -87, 3, 4)
staff.fill({ color: 0x0a0014 })
// Staff orb glow
staff.circle(18, -84, 14)
staff.stroke({ color: 0xcc00ff, width: 2.5, alpha: 0.6 })
staff.circle(18, -84, 7)
staff.fill({ color: 0x6600cc, alpha: 0.75 })
// HP bar
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x110022 })
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.stroke({ color: 0x6600cc, width: 1 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0x8800ff })
// Phase indicator pips (3 small diamonds above HP bar)
const phasePips = new Graphics()
for (let i = 0; i < 3; i++) {
const px = -16 + i * 16
phasePips.poly([px, HP_Y - 10, px - 5, HP_Y - 6, px, HP_Y - 2, px + 5, HP_Y - 6])
phasePips.fill({ color: 0x8800ff, alpha: 0.9 })
}
container.addChild(aura, shadow, robe, pauldrons, ribs, head, crown, staff, hpBg, hpBar, phasePips)
container.x = sx
container.y = sy
container.zIndex = 60
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 1500, max: 1500, armor: 5, magicResist: 8 }
const movement: MovementComp = { speed: 28, path: [...path], pathIndex: 0, distanceTravelled: 0 }
const render: RenderComp = { container, hpBar, label: null }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 150, essence: 10 }
const statusEffects: StatusEffectsComp = { effects: [] }
const lichKingComp: LichKingComp = {
phase: 1,
summonTimer: 7,
summonCooldown: 7,
pulseTimer: 0,
pulseCooldown: 4,
}
Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects, lichKingComp })
entity.tags.add('enemy')
entity.tags.add('lich_king')
entity.tags.add('boss')
entity.tags.add('unit')
}
export function updateLichKingHpBar(entity: Entity): void {
const hp = entity.health as HealthComp
const r = entity.render as RenderComp
if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return
const bar = r.hpBar as Graphics
const pct = Math.max(0, hp.current / hp.max)
bar.clear()
bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
const lk = entity.lichKingComp as LichKingComp | undefined
const color = lk?.phase === 3 ? 0xff0044 : lk?.phase === 2 ? 0xdd00ff : 0x8800ff
bar.fill({ color })
}
+129
View File
@@ -0,0 +1,129 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 44
const HP_H = 6
const HP_Y = -50
export interface NecroComp {
healTimer: number
healCooldown: number
healAmount: number
healRadius: number
}
export function createNecromancer(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
// Dark aura
const aura = new Graphics()
aura.circle(0, -16, 26)
aura.fill({ color: 0x220033, alpha: 0.45 })
aura.circle(0, -16, 26)
aura.stroke({ color: 0x660099, width: 2, alpha: 0.55 })
// Ground shadow
const shadow = new Graphics()
shadow.ellipse(0, 4, 14, 5)
shadow.fill({ color: 0x000000, alpha: 0.35 })
// Robe body — wide at base, iso 2.5D style
const robe = new Graphics()
// Robe base / hem
robe.poly([0, 0, -14, -8, -12, -28, 0, -32, 12, -28, 14, -8])
robe.fill({ color: 0x1a0033 })
robe.poly([0, 0, -14, -8, -12, -28, 0, -32, 12, -28, 14, -8])
robe.stroke({ color: 0x660099, width: 1.5 })
// Robe hem detail line
robe.moveTo(-13, -10); robe.lineTo(13, -10)
robe.stroke({ color: 0x9900cc, width: 1, alpha: 0.7 })
// Belt rune diamond
robe.poly([0, -16, -5, -20, 0, -24, 5, -20])
robe.fill({ color: 0x9900cc, alpha: 0.9 })
// Head/skull
const head = new Graphics()
head.circle(0, -38, 8)
head.fill({ color: 0xddd8c4 })
// Cheekbones
head.ellipse(-5, -36, 2.5, 3)
head.fill({ color: 0xccca9a })
head.ellipse(5, -36, 2.5, 3)
head.fill({ color: 0xccca9a })
// Eye sockets glowing
head.ellipse(-2.5, -40, 2.5, 3)
head.fill({ color: 0x550088 })
head.circle(-2.5, -40, 2)
head.fill({ color: 0xaa00ff, alpha: 0.9 })
head.ellipse(2.5, -40, 2.5, 3)
head.fill({ color: 0x550088 })
head.circle(2.5, -40, 2)
head.fill({ color: 0xaa00ff, alpha: 0.9 })
// Pointed hood
const hood = new Graphics()
hood.poly([0, -58, -10, -44, 10, -44])
hood.fill({ color: 0x1a0033 })
hood.stroke({ color: 0x9900cc, width: 1.5 })
hood.rect(-11, -44, 22, 7)
hood.fill({ color: 0x2a0055 })
hood.stroke({ color: 0x660099, width: 1 })
// Necrotic staff
const staff = new Graphics()
staff.rect(11, -48, 3, 52)
staff.fill({ color: 0x3a2a1a })
// Skull ornament on staff top
staff.circle(12, -50, 5)
staff.fill({ color: 0xddd8c4 })
staff.ellipse(10, -52, 2, 2.5)
staff.fill({ color: 0x330044 })
staff.ellipse(14, -52, 2, 2.5)
staff.fill({ color: 0x330044 })
// Staff glow
staff.circle(12, -50, 8)
staff.stroke({ color: 0x9900cc, width: 2, alpha: 0.55 })
staff.circle(12, -50, 4)
staff.fill({ color: 0x9900cc, alpha: 0.7 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x220022 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0xaa00cc })
container.addChild(aura, shadow, robe, head, hood, staff, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 10
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 350, max: 350, armor: 8, magicResist: 5 }
const movement: MovementComp = { speed: 40, path: [...path], pathIndex: 0, distanceTravelled: 0 }
const render: RenderComp = { container, hpBar, label: null }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 40, essence: 2 }
const statusEffects: StatusEffectsComp = { effects: [] }
const necroComp: NecroComp = { healTimer: 3, healCooldown: 5, healAmount: 40, healRadius: 100 }
Object.assign(entity, { transform, health, movement, render, team, loot, statusEffects, necroComp })
entity.tags.add('enemy')
entity.tags.add('necromancer')
entity.tags.add('unit')
}
export function updateNecromancerHpBar(entity: Entity): void {
const hp = entity.health as HealthComp
const r = entity.render as RenderComp
if (!hp || !r?.hpBar || (r.hpBar as Graphics).destroyed) return
const bar = r.hpBar as Graphics
const pct = Math.max(0, hp.current / hp.max)
bar.clear()
bar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
bar.fill({ color: pct > 0.5 ? 0xaa00cc : 0xff0066 })
}
+104
View File
@@ -0,0 +1,104 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 32
const HP_H = 5
const HP_Y = -40
export function createOrc(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
const body = new Graphics()
// Ground shadow
body.ellipse(0, 4, 14, 5)
body.fill({ color: 0x000000, alpha: 0.35 })
// Legs — wider, dark green
body.rect(-9, -6, 7, 14)
body.fill({ color: 0x2a5a1a })
body.rect(2, -6, 7, 14)
body.fill({ color: 0x2a5a1a })
// Armor/torso block
body.rect(-10, -22, 20, 18)
body.fill({ color: 0x3a6a28 })
body.rect(-10, -22, 20, 18)
body.stroke({ color: 0x888888, width: 1.5 })
// Armor chest lines
body.moveTo(-10, -14)
body.lineTo(10, -14)
body.stroke({ color: 0x666666, width: 1, alpha: 0.7 })
body.moveTo(0, -22)
body.lineTo(0, -4)
body.stroke({ color: 0x666666, width: 1, alpha: 0.5 })
// Shoulders — pauldrons
body.ellipse(-13, -18, 6, 5)
body.fill({ color: 0x777777 })
body.ellipse(-13, -18, 6, 5)
body.stroke({ color: 0x444444, width: 1 })
body.ellipse(13, -18, 6, 5)
body.fill({ color: 0x777777 })
body.ellipse(13, -18, 6, 5)
body.stroke({ color: 0x444444, width: 1 })
// Head
body.circle(0, -32, 10)
body.fill({ color: 0x4a7a2a })
body.circle(0, -32, 10)
body.stroke({ color: 0x2a5a1a, width: 1.5 })
// Tusks (white protrusions from lower head)
body.poly([-5, -25, -7, -20, -3, -22])
body.fill({ color: 0xeeeebb })
body.poly([5, -25, 7, -20, 3, -22])
body.fill({ color: 0xeeeebb })
// Eyes
body.circle(-4, -33, 3)
body.fill({ color: 0xff4400 })
body.circle(-4, -33, 1.5)
body.fill({ color: 0xffaa00 })
body.circle(4, -33, 3)
body.fill({ color: 0xff4400 })
body.circle(4, -33, 1.5)
body.fill({ color: 0xffaa00 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x330000 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0x44ff44 })
container.addChild(body, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 5
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 120, max: 120, armor: 6, magicResist: 0 }
const movement: MovementComp = { speed: 45, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 12, essence: 0 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('orc')
}
export function updateOrcHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 })
}
+113
View File
@@ -0,0 +1,113 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 44
const HP_H = 5
const HP_Y = -50
export function createTroll(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
const body = new Graphics()
// Large ground shadow
body.ellipse(0, 5, 20, 7)
body.fill({ color: 0x000000, alpha: 0.4 })
// Feet/legs
body.rect(-14, -6, 10, 16)
body.fill({ color: 0x3a6a1a })
body.rect(4, -6, 10, 16)
body.fill({ color: 0x3a6a1a })
// Massive torso
body.ellipse(0, -18, 16, 18)
body.fill({ color: 0x4a7a2a })
body.ellipse(0, -18, 16, 18)
body.stroke({ color: 0x2a5a1a, width: 2 })
// Warts/bumps on body
body.circle(-10, -14, 4)
body.fill({ color: 0x3a6a1a })
body.circle(9, -12, 3)
body.fill({ color: 0x3a6a1a })
body.circle(-4, -24, 3)
body.fill({ color: 0x3a6a1a })
body.circle(8, -22, 4)
body.fill({ color: 0x2a5a1a })
// Arms — thick
body.ellipse(-18, -20, 6, 12)
body.fill({ color: 0x4a7a2a })
body.ellipse(18, -20, 6, 12)
body.fill({ color: 0x4a7a2a })
// Big round head
body.circle(0, -38, 14)
body.fill({ color: 0x5a8a3a })
body.circle(0, -38, 14)
body.stroke({ color: 0x2a5a1a, width: 2 })
// Brow ridge
body.ellipse(0, -44, 14, 5)
body.fill({ color: 0x3a6a1a })
// Warts on head
body.circle(-8, -36, 3)
body.fill({ color: 0x3a6a1a })
// Eyes
body.circle(-5, -39, 3.5)
body.fill({ color: 0xff3300 })
body.circle(-5, -39, 1.5)
body.fill({ color: 0xffaa00 })
body.circle(5, -39, 3.5)
body.fill({ color: 0xff3300 })
body.circle(5, -39, 1.5)
body.fill({ color: 0xffaa00 })
// Nose
body.ellipse(0, -35, 4, 3)
body.fill({ color: 0x4a7a2a })
// Regen glow effect — subtle green aura
body.circle(0, -18, 20)
body.stroke({ color: 0x44ff44, width: 1.5, alpha: 0.2 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x330000 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0x44ff44 })
container.addChild(body, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 10
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 220, max: 220, armor: 4, magicResist: 0, regenRate: 8 }
const movement: MovementComp = { speed: 35, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 22, essence: 0 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('troll')
}
export function updateTrollHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 })
}
+20
View File
@@ -0,0 +1,20 @@
import type { Entity } from '@/game/core/EntityManager'
import { updateGoblinHpBar } from './goblin'
import { updateOrcHpBar } from './orc'
import { updateWargHpBar } from './warg'
import { updateWraithHpBar } from './wraith'
import { updateTrollHpBar } from './troll'
import { updateGolemHpBar } from './golem'
import { updateNecromancerHpBar } from './necromancer'
import { updateDragonHpBar } from './dragon'
export function refreshHp(entity: Entity): void {
if (entity.tags.has('goblin')) updateGoblinHpBar(entity)
else if (entity.tags.has('orc')) updateOrcHpBar(entity)
else if (entity.tags.has('warg')) updateWargHpBar(entity)
else if (entity.tags.has('wraith')) updateWraithHpBar(entity)
else if (entity.tags.has('troll')) updateTrollHpBar(entity)
else if (entity.tags.has('golem')) updateGolemHpBar(entity)
else if (entity.tags.has('necromancer')) updateNecromancerHpBar(entity)
else if (entity.tags.has('dragon')) updateDragonHpBar(entity)
}
+101
View File
@@ -0,0 +1,101 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 28
const HP_H = 4
const HP_Y = -26
export function createWarg(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
const body = new Graphics()
// Ground shadow
body.ellipse(0, 4, 14, 5)
body.fill({ color: 0x000000, alpha: 0.35 })
// 4 legs — small rects
body.rect(-10, -4, 4, 10)
body.fill({ color: 0x5a3a1a })
body.rect(-4, -3, 4, 9)
body.fill({ color: 0x5a3a1a })
body.rect(2, -3, 4, 9)
body.fill({ color: 0x5a3a1a })
body.rect(8, -4, 4, 10)
body.fill({ color: 0x5a3a1a })
// Body (low, wide ellipse)
body.ellipse(0, -10, 14, 8)
body.fill({ color: 0x6a4a2a })
body.ellipse(0, -10, 14, 8)
body.stroke({ color: 0x4a2a0a, width: 1 })
// Tail
body.poly([-14, -10, -20, -14, -16, -7])
body.fill({ color: 0x5a3a1a })
// Head — forward shifted in iso +x direction
body.ellipse(10, -14, 8, 6)
body.fill({ color: 0x7a5a3a })
body.ellipse(10, -14, 8, 6)
body.stroke({ color: 0x4a2a0a, width: 1 })
// Snout
body.ellipse(15, -13, 4, 3)
body.fill({ color: 0x6a4a2a })
// Ears — triangles on head
body.poly([8, -18, 5, -24, 11, -20])
body.fill({ color: 0x5a3a1a })
body.poly([14, -18, 12, -24, 17, -20])
body.fill({ color: 0x5a3a1a })
// Eye
body.circle(12, -15, 2)
body.fill({ color: 0xff8800 })
body.circle(12, -15, 1)
body.fill({ color: 0xffcc00 })
// Fur texture marks
body.moveTo(-8, -12); body.lineTo(-2, -9)
body.stroke({ color: 0x4a2a0a, width: 0.8, alpha: 0.5 })
body.moveTo(2, -14); body.lineTo(8, -12)
body.stroke({ color: 0x4a2a0a, width: 0.8, alpha: 0.5 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x330000 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0x44ff44 })
container.addChild(body, hpBg, hpBar)
container.x = sx
container.y = sy
container.zIndex = 5
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 25, max: 25, armor: 0, magicResist: 0 }
const movement: MovementComp = { speed: 140, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 8, essence: 0 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('warg')
}
export function updateWargHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
r.hpBar.fill({ color: pct > 0.5 ? 0x44ff44 : pct > 0.25 ? 0xffdd44 : 0xff4444 })
}
+88
View File
@@ -0,0 +1,88 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, MovementComp, TeamComp, LootComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const HP_W = 30
const HP_H = 4
const HP_Y = -45
export function createWraith(entity: Entity, path: { x: number; y: number }[], sx: number, sy: number): void {
const container = new Container()
const body = new Graphics()
// No shadow — floats!
// Flowing robe — wide at bottom, tapering up, semi-transparent
body.poly([0, -38, -14, -20, -18, -5, -14, 4, 0, 6, 14, 4, 18, -5, 14, -20])
body.fill({ color: 0x6633aa, alpha: 0.75 })
body.poly([0, -38, -14, -20, -18, -5, -14, 4, 0, 6, 14, 4, 18, -5, 14, -20])
body.stroke({ color: 0xcc99ff, width: 1.5, alpha: 0.85 })
// Robe tattered bottom — wispy tendrils
body.poly([-14, 4, -18, 12, -10, 8, -6, 14, 0, 6])
body.fill({ color: 0x8844cc, alpha: 0.55 })
body.poly([0, 6, 6, 14, 10, 8, 18, 12, 14, 4])
body.fill({ color: 0x8844cc, alpha: 0.55 })
// Inner robe highlight
body.poly([0, -36, -8, -20, -10, -5, 0, 0, 10, -5, 8, -20])
body.fill({ color: 0x9966cc, alpha: 0.4 })
// Hood/head top
body.ellipse(0, -32, 9, 8)
body.fill({ color: 0x441188, alpha: 0.9 })
// Glowing eyes
body.circle(-4, -30, 3)
body.fill({ color: 0xffaaff, alpha: 0.95 })
body.circle(-4, -30, 1.5)
body.fill({ color: 0xffffff, alpha: 0.9 })
body.circle(4, -30, 3)
body.fill({ color: 0xffaaff, alpha: 0.95 })
body.circle(4, -30, 1.5)
body.fill({ color: 0xffffff, alpha: 0.9 })
// Magical aura wisps
body.circle(-6, -15, 4)
body.fill({ color: 0x9944cc, alpha: 0.3 })
body.circle(6, -18, 3)
body.fill({ color: 0xbb66ff, alpha: 0.3 })
const hpBg = new Graphics()
hpBg.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBg.fill({ color: 0x330033 })
const hpBar = new Graphics()
hpBar.rect(-HP_W / 2, HP_Y, HP_W, HP_H)
hpBar.fill({ color: 0xcc44ff })
container.addChild(body, hpBg, hpBar)
container.x = sx
container.y = sy
container.alpha = 0.88
container.zIndex = 5
getWorldContainer().addChild(container)
const transform: TransformComp = { x: sx, y: sy, rotation: 0 }
const health: HealthComp = { current: 55, max: 55, armor: 0, magicResist: 0, physicalImmune: true }
const movement: MovementComp = { speed: 90, path, pathIndex: 0, distanceTravelled: 0 }
const team: TeamComp = { team: 'enemy' }
const loot: LootComp = { gold: 15, essence: 0 }
const render: RenderComp = { container, hpBar, label: null }
const statusEffects: StatusEffectsComp = { effects: [] }
Object.assign(entity, { transform, health, movement, team, loot, render, statusEffects })
entity.tags.add('enemy'); entity.tags.add('unit'); entity.tags.add('wraith')
}
export function updateWraithHpBar(entity: Entity): void {
const h = entity.health as HealthComp
const r = entity.render as RenderComp
if (!r.hpBar || !h) return
const pct = Math.max(0, h.current / h.max)
r.hpBar.clear()
r.hpBar.rect(-HP_W / 2, HP_Y, HP_W * pct, HP_H)
r.hpBar.fill({ color: pct > 0.5 ? 0xcc44ff : pct > 0.25 ? 0xffaaff : 0xff4444 })
}
+110
View File
@@ -0,0 +1,110 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, RenderComp } from '@/game/components'
import type { HeroComp } from '@/game/components/Hero'
import { makeSpells } from '@/game/components/Hero'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function createArchmage(entity: Entity, startX: number, startY: number): void {
const container = new Container()
// Ground shadow
const shadow = new Graphics()
shadow.ellipse(0, 4, 10, 4)
shadow.fill({ color: 0x000000, alpha: 0.35 })
// Arcane aura
const aura = new Graphics()
aura.circle(0, -16, 24)
aura.stroke({ color: 0x9966ff, width: 2, alpha: 0.4 })
aura.circle(0, -16, 20)
aura.stroke({ color: 0xcc99ff, width: 1, alpha: 0.2 })
// Robe — tapers from wide hem to waist
const robe = new Graphics()
// Robe hem/base
robe.poly([0, 0, -12, -8, -10, -28, 0, -32, 10, -28, 12, -8])
robe.fill({ color: 0x2a1a4a })
robe.poly([0, 0, -12, -8, -10, -28, 0, -32, 10, -28, 12, -8])
robe.stroke({ color: 0x6633aa, width: 1.5 })
// Robe hem gold trim
robe.moveTo(-11, -10); robe.lineTo(11, -10)
robe.stroke({ color: 0xffd700, width: 1, alpha: 0.65 })
// Belt buckle gem
robe.poly([0, -18, -4, -22, 0, -26, 4, -22])
robe.fill({ color: 0x00ccff, alpha: 0.9 })
// Head (slightly tilted forward for iso feel)
const head = new Graphics()
head.circle(0, -38, 7)
head.fill({ color: 0xd4a574 })
// Beard
head.ellipse(0, -33, 4, 4)
head.fill({ color: 0xd4c0a0 })
// Eyes
head.circle(-2, -39, 1.2)
head.fill({ color: 0x222222 })
head.circle(2, -39, 1.2)
head.fill({ color: 0x222222 })
// Eye highlight
head.circle(-1.5, -39.5, 0.5)
head.fill({ color: 0xffffff })
head.circle(2.5, -39.5, 0.5)
head.fill({ color: 0xffffff })
// Wizard hat
const hat = new Graphics()
// Hat brim
hat.rect(-10, -44, 20, 5)
hat.fill({ color: 0x2a1050 })
hat.rect(-10, -44, 20, 5)
hat.stroke({ color: 0x9966ff, width: 1 })
// Hat cone
hat.poly([0, -62, -8, -44, 8, -44])
hat.fill({ color: 0x1a0a2e })
hat.stroke({ color: 0x9966ff, width: 1.5 })
// Hat star / rune
hat.circle(0, -52, 2.5)
hat.fill({ color: 0xffd700 })
// Hat band stripe
hat.moveTo(-8, -46); hat.lineTo(8, -46)
hat.stroke({ color: 0xffd700, width: 1, alpha: 0.7 })
// Staff (right side, rising upward)
const staff = new Graphics()
staff.rect(10, -50, 3, 54)
staff.fill({ color: 0x5a3a1a })
staff.stroke({ color: 0x8a5a2a, width: 0.8 })
// Staff orb top
staff.circle(11, -52, 6)
staff.fill({ color: 0x9966ff, alpha: 0.9 })
staff.circle(11, -52, 4)
staff.fill({ color: 0xcc99ff, alpha: 0.8 })
staff.circle(11, -52, 2)
staff.fill({ color: 0xffffff, alpha: 0.75 })
// Staff glow ring
staff.circle(11, -52, 9)
staff.stroke({ color: 0x9966ff, width: 1.5, alpha: 0.4 })
container.addChild(shadow, aura, robe, head, hat, staff)
container.x = startX
container.y = startY
container.zIndex = 100
getWorldContainer().addChild(container)
const transform: TransformComp = { x: startX, y: startY, rotation: 0 }
const render: RenderComp = { container, hpBar: null, label: null }
const hero: HeroComp = {
targetX: null,
targetY: null,
speed: 120,
xp: 0,
level: 1,
spells: makeSpells(),
bobTime: 0,
}
Object.assign(entity, { transform, render, hero })
entity.tags.add('hero')
entity.tags.add('unit')
}
+46
View File
@@ -0,0 +1,46 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function createArrow(
entity: Entity,
startX: number,
startY: number,
targetId: number,
damage: number,
ownerId: number,
): void {
const container = new Container()
const gfx = new Graphics()
// arrow shaft
gfx.rect(-6, -1, 10, 2)
gfx.fill({ color: 0xc9a14a })
// arrowhead
gfx.poly([-6, -3, -6, 3, -12, 0])
gfx.fill({ color: 0xe8e0c0 })
// trail glow
gfx.rect(4, -0.5, 8, 1)
gfx.fill({ color: 0xc9a14a, alpha: 0.4 })
container.addChild(gfx)
container.x = startX
container.y = startY
getWorldContainer().addChild(container)
const transform: TransformComp = { x: startX, y: startY, rotation: 0 }
const projectile: ProjectileComp = {
speed: 360,
targetId,
damage,
damageType: 'physical',
ownerId,
hitRadius: 12,
projectileType: 'arrow',
}
const render: RenderComp = { container, hpBar: null, label: null }
Object.assign(entity, { transform, projectile, render })
entity.tags.add('projectile')
}
+39
View File
@@ -0,0 +1,39 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function createFireball(
entity: Entity,
startX: number,
startY: number,
targetId: number,
damage: number,
ownerId: number,
): void {
const container = new Container()
const gfx = new Graphics()
gfx.circle(0, 0, 7)
gfx.fill({ color: 0xff6600 })
gfx.circle(0, 0, 4)
gfx.fill({ color: 0xffdd00 })
const trail = new Graphics()
trail.circle(4, 0, 4)
trail.fill({ color: 0xff4400, alpha: 0.5 })
trail.circle(9, 0, 2.5)
trail.fill({ color: 0xff2200, alpha: 0.3 })
container.addChild(trail, gfx)
container.x = startX
container.y = startY
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: startX, y: startY, rotation: 0 } as TransformComp,
projectile: { speed: 280, targetId, damage, damageType: 'magic', ownerId, hitRadius: 14, projectileType: 'fireball' } as ProjectileComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('projectile')
}
+33
View File
@@ -0,0 +1,33 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function createIcicle(
entity: Entity,
startX: number,
startY: number,
targetId: number,
damage: number,
ownerId: number,
): void {
const container = new Container()
const gfx = new Graphics()
gfx.poly([0, -8, -3, 0, 0, 5, 3, 0])
gfx.fill({ color: 0xaaeeff })
gfx.poly([0, -8, -3, 0, 0, 5, 3, 0])
gfx.stroke({ color: 0x6ecbd5, width: 1 })
container.addChild(gfx)
container.x = startX
container.y = startY
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: startX, y: startY, rotation: 0 } as TransformComp,
projectile: { speed: 320, targetId, damage, damageType: 'magic', ownerId, hitRadius: 10, projectileType: 'icicle' } as ProjectileComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('projectile')
}
@@ -0,0 +1,36 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, ProjectileComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function createLightning(
entity: Entity,
startX: number,
startY: number,
targetId: number,
damage: number,
ownerId: number,
): void {
const container = new Container()
const gfx = new Graphics()
gfx.circle(0, 0, 5)
gfx.fill({ color: 0xffffff })
gfx.circle(0, 0, 3)
gfx.fill({ color: 0xaaaaff })
// small bolt shape
gfx.poly([2, -6, -1, 0, 2, 0, -2, 6])
gfx.stroke({ color: 0xffd700, width: 1.5 })
container.addChild(gfx)
container.x = startX
container.y = startY
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: startX, y: startY, rotation: 0 } as TransformComp,
projectile: { speed: 500, targetId, damage, damageType: 'magic', ownerId, hitRadius: 12, projectileType: 'lightning' } as ProjectileComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('projectile')
}
+107
View File
@@ -0,0 +1,107 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type {
TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp,
} from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap'
const RANGE = 180
export const ARCHER_COST = 50
export function createArcherTower(
entity: Entity,
tileX: number,
tileY: number,
worldX: number,
worldY: number,
): void {
const container = new Container()
const hw = ISO_HALF_W, hh = ISO_HALF_H
// Iso stone base platform
const base = new Graphics()
// Top face of platform
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.fill({ color: 0x6a5040 })
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.stroke({ color: 0xc9a14a, width: 1.5, alpha: 0.8 })
// Left side face
base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6])
base.fill({ color: 0x4a3828 })
// Right side face
base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6])
base.fill({ color: 0x3a2818 })
// Stone tower body (rising upward from center)
const tower = new Graphics()
// Tower block
tower.rect(-10, -40, 20, 30)
tower.fill({ color: 0x6a5040 })
tower.rect(-10, -40, 20, 30)
tower.stroke({ color: 0x8a6850, width: 1.5 })
// Battlements
for (let i = 0; i < 3; i++) {
tower.rect(-10 + i * 7, -46, 5, 8)
tower.fill({ color: 0x6a5040 })
tower.rect(-10 + i * 7, -46, 5, 8)
tower.stroke({ color: 0x8a6850, width: 1 })
}
// Arrow slit window
tower.rect(-2, -34, 4, 8)
tower.fill({ color: 0x1a0f0a })
// Rune glow on tower
tower.circle(0, -28, 4)
tower.fill({ color: 0xc9a14a, alpha: 0.75 })
// Archer figure on top
const archer = new Graphics()
// Body (small)
archer.rect(-3, -56, 6, 10)
archer.fill({ color: 0x884422 })
// Head
archer.circle(0, -59, 3)
archer.fill({ color: 0xd4a574 })
// Bow
archer.moveTo(-6, -57); archer.lineTo(-6, -52)
archer.stroke({ color: 0x8a6030, width: 2 })
archer.moveTo(-6, -57); archer.lineTo(-1, -54)
archer.stroke({ color: 0xddaa44, width: 0.8, alpha: 0.8 })
archer.moveTo(-6, -52); archer.lineTo(-1, -54)
archer.stroke({ color: 0xddaa44, width: 0.8, alpha: 0.8 })
// Range circle (hidden by default)
const rangeCircle = new Graphics()
rangeCircle.circle(0, 0, RANGE)
rangeCircle.stroke({ color: 0xc9a14a, width: 1, alpha: 0.3 })
rangeCircle.fill({ color: 0xc9a14a, alpha: 0.04 })
rangeCircle.visible = false
container.addChild(base, tower, archer, rangeCircle)
container.x = worldX
container.y = worldY
container.eventMode = 'static'
container.cursor = 'pointer'
container.on('pointerover', () => { rangeCircle.visible = true })
container.on('pointerout', () => { rangeCircle.visible = false })
getWorldContainer().addChild(container)
const transform: TransformComp = { x: worldX, y: worldY, rotation: 0 }
const targeting: TargetingComp = { range: RANGE, mode: 'first', targetId: null }
const attack: AttackComp = {
damage: 8,
damageType: 'physical',
cooldown: 0.9,
timer: 0,
projectileType: 'arrow',
}
const team: TeamComp = { team: 'player' }
const towerComp: TowerComp = { tileX, tileY, tier: 1, towerId: 'archer', totalCost: ARCHER_COST, leyLineBuff: false }
const render: RenderComp = { container, hpBar: null, label: null }
Object.assign(entity, { transform, targeting, attack, team, towerComp, render })
entity.tags.add('tower')
entity.tags.add('unit')
}
+97
View File
@@ -0,0 +1,97 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap'
export const CRYO_COST = 90
export const CRYO_RANGE = 160
export function createCryomancer(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void {
const container = new Container()
const hw = ISO_HALF_W, hh = ISO_HALF_H
// Iso ice crystal base
const base = new Graphics()
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.fill({ color: 0x1a4a6a })
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.stroke({ color: 0x6ecbd5, width: 1.5, alpha: 0.9 })
// Left side — icy blue-dark
base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6])
base.fill({ color: 0x0a1a3a })
// Right side
base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6])
base.fill({ color: 0x081228 })
// Ice facet lines on top
base.moveTo(-16, -2); base.lineTo(0, -hh); base.lineTo(16, -2)
base.stroke({ color: 0x9eeeff, width: 0.8, alpha: 0.5 })
base.moveTo(-hw, 0); base.lineTo(0, 0); base.lineTo(hw, 0)
base.stroke({ color: 0x9eeeff, width: 0.6, alpha: 0.35 })
// Ice tower body
const tower = new Graphics()
tower.rect(-9, -44, 18, 34)
tower.fill({ color: 0x1a3a5a })
tower.rect(-9, -44, 18, 34)
tower.stroke({ color: 0x2a6a8a, width: 1.5 })
// Ice facets on tower face
tower.moveTo(-9, -30); tower.lineTo(0, -32); tower.lineTo(9, -30)
tower.stroke({ color: 0x6ecbd5, width: 1, alpha: 0.6 })
tower.moveTo(-9, -20); tower.lineTo(0, -22); tower.lineTo(9, -20)
tower.stroke({ color: 0x6ecbd5, width: 0.8, alpha: 0.4 })
// Pointed frost spire
const spire = new Graphics()
// Main spire
spire.poly([0, -62, -6, -44, 6, -44])
spire.fill({ color: 0x4a9aaa })
spire.stroke({ color: 0x6ecbd5, width: 1 })
// Secondary spire left
spire.poly([-5, -52, -10, -44, -2, -44])
spire.fill({ color: 0x3a7a8a, alpha: 0.9 })
// Secondary spire right
spire.poly([5, -52, 10, -44, 2, -44])
spire.fill({ color: 0x3a7a8a, alpha: 0.9 })
// Frost crystal at spire top
spire.circle(0, -62, 4)
spire.fill({ color: 0x9eeeff, alpha: 0.9 })
spire.poly([0, -67, 4, -62, 0, -57, -4, -62])
spire.fill({ color: 0xffffff, alpha: 0.85 })
// Frost glow
spire.circle(0, -62, 8)
spire.stroke({ color: 0x6ecbd5, width: 1.5, alpha: 0.5 })
// Frost crystals on base
const crystals = new Graphics()
crystals.poly([-14, -6, -17, -14, -11, -6])
crystals.fill({ color: 0x6ecbd5, alpha: 0.8 })
crystals.poly([14, -4, 17, -12, 11, -4])
crystals.fill({ color: 0x6ecbd5, alpha: 0.7 })
// Range circle
const rangeCircle = new Graphics()
rangeCircle.circle(0, 0, CRYO_RANGE)
rangeCircle.stroke({ color: 0x6ecbd5, width: 1, alpha: 0.3 })
rangeCircle.fill({ color: 0x6ecbd5, alpha: 0.04 })
rangeCircle.visible = false
container.addChild(base, tower, spire, crystals, rangeCircle)
container.x = wx
container.y = wy
container.eventMode = 'static'
container.cursor = 'pointer'
container.on('pointerover', () => { rangeCircle.visible = true })
container.on('pointerout', () => { rangeCircle.visible = false })
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: wx, y: wy, rotation: 0 } as TransformComp,
targeting: { range: CRYO_RANGE, mode: 'first', targetId: null } as TargetingComp,
attack: { damage: 6, damageType: 'magic', cooldown: 1.1, timer: 0, projectileType: 'icicle' } as AttackComp,
team: { team: 'player' } as TeamComp,
towerComp: { tileX, tileY, tier: 1, towerId: 'cryomancer', totalCost: CRYO_COST, leyLineBuff: false } as TowerComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('tower'); entity.tags.add('unit')
}
+89
View File
@@ -0,0 +1,89 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap'
export const PYROMANCER_COST = 80
export const PYRO_RANGE = 150
export const PYRO_AOE_RADIUS = 48
export function createPyromancer(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void {
const container = new Container()
const hw = ISO_HALF_W, hh = ISO_HALF_H
// Iso stone base — fiery orange-tinted
const base = new Graphics()
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.fill({ color: 0x6a3010 })
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.stroke({ color: 0xe8702a, width: 1.5, alpha: 0.85 })
base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6])
base.fill({ color: 0x3a1a0a })
base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6])
base.fill({ color: 0x2a1008 })
// Lava cracks in base
base.moveTo(-8, -2); base.lineTo(-4, 2); base.lineTo(2, 0)
base.stroke({ color: 0xff6600, width: 1, alpha: 0.55 })
base.moveTo(6, -4); base.lineTo(10, 0)
base.stroke({ color: 0xff6600, width: 1, alpha: 0.45 })
// Spire body
const tower = new Graphics()
tower.rect(-10, -42, 20, 32)
tower.fill({ color: 0x5a2a10 })
tower.rect(-10, -42, 20, 32)
tower.stroke({ color: 0x7a3a18, width: 1.5 })
// Battlements
for (let i = 0; i < 3; i++) {
tower.rect(-10 + i * 7, -48, 5, 8)
tower.fill({ color: 0x5a2a10 })
tower.stroke({ color: 0x7a3a18, width: 1 })
}
// Fire rune on tower face
tower.circle(0, -32, 5)
tower.fill({ color: 0xff4400, alpha: 0.65 })
tower.circle(0, -32, 3)
tower.fill({ color: 0xff8800, alpha: 0.8 })
// Flaming spire tip
const flame = new Graphics()
// Outer flame
flame.poly([0, -62, -6, -52, -8, -44, 0, -48, 8, -44, 6, -52])
flame.fill({ color: 0xff4400, alpha: 0.85 })
// Middle flame
flame.poly([0, -60, -4, -52, -5, -46, 0, -50, 5, -46, 4, -52])
flame.fill({ color: 0xff8800, alpha: 0.9 })
// Inner bright flame
flame.poly([0, -58, -2, -52, 0, -48, 2, -52])
flame.fill({ color: 0xffdd00, alpha: 0.95 })
// Flame glow ring
flame.circle(0, -52, 8)
flame.stroke({ color: 0xff6600, width: 2, alpha: 0.4 })
// Range circle
const rangeCircle = new Graphics()
rangeCircle.circle(0, 0, PYRO_RANGE)
rangeCircle.stroke({ color: 0xe8702a, width: 1, alpha: 0.3 })
rangeCircle.fill({ color: 0xe8702a, alpha: 0.04 })
rangeCircle.visible = false
container.addChild(base, tower, flame, rangeCircle)
container.x = wx
container.y = wy
container.eventMode = 'static'
container.cursor = 'pointer'
container.on('pointerover', () => { rangeCircle.visible = true })
container.on('pointerout', () => { rangeCircle.visible = false })
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: wx, y: wy, rotation: 0 } as TransformComp,
targeting: { range: PYRO_RANGE, mode: 'first', targetId: null } as TargetingComp,
attack: { damage: 12, damageType: 'magic', cooldown: 1.6, timer: 0, projectileType: 'fireball' } as AttackComp,
team: { team: 'player' } as TeamComp,
towerComp: { tileX, tileY, tier: 1, towerId: 'pyromancer', totalCost: PYROMANCER_COST, leyLineBuff: false } as TowerComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('tower'); entity.tags.add('unit')
}
+94
View File
@@ -0,0 +1,94 @@
import { Container, Graphics } from 'pixi.js'
import type { Entity } from '@/game/core/EntityManager'
import type { TransformComp, TargetingComp, AttackComp, TeamComp, TowerComp, RenderComp } from '@/game/components'
import { getWorldContainer } from '@/game/rendering/WorldContext'
import { ISO_HALF_W, ISO_HALF_H } from '@/game/map/GridMap'
export const STORM_COST = 110
export const STORM_RANGE = 200
export function createStormcaller(entity: Entity, tileX: number, tileY: number, wx: number, wy: number): void {
const container = new Container()
const hw = ISO_HALF_W, hh = ISO_HALF_H
// Iso dark stone base
const base = new Graphics()
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.fill({ color: 0x2a2a4a })
base.poly([0, -hh, hw, 0, 0, hh, -hw, 0])
base.stroke({ color: 0xffd700, width: 2, alpha: 0.9 })
// Left side
base.poly([-hw, 0, 0, hh, 0, hh + 6, -hw, 6])
base.fill({ color: 0x1a1a2a })
// Right side
base.poly([0, hh, hw, 0, hw, 6, 0, hh + 6])
base.fill({ color: 0x121220 })
// Lightning rune lines on base
base.moveTo(-10, -4); base.lineTo(-2, -8); base.lineTo(-6, -12); base.lineTo(2, -16)
base.stroke({ color: 0xffd700, width: 1, alpha: 0.55 })
base.moveTo(10, -2); base.lineTo(4, -8); base.lineTo(8, -12)
base.stroke({ color: 0xffd700, width: 1, alpha: 0.4 })
// Obelisk body
const obelisk = new Graphics()
obelisk.rect(-8, -50, 16, 40)
obelisk.fill({ color: 0x2a2a4a })
obelisk.rect(-8, -50, 16, 40)
obelisk.stroke({ color: 0x4a4a7a, width: 1.5 })
// Obelisk pyramid cap
obelisk.poly([0, -60, -8, -50, 8, -50])
obelisk.fill({ color: 0x5555aa })
obelisk.stroke({ color: 0x8888cc, width: 1 })
// Rune glyphs on obelisk faces
obelisk.moveTo(-6, -44); obelisk.lineTo(-2, -38); obelisk.lineTo(2, -42); obelisk.lineTo(6, -36)
obelisk.stroke({ color: 0xffd700, width: 1, alpha: 0.65 })
obelisk.moveTo(-5, -28); obelisk.lineTo(0, -24); obelisk.lineTo(5, -28)
obelisk.stroke({ color: 0xffd700, width: 1, alpha: 0.5 })
// Lightning orb at top
const orb = new Graphics()
orb.circle(0, -62, 8)
orb.fill({ color: 0xffd700, alpha: 0.75 })
orb.circle(0, -62, 5)
orb.fill({ color: 0xffffff, alpha: 0.9 })
orb.circle(0, -62, 2)
orb.fill({ color: 0x8888ff, alpha: 0.95 })
// Orb glow ring
orb.circle(0, -62, 12)
orb.stroke({ color: 0xffd700, width: 2, alpha: 0.4 })
orb.circle(0, -62, 18)
orb.stroke({ color: 0xffd700, width: 1, alpha: 0.2 })
// Static discharge sparks
orb.moveTo(0, -62); orb.lineTo(-14, -70)
orb.stroke({ color: 0xffd700, width: 1, alpha: 0.5 })
orb.moveTo(0, -62); orb.lineTo(14, -68)
orb.stroke({ color: 0xffd700, width: 1, alpha: 0.5 })
orb.moveTo(0, -62); orb.lineTo(0, -76)
orb.stroke({ color: 0xffffff, width: 1, alpha: 0.4 })
// Range circle
const rangeCircle = new Graphics()
rangeCircle.circle(0, 0, STORM_RANGE)
rangeCircle.stroke({ color: 0xffd700, width: 1, alpha: 0.3 })
rangeCircle.fill({ color: 0xffd700, alpha: 0.03 })
rangeCircle.visible = false
container.addChild(base, obelisk, orb, rangeCircle)
container.x = wx
container.y = wy
container.eventMode = 'static'
container.cursor = 'pointer'
container.on('pointerover', () => { rangeCircle.visible = true })
container.on('pointerout', () => { rangeCircle.visible = false })
getWorldContainer().addChild(container)
Object.assign(entity, {
transform: { x: wx, y: wy, rotation: 0 } as TransformComp,
targeting: { range: STORM_RANGE, mode: 'first', targetId: null } as TargetingComp,
attack: { damage: 18, damageType: 'magic', cooldown: 2.0, timer: 0, projectileType: 'lightning' } as AttackComp,
team: { team: 'player' } as TeamComp,
towerComp: { tileX, tileY, tier: 1, towerId: 'stormcaller', totalCost: STORM_COST, leyLineBuff: false } as TowerComp,
render: { container, hpBar: null, label: null } as RenderComp,
})
entity.tags.add('tower'); entity.tags.add('unit')
}
+99
View File
@@ -0,0 +1,99 @@
export const TILE_SIZE = 64 // keep for legacy
export const ISO_HALF_W = 32
export const ISO_HALF_H = 16
export type TileType = 'buildable' | 'path' | 'blocked' | 'ley_line' | 'nexus' | 'spawn'
export interface Tile {
x: number
y: number
type: TileType
towerEntityId?: number
}
export class GridMap {
readonly cols: number
readonly rows: number
readonly isoOriginX: number
private tiles: Tile[]
constructor(cols: number, rows: number) {
this.cols = cols
this.rows = rows
this.isoOriginX = rows * ISO_HALF_W
this.tiles = Array.from({ length: cols * rows }, (_, i) => ({
x: i % cols,
y: Math.floor(i / cols),
type: 'buildable',
}))
}
get(x: number, y: number): Tile | null {
if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return null
return this.tiles[y * this.cols + x]
}
set(x: number, y: number, type: TileType): void {
const t = this.get(x, y)
if (t) t.type = type
}
isWalkable(x: number, y: number): boolean {
const t = this.get(x, y)
return t !== null && (t.type === 'path' || t.type === 'spawn' || t.type === 'nexus')
}
isBuildable(x: number, y: number): boolean {
const t = this.get(x, y)
return t !== null && (t.type === 'buildable' || t.type === 'ley_line') && !t.towerEntityId
}
/** ISO screen coords → tile coords */
screenToTile(sx: number, sy: number): { x: number; y: number } {
const lx = sx - this.isoOriginX
const ly = sy
return {
x: Math.floor((lx / ISO_HALF_W + ly / ISO_HALF_H) / 2),
y: Math.floor((ly / ISO_HALF_H - lx / ISO_HALF_W) / 2),
}
}
/** Tile coords → ISO top-left (top vertex of diamond) */
tileToScreen(tx: number, ty: number): { x: number; y: number } {
return {
x: (tx - ty) * ISO_HALF_W + this.isoOriginX,
y: (tx + ty) * ISO_HALF_H - ISO_HALF_H,
}
}
/** Center of tile in ISO screen coords */
tileCenter(tx: number, ty: number): { x: number; y: number } {
return {
x: (tx - ty) * ISO_HALF_W + this.isoOriginX,
y: (tx + ty) * ISO_HALF_H,
}
}
isoMapWidth(): number { return (this.cols + this.rows) * ISO_HALF_W }
isoMapHeight(): number { return (this.cols + this.rows) * ISO_HALF_H + ISO_HALF_H }
loadFromLayout(layout: string[]): void {
for (let row = 0; row < layout.length && row < this.rows; row++) {
for (let col = 0; col < layout[row].length && col < this.cols; col++) {
const ch = layout[row][col]
const type: TileType =
ch === 'P' ? 'path'
: ch === 'S' ? 'spawn'
: ch === 'N' ? 'nexus'
: ch === 'L' ? 'ley_line'
: ch === 'X' ? 'blocked'
: 'buildable'
this.set(col, row, type)
}
}
}
allTiles(): Tile[] {
return this.tiles
}
}
+29
View File
@@ -0,0 +1,29 @@
import { GridMap } from './GridMap'
export interface LeyLineSegment {
x1: number; y1: number
x2: number; y2: number
}
/** Returns pairs of connected ley-line tiles for visual rendering */
export function getLeyLineSegments(map: GridMap): LeyLineSegment[] {
const segments: LeyLineSegment[] = []
const dirs = [[1, 0], [0, 1]]
for (const tile of map.allTiles()) {
if (tile.type !== 'ley_line') continue
for (const [dx, dy] of dirs) {
const neighbor = map.get(tile.x + dx, tile.y + dy)
if (neighbor?.type === 'ley_line') {
segments.push({ x1: tile.x, y1: tile.y, x2: neighbor.x, y2: neighbor.y })
}
}
}
return segments
}
/** Check if a tile is adjacent to or on a ley line (for tower bonuses) */
export function isNearLeyLine(map: GridMap, tx: number, ty: number): boolean {
const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1]]
return dirs.some(([dx, dy]) => map.get(tx + dx, ty + dy)?.type === 'ley_line')
}
+49
View File
@@ -0,0 +1,49 @@
import type { PathRequest, PathResult } from '@/workers/pathfinding.worker'
import { GridMap } from './GridMap'
type PendingCallback = (path: { x: number; y: number }[] | null) => void
let worker: Worker | null = null
let reqId = 0
const pending = new Map<number, PendingCallback>()
function getWorker(): Worker {
if (!worker) {
worker = new Worker(
new URL('@/workers/pathfinding.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e: MessageEvent<PathResult>) => {
const cb = pending.get(e.data.id)
if (cb) {
pending.delete(e.data.id)
cb(e.data.path)
}
}
}
return worker
}
export function findPath(
map: GridMap,
start: { x: number; y: number },
goal: { x: number; y: number }
): Promise<{ x: number; y: number }[] | null> {
return new Promise((resolve) => {
const id = ++reqId
pending.set(id, resolve)
const walkable: boolean[][] = Array.from({ length: map.rows }, (_, row) =>
Array.from({ length: map.cols }, (_, col) => map.isWalkable(col, row))
)
const req: PathRequest = { id, grid: walkable, cols: map.cols, rows: map.rows, start, goal }
getWorker().postMessage(req)
})
}
export function terminatePathWorker(): void {
worker?.terminate()
worker = null
pending.clear()
}
+181
View File
@@ -0,0 +1,181 @@
import { Container, Graphics, Text, TextStyle } from 'pixi.js'
import { GridMap, ISO_HALF_W, ISO_HALF_H, type TileType } from '@/game/map/GridMap'
import { getLeyLineSegments } from '@/game/map/LeyLines'
const TILE_TOP: Record<TileType, number> = {
buildable: 0x2d5a1e,
path: 0x6b4226,
blocked: 0x1a1a28,
ley_line: 0x1a3a5c,
nexus: 0x2a1060,
spawn: 0x601010,
}
const TILE_SIDE_L: Record<TileType, number> = {
buildable: 0x1e3e14,
path: 0x4a2e18,
blocked: 0x111120,
ley_line: 0x102840,
nexus: 0x1a0840,
spawn: 0x400808,
}
const TILE_SIDE_R: Record<TileType, number> = {
buildable: 0x163010,
path: 0x3a2010,
blocked: 0x0c0c18,
ley_line: 0x0a1e30,
nexus: 0x140630,
spawn: 0x300606,
}
const TILE_EDGE: Record<TileType, number> = {
buildable: 0x3a7a28,
path: 0x8a5a30,
blocked: 0x2a2a38,
ley_line: 0x2a5a8a,
nexus: 0x3020a0,
spawn: 0x801818,
}
const SIDE_DEPTH = 8 // px depth of 3D side faces
export class MapRenderer {
readonly container = new Container()
private leyGfx = new Graphics()
private debugContainer = new Container()
private map: GridMap | null = null
private _debugPath: { x: number; y: number }[] = []
constructor() {
this.container.addChild(this.leyGfx)
this.container.addChild(this.debugContainer)
}
load(map: GridMap): void {
this.map = map
this._drawTiles()
this._drawLeyLines()
}
showPath(path: { x: number; y: number }[]): void {
this._debugPath = path
this._drawDebug()
}
private _drawTiles(): void {
if (!this.map) return
const hw = ISO_HALF_W, hh = ISO_HALF_H, sd = SIDE_DEPTH
const sideLGfx = new Graphics()
const sideRGfx = new Graphics()
const topGfx = new Graphics()
const decoGfx = new Graphics()
// Draw in back-to-front order: sort tiles by (tx + ty) ascending
const sorted = [...this.map.allTiles()].sort((a, b) => (a.x + a.y) - (b.x + b.y))
for (const tile of sorted) {
const { x: cx, y: cy } = this.map.tileCenter(tile.x, tile.y)
// Left side face (south-west)
sideLGfx.poly([cx - hw, cy, cx, cy + hh, cx, cy + hh + sd, cx - hw, cy + sd])
sideLGfx.fill({ color: TILE_SIDE_L[tile.type] })
// Right side face (south-east)
sideRGfx.poly([cx, cy + hh, cx + hw, cy, cx + hw, cy + sd, cx, cy + hh + sd])
sideRGfx.fill({ color: TILE_SIDE_R[tile.type] })
// Top diamond face
topGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
topGfx.fill({ color: TILE_TOP[tile.type] })
topGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
topGfx.stroke({ color: TILE_EDGE[tile.type], width: 1, alpha: 0.5 })
// Decorations
if (tile.type === 'spawn') {
decoGfx.circle(cx, cy, 10)
decoGfx.fill({ color: 0xff4444, alpha: 0.7 })
decoGfx.circle(cx, cy, 10)
decoGfx.stroke({ color: 0xff8888, width: 1.5 })
}
if (tile.type === 'nexus') {
decoGfx.circle(cx, cy, 14)
decoGfx.fill({ color: 0x8844ff, alpha: 0.8 })
decoGfx.circle(cx, cy, 14)
decoGfx.stroke({ color: 0xbb88ff, width: 2 })
// Nexus gem
decoGfx.poly([cx, cy - 10, cx + 8, cy, cx, cy + 10, cx - 8, cy])
decoGfx.fill({ color: 0xcc88ff, alpha: 0.9 })
}
}
this.container.addChildAt(sideLGfx, 0)
this.container.addChildAt(sideRGfx, 1)
this.container.addChildAt(topGfx, 2)
this.container.addChildAt(decoGfx, 3)
}
private _drawLeyLines(): void {
if (!this.map) return
this.leyGfx.clear()
const segments = getLeyLineSegments(this.map)
for (const { x1, y1, x2, y2 } of segments) {
const { x: sx1, y: sy1 } = this.map.tileCenter(x1, y1)
const { x: sx2, y: sy2 } = this.map.tileCenter(x2, y2)
this.leyGfx.moveTo(sx1, sy1)
this.leyGfx.lineTo(sx2, sy2)
this.leyGfx.stroke({ color: 0x6ecbd5, width: 8, alpha: 0.12 })
this.leyGfx.moveTo(sx1, sy1)
this.leyGfx.lineTo(sx2, sy2)
this.leyGfx.stroke({ color: 0x6ecbd5, width: 3, alpha: 0.6 })
}
for (const tile of this.map.allTiles()) {
if (tile.type !== 'ley_line') continue
const { x: cx, y: cy } = this.map.tileCenter(tile.x, tile.y)
this.leyGfx.circle(cx, cy, 5)
this.leyGfx.fill({ color: 0x6ecbd5, alpha: 0.9 })
}
}
private _drawDebug(): void {
this.debugContainer.removeChildren()
if (this._debugPath.length === 0 || !this.map) return
const gfx = new Graphics()
const pts = this._debugPath
for (let i = 1; i < pts.length; i++) {
const { x: x1, y: y1 } = this.map.tileCenter(pts[i - 1].x, pts[i - 1].y)
const { x: x2, y: y2 } = this.map.tileCenter(pts[i].x, pts[i].y)
gfx.moveTo(x1, y1)
gfx.lineTo(x2, y2)
gfx.stroke({ color: 0xffdd44, width: 2, alpha: 0.7 })
}
for (let i = 0; i < pts.length; i++) {
const { x, y } = this.map.tileCenter(pts[i].x, pts[i].y)
const isEnd = i === 0 || i === pts.length - 1
gfx.circle(x, y, isEnd ? 8 : 4)
gfx.fill({
color: i === 0 ? 0xff4444 : i === pts.length - 1 ? 0x44ff88 : 0xffdd44,
alpha: 0.9,
})
}
for (let i = 0; i < pts.length; i += 4) {
const { x, y } = this.map.tileCenter(pts[i].x, pts[i].y)
const label = new Text({
text: String(i),
style: new TextStyle({ fill: 0xffffff, fontSize: 9, fontFamily: 'monospace' }),
})
label.x = x + 6
label.y = y - 6
this.debugContainer.addChild(label)
}
this.debugContainer.addChild(gfx)
}
destroy(): void {
this.container.destroy({ children: true })
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Application, Container } from 'pixi.js'
export interface GameLayers {
bg: Container
terrain: Container
fxBelow: Container
units: Container
fxAbove: Container
ui: Container
}
let app: Application | null = null
let _canvas: HTMLCanvasElement | null = null
export let layers: GameLayers | null = null
/** Pass the wrapper DIV — PixiJS creates a fresh canvas each time to avoid WebGL context reuse bugs */
export async function initPixi(wrapper: HTMLElement): Promise<Application> {
// fresh canvas every init so WebGL context is never reused after destroy
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;'
wrapper.appendChild(canvas)
_canvas = canvas
const _app = new Application()
app = _app
await _app.init({
canvas,
resizeTo: wrapper,
background: '#0E1220',
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
preference: 'webgl',
})
if (app !== _app) {
canvas.remove()
try { _app.destroy(false, { children: true }) } catch { /* partial init */ }
throw new Error('PixiJS init cancelled')
}
layers = {
bg: new Container(),
terrain: new Container(),
fxBelow: new Container(),
units: new Container(),
fxAbove: new Container(),
ui: new Container(),
}
for (const layer of Object.values(layers)) {
_app.stage.addChild(layer)
}
return _app
}
export function destroyPixi(): void {
if (!app) return
const _app = app
app = null
layers = null
_canvas?.remove()
_canvas = null
try { _app.destroy(false, { children: true }) } catch { /* ignore partial init */ }
}
export function getApp(): Application {
if (!app) throw new Error('PixiJS not initialized')
return app
}
export function getSize(): { width: number; height: number } {
return app
? { width: app.renderer.width, height: app.renderer.height }
: { width: 0, height: 0 }
}
export function getCanvas(): HTMLCanvasElement | null {
return _canvas
}
+12
View File
@@ -0,0 +1,12 @@
import { Container } from 'pixi.js'
let _world: Container | null = null
export function setWorldContainer(c: Container | null): void {
_world = c
}
export function getWorldContainer(): Container {
if (!_world) throw new Error('World container not set')
return _world
}
+115
View File
@@ -0,0 +1,115 @@
import { Container } from 'pixi.js'
import { clamp } from '@/lib/math'
export interface CameraBounds {
minX: number; maxX: number
minY: number; maxY: number
minZoom: number; maxZoom: number
}
export class Camera {
x = 0
y = 0
zoom = 1
private root: Container
private bounds: CameraBounds
private isDragging = false
private lastPointer = { x: 0, y: 0 }
private _shakeIntensity = 0
private _shakeDuration = 0
private _shakeTime = 0
private _shakeX = 0
private _shakeY = 0
constructor(root: Container, bounds: CameraBounds) {
this.root = root
this.bounds = bounds
}
shake(intensity: number, duration: number): void {
this._shakeIntensity = intensity
this._shakeDuration = duration
this._shakeTime = duration
}
update(dt: number): void {
if (this._shakeTime <= 0) {
if (this._shakeX !== 0 || this._shakeY !== 0) {
this._shakeX = 0
this._shakeY = 0
this._apply()
}
return
}
this._shakeTime = Math.max(0, this._shakeTime - dt)
const progress = this._shakeTime / this._shakeDuration
const amp = this._shakeIntensity * progress
this._shakeX = (Math.random() * 2 - 1) * amp
this._shakeY = (Math.random() * 2 - 1) * amp
this._apply()
}
pan(dx: number, dy: number): void {
this.x = clamp(this.x + dx, this.bounds.minX, this.bounds.maxX)
this.y = clamp(this.y + dy, this.bounds.minY, this.bounds.maxY)
this._apply()
}
zoomAt(factor: number, screenX: number, screenY: number): void {
const oldZoom = this.zoom
this.zoom = clamp(this.zoom * factor, this.bounds.minZoom, this.bounds.maxZoom)
const scale = this.zoom / oldZoom
this.x = screenX - (screenX - this.x) * scale
this.y = screenY - (screenY - this.y) * scale
this._apply()
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - this.x) / this.zoom,
y: (sy - this.y) / this.zoom,
}
}
attachPointerEvents(el: HTMLElement): () => void {
const onDown = (e: PointerEvent) => {
if (e.button === 1 || e.button === 2) {
this.isDragging = true
this.lastPointer = { x: e.clientX, y: e.clientY }
el.setPointerCapture(e.pointerId)
}
}
const onMove = (e: PointerEvent) => {
if (!this.isDragging) return
const dx = e.clientX - this.lastPointer.x
const dy = e.clientY - this.lastPointer.y
this.lastPointer = { x: e.clientX, y: e.clientY }
this.pan(dx, dy)
}
const onUp = () => { this.isDragging = false }
const onWheel = (e: WheelEvent) => {
e.preventDefault()
const factor = e.deltaY < 0 ? 1.1 : 0.9
const rect = el.getBoundingClientRect()
this.zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top)
}
el.addEventListener('pointerdown', onDown)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('wheel', onWheel, { passive: false })
return () => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('wheel', onWheel)
}
}
private _apply(): void {
this.root.x = this.x + this._shakeX
this.root.y = this.y + this._shakeY
this.root.scale.set(this.zoom)
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, TargetingComp, AttackComp, RenderComp } from '@/game/components'
import { createArrow } from '@/game/entities/projectiles/arrow'
import { createFireball } from '@/game/entities/projectiles/fireball'
import { createIcicle } from '@/game/entities/projectiles/icicle'
import { createLightning } from '@/game/entities/projectiles/lightning'
export function attackSystem(entities: EntityManager, dt: number): void {
for (const tower of entities.withTag('tower')) {
const t = tower.transform as TransformComp
const tgt = tower.targeting as TargetingComp
const atk = tower.attack as AttackComp
const ren = tower.render as RenderComp
if (!t || !tgt || !atk) continue
atk.timer = Math.max(0, atk.timer - dt)
if (tgt.targetId === null || atk.timer > 0) continue
const target = entities.get(tgt.targetId)
if (!target || target.tags.has('dead')) { tgt.targetId = null; continue }
const te = target.transform as TransformComp
if (!te) continue
const proj = entities.create(['projectile'])
const args: [typeof proj, number, number, number, number, number] = [proj, t.x, t.y, target.id, atk.damage, tower.id]
switch (atk.projectileType) {
case 'fireball': createFireball(...args); break
case 'icicle': createIcicle(...args); break
case 'lightning': createLightning(...args); break
default: createArrow(...args); break
}
atk.timer = atk.cooldown
}
}
+76
View File
@@ -0,0 +1,76 @@
import { Graphics } from 'pixi.js'
import type { EntityManager } from '@/game/core/EntityManager'
import type { RenderComp, TowerComp, TransformComp } from '@/game/components'
import type { GridMap } from '@/game/map/GridMap'
import { getWorldContainer } from '@/game/rendering/WorldContext'
function spawnDeathParticles(x: number, y: number, color: number): void {
let world = null
try { world = getWorldContainer() } catch { return }
if (!world) return
const gfx = new Graphics()
const count = 6
const angles: number[] = []
const speeds: number[] = []
for (let i = 0; i < count; i++) {
angles.push((i / count) * Math.PI * 2 + Math.random() * 0.5)
speeds.push(40 + Math.random() * 40)
}
world.addChild(gfx)
let life = 0.35
const tick = () => {
life -= 1 / 60
if (life <= 0) { gfx.destroy(); return }
const t = 1 - life / 0.35
gfx.clear()
for (let i = 0; i < count; i++) {
const dist = speeds[i] * t
const px = x + Math.cos(angles[i]) * dist
const py = y + Math.sin(angles[i]) * dist
const r = 3 * (1 - t)
gfx.circle(px, py, Math.max(0.5, r))
gfx.fill({ color, alpha: life / 0.35 })
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
const ENEMY_COLORS: Record<string, number> = {
goblin: 0x44aa44,
orc: 0x228822,
warg: 0x886644,
wraith: 0x9966ff,
troll: 0x557755,
golem: 0x8899aa,
}
export function deathSystem(entities: EntityManager, map: GridMap): void {
for (const entity of entities.withTag('dead')) {
const r = entity.render as RenderComp
const t = entity.transform as TransformComp | undefined
if (r?.container && t && entity.tags.has('enemy')) {
let color = 0xffffff
for (const [tag, c] of Object.entries(ENEMY_COLORS)) {
if (entity.tags.has(tag)) { color = c; break }
}
spawnDeathParticles(t.x, t.y, color)
}
if (r?.container) {
r.container.destroy({ children: true })
}
// free tower tile
const tc = entity.towerComp as TowerComp
if (tc) {
const tile = map.get(tc.tileX, tc.tileY)
if (tile) tile.towerEntityId = undefined
}
entities.destroy(entity.id)
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, RenderComp } from '@/game/components'
import type { HeroComp } from '@/game/components/Hero'
const XP_PER_LEVEL = [0, 120, 280, 500, 800]
const STOP_DIST = 4
export function heroSystem(entities: EntityManager, dt: number): void {
for (const entity of entities.withTag('hero')) {
const t = entity.transform as TransformComp
const h = entity.hero as HeroComp
const r = entity.render as RenderComp
if (!t || !h) continue
// Tick spell cooldowns
for (const spell of h.spells) {
if (spell.timer > 0) spell.timer = Math.max(0, spell.timer - dt)
}
// Move toward target
if (h.targetX !== null && h.targetY !== null) {
const dx = h.targetX - t.x
const dy = h.targetY - t.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist <= STOP_DIST) {
t.x = h.targetX; t.y = h.targetY
h.targetX = null; h.targetY = null
} else {
const step = Math.min(h.speed * dt, dist)
t.x += (dx / dist) * step
t.y += (dy / dist) * step
}
}
// Bob animation on container
h.bobTime += dt
if (r?.container) {
r.container.x = t.x
r.container.y = t.y + Math.sin(h.bobTime * 2.5) * 2
}
// XP level-up check
const threshold = XP_PER_LEVEL[Math.min(h.level, XP_PER_LEVEL.length - 1)]
if (h.xp >= threshold && h.level < XP_PER_LEVEL.length) {
h.level++
}
}
}
export function heroAddXp(entities: EntityManager, amount: number): void {
for (const entity of entities.withTag('hero')) {
const h = entity.hero as HeroComp
if (h) h.xp += amount
}
}
export function getHeroComp(entities: EntityManager): HeroComp | null {
const heroes = entities.withTag('hero')
if (heroes.length === 0) return null
return heroes[0].hero as HeroComp
}
+169
View File
@@ -0,0 +1,169 @@
import { Graphics } from 'pixi.js'
import type { EntityManager } from '@/game/core/EntityManager'
import type { HealthComp, MovementComp, RenderComp, TransformComp } from '@/game/components'
import type { LichKingComp } from '@/game/entities/enemies/lich_king'
import { createWraith } from '@/game/entities/enemies/wraith'
import { createNecromancer } from '@/game/entities/enemies/necromancer'
import { eventBus } from '@/game/core/EventBus'
import { getWorldContainer } from '@/game/rendering/WorldContext'
const PHASE2_HP = 0.6 // 60% → phase 2
const PHASE3_HP = 0.3 // 30% → phase 3
export function lichKingSystem(
entities: EntityManager,
path: { x: number; y: number }[],
dt: number,
): void {
for (const entity of entities.withTag('lich_king')) {
if (entity.tags.has('dead')) continue
const hp = entity.health as HealthComp
const lk = entity.lichKingComp as LichKingComp
const mv = entity.movement as MovementComp
const r = entity.render as RenderComp
if (!hp || !lk || !mv) continue
const pct = hp.current / hp.max
// Phase transitions
if (lk.phase === 1 && pct <= PHASE2_HP) {
_enterPhase2(entity, lk, mv, r)
} else if (lk.phase === 2 && pct <= PHASE3_HP) {
_enterPhase3(entity, lk, mv, r)
}
lk.summonTimer -= dt
if (lk.phase === 1) {
// Phase 1: summon 2 wraiths
if (lk.summonTimer <= 0) {
lk.summonTimer = lk.summonCooldown
_summonWraiths(entities, path, 2)
}
} else if (lk.phase === 2) {
// Phase 2: summon 1 necromancer + 1 wraith
if (lk.summonTimer <= 0) {
lk.summonTimer = lk.summonCooldown
_summonNecromancer(entities, path)
_summonWraiths(entities, path, 1)
}
} else {
// Phase 3: nexus pulse damage
lk.pulseTimer -= dt
if (lk.pulseTimer <= 0) {
lk.pulseTimer = lk.pulseCooldown
eventBus.emit('lich:nexus_damage', { amount: 1 })
_spawnPulse(entity)
}
// Still summons in phase 3
if (lk.summonTimer <= 0) {
lk.summonTimer = lk.summonCooldown
_summonWraiths(entities, path, 2)
}
}
}
}
function _enterPhase2(
entity: ReturnType<EntityManager['create']>,
lk: LichKingComp,
mv: MovementComp,
r: RenderComp,
): void {
lk.phase = 2
lk.summonCooldown = 5
lk.summonTimer = 1
mv.speed = 42
if (r?.container) {
r.container.tint = 0xdd88ff
setTimeout(() => { if (r.container && !r.container.destroyed) r.container.tint = 0xffffff }, 300)
}
eventBus.emit('lich:phase_change', { entity, phase: 2 })
_spawnPhaseShockwave(entity)
}
function _enterPhase3(
entity: ReturnType<EntityManager['create']>,
lk: LichKingComp,
mv: MovementComp,
r: RenderComp,
): void {
lk.phase = 3
lk.summonCooldown = 4
lk.summonTimer = 1
lk.pulseTimer = 2
mv.speed = 55
if (r?.container) {
r.container.tint = 0xff4488
setTimeout(() => { if (r.container && !r.container.destroyed) r.container.tint = 0xffffff }, 500)
}
eventBus.emit('lich:phase_change', { entity, phase: 3 })
_spawnPhaseShockwave(entity)
eventBus.emit('camera:shake', { intensity: 10, duration: 0.6 })
}
function _summonWraiths(entities: EntityManager, path: { x: number; y: number }[], count: number): void {
for (let i = 0; i < count; i++) {
const e = entities.create()
const sp = path[0]
const jitter = (Math.random() - 0.5) * 12
createWraith(e, path, sp.x + jitter, sp.y + jitter)
}
}
function _summonNecromancer(entities: EntityManager, path: { x: number; y: number }[]): void {
const e = entities.create()
const sp = path[0]
createNecromancer(e, path, sp.x, sp.y)
}
function _spawnPhaseShockwave(entity: ReturnType<EntityManager['create']>): void {
const t = entity.transform as TransformComp
if (!t) return
const gfx = new Graphics()
gfx.x = t.x
gfx.y = t.y
gfx.zIndex = 200
getWorldContainer().addChild(gfx)
let radius = 0
let alpha = 0.9
const expand = () => {
if (gfx.destroyed) return
radius += 8
alpha -= 0.045
if (alpha <= 0) { gfx.destroy(); return }
gfx.clear()
gfx.circle(0, -30, radius)
gfx.stroke({ color: 0xcc00ff, width: 3, alpha })
requestAnimationFrame(expand)
}
requestAnimationFrame(expand)
}
function _spawnPulse(entity: ReturnType<EntityManager['create']>): void {
const t = entity.transform as TransformComp
if (!t) return
const gfx = new Graphics()
gfx.x = t.x
gfx.y = t.y
gfx.zIndex = 200
getWorldContainer().addChild(gfx)
let radius = 0
let alpha = 0.8
const expand = () => {
if (gfx.destroyed) return
radius += 14
alpha -= 0.06
if (alpha <= 0) { gfx.destroy(); return }
gfx.clear()
gfx.circle(0, -30, radius)
gfx.fill({ color: 0x440000, alpha: alpha * 0.3 })
gfx.circle(0, -30, radius)
gfx.stroke({ color: 0xff0044, width: 2.5, alpha })
requestAnimationFrame(expand)
}
requestAnimationFrame(expand)
}
+49
View File
@@ -0,0 +1,49 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, MovementComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { getSlowMultiplier } from '@/game/components/StatusEffect'
import { eventBus } from '@/game/core/EventBus'
export function movementSystem(entities: EntityManager, dt: number): void {
for (const entity of entities.withTag('enemy')) {
if (entity.tags.has('dead')) continue
const t = entity.transform as TransformComp
const m = entity.movement as MovementComp
if (!t || !m || m.path.length === 0) continue
const se = entity.statusEffects as StatusEffectsComp | undefined
const slowMult = se ? getSlowMultiplier(se) : 1
let remaining = m.speed * slowMult * dt
while (remaining > 0 && m.pathIndex < m.path.length - 1) {
const target = m.path[m.pathIndex + 1]
const dx = target.x - t.x
const dy = target.y - t.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist === 0) { m.pathIndex++; continue }
if (remaining >= dist) {
t.x = target.x
t.y = target.y
m.pathIndex++
remaining -= dist
m.distanceTravelled += dist
} else {
const nx = dx / dist
const ny = dy / dist
t.x += nx * remaining
t.y += ny * remaining
t.rotation = Math.atan2(dy, dx)
m.distanceTravelled += remaining
remaining = 0
}
}
if (m.pathIndex >= m.path.length - 1) {
eventBus.emit('enemy:reached_end', { id: entity.id, damage: 1 })
entity.tags.add('dead')
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Graphics } from 'pixi.js'
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, HealthComp, RenderComp } from '@/game/components'
import type { NecroComp } from '@/game/entities/enemies/necromancer'
import { refreshHp } from '@/game/entities/enemies/updateHpBar'
import { getWorldContainer } from '@/game/rendering/WorldContext'
export function necromancerSystem(entities: EntityManager, dt: number): void {
for (const entity of entities.withTag('necromancer')) {
if (entity.tags.has('dead')) continue
const comp = entity.necroComp as NecroComp
if (!comp) continue
comp.healTimer -= dt
if (comp.healTimer > 0) continue
comp.healTimer = comp.healCooldown
const t = entity.transform as TransformComp
if (!t) continue
let healed = false
for (const other of entities.withTag('enemy')) {
if (other.id === entity.id || other.tags.has('dead')) continue
const ot = other.transform as TransformComp
if (!ot) continue
const dx = ot.x - t.x
const dy = ot.y - t.y
if (dx * dx + dy * dy > comp.healRadius * comp.healRadius) continue
const hp = other.health as HealthComp
if (!hp || hp.current >= hp.max) continue
hp.current = Math.min(hp.max, hp.current + comp.healAmount)
refreshHp(other)
healed = true
}
if (healed) _spawnHealPulse(t.x, t.y, comp.healRadius)
}
}
function _spawnHealPulse(x: number, y: number, r: number): void {
let world = null
try { world = getWorldContainer() } catch { return }
if (!world) return
const gfx = new Graphics()
gfx.circle(x, y, r)
gfx.stroke({ color: 0x9900cc, width: 3, alpha: 0.8 })
gfx.circle(x, y, r * 0.5)
gfx.fill({ color: 0x660099, alpha: 0.2 })
world.addChild(gfx)
let life = 0.5
const tick = () => {
life -= 1 / 60
gfx.alpha = Math.max(0, life / 0.5)
gfx.scale.set(1 + (0.5 - life) * 0.3)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
+225
View File
@@ -0,0 +1,225 @@
import { Graphics, Text, TextStyle } from 'pixi.js'
import type { EntityManager, Entity } from '@/game/core/EntityManager'
import type { TransformComp, ProjectileComp, HealthComp, RenderComp, LootComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { applyStatus } from '@/game/components/StatusEffect'
import { resolveDamage } from '@/game/combat/DamageResolver'
import { eventBus } from '@/game/core/EventBus'
import { getWorldContainer } from '@/game/rendering/WorldContext'
import { refreshHp } from '@/game/entities/enemies/updateHpBar'
import { PYRO_AOE_RADIUS } from '@/game/entities/towers/pyromancer'
const CHAIN_RANGE = 110
const CHAIN_DMG_MULT = 0.6
export function projectileSystem(entities: EntityManager, dt: number): void {
for (const proj of entities.withTag('projectile')) {
const t = proj.transform as TransformComp
const p = proj.projectile as ProjectileComp
const r = proj.render as RenderComp
if (!t || !p) continue
const target = entities.get(p.targetId)
if (!target || target.tags.has('dead')) {
r?.container?.destroy()
entities.destroy(proj.id)
continue
}
const te = target.transform as TransformComp
if (!te) continue
const dx = te.x - t.x
const dy = te.y - t.y
const d2 = dx * dx + dy * dy
const step = p.speed * dt
if (d2 <= (step + p.hitRadius) * (step + p.hitRadius)) {
const health = target.health as HealthComp
if (health) {
const dmg = resolveDamage(p.damage, p.damageType, health)
health.current -= dmg
refreshHp(target)
spawnDamageNumber(te.x, te.y, dmg)
if (dmg > 0) flashHit(target)
if (health.current <= 0) killEnemy(target)
}
applyProjectileEffect(entities, p, target, te)
r?.container?.destroy()
entities.destroy(proj.id)
} else {
const d = Math.sqrt(d2)
t.x += (dx / d) * step
t.y += (dy / d) * step
t.rotation = Math.atan2(dy, dx)
if (r?.container) {
r.container.x = t.x
r.container.y = t.y
r.container.rotation = t.rotation
}
}
}
}
function applyProjectileEffect(
entities: EntityManager,
p: ProjectileComp,
primary: ReturnType<EntityManager['get']>,
primaryPos: TransformComp,
): void {
if (!primary) return
if (p.projectileType === 'fireball') {
// AoE splash
const se = primary.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'burn', duration: 3, strength: 4 })
for (const enemy of entities.withTag('enemy')) {
if (enemy.id === primary.id || enemy.tags.has('dead')) continue
const et = enemy.transform as TransformComp
if (!et) continue
const dx = et.x - primaryPos.x
const dy = et.y - primaryPos.y
if (dx * dx + dy * dy > PYRO_AOE_RADIUS * PYRO_AOE_RADIUS) continue
const h = enemy.health as HealthComp
if (h) {
const aoe = resolveDamage(Math.floor(p.damage * 0.6), p.damageType, h)
h.current -= aoe
refreshHp(enemy)
spawnDamageNumber(et.x, et.y, aoe)
if (h.current <= 0) killEnemy(enemy)
}
const ese = enemy.statusEffects as StatusEffectsComp | undefined
if (ese) applyStatus(ese, { type: 'burn', duration: 3, strength: 4 })
}
flashCircle(primaryPos.x, primaryPos.y, PYRO_AOE_RADIUS)
} else if (p.projectileType === 'icicle') {
const se = primary.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'slow', duration: 2.5, strength: 0.5 })
} else if (p.projectileType === 'lightning') {
// chain to 2 nearest enemies
const candidates = entities.withTag('enemy')
.filter((e) => !e.tags.has('dead') && e.id !== primary.id)
candidates.sort((a, b) => {
const at = a.transform as TransformComp
const bt = b.transform as TransformComp
const da = (at.x - primaryPos.x) ** 2 + (at.y - primaryPos.y) ** 2
const db = (bt.x - primaryPos.x) ** 2 + (bt.y - primaryPos.y) ** 2
return da - db
})
for (const ch of candidates.slice(0, 2)) {
const ct = ch.transform as TransformComp
if (!ct) continue
const dd = (ct.x - primaryPos.x) ** 2 + (ct.y - primaryPos.y) ** 2
if (dd > CHAIN_RANGE * CHAIN_RANGE) continue
const ch2 = ch.health as HealthComp
if (ch2) {
const chDmg = resolveDamage(Math.floor(p.damage * CHAIN_DMG_MULT), p.damageType, ch2)
ch2.current -= chDmg
refreshHp(ch)
spawnDamageNumber(ct.x, ct.y, chDmg)
if (ch2.current <= 0) killEnemy(ch)
}
flashArc(primaryPos.x, primaryPos.y, ct.x, ct.y)
}
}
}
function killEnemy(entity: NonNullable<ReturnType<EntityManager['get']>>): void {
entity.tags.add('dead')
const loot = entity.loot as LootComp | undefined
eventBus.emit('enemy:died', {
id: entity.id,
gold: loot?.gold ?? 0,
essence: loot?.essence ?? 0,
})
}
function spawnDamageNumber(x: number, y: number, dmg: number): void {
let world: ReturnType<typeof getWorldContainer> | null = null
try { world = getWorldContainer() } catch { return }
const text = new Text({
text: String(dmg),
style: new TextStyle({ fill: 0xffdd44, fontSize: 14, fontFamily: 'monospace', fontWeight: 'bold' }),
})
text.x = x + (Math.random() - 0.5) * 20
text.y = y - 20
text.anchor.set(0.5)
world.addChild(text)
let vy = -60; let life = 0.8
const tick = () => {
life -= 1 / 60; vy += 120 / 60; text.y += vy / 60
text.alpha = Math.max(0, life / 0.8)
if (life <= 0) text.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
function flashCircle(x: number, y: number, r: number): void {
let world: ReturnType<typeof getWorldContainer> | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
gfx.circle(x, y, r)
gfx.fill({ color: 0xff6600, alpha: 0.35 })
gfx.circle(x, y, r)
gfx.stroke({ color: 0xffaa00, width: 2, alpha: 0.7 })
world.addChild(gfx)
let life = 0.3
const tick = () => {
life -= 1 / 60
gfx.alpha = Math.max(0, life / 0.3)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
function flashArc(x1: number, y1: number, x2: number, y2: number): void {
let world: ReturnType<typeof getWorldContainer> | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
// zigzag lightning arc
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * 20
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * 20
gfx.moveTo(x1, y1)
gfx.lineTo(mx, my)
gfx.lineTo(x2, y2)
gfx.stroke({ color: 0xffffff, width: 2, alpha: 0.9 })
gfx.moveTo(x1, y1)
gfx.lineTo(mx, my)
gfx.lineTo(x2, y2)
gfx.stroke({ color: 0xaaaaff, width: 4, alpha: 0.4 })
world.addChild(gfx)
let life = 0.2
const tick = () => {
life -= 1 / 60
gfx.alpha = Math.max(0, life / 0.2)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
function flashHit(entity: Entity): void {
const r = entity.render as RenderComp
if (!r?.container || r.container.destroyed) return
const flash = new Graphics()
flash.rect(-24, -24, 48, 48)
flash.fill({ color: 0xffffff, alpha: 0.6 })
r.container.addChild(flash)
let life = 0.1
const tick = () => {
life -= 1 / 60
flash.alpha = Math.max(0, life / 0.1)
if (life <= 0) { if (!flash.destroyed) flash.destroy() }
else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
+27
View File
@@ -0,0 +1,27 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, RenderComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
export function renderSystem(entities: EntityManager): void {
for (const entity of entities.withTag('unit')) {
const t = entity.transform as TransformComp
const r = entity.render as RenderComp
if (!t || !r?.container) continue
r.container.x = t.x
r.container.y = t.y
r.container.zIndex = Math.floor(t.y)
// Status effect tinting (enemies only)
if (entity.tags.has('enemy')) {
const se = entity.statusEffects as StatusEffectsComp | undefined
if (se) {
const hasSlow = se.effects.some((e) => e.type === 'slow' || e.type === 'freeze')
const hasBurn = se.effects.some((e) => e.type === 'burn')
if (hasSlow) r.container.tint = 0x88ccff
else if (hasBurn) r.container.tint = 0xff8844
else r.container.tint = 0xffffff
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { HealthComp } from '@/game/components'
import type { StatusEffectsComp } from '@/game/components/StatusEffect'
import { eventBus } from '@/game/core/EventBus'
import type { LootComp } from '@/game/components'
import { refreshHp } from '@/game/entities/enemies/updateHpBar'
const BURN_TICK = 0.5
export function statusEffectSystem(entities: EntityManager, dt: number): void {
for (const entity of entities.withTag('enemy')) {
if (entity.tags.has('dead')) continue
const hp = entity.health as HealthComp | undefined
const se = entity.statusEffects as StatusEffectsComp | undefined
// HP regen (troll, etc.)
if (hp && hp.regenRate && hp.regenRate > 0) {
hp.current = Math.min(hp.max, hp.current + hp.regenRate * dt)
refreshHp(entity)
}
if (!se) continue
let burnDmg = 0
for (let i = se.effects.length - 1; i >= 0; i--) {
const eff = se.effects[i]
eff.duration -= dt
if (eff.type === 'burn') {
eff.tickTimer -= dt
if (eff.tickTimer <= 0) {
burnDmg += Math.round(eff.strength * BURN_TICK)
eff.tickTimer = BURN_TICK
}
}
if (eff.duration <= 0) se.effects.splice(i, 1)
}
if (hp && burnDmg > 0) {
hp.current -= burnDmg
refreshHp(entity)
if (hp.current <= 0) {
entity.tags.add('dead')
const loot = entity.loot as LootComp | undefined
eventBus.emit('enemy:died', { id: entity.id, gold: loot?.gold ?? 0, essence: loot?.essence ?? 0 })
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { TransformComp, TargetingComp, MovementComp, HealthComp } from '@/game/components'
import { dist2 } from '@/lib/math'
export function targetingSystem(entities: EntityManager): void {
const enemies = entities.withTag('enemy').filter((e) => !e.tags.has('dead'))
for (const tower of entities.withTag('tower')) {
const t = tower.transform as TransformComp
const tgt = tower.targeting as TargetingComp
if (!t || !tgt) continue
// validate current target
if (tgt.targetId !== null) {
const cur = entities.get(tgt.targetId)
if (!cur || cur.tags.has('dead')) {
tgt.targetId = null
} else {
const et = cur.transform as TransformComp
if (dist2(t.x, t.y, et.x, et.y) > tgt.range * tgt.range) {
tgt.targetId = null
}
}
}
if (tgt.targetId !== null) continue
let best = null
let bestVal = Infinity
for (const enemy of enemies) {
const et = enemy.transform as TransformComp
if (!et) continue
const d2 = dist2(t.x, t.y, et.x, et.y)
if (d2 > tgt.range * tgt.range) continue
let val: number
const mov = enemy.movement as MovementComp | undefined
const hp = enemy.health as HealthComp | undefined
if (tgt.mode === 'first') {
val = -(mov?.distanceTravelled ?? 0)
} else if (tgt.mode === 'last') {
val = mov?.distanceTravelled ?? 0
} else if (tgt.mode === 'strongest') {
val = -(hp?.current ?? 0)
} else {
val = d2
}
if (val < bestVal) { bestVal = val; best = enemy }
}
tgt.targetId = best?.id ?? null
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { EntityManager } from '@/game/core/EntityManager'
import type { GridMap } from '@/game/map/GridMap'
import type { HealthComp, MovementComp } from '@/game/components'
import { createGoblin } from '@/game/entities/enemies/goblin'
import { createOrc } from '@/game/entities/enemies/orc'
import { createWarg } from '@/game/entities/enemies/warg'
import { createWraith } from '@/game/entities/enemies/wraith'
import { createTroll } from '@/game/entities/enemies/troll'
import { createGolem } from '@/game/entities/enemies/golem'
import { createNecromancer } from '@/game/entities/enemies/necromancer'
import { createDragon } from '@/game/entities/enemies/dragon'
import { createLichKing } from '@/game/entities/enemies/lich_king'
import { eventBus } from '@/game/core/EventBus'
import { getWaveDef, type EnemyType } from '@/data/waves'
import { useSettingsStore } from '@/state/settingsStore'
import { useGameStore } from '@/state/gameStore'
export interface WaveState {
active: boolean
waveIndex: number
spawnQueue: EnemyType[]
spawnTimer: number
spawnInterval: number
aliveCount: number
countdown: number
}
const BETWEEN_WAVE_TIME = 10
export function createWaveState(): WaveState {
return {
active: false,
waveIndex: 0,
spawnQueue: [],
spawnTimer: 0,
spawnInterval: 1.2,
aliveCount: 0,
countdown: 0,
}
}
export function startWave(state: WaveState, waveIndex: number): void {
if (state.active) return
const levelId = useGameStore.getState().currentLevelId
const def = getWaveDef(waveIndex, levelId)
state.active = true
state.waveIndex = waveIndex
state.spawnQueue = [...def.enemies]
state.spawnTimer = 0
state.spawnInterval = def.interval
state.aliveCount = state.spawnQueue.length
state.countdown = 0
eventBus.emit('wave:start', { waveIndex })
}
export function waveSystem(
state: WaveState,
entities: EntityManager,
_map: GridMap,
path: { x: number; y: number }[],
dt: number,
): void {
if (!state.active) return
const alive = entities.withTag('enemy').filter((e) => !e.tags.has('dead'))
state.aliveCount = alive.length + state.spawnQueue.length
if (state.spawnQueue.length > 0) {
state.spawnTimer -= dt
if (state.spawnTimer <= 0) {
const type = state.spawnQueue.shift()!
const entity = entities.create()
const spawn = path[0]
const sx = spawn.x + (Math.random() - 0.5) * 8
const sy = spawn.y + (Math.random() - 0.5) * 8
spawnEnemy(type, entity, path, sx, sy)
applyDifficulty(entity)
state.spawnTimer = state.spawnInterval
}
}
if (state.spawnQueue.length === 0 && alive.length === 0) {
state.active = false
state.countdown = BETWEEN_WAVE_TIME
eventBus.emit('wave:end', { waveIndex: state.waveIndex })
}
}
function applyDifficulty(entity: ReturnType<EntityManager['create']>): void {
const diff = useSettingsStore.getState().difficulty
if (diff === 'normal') return
const hpMult = diff === 'heroic' ? 1.35 : 1.75
const speedMult = diff === 'heroic' ? 1.1 : 1.25
const hp = entity.health as HealthComp | undefined
if (hp) { hp.current = Math.round(hp.current * hpMult); hp.max = Math.round(hp.max * hpMult) }
const mv = entity.movement as MovementComp | undefined
if (mv) mv.speed = Math.round(mv.speed * speedMult)
}
function spawnEnemy(
type: EnemyType,
entity: ReturnType<EntityManager['create']>,
path: { x: number; y: number }[],
sx: number,
sy: number,
): void {
const flyPath = [path[0], path[path.length - 1]]
switch (type) {
case 'orc': createOrc(entity, path, sx, sy); break
case 'warg': createWarg(entity, path, sx, sy); break
case 'wraith': createWraith(entity, path, sx, sy); break
case 'troll': createTroll(entity, path, sx, sy); break
case 'golem': createGolem(entity, path, sx, sy); break
case 'necromancer': createNecromancer(entity, path, sx, sy); break
case 'dragon': createDragon(entity, flyPath, sx, sy - 30); break
case 'lich_king': createLichKing(entity, path, sx, sy); break
default: createGoblin(entity, path, sx, sy); break
}
}
+16
View File
@@ -0,0 +1,16 @@
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
export const clamp = (v: number, min: number, max: number) =>
Math.min(Math.max(v, min), max)
export const dist = (ax: number, ay: number, bx: number, by: number) =>
Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2)
export const dist2 = (ax: number, ay: number, bx: number, by: number) =>
(bx - ax) ** 2 + (by - ay) ** 2
export const angleTo = (ax: number, ay: number, bx: number, by: number) =>
Math.atan2(by - ay, bx - ax)
export const toRad = (deg: number) => (deg * Math.PI) / 180
export const toDeg = (rad: number) => (rad * 180) / Math.PI
+16
View File
@@ -0,0 +1,16 @@
export function mulberry32(seed: number) {
return function () {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
let _rand = mulberry32(Date.now())
export const rand = () => _rand()
export const randInt = (min: number, max: number) => Math.floor(rand() * (max - min + 1)) + min
export const randOf = <T>(arr: T[]): T => arr[randInt(0, arr.length - 1)]
export const setSeed = (seed: number) => { _rand = mulberry32(seed) }
+53
View File
@@ -0,0 +1,53 @@
const SAVE_KEY = 'arcanum_save'
const SAVE_VERSION = 2
export interface SaveData {
version: number
meta: {
essence: number
unlockedRunes: string[]
completedLevels: string[]
}
settings: {
masterVolume: number
sfxVolume: number
musicVolume: number
difficulty?: string
}
}
const DEFAULT_SAVE: SaveData = {
version: SAVE_VERSION,
meta: {
essence: 0,
unlockedRunes: ['pierce', 'burn'],
completedLevels: [],
},
settings: {
masterVolume: 0.8,
sfxVolume: 0.7,
musicVolume: 0.5,
difficulty: 'normal',
},
}
export function loadSave(): SaveData {
try {
const raw = localStorage.getItem(SAVE_KEY)
if (!raw) return { ...DEFAULT_SAVE }
const parsed = JSON.parse(raw) as SaveData
// Migrate older saves
if (!parsed.version || parsed.version < SAVE_VERSION) return { ...DEFAULT_SAVE }
return parsed
} catch {
return { ...DEFAULT_SAVE }
}
}
export function writeSave(data: SaveData): void {
localStorage.setItem(SAVE_KEY, JSON.stringify({ ...data, version: SAVE_VERSION }))
}
export function clearSave(): void {
localStorage.removeItem(SAVE_KEY)
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/globals.css'
import { App } from '@/App'
import { useMetaStore } from '@/state/metaStore'
import { useSettingsStore } from '@/state/settingsStore'
useMetaStore.getState().load()
useSettingsStore.getState().load()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+85
View File
@@ -0,0 +1,85 @@
import { create } from 'zustand'
export type GameScreen = 'menu' | 'campaign' | 'game' | 'tome' | 'settings' | 'gameover'
interface GameState {
screen: GameScreen
currentLevelId: string
gold: number
mana: number
nexusHp: number
nexusMaxHp: number
wave: number
maxWaves: number
phase: 'build' | 'combat' | 'paused'
won: boolean | null
selectedTowerTile: { x: number; y: number } | null
setScreen: (s: GameScreen) => void
setCurrentLevelId: (id: string) => void
setGold: (g: number) => void
addGold: (g: number) => void
spendGold: (g: number) => boolean
setMana: (m: number) => void
addMana: (m: number) => void
spendMana: (m: number) => boolean
damageNexus: (dmg: number) => void
setWave: (w: number) => void
setPhase: (p: 'build' | 'combat' | 'paused') => void
setWon: (won: boolean) => void
setSelectedTowerTile: (tile: { x: number; y: number } | null) => void
resetGame: () => void
}
export const useGameStore = create<GameState>((set, get) => ({
screen: 'menu',
currentLevelId: 'kings_road',
gold: 150,
mana: 100,
nexusHp: 20,
nexusMaxHp: 20,
wave: 0,
maxWaves: 20,
phase: 'build',
won: null,
selectedTowerTile: null,
setScreen: (screen) => set({ screen }),
setCurrentLevelId: (currentLevelId) => set({ currentLevelId }),
setGold: (gold) => set({ gold }),
addGold: (amount) => set((s) => ({ gold: s.gold + amount })),
spendGold: (amount) => {
const { gold } = get()
if (gold < amount) return false
set({ gold: gold - amount })
return true
},
setMana: (mana) => set({ mana }),
addMana: (amount) => set((s) => ({ mana: Math.min(s.mana + amount, 200) })),
spendMana: (amount) => {
const { mana } = get()
if (mana < amount) return false
set({ mana: mana - amount })
return true
},
damageNexus: (dmg) =>
set((s) => {
const nexusHp = Math.max(0, s.nexusHp - dmg)
return { nexusHp }
}),
setWave: (wave) => set({ wave }),
setPhase: (phase) => set({ phase }),
setWon: (won) => set({ won }),
setSelectedTowerTile: (tile) => set({ selectedTowerTile: tile }),
resetGame: () =>
set({
gold: 150,
mana: 100,
nexusHp: 20,
nexusMaxHp: 20,
wave: 0,
phase: 'build',
won: null,
selectedTowerTile: null,
}),
}))
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand'
import { loadSave, writeSave } from '@/lib/save'
interface MetaState {
essence: number
unlockedRunes: string[]
completedLevels: string[]
addEssence: (amount: number) => void
unlockRune: (id: string) => void
completeLevel: (id: string) => void
load: () => void
}
export const useMetaStore = create<MetaState>((set, get) => ({
essence: 0,
unlockedRunes: ['pierce', 'burn'],
completedLevels: [],
addEssence: (amount) => {
set((s) => ({ essence: s.essence + amount }))
const s = get()
const save = loadSave()
writeSave({ ...save, meta: { ...save.meta, essence: s.essence } })
},
unlockRune: (id) => {
set((s) => ({ unlockedRunes: [...new Set([...s.unlockedRunes, id])] }))
const s = get()
const save = loadSave()
writeSave({ ...save, meta: { ...save.meta, unlockedRunes: s.unlockedRunes } })
},
completeLevel: (id) => {
set((s) => ({ completedLevels: [...new Set([...s.completedLevels, id])] }))
const s = get()
const save = loadSave()
writeSave({ ...save, meta: { ...save.meta, completedLevels: s.completedLevels } })
},
load: () => {
const save = loadSave()
set({
essence: save.meta.essence,
unlockedRunes: save.meta.unlockedRunes,
completedLevels: save.meta.completedLevels,
})
},
}))
+53
View File
@@ -0,0 +1,53 @@
import { create } from 'zustand'
import { loadSave, writeSave } from '@/lib/save'
export type Difficulty = 'normal' | 'heroic' | 'nightmare'
interface SettingsState {
masterVolume: number
sfxVolume: number
musicVolume: number
showFps: boolean
difficulty: Difficulty
setMasterVolume: (v: number) => void
setSfxVolume: (v: number) => void
setMusicVolume: (v: number) => void
setShowFps: (v: boolean) => void
setDifficulty: (d: Difficulty) => void
load: () => void
}
export const useSettingsStore = create<SettingsState>((set) => ({
masterVolume: 0.8,
sfxVolume: 0.7,
musicVolume: 0.5,
showFps: false,
difficulty: 'normal',
setMasterVolume: (masterVolume) => {
set({ masterVolume })
const save = loadSave()
writeSave({ ...save, settings: { ...save.settings, masterVolume } })
},
setSfxVolume: (sfxVolume) => {
set({ sfxVolume })
const save = loadSave()
writeSave({ ...save, settings: { ...save.settings, sfxVolume } })
},
setMusicVolume: (musicVolume) => {
set({ musicVolume })
const save = loadSave()
writeSave({ ...save, settings: { ...save.settings, musicVolume } })
},
setShowFps: (showFps) => set({ showFps }),
setDifficulty: (difficulty) => {
set({ difficulty })
const save = loadSave()
writeSave({ ...save, settings: { ...save.settings, difficulty } })
},
load: () => {
const save = loadSave()
set({ ...save.settings })
},
}))
+345
View File
@@ -0,0 +1,345 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
background: #0E1220;
color: #E9DCC0;
font-family: 'EB Garamond', serif;
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: rgba(14,18,32,0.8); }
::-webkit-scrollbar-thumb {
background: rgba(201,161,74,0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: rgba(201,161,74,0.55); }
input[type=range] {
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: rgba(201,161,74,0.25);
outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #C9A14A;
cursor: pointer;
border: 2px solid #0E1220;
box-shadow: 0 0 6px rgba(201,161,74,0.6);
}
}
@layer components {
/* ═══════════════════════════════════════
BUTTONS
═══════════════════════════════════════ */
.btn-rune {
@apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3
transition-all duration-300 cursor-pointer select-none;
color: #E9DCC0;
border: 1px solid rgba(201,161,74,0.5);
background-color: #1a2035;
clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%);
}
.btn-rune::before {
content: '';
@apply absolute inset-0 opacity-0 transition-opacity duration-300;
background: linear-gradient(135deg, rgba(201,161,74,0.08) 0%, transparent 60%);
}
.btn-rune:hover {
border-color: #C9A14A;
color: #C9A14A;
box-shadow: 0 0 20px 4px rgba(201,161,74,0.4), inset 0 0 12px rgba(201,161,74,0.08);
}
.btn-rune:hover::before { @apply opacity-100; }
.btn-rune:active { transform: scale(0.97); }
.btn-rune:disabled {
@apply opacity-30 cursor-not-allowed;
box-shadow: none;
}
.btn-ghost {
@apply font-cinzel font-medium tracking-widest uppercase px-5 py-2.5
transition-all duration-200 cursor-pointer select-none;
color: rgba(233,220,192,0.55);
border: 1px solid rgba(233,220,192,0.2);
}
.btn-ghost:hover {
border-color: rgba(233,220,192,0.5);
color: #E9DCC0;
}
.btn-danger {
@apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3
transition-all duration-300 cursor-pointer select-none;
color: #fda4af;
border: 1px solid rgba(225,29,72,0.4);
background-color: #1a2035;
clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%);
}
.btn-danger:hover {
border-color: rgba(251,113,133,0.8);
color: #fecdd3;
background-color: rgba(136,19,55,0.3);
box-shadow: 0 0 16px rgba(244,63,94,0.35);
}
.btn-frost {
@apply relative font-cinzel font-semibold tracking-widest uppercase px-6 py-3
transition-all duration-300 cursor-pointer select-none;
color: #6ECBD5;
border: 1px solid rgba(110,203,213,0.4);
background-color: #1a2035;
clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%);
}
.btn-frost:hover {
border-color: #6ECBD5;
background-color: rgba(110,203,213,0.1);
box-shadow: 0 0 18px rgba(110,203,213,0.4);
}
/* ═══════════════════════════════════════
PANELS
═══════════════════════════════════════ */
.panel-parchment {
@apply relative;
background-color: #1a2035;
border: 1px solid rgba(201,161,74,0.3);
background-image:
radial-gradient(ellipse at top left, rgba(201,161,74,0.06) 0%, transparent 55%),
radial-gradient(ellipse at bottom right, rgba(110,203,213,0.04) 0%, transparent 55%);
box-shadow:
0 0 0 1px rgba(201,161,74,0.12),
0 6px 40px rgba(0,0,0,0.65),
inset 0 1px 0 rgba(201,161,74,0.18);
}
.panel-dark {
@apply relative;
background-color: #070b14;
border: 1px solid rgba(233,220,192,0.1);
box-shadow:
0 0 0 1px rgba(255,255,255,0.04),
0 8px 48px rgba(0,0,0,0.85),
inset 0 1px 0 rgba(255,255,255,0.04);
}
.panel-frost {
@apply relative;
background-color: #0E1220;
border: 1px solid rgba(110,203,213,0.3);
background-image:
radial-gradient(ellipse at top, rgba(110,203,213,0.08) 0%, transparent 60%);
box-shadow:
0 0 0 1px rgba(110,203,213,0.12),
0 4px 32px rgba(0,0,0,0.6),
inset 0 1px 0 rgba(110,203,213,0.12);
}
.panel-boss {
@apply relative;
background-color: #070b14;
border: 1px solid rgba(155,77,232,0.4);
background-image:
radial-gradient(ellipse at top, rgba(155,77,232,0.08) 0%, transparent 60%);
box-shadow:
0 0 0 1px rgba(155,77,232,0.2),
0 8px 48px rgba(0,0,0,0.85),
0 0 60px rgba(155,77,232,0.1),
inset 0 1px 0 rgba(155,77,232,0.1);
}
.hud-panel {
@apply relative backdrop-blur-sm;
background-color: rgba(14,18,32,0.8);
border: 1px solid rgba(201,161,74,0.2);
box-shadow:
0 0 0 1px rgba(201,161,74,0.08),
0 4px 24px rgba(0,0,0,0.7),
inset 0 1px 0 rgba(201,161,74,0.12);
}
/* ═══════════════════════════════════════
DIVIDERS
═══════════════════════════════════════ */
.rune-divider {
@apply w-full h-px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(201,161,74,0.25) 15%,
rgba(201,161,74,0.65) 50%,
rgba(201,161,74,0.25) 85%,
transparent 100%
);
}
.rune-divider-frost {
@apply w-full h-px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(110,203,213,0.25) 15%,
rgba(110,203,213,0.65) 50%,
rgba(110,203,213,0.25) 85%,
transparent 100%
);
}
.rune-divider-rose {
@apply w-full h-px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(244,63,94,0.25) 15%,
rgba(244,63,94,0.65) 50%,
rgba(244,63,94,0.25) 85%,
transparent 100%
);
}
/* ═══════════════════════════════════════
TYPOGRAPHY
═══════════════════════════════════════ */
.text-title {
@apply font-cinzel font-bold tracking-widest;
color: #C9A14A;
text-shadow: 0 0 24px rgba(201,161,74,0.55), 0 2px 4px rgba(0,0,0,0.8);
}
.text-subtitle {
@apply font-cinzel font-normal tracking-wider;
color: rgba(233,220,192,0.65);
}
.text-glow-gold {
text-shadow: 0 0 12px rgba(201,161,74,0.9), 0 0 24px rgba(201,161,74,0.4);
}
.text-glow-frost {
text-shadow: 0 0 12px rgba(110,203,213,0.9), 0 0 24px rgba(110,203,213,0.4);
}
.text-glow-ember {
text-shadow: 0 0 12px rgba(232,112,42,0.9), 0 0 24px rgba(232,112,42,0.4);
}
.text-glow-rose {
text-shadow: 0 0 12px rgba(244,63,94,0.9), 0 0 24px rgba(244,63,94,0.4);
}
.text-glow-arcane {
text-shadow: 0 0 12px rgba(155,77,232,0.9), 0 0 24px rgba(155,77,232,0.4);
}
.text-shimmer {
background: linear-gradient(
90deg,
rgba(201,161,74,0.6) 0%,
rgba(240,192,96,1) 40%,
rgba(201,161,74,0.6) 60%,
rgba(240,192,96,1) 100%
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 2.5s linear infinite;
}
/* ═══════════════════════════════════════
ORNAMENTS
═══════════════════════════════════════ */
.ornament-line {
@apply flex items-center gap-3;
}
.ornament-line::before, .ornament-line::after {
content: '';
@apply flex-1 h-px;
background: linear-gradient(90deg, transparent, rgba(201,161,74,0.45));
}
.ornament-line::after {
background: linear-gradient(90deg, rgba(201,161,74,0.45), transparent);
}
.corner-mark {
@apply absolute w-3 h-3;
border-color: rgba(201,161,74,0.6);
}
.corner-mark-tl { @apply top-0 left-0 border-t border-l; }
.corner-mark-tr { @apply top-0 right-0 border-t border-r; }
.corner-mark-bl { @apply bottom-0 left-0 border-b border-l; }
.corner-mark-br { @apply bottom-0 right-0 border-b border-r; }
/* ═══════════════════════════════════════
STAT CELLS
═══════════════════════════════════════ */
.stat-cell {
@apply flex flex-col items-center gap-0.5 px-2 py-1.5;
}
.stat-cell-label {
@apply font-cinzel text-[9px] uppercase tracking-wider;
color: rgba(233,220,192,0.4);
}
.stat-cell-value {
@apply font-cinzel text-sm font-bold;
color: #E9DCC0;
}
.toggle-switch {
@apply relative w-11 h-6 cursor-pointer transition-colors duration-200;
}
.toggle-switch input { @apply sr-only; }
}
@layer utilities {
.glow-gold { box-shadow: 0 0 14px rgba(201,161,74,0.65); }
.glow-frost { box-shadow: 0 0 14px rgba(110,203,213,0.65); }
.glow-ember { box-shadow: 0 0 14px rgba(232,112,42,0.65); }
.glow-rose { box-shadow: 0 0 14px rgba(244,63,94,0.65); }
.glow-arcane { box-shadow: 0 0 14px rgba(155,77,232,0.65); }
.glow-gold-lg { box-shadow: 0 0 28px rgba(201,161,74,0.5), 0 0 56px rgba(201,161,74,0.2); }
.glow-frost-lg { box-shadow: 0 0 28px rgba(110,203,213,0.5), 0 0 56px rgba(110,203,213,0.2); }
.glow-rose-lg { box-shadow: 0 0 28px rgba(244,63,94,0.5), 0 0 56px rgba(244,63,94,0.2); }
.glow-arcane-lg { box-shadow: 0 0 28px rgba(155,77,232,0.5), 0 0 56px rgba(155,77,232,0.2); }
.bg-gradient-radial {
background: radial-gradient(var(--tw-gradient-stops));
}
.clip-rune {
clip-path: polygon(8px 0%, 100% 0%, calc(100% - 8px) 100%, 0% 100%);
}
.clip-diamond {
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
}
.clip-hex {
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.border-ornament {
border-width: 1px;
border-style: solid;
border-image: linear-gradient(135deg, rgba(201,161,74,0.6) 0%, rgba(201,161,74,0.15) 50%, rgba(201,161,74,0.6) 100%) 1;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { useState, useEffect } from 'react'
import { useGameStore } from '@/state/gameStore'
export function BossCutscene() {
const currentLevelId = useGameStore((s) => s.currentLevelId)
const wave = useGameStore((s) => s.wave)
const [visible, setVisible] = useState(false)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (currentLevelId === 'obsidian_keep' && wave === 0 && !dismissed) {
setVisible(true)
}
}, [currentLevelId, wave, dismissed])
if (!visible) return null
const dismiss = () => { setVisible(false); setDismissed(true) }
return (
<div
className="absolute inset-0 z-50 flex items-center justify-center pointer-events-auto overflow-hidden"
onClick={dismiss}
>
{/* Atmospheric background */}
<div className="absolute inset-0 bg-void/94 backdrop-blur-sm" />
<div className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(ellipse 55% 50% at 50% 50%, rgba(136,0,255,0.1) 0%, transparent 65%)' }} />
{/* Card */}
<div
className="relative z-10 panel-dark border-arcane/30 max-w-lg mx-6 p-8 flex flex-col items-center gap-5 text-center animate-scale-in"
onClick={(e) => e.stopPropagation()}
style={{ boxShadow: '0 0 60px rgba(155,77,232,0.15), inset 0 0 40px rgba(0,0,0,0.3)' }}
>
{/* Corner marks */}
<span className="corner-mark corner-mark-tl" style={{ borderColor: 'rgba(155,77,232,0.5)' }} />
<span className="corner-mark corner-mark-tr" style={{ borderColor: 'rgba(155,77,232,0.5)' }} />
<span className="corner-mark corner-mark-bl" style={{ borderColor: 'rgba(155,77,232,0.5)' }} />
<span className="corner-mark corner-mark-br" style={{ borderColor: 'rgba(155,77,232,0.5)' }} />
{/* Boss icon */}
<div className="relative">
<div
className="w-24 h-24 rounded-full bg-midnight border-2 border-arcane/60 flex items-center justify-center animate-float"
style={{ boxShadow: '0 0 40px rgba(136,0,255,0.45), inset 0 0 20px rgba(136,0,255,0.15)' }}
>
<span className="text-5xl">💀</span>
</div>
<div className="absolute inset-0 rounded-full animate-pulse-soft"
style={{ boxShadow: '0 0 32px rgba(136,0,255,0.5)' }} />
</div>
{/* Title */}
<div className="flex flex-col items-center gap-1">
<p className="font-cinzel text-[10px] text-rose-400/70 uppercase tracking-[0.4em]">Финальное испытание</p>
<h2 className="font-cinzel text-2xl font-black text-gold tracking-wide text-glow-gold">
Обсидиановая Цитадель
</h2>
<p className="font-garamond text-xs text-arcane/70 italic tracking-wider mt-0.5">
Король-Лич ожидает
</p>
</div>
<div className="ornament-line w-full">
<span className="px-3 text-arcane/40 text-xs"></span>
</div>
{/* Lore */}
<div className="flex flex-col gap-3">
<p className="font-garamond text-sm text-parchment/75 leading-relaxed">
В самом сердце Чёрных Земель восстал{' '}
<span className="text-rose-300 font-semibold">Король-Лич</span> повелитель
смерти, некогда павший от руки Первых Архимагов. Теперь он ведёт бесконечные
орды нежити на последний оплот живых.
</p>
<p className="font-garamond text-sm text-parchment/50 leading-relaxed">
Он проходит через{' '}
<span className="text-arcane/80">три фазы могущества</span>. В последней
наносит прямой урон нексусу.{' '}
<span className="text-frost/75">Уничтожь его прежде, чем цитадель падёт.</span>
</p>
</div>
<div className="ornament-line w-full">
<span className="px-3 text-gold/30 text-xs"></span>
</div>
{/* Rewards */}
<div className="flex gap-8">
<div className="panel-frost px-4 py-2 flex flex-col items-center gap-0.5">
<span className="text-frost text-lg"></span>
<span className="font-cinzel text-xs text-frost font-bold">+30 Эссенции</span>
</div>
<div className="flex flex-col items-center gap-0.5 px-4 py-2 border border-gold/25 bg-gold/5">
<span className="text-gold text-lg"></span>
<span className="font-cinzel text-xs text-gold font-bold">+150 Золота</span>
</div>
</div>
<button
onClick={dismiss}
className="mt-1 px-10 py-3 font-cinzel text-sm border border-arcane/60 text-arcane hover:bg-arcane/10 hover:text-arcane/90 hover:border-arcane/80 transition-all duration-200 tracking-[0.25em] uppercase"
style={{ boxShadow: '0 0 16px rgba(155,77,232,0.15)' }}
>
Принять вызов
</button>
<p className="font-garamond text-[10px] text-parchment/20 italic">нажмите куда угодно, чтобы закрыть</p>
</div>
</div>
)
}
+231
View File
@@ -0,0 +1,231 @@
import { useState, useEffect } from 'react'
import { useGameStore } from '@/state/gameStore'
import { getEngine } from '@/game/core/GameEngine'
import type { PlacementMode } from '@/game/LevelScene'
import { ARCHER_COST } from '@/game/entities/towers/archer'
import { PYROMANCER_COST } from '@/game/entities/towers/pyromancer'
import { CRYO_COST } from '@/game/entities/towers/cryomancer'
import { STORM_COST } from '@/game/entities/towers/stormcaller'
import { TowerInfoPanel } from './TowerInfoPanel'
import { SpellBar } from './SpellBar'
import { BossCutscene } from './BossCutscene'
interface GameHUDProps { onExit: () => void }
const TOWER_DEFS = [
{ mode: 'archer' as PlacementMode, icon: '🏹', name: 'Лучник', cost: ARCHER_COST, title: 'Лучники — дальний физический урон' },
{ mode: 'pyromancer' as PlacementMode, icon: '🔥', name: 'Пиромант', cost: PYROMANCER_COST, title: 'Пиромант — AoE огонь + поджог' },
{ mode: 'cryomancer' as PlacementMode, icon: '❄', name: 'Криомант', cost: CRYO_COST, title: 'Криомант — замедление + заморозка' },
{ mode: 'stormcaller' as PlacementMode, icon: '⚡', name: 'Шторм', cost: STORM_COST, title: 'Повелитель бурь — цепная молния' },
]
export function GameHUD({ onExit }: GameHUDProps) {
const { gold, mana, nexusHp, nexusMaxHp, wave, maxWaves, phase } = useGameStore()
const [placement, setPlacement] = useState<PlacementMode>('none')
const [countdown, setCountdown] = useState(0)
const [aliveCount, setAliveCount] = useState(0)
useEffect(() => {
if (phase !== 'build') { setCountdown(0); return }
const id = setInterval(() => {
const wv = getEngine()?.getScene()?.getWaveState()
setCountdown(wv ? Math.ceil(wv.countdown) : 0)
}, 100)
return () => clearInterval(id)
}, [phase])
useEffect(() => {
if (phase !== 'combat') { setAliveCount(0); return }
const id = setInterval(() => {
setAliveCount(getEngine()?.getScene()?.getWaveState()?.aliveCount ?? 0)
}, 200)
return () => clearInterval(id)
}, [phase])
useEffect(() => {
const cancel = (e: MouseEvent) => {
if (e.button === 2) { setPlacement('none'); getEngine()?.getScene()?.setPlacementMode('none') }
}
window.addEventListener('mousedown', cancel)
return () => window.removeEventListener('mousedown', cancel)
}, [])
const handleStartWave = () => getEngine()?.getScene()?.startWave()
const selectTower = (mode: PlacementMode) => {
const next = placement === mode ? 'none' : mode
setPlacement(next)
getEngine()?.getScene()?.setPlacementMode(next)
}
const nexusPct = nexusHp / nexusMaxHp
const nexusColor = nexusPct > 0.5 ? '#4ade80' : nexusPct > 0.25 ? '#f59e0b' : '#ef4444'
return (
<div className="absolute inset-0 pointer-events-none">
<BossCutscene />
{/* ── TOP ROW ── */}
<div className="absolute top-0 left-0 right-0 flex items-start justify-between px-3 pt-2.5 gap-2">
{/* Resources — left */}
<div className="hud-panel flex items-stretch pointer-events-auto">
<ResourceChip icon="⚜" value={gold} label="Золото" color="text-gold" />
<div className="w-px bg-gold/12 my-1.5" />
<ResourceChip icon="✦" value={mana} label="Мана" color="text-frost" />
</div>
{/* Wave + phase — center */}
<div className="hud-panel flex flex-col items-center px-5 py-2 pointer-events-auto min-w-[130px]">
<div className="flex items-center gap-1.5">
<PhaseDot phase={phase} />
<span className="font-cinzel text-[10px] text-parchment/40 uppercase tracking-wider">
{phase === 'build' ? 'Постройка' : phase === 'combat' ? 'Сражение' : 'Пауза'}
</span>
</div>
<div className="flex items-baseline gap-1 mt-0.5">
<span className="font-cinzel text-xl font-bold text-gold leading-none">{wave}</span>
<span className="font-cinzel text-xs text-parchment/30">/{maxWaves}</span>
</div>
{phase === 'combat' && aliveCount > 0 && (
<span className="font-cinzel text-[9px] text-rose-400/70 mt-0.5">{aliveCount} врагов</span>
)}
{phase === 'build' && countdown > 0 && (
<span className="font-cinzel text-[9px] text-parchment/35 mt-0.5">авто {countdown}с</span>
)}
</div>
{/* Nexus + exit — right */}
<div className="hud-panel flex items-center gap-3 px-3 py-2 pointer-events-auto">
<div className="flex flex-col items-end gap-1 min-w-[106px]">
<div className="flex items-center gap-2">
<span className="font-cinzel text-[10px] text-parchment/40 uppercase tracking-wider">Нексус</span>
<span className="font-cinzel text-xs font-bold text-parchment">
{nexusHp}<span className="text-parchment/30 font-normal">/{nexusMaxHp}</span>
</span>
</div>
<div className="w-full h-1.5 bg-midnight/60 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${nexusPct * 100}%`, backgroundColor: nexusColor,
boxShadow: `0 0 5px ${nexusColor}88` }}
/>
</div>
</div>
<button
onClick={onExit}
className="text-parchment/25 hover:text-rose-400 transition-colors text-lg leading-none"
title="Выйти"
>
</button>
</div>
</div>
{/* ── PLACEMENT HINT ── */}
{placement !== 'none' && (
<div className="absolute top-[52px] left-1/2 -translate-x-1/2 pointer-events-none">
<div className="hud-panel px-4 py-1.5 animate-slide-up">
<span className="font-cinzel text-gold/70 text-xs tracking-wider">
Кликните на тайл · ПКМ отмена
</span>
</div>
</div>
)}
{/* ── BOTTOM ROW ── */}
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between px-3 pb-3 gap-3">
{/* Tower shop */}
<div className="hud-panel flex items-end gap-1 px-2 py-2 pointer-events-auto">
<span className="font-cinzel text-[9px] text-parchment/30 uppercase tracking-widest self-end mb-1.5 mr-1 writing-mode-vertical">
Башни
</span>
{TOWER_DEFS.map((td) => (
<TowerBtn
key={td.mode}
{...td}
gold={gold}
active={placement === td.mode}
onClick={() => selectTower(td.mode)}
/>
))}
</div>
{/* Spell bar */}
<div className="pointer-events-auto">
<SpellBar />
</div>
{/* Wave control + TowerInfo */}
<div className="flex flex-col items-end gap-2 pointer-events-auto">
{phase === 'build' && (
<button
className="btn-rune text-sm px-7 py-2.5"
onClick={handleStartWave}
disabled={wave >= maxWaves}
>
Волна {wave + 1}
</button>
)}
{phase === 'combat' && (
<div className="hud-panel px-4 py-2.5 flex items-center gap-2.5">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
<span className="font-cinzel text-xs text-rose-400/80 tracking-wider">Бой идёт</span>
</div>
)}
<TowerInfoPanel />
</div>
</div>
</div>
)
}
function TowerBtn({ mode, icon, name, cost, gold, active, onClick, title }: {
mode: PlacementMode; icon: string; name: string; cost: number
gold: number; active: boolean; onClick: () => void; title: string
}) {
const canAfford = gold >= cost
return (
<button
onClick={canAfford ? onClick : undefined}
title={title}
className={`
relative flex flex-col items-center justify-between w-[58px] h-[64px] border
pt-2 pb-1 transition-all duration-150 select-none
${active
? 'border-gold bg-gold/12 glow-gold'
: canAfford
? 'border-gold/30 bg-midnight/50 hover:border-gold/65 hover:bg-gold/8'
: 'border-parchment/12 bg-midnight/20 opacity-40 cursor-not-allowed'
}
`}
>
<span className="text-[22px] leading-none">{icon}</span>
<span className="font-cinzel text-[9px] text-parchment/55 leading-none">{name}</span>
<span className={`font-cinzel text-[10px] font-bold leading-none ${canAfford ? 'text-gold' : 'text-rose-400/60'}`}>
{cost}
</span>
{active && <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-gold animate-pulse" />}
</button>
)
}
function ResourceChip({ icon, value, label, color }: {
icon: string; value: number; label: string; color: string
}) {
return (
<div className="flex items-center gap-2 px-3 py-2">
<span className={`${color} text-base leading-none`}>{icon}</span>
<div className="flex flex-col leading-none">
<span className="font-cinzel text-[9px] text-parchment/30 uppercase tracking-wider">{label}</span>
<span className={`font-cinzel text-sm font-bold ${color} mt-0.5`}>{value}</span>
</div>
</div>
)
}
function PhaseDot({ phase }: { phase: 'build' | 'combat' | 'paused' }) {
const color = phase === 'combat' ? 'bg-rose-500' : phase === 'build' ? 'bg-emerald-500' : 'bg-parchment/35'
return <span className={`w-1.5 h-1.5 rounded-full ${color} ${phase === 'combat' ? 'animate-pulse' : ''}`} />
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback } from 'react'
import { useGameStore } from '@/state/gameStore'
import { getEngine } from '@/game/core/GameEngine'
import { HERO_SPELLS } from '@/game/components/Hero'
export function SpellBar() {
const mana = useGameStore((s) => s.mana)
const [timers, setTimers] = useState<Record<string, number>>({})
const [spellMode, setSpellMode] = useState<string | null>(null)
const [heroLevel, setHeroLevel] = useState(1)
const sync = useCallback(() => {
const scene = getEngine()?.getScene()
if (!scene) return
const h = scene.getHeroComp()
if (h) {
const t: Record<string, number> = {}
for (const s of h.spells) t[s.id] = s.timer
setTimers(t)
setHeroLevel(h.level)
}
setSpellMode(scene.getSpellMode())
}, [])
useEffect(() => {
const id = setInterval(sync, 100)
return () => clearInterval(id)
}, [sync])
useEffect(() => {
const keys: Record<string, string> = { q: 'fireball', w: 'blizzard', e: 'blink', r: 'timewarp' }
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
const id = keys[e.key.toLowerCase()]
if (id) handleSpell(id)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mana, timers])
const handleSpell = (id: string) => {
const scene = getEngine()?.getScene()
if (!scene) return
const spell = HERO_SPELLS.find((s) => s.id === id)
if (!spell) return
const timer = timers[id] ?? 0
if (timer > 0 || mana < spell.manaCost) return
if (spell.targetMode === 'global') {
scene.castSpell(id, 0, 0)
sync()
} else {
const next = scene.getSpellMode() === id ? null : id
scene.setSpellMode(next)
setSpellMode(next)
}
}
return (
<div className="hud-panel flex flex-col items-center gap-1.5 px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-cinzel text-[9px] text-parchment/30 uppercase tracking-widest">Архимаг</span>
<span className="font-cinzel text-[10px] text-gold font-bold">Ур.{heroLevel}</span>
</div>
<div className="flex gap-1.5">
{HERO_SPELLS.map((spell) => {
const timer = timers[spell.id] ?? 0
const onCd = timer > 0
const canAfford = mana >= spell.manaCost
const active = spellMode === spell.id
return (
<SpellBtn
key={spell.id}
spell={spell}
timer={timer}
onCooldown={onCd}
canAfford={canAfford}
active={active}
onClick={() => handleSpell(spell.id)}
/>
)
})}
</div>
</div>
)
}
function SpellBtn({ spell, timer, onCooldown, canAfford, active, onClick }: {
spell: typeof HERO_SPELLS[number]
timer: number; onCooldown: boolean; canAfford: boolean; active: boolean; onClick: () => void
}) {
const disabled = onCooldown || !canAfford
return (
<button
onClick={onClick}
disabled={disabled}
title={`${spell.nameRu} [${spell.hotkey.toUpperCase()}] — ${spell.manaCost}✦ мана`}
className={`
relative w-[52px] h-[56px] border flex flex-col items-center justify-center
gap-0.5 overflow-hidden transition-all duration-150 select-none
${active
? 'border-frost/80 bg-frost/15 glow-frost'
: !disabled
? 'border-gold/30 bg-midnight/50 hover:border-gold/65 hover:bg-gold/8'
: 'border-parchment/10 bg-midnight/20 opacity-40 cursor-not-allowed'
}
`}
>
<span className="text-xl leading-none">{spell.icon}</span>
<span className="font-cinzel text-[8px] text-parchment/50 leading-none">
{spell.nameRu.split(' ')[0]}
</span>
<span className={`font-cinzel text-[8px] leading-none font-bold ${canAfford ? 'text-frost' : 'text-rose-400/70'}`}>
{spell.manaCost}
</span>
<span className="absolute top-0.5 right-1 font-cinzel text-[7px] text-parchment/25 uppercase">
{spell.hotkey}
</span>
{/* Cooldown overlay */}
{onCooldown && (
<div className="absolute inset-0 bg-midnight/78 flex items-center justify-center">
<span className="font-cinzel text-sm text-parchment font-bold">{Math.ceil(timer)}</span>
</div>
)}
{active && <span className="absolute inset-0 border border-frost/50 animate-pulse pointer-events-none" />}
</button>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback } from 'react'
import { useGameStore } from '@/state/gameStore'
import { getEngine } from '@/game/core/GameEngine'
import type { TowerInfo } from '@/game/LevelScene'
const TIER_LABELS = ['', 'I', 'II', 'III']
export function TowerInfoPanel() {
const selectedTile = useGameStore((s) => s.selectedTowerTile)
const gold = useGameStore((s) => s.gold)
const [info, setInfo] = useState<TowerInfo | null>(null)
const refresh = useCallback(() => {
if (!selectedTile) { setInfo(null); return }
const next = getEngine()?.getScene()?.getTowerInfo(selectedTile.x, selectedTile.y) ?? null
setInfo(next)
}, [selectedTile])
useEffect(() => { refresh() }, [refresh])
if (!info) return null
const canUpgrade = info.upgradeCost !== null && gold >= info.upgradeCost
const atMax = info.tier >= info.maxTier
return (
<div className="panel-parchment p-3 w-[192px] flex flex-col gap-2 pointer-events-auto animate-scale-in">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl shrink-0">{info.icon}</span>
<div className="min-w-0">
<p className="font-cinzel text-[11px] text-gold font-bold leading-none truncate">{info.nameRu}</p>
<div className="flex items-center gap-1 mt-1">
{Array.from({ length: info.maxTier }, (_, i) => (
<span
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
i < info.tier ? 'bg-gold' : 'bg-parchment/15'
}`}
/>
))}
<span className="font-cinzel text-[9px] text-parchment/40 ml-1">
{TIER_LABELS[info.tier]}
</span>
</div>
</div>
</div>
<button
onClick={() => useGameStore.getState().setSelectedTowerTile(null)}
className="text-parchment/25 hover:text-parchment/70 text-xs leading-none shrink-0 mt-0.5"
>
</button>
</div>
{/* Ley-line badge */}
{info.leyLineBuff && (
<div className="flex items-center gap-1.5 bg-frost/8 border border-frost/25 px-2 py-1 -mx-1">
<span className="text-frost text-[10px]"></span>
<span className="font-cinzel text-[9px] text-frost tracking-wide">Лей-линия ×1.2</span>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-0 border border-gold/12 overflow-hidden">
<StatCell label="Урон" value={info.damage} />
<StatCell label="Атк/с" value={`${(1 / info.cooldown).toFixed(1)}`} border />
<StatCell label="Радиус" value={info.range} border />
</div>
{/* Buttons */}
<div className="flex gap-1.5 pt-1">
<button
onClick={() => {
if (!selectedTile) return
getEngine()?.getScene()?.upgradeTower(selectedTile.x, selectedTile.y)
refresh()
}}
disabled={atMax || !canUpgrade}
className={`
flex-1 py-1.5 font-cinzel text-[10px] border transition-colors clip-rune
${atMax
? 'border-parchment/12 text-parchment/18 cursor-not-allowed bg-transparent'
: canUpgrade
? 'border-gold/50 text-gold bg-gold/8 hover:bg-gold/15'
: 'border-parchment/18 text-parchment/28 cursor-not-allowed bg-transparent'
}
`}
>
{atMax ? '✓ Макс' : `${info.upgradeCost}`}
</button>
<button
onClick={() => {
if (!selectedTile) return
getEngine()?.getScene()?.sellTower(selectedTile.x, selectedTile.y)
setInfo(null)
}}
className="flex-1 py-1.5 font-cinzel text-[10px] border border-rose-400/25 text-rose-400/65 hover:bg-rose-400/8 hover:text-rose-400 transition-colors clip-rune"
>
{info.sellValue}
</button>
</div>
</div>
)
}
function StatCell({ label, value, border }: { label: string; value: number | string; border?: boolean }) {
return (
<div className={`flex flex-col items-center py-1.5 px-1 ${border ? 'border-l border-gold/12' : ''}`}>
<span className="font-cinzel text-[8px] text-parchment/35 uppercase tracking-wider">{label}</span>
<span className="font-cinzel text-xs text-parchment font-bold mt-0.5">{value}</span>
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useGameStore } from '@/state/gameStore'
import { useMetaStore } from '@/state/metaStore'
interface Level {
id: string
name: string
nameRu: string
waves: number
difficulty: 'Новобранец' | 'Рыцарь' | 'Архимаг' | 'Босс'
unlockedBy: string | null
x: string
y: string
biomeColor: string
biomeGlow: string
icon: string
}
const LEVELS: Level[] = [
{
id: 'kings_road',
name: "King's Road",
nameRu: 'Королевский Тракт',
waves: 20,
difficulty: 'Новобранец',
unlockedBy: null,
x: '18%', y: '62%',
biomeColor: 'border-emerald-500/60',
biomeGlow: 'rgba(74,222,128,0.2)',
icon: '⚔',
},
{
id: 'whispering_woods',
name: 'Whispering Woods',
nameRu: 'Шепчущий Лес',
waves: 20,
difficulty: 'Рыцарь',
unlockedBy: 'kings_road',
x: '46%', y: '36%',
biomeColor: 'border-gold/60',
biomeGlow: 'rgba(201,161,74,0.2)',
icon: '🌲',
},
{
id: 'frostfall_pass',
name: 'Frostfall Pass',
nameRu: 'Морозный Перевал',
waves: 20,
difficulty: 'Архимаг',
unlockedBy: 'whispering_woods',
x: '70%', y: '22%',
biomeColor: 'border-frost/60',
biomeGlow: 'rgba(110,203,213,0.2)',
icon: '❄',
},
{
id: 'obsidian_keep',
name: 'Obsidian Keep',
nameRu: 'Обсидиановая Цитадель',
waves: 10,
difficulty: 'Босс',
unlockedBy: 'frostfall_pass',
x: '84%', y: '56%',
biomeColor: 'border-arcane/60',
biomeGlow: 'rgba(155,77,232,0.22)',
icon: '💀',
},
]
const difficultyStyle: Record<string, string> = {
'Новобранец': 'text-emerald-400',
'Рыцарь': 'text-gold',
'Архимаг': 'text-rose-400',
'Босс': 'text-arcane',
}
export function CampaignMap() {
const setScreen = useGameStore((s) => s.setScreen)
const resetGame = useGameStore((s) => s.resetGame)
const setCurrentLevelId = useGameStore((s) => s.setCurrentLevelId)
const completedLevels = useMetaStore((s) => s.completedLevels)
const isUnlocked = (level: Level) =>
level.unlockedBy === null || completedLevels.includes(level.unlockedBy)
const handlePlay = (levelId: string) => {
setCurrentLevelId(levelId)
resetGame()
setScreen('game')
}
return (
<div className="w-full h-full flex flex-col bg-midnight-deep overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-8 py-4 border-b border-gold/15 bg-midnight/60 backdrop-blur-sm shrink-0">
<button
onClick={() => setScreen('menu')}
className="btn-ghost text-xs py-2 px-4"
>
Назад
</button>
<div className="flex flex-col items-center">
<h2 className="text-title text-xl tracking-[0.3em]">Карта Кампании</h2>
<p className="font-garamond text-parchment/35 text-xs mt-0.5 tracking-wider">
{completedLevels.length} / {LEVELS.length} уровней завершено
</p>
</div>
<div className="w-24" />
</div>
{/* Map area */}
<div className="relative flex-1 w-full overflow-hidden">
{/* Background atmosphere */}
<div className="absolute inset-0"
style={{ background: 'radial-gradient(ellipse 70% 60% at 50% 40%, #111828 0%, #070b14 100%)' }} />
<div className="absolute inset-0 opacity-30"
style={{ backgroundImage: 'radial-gradient(circle at 20% 65%, rgba(74,222,128,0.08) 0%, transparent 35%), radial-gradient(circle at 47% 38%, rgba(201,161,74,0.07) 0%, transparent 30%), radial-gradient(circle at 70% 22%, rgba(110,203,213,0.07) 0%, transparent 25%), radial-gradient(circle at 84% 55%, rgba(155,77,232,0.1) 0%, transparent 30%)' }} />
{/* SVG connection lines */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" aria-hidden>
<defs>
<filter id="glow-line">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<line x1="18%" y1="62%" x2="46%" y2="36%" stroke="rgba(201,161,74,0.18)" strokeWidth="2" strokeDasharray="8 5" />
<line x1="46%" y1="36%" x2="70%" y2="22%" stroke="rgba(201,161,74,0.18)" strokeWidth="2" strokeDasharray="8 5" />
<line x1="70%" y1="22%" x2="84%" y2="56%" stroke="rgba(155,77,232,0.15)" strokeWidth="2" strokeDasharray="6 5" />
</svg>
{/* Level nodes */}
{LEVELS.map((level) => {
const completed = completedLevels.includes(level.id)
const unlocked = isUnlocked(level)
return (
<div
key={level.id}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: level.x, top: level.y }}
>
<div className="flex flex-col items-center gap-2.5">
{/* Node button */}
<button
onClick={unlocked ? () => handlePlay(level.id) : undefined}
disabled={!unlocked}
className={`
relative w-16 h-16 rounded-full border-2 flex items-center justify-center
transition-all duration-300 select-none
${!unlocked
? 'border-parchment/15 bg-midnight/60 cursor-not-allowed'
: completed
? `${level.biomeColor} bg-midnight hover:scale-110`
: `${level.biomeColor} bg-midnight hover:scale-110`
}
`}
style={unlocked ? { boxShadow: `0 0 18px ${level.biomeGlow}, 0 0 40px ${level.biomeGlow}` } : undefined}
>
<span className="text-2xl">
{!unlocked ? '🔒' : completed ? '✦' : level.icon}
</span>
{unlocked && !completed && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-ember rounded-full animate-pulse" />
)}
{completed && (
<span
className="absolute inset-0 rounded-full"
style={{ boxShadow: `inset 0 0 16px ${level.biomeGlow}` }}
/>
)}
</button>
{/* Label card */}
<div
className={`panel-parchment px-3 py-2 text-center min-w-[148px] transition-all duration-300 ${
unlocked ? 'opacity-100' : 'opacity-45'
}`}
>
<p className="font-cinzel text-[11px] text-gold font-bold tracking-wide leading-none">
{level.name}
</p>
<p className="font-garamond text-[11px] text-parchment/60 mt-0.5 leading-none">
{level.nameRu}
</p>
<div className="rune-divider my-1.5" />
<div className="flex items-center justify-center gap-2">
<span className={`font-cinzel text-[10px] font-bold ${difficultyStyle[level.difficulty]}`}>
{level.difficulty}
</span>
<span className="text-parchment/25 text-[10px]">·</span>
<span className="font-garamond text-[11px] text-parchment/45">
{level.waves} волн
</span>
</div>
{completed && (
<p className="font-cinzel text-[9px] text-gold/60 mt-1 tracking-widest uppercase">
Пройдено
</p>
)}
</div>
</div>
</div>
)
})}
{/* Bottom lore */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
<p className="font-garamond text-parchment/20 text-xs italic tracking-wider text-center">
«Тьма не знает покоя каждый рубеж должен устоять»
</p>
</div>
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useRef } from 'react'
import { useGameStore } from '@/state/gameStore'
import { useMetaStore } from '@/state/metaStore'
const ESSENCE_REWARDS: Record<string, number> = {
kings_road: 5,
whispering_woods: 10,
frostfall_pass: 15,
obsidian_keep: 30,
}
export function GameOver() {
const { won, wave, maxWaves, currentLevelId } = useGameStore()
const setScreen = useGameStore((s) => s.setScreen)
const resetGame = useGameStore((s) => s.resetGame)
const completeLevel = useMetaStore((s) => s.completeLevel)
const addEssence = useMetaStore((s) => s.addEssence)
const essenceEarned = won ? (ESSENCE_REWARDS[currentLevelId] ?? 0) : 0
const persisted = useRef(false)
useEffect(() => {
if (won && !persisted.current) {
persisted.current = true
completeLevel(currentLevelId)
if (essenceEarned > 0) addEssence(essenceEarned)
}
}, [won, currentLevelId, essenceEarned, completeLevel, addEssence])
return (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
{/* Background */}
<div className="absolute inset-0 bg-midnight-deep/96 backdrop-blur-sm" />
{/* Atmospheric glow */}
<div
className="absolute inset-0 pointer-events-none animate-pulse-soft"
style={{
background: won
? 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(201,161,74,0.07) 0%, transparent 70%)'
: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(244,63,94,0.06) 0%, transparent 70%)',
}}
/>
{/* Card */}
<div className={`relative z-10 flex flex-col items-center gap-5 max-w-sm w-full mx-4 animate-scale-in ${
won ? 'panel-parchment' : 'panel-dark border-rose-900/50'
} p-8`}>
{/* Corner marks */}
<span className="corner-mark corner-mark-tl" style={{ borderColor: won ? 'rgba(201,161,74,0.5)' : 'rgba(244,63,94,0.3)' }} />
<span className="corner-mark corner-mark-tr" style={{ borderColor: won ? 'rgba(201,161,74,0.5)' : 'rgba(244,63,94,0.3)' }} />
<span className="corner-mark corner-mark-bl" style={{ borderColor: won ? 'rgba(201,161,74,0.5)' : 'rgba(244,63,94,0.3)' }} />
<span className="corner-mark corner-mark-br" style={{ borderColor: won ? 'rgba(201,161,74,0.5)' : 'rgba(244,63,94,0.3)' }} />
{won ? (
<>
{/* Victory */}
<div className="relative">
<div className="text-6xl animate-float">👑</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 rounded-full animate-pulse-soft"
style={{ background: 'radial-gradient(circle, rgba(201,161,74,0.15) 0%, transparent 70%)' }} />
</div>
</div>
<div className="flex flex-col items-center gap-1">
<p className="font-cinzel text-[10px] text-gold/55 tracking-[0.4em] uppercase">Победа</p>
<h1 className="font-cinzel text-3xl font-black text-gold tracking-wider text-glow-gold">
Нексус выстоял
</h1>
</div>
<p className="font-garamond text-parchment/60 text-sm text-center leading-relaxed">
Все {maxWaves} волн отражены.<br />
Королевство живёт ещё один день.
</p>
{essenceEarned > 0 && (
<div className="panel-frost px-5 py-2.5 flex items-center gap-3">
<span className="text-xl"></span>
<div>
<p className="font-cinzel text-[9px] text-frost/60 uppercase tracking-wider">Эссенция</p>
<p className="font-cinzel text-lg text-frost font-bold text-glow-frost">+{essenceEarned}</p>
</div>
</div>
)}
</>
) : (
<>
{/* Defeat */}
<div className="relative">
<div className="text-6xl animate-flicker">💀</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 rounded-full animate-pulse-soft"
style={{ background: 'radial-gradient(circle, rgba(244,63,94,0.12) 0%, transparent 70%)' }} />
</div>
</div>
<div className="flex flex-col items-center gap-1">
<p className="font-cinzel text-[10px] text-rose-400/55 tracking-[0.4em] uppercase">Поражение</p>
<h1 className="font-cinzel text-3xl font-black text-rose-400 tracking-wider text-glow-rose">
Нексус пал
</h1>
</div>
<p className="font-garamond text-parchment/55 text-sm text-center leading-relaxed">
Тьма поглотила цитадель на волне {wave}.<br />
Но архимаги не сдаются.
</p>
</>
)}
<div className={won ? 'rune-divider w-full' : 'rune-divider-rose w-full'} />
{/* Actions */}
<div className="flex gap-3 w-full">
<button
onClick={() => { resetGame(); setScreen('game') }}
className={`flex-1 py-3 text-sm ${won ? 'btn-rune' : 'btn-danger'}`}
>
Снова
</button>
<button
onClick={() => { resetGame(); setScreen('campaign') }}
className="flex-1 py-3 text-sm btn-ghost"
>
Кампания
</button>
</div>
</div>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
import { initPixi, destroyPixi } from '@/game/rendering/PixiRoot'
import { createEngine, getEngine } from '@/game/core/GameEngine'
import { useGameStore } from '@/state/gameStore'
import { useSettingsStore } from '@/state/settingsStore'
import { GameHUD } from '@/ui/hud/GameHUD'
import { LEVEL_MAP } from '@/data/levels'
export function GameScreen() {
const wrapperRef = useRef<HTMLDivElement>(null)
const setScreen = useGameStore((s) => s.setScreen)
const showFps = useSettingsStore((s) => s.showFps)
const currentLevelId = useGameStore((s) => s.currentLevelId)
useEffect(() => {
if (!wrapperRef.current) return
const wrapper = wrapperRef.current
let cancelled = false
const levelDef = LEVEL_MAP[currentLevelId] ?? LEVEL_MAP['kings_road']
const init = async () => {
await initPixi(wrapper)
if (cancelled) { destroyPixi(); return }
const engine = createEngine()
engine.setShowFps(showFps)
engine.loadLevel(levelDef, wrapper)
engine.start()
}
init().catch((e: unknown) => {
if (e instanceof Error && e.message === 'PixiJS init cancelled') return
console.error(e)
})
return () => {
cancelled = true
getEngine()?.destroy()
destroyPixi()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
getEngine()?.setShowFps(showFps)
}, [showFps])
return (
<div className="relative w-full h-full bg-midnight overflow-hidden">
{/* PixiJS создаёт canvas внутри этого div динамически */}
<div ref={wrapperRef} className="absolute inset-0" />
<GameHUD onExit={() => setScreen('menu')} />
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef } from 'react'
import { useGameStore } from '@/state/gameStore'
import { useMetaStore } from '@/state/metaStore'
import { useSettingsStore } from '@/state/settingsStore'
import gsap from 'gsap'
export function MainMenu() {
const setScreen = useGameStore((s) => s.setScreen)
const loadMeta = useMetaStore((s) => s.load)
const loadSettings = useSettingsStore((s) => s.load)
const titleRef = useRef<HTMLDivElement>(null)
const navRef = useRef<HTMLDivElement>(null)
const bgRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadMeta()
loadSettings()
const tl = gsap.timeline({ defaults: { ease: 'power2.out' } })
tl.fromTo(bgRef.current, { opacity: 0 }, { opacity: 1, duration: 2.5, ease: 'power1.inOut' })
tl.fromTo(titleRef.current, { opacity: 0, y: -24, filter: 'blur(6px)' },
{ opacity: 1, y: 0, filter: 'blur(0px)', duration: 1.1 }, '-=1.6')
tl.fromTo(navRef.current, { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.8 }, '-=0.5')
}, [loadMeta, loadSettings])
return (
<div className="relative w-full h-full flex flex-col items-center justify-center overflow-hidden bg-midnight-deep">
{/* Animated background */}
<div ref={bgRef} className="absolute inset-0 pointer-events-none select-none" aria-hidden>
<div className="absolute inset-0"
style={{ background: 'radial-gradient(ellipse 80% 70% at 50% 55%, #12182e 0%, #070b14 100%)' }} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[700px] h-[700px] rounded-full animate-pulse-soft"
style={{ background: 'radial-gradient(ellipse, rgba(201,161,74,0.07) 0%, transparent 65%)' }} />
<div className="absolute right-[15%] top-[20%] w-[360px] h-[360px] rounded-full animate-pulse-soft"
style={{ background: 'radial-gradient(ellipse, rgba(110,203,213,0.06) 0%, transparent 65%)', animationDelay: '1.2s' }} />
<div className="absolute left-[10%] bottom-[20%] w-[280px] h-[280px] rounded-full animate-pulse-soft"
style={{ background: 'radial-gradient(ellipse, rgba(155,77,232,0.05) 0%, transparent 65%)', animationDelay: '2s' }} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full animate-spin-slow"
style={{ border: '1px solid rgba(201,161,74,0.07)' }} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] rounded-full animate-spin-slow-rev"
style={{ border: '1px dashed rgba(201,161,74,0.09)' }} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[280px] h-[280px] rounded-full animate-spin-slow"
style={{ border: '1px solid rgba(110,203,213,0.07)', animationDuration: '18s' }} />
<RuneParticles />
</div>
{/* Main content */}
<div className="relative z-10 flex flex-col items-center gap-10 px-6">
{/* Title */}
<div ref={titleRef} className="flex flex-col items-center gap-4">
<div className="ornament-line w-56">
<span className="font-cinzel text-[10px] text-gold/55 tracking-[0.45em] uppercase whitespace-nowrap px-3">
Arcanum
</span>
</div>
<div className="relative text-center">
<h1 className="font-cinzel font-black text-[clamp(2.6rem,7vw,5.5rem)] leading-[1.05] tracking-wide text-shimmer">
Wardens of<br />the Realm
</h1>
<span className="absolute -top-2 -left-4 text-gold/25 font-cinzel text-2xl select-none"></span>
<span className="absolute -top-2 -right-4 text-gold/25 font-cinzel text-2xl select-none"></span>
</div>
<p className="font-garamond text-parchment/45 tracking-[0.25em] text-sm uppercase mt-1">
Protect the Nexus · Command the Arcane
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 w-72">
<div className="flex-1 h-px bg-gradient-to-r from-transparent to-gold/40" />
<span className="text-gold/50 text-sm font-cinzel"></span>
<div className="flex-1 h-px bg-gradient-to-l from-transparent to-gold/40" />
</div>
{/* Navigation */}
<nav ref={navRef} className="flex flex-col items-center gap-3 w-64">
<button className="btn-rune w-full text-sm py-3.5" onClick={() => setScreen('campaign')}>
&nbsp;Начать поход
</button>
<button className="btn-rune w-full text-sm py-3.5" onClick={() => setScreen('tome')}>
&nbsp;Том Рун
</button>
<button className="btn-ghost w-full text-sm py-3" onClick={() => setScreen('settings')}>
&nbsp;Настройки
</button>
</nav>
<p className="font-cinzel text-[10px] text-parchment/18 tracking-widest uppercase mt-2">
v0.8.0 · Arcanum Engine
</p>
</div>
</div>
)
}
const RUNE_DATA = [
{ glyph: 'ᚠ', pos: { top: '12%', left: '7%' }, delay: '0s', size: 'text-xl' },
{ glyph: 'ᚢ', pos: { top: '22%', right: '9%' }, delay: '0.6s', size: 'text-lg' },
{ glyph: 'ᚦ', pos: { top: '68%', left: '5%' }, delay: '1.2s', size: 'text-2xl' },
{ glyph: 'ᚨ', pos: { top: '78%', right: '7%' }, delay: '1.8s', size: 'text-xl' },
{ glyph: 'ᚱ', pos: { top: '42%', left: '2%' }, delay: '0.9s', size: 'text-lg' },
{ glyph: '', pos: { top: '55%', right: '4%' }, delay: '2.1s', size: 'text-2xl' },
{ glyph: '', pos: { top: '8%', left: '22%' }, delay: '0.3s', size: 'text-lg' },
{ glyph: 'ᚹ', pos: { top: '88%', left: '18%' }, delay: '1.5s', size: 'text-xl' },
{ glyph: 'ᚺ', pos: { top: '5%', right: '24%' }, delay: '2.4s', size: 'text-lg' },
{ glyph: 'ᚾ', pos: { top: '92%', right: '20%' }, delay: '0.75s', size: 'text-2xl' },
{ glyph: '', pos: { top: '50%', left: '11%' }, delay: '3s', size: 'text-lg' },
{ glyph: 'ᛃ', pos: { top: '33%', right: '13%' }, delay: '1.9s', size: 'text-xl' },
]
function RuneParticles() {
return (
<>
{RUNE_DATA.map((r, i) => (
<div
key={i}
className={`absolute ${r.size} font-cinzel text-gold/10 animate-float pointer-events-none select-none`}
style={{ ...r.pos, animationDelay: r.delay, animationDuration: `${4 + i * 0.35}s` }}
>
{r.glyph}
</div>
))}
</>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useGameStore } from '@/state/gameStore'
import { useSettingsStore, type Difficulty } from '@/state/settingsStore'
function VolumeRow({ label, icon, value, onChange }: {
label: string; icon: string; value: number; onChange: (v: number) => void
}) {
return (
<div className="flex items-center gap-3">
<span className="text-base w-5 text-center">{icon}</span>
<span className="font-garamond text-parchment/70 text-sm w-32 shrink-0">{label}</span>
<input
type="range" min={0} max={1} step={0.05} value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="font-cinzel text-gold text-xs w-9 text-right">{Math.round(value * 100)}%</span>
</div>
)
}
const DIFFICULTIES: { id: Difficulty; label: string; icon: string; color: string; desc: string }[] = [
{ id: 'normal', label: 'Обычный', icon: '⚔', color: 'text-emerald-400', desc: 'Для новичков' },
{ id: 'heroic', label: 'Героический', icon: '🛡', color: 'text-gold', desc: '+35% HP, +10% скорость' },
{ id: 'nightmare', label: 'Кошмар', icon: '💀', color: 'text-rose-400', desc: '+75% HP, +25% скорость' },
]
function Section({ title }: { title: string }) {
return (
<div className="ornament-line">
<span className="font-cinzel text-[11px] text-gold/70 uppercase tracking-[0.3em] px-3">{title}</span>
</div>
)
}
export function Settings() {
const setScreen = useGameStore((s) => s.setScreen)
const {
masterVolume, sfxVolume, musicVolume, showFps, difficulty,
setMasterVolume, setSfxVolume, setMusicVolume, setShowFps, setDifficulty,
} = useSettingsStore()
return (
<div className="w-full h-full flex flex-col bg-midnight-deep overflow-hidden">
<div className="flex items-center justify-between px-8 py-4 border-b border-gold/15 bg-midnight/60 backdrop-blur-sm shrink-0">
<button onClick={() => setScreen('menu')} className="btn-ghost text-xs py-2 px-4">
Назад
</button>
<h2 className="text-title text-xl tracking-[0.3em]">Настройки</h2>
<div className="w-24" />
</div>
<div className="flex-1 flex items-center justify-center p-8 overflow-y-auto">
<div className="panel-parchment w-full max-w-lg flex flex-col gap-5 p-7">
<Section title="Сложность" />
<div className="grid grid-cols-3 gap-2">
{DIFFICULTIES.map((d) => (
<button
key={d.id}
onClick={() => setDifficulty(d.id)}
className={`
flex flex-col items-center gap-1.5 py-3 px-2 border transition-all duration-200
${difficulty === d.id
? 'border-gold/60 bg-gold/10'
: 'border-parchment/15 bg-midnight/30 hover:border-gold/35 hover:bg-gold/5'
}
`}
>
<span className="text-xl">{d.icon}</span>
<span className={`font-cinzel text-[11px] font-bold tracking-wide ${d.color}`}>{d.label}</span>
<span className="font-garamond text-[10px] text-parchment/45 text-center leading-tight">{d.desc}</span>
{difficulty === d.id && (
<span className="font-cinzel text-[9px] text-gold/60 tracking-widest uppercase"> Выбрано</span>
)}
</button>
))}
</div>
<Section title="Звук" />
<div className="flex flex-col gap-3.5 px-1">
<VolumeRow label="Общая громкость" icon="🔊" value={masterVolume} onChange={setMasterVolume} />
<VolumeRow label="Звуки игры" icon="⚔" value={sfxVolume} onChange={setSfxVolume} />
<VolumeRow label="Музыка" icon="♪" value={musicVolume} onChange={setMusicVolume} />
</div>
<Section title="Интерфейс" />
<div className="flex items-center justify-between px-1 py-0.5">
<div>
<p className="font-cinzel text-sm text-parchment/80 tracking-wide">Показать FPS</p>
<p className="font-garamond text-xs text-parchment/40 mt-0.5">Счётчик кадров в секунду</p>
</div>
<button
onClick={() => setShowFps(!showFps)}
className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 ${
showFps ? 'bg-gold/80 border border-gold' : 'bg-midnight border border-parchment/20'
}`}
>
<span className={`absolute top-0.5 w-5 h-5 rounded-full bg-parchment transition-all duration-200 ${
showFps ? 'left-[22px]' : 'left-0.5'
}`} />
</button>
</div>
</div>
</div>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { useGameStore } from '@/state/gameStore'
import { useMetaStore } from '@/state/metaStore'
interface RuneDef {
id: string
symbol: string
name: string
description: string
cost: number
color: string
category: 'offense' | 'control' | 'support'
}
const ALL_RUNES: RuneDef[] = [
{ id: 'pierce', symbol: 'ᚦ', name: 'Пронзание', description: 'Снаряды проходят сквозь врагов', cost: 0, color: 'text-parchment', category: 'offense' },
{ id: 'burn', symbol: 'ᚠ', name: 'Горение', description: 'Поджигает цель — DoT каждую секунду', cost: 0, color: 'text-ember', category: 'offense' },
{ id: 'chain', symbol: 'ᚱ', name: 'Цепь', description: 'Урон перепрыгивает на 2 соседние цели', cost: 80, color: 'text-gold', category: 'offense' },
{ id: 'fracture', symbol: 'ᚨ', name: 'Излом', description: 'Снижает броню цели на 2 секунды', cost: 110, color: 'text-amber-400', category: 'offense' },
{ id: 'shock', symbol: 'ᛃ', name: 'Разряд', description: 'Оглушает цель на 0.5 сек', cost: 100, color: 'text-yellow-300', category: 'control' },
{ id: 'frost', symbol: 'ᚾ', name: 'Иней', description: 'Замедляет врагов на 30%', cost: 50, color: 'text-frost', category: 'control' },
{ id: 'weight', symbol: '', name: 'Тяжесть', description: 'Замедляет бронированных врагов', cost: 70, color: 'text-stone-400', category: 'control' },
{ id: 'curse', symbol: 'ᚺ', name: 'Проклятие', description: 'Цель получает +20% урона', cost: 150, color: 'text-rose-600', category: 'control' },
{ id: 'seek', symbol: '', name: 'Поиск', description: 'Снаряды чуть наводятся на цель', cost: 60, color: 'text-emerald-400', category: 'support' },
{ id: 'echo', symbol: 'ᚹ', name: 'Эхо', description: 'Каждый 5-й выстрел — двойной урон', cost: 90, color: 'text-purple-400', category: 'support' },
{ id: 'vampire', symbol: '', name: 'Вампиризм', description: 'Башня восстанавливает 1 ОЗ стены за 10 убийств', cost: 120, color: 'text-rose-400', category: 'support' },
{ id: 'hex', symbol: 'ᚢ', name: 'Гекс', description: 'Отменяет регенерацию и неуязвимость', cost: 200, color: 'text-violet-400', category: 'support' },
]
const CAT_LABELS = { offense: 'Нападение', control: 'Контроль', support: 'Поддержка' } as const
const CAT_COLORS = { offense: 'text-ember', control: 'text-frost', support: 'text-emerald-400' } as const
export function TomeOfRunes() {
const setScreen = useGameStore((s) => s.setScreen)
const { essence, unlockedRunes, unlockRune } = useMetaStore()
return (
<div className="w-full h-full flex flex-col bg-midnight-deep overflow-hidden">
<div className="flex items-center justify-between px-8 py-4 border-b border-gold/15 bg-midnight/60 backdrop-blur-sm shrink-0">
<button onClick={() => setScreen('menu')} className="btn-ghost text-xs py-2 px-4">
Назад
</button>
<h2 className="text-title text-xl tracking-[0.3em]">Том Рун</h2>
<div className="panel-frost flex items-center gap-2.5 px-4 py-2">
<span className="text-frost text-sm"></span>
<div>
<p className="font-cinzel text-[9px] text-frost/55 uppercase tracking-wider">Эссенция</p>
<p className="font-cinzel text-base text-frost font-bold text-glow-frost leading-none">{essence}</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<p className="font-garamond text-parchment/35 text-center text-sm mb-6 italic">
Откройте новые руны за эссенцию, добытую в боях с боссами
</p>
{(['offense', 'control', 'support'] as const).map((cat) => (
<div key={cat} className="mb-6">
<div className="ornament-line mb-4">
<span className={`font-cinzel text-[11px] ${CAT_COLORS[cat]} uppercase tracking-[0.3em] px-3`}>
{CAT_LABELS[cat]}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 max-w-4xl mx-auto">
{ALL_RUNES.filter((r) => r.category === cat).map((rune) => {
const unlocked = unlockedRunes.includes(rune.id)
const canAfford = essence >= rune.cost
return (
<RuneCard
key={rune.id}
rune={rune}
unlocked={unlocked}
canAfford={canAfford}
onUnlock={() => canAfford && unlockRune(rune.id)}
/>
)
})}
</div>
</div>
))}
</div>
</div>
)
}
function RuneCard({ rune, unlocked, canAfford, onUnlock }: {
rune: RuneDef; unlocked: boolean; canAfford: boolean; onUnlock: () => void
}) {
return (
<div className={`
relative flex flex-col items-center gap-2 p-4 border transition-all duration-300
${unlocked ? 'panel-parchment border-gold/35' : 'border-parchment/12 bg-midnight/40 opacity-55'}
`}>
{unlocked && (
<>
<span className="corner-mark corner-mark-tl" />
<span className="corner-mark corner-mark-br" />
</>
)}
<span className={`text-4xl font-cinzel ${rune.color} ${!unlocked ? 'grayscale opacity-40' : ''} transition-all`}>
{rune.symbol}
</span>
<p className={`font-cinzel text-sm font-bold tracking-wide ${rune.color}`}>{rune.name}</p>
<p className="font-garamond text-[11px] text-parchment/55 text-center leading-relaxed">{rune.description}</p>
<div className="rune-divider" />
{unlocked ? (
<span className="font-cinzel text-[10px] text-gold/65 tracking-widest uppercase"> Изучена</span>
) : rune.cost === 0 ? (
<span className="font-cinzel text-[10px] text-parchment/35 tracking-widest uppercase">Начальная</span>
) : (
<button
onClick={onUnlock}
disabled={!canAfford}
className={`btn-rune text-[10px] px-4 py-1.5 w-full ${!canAfford ? 'opacity-30 cursor-not-allowed' : ''}`}
>
{rune.cost}
</button>
)}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
export type PathRequest = {
id: number
grid: boolean[][] // walkable[row][col]
cols: number
rows: number
start: { x: number; y: number }
goal: { x: number; y: number }
}
export type PathResult = {
id: number
path: { x: number; y: number }[] | null
}
type Node = {
x: number
y: number
g: number
h: number
f: number
parent: Node | null
}
function heuristic(ax: number, ay: number, bx: number, by: number): number {
return Math.abs(ax - bx) + Math.abs(ay - by)
}
function astar(
walkable: boolean[][],
cols: number,
rows: number,
start: { x: number; y: number },
goal: { x: number; y: number }
): { x: number; y: number }[] | null {
const key = (x: number, y: number) => y * cols + x
const open = new Map<number, Node>()
const closed = new Set<number>()
const startNode: Node = {
x: start.x, y: start.y,
g: 0,
h: heuristic(start.x, start.y, goal.x, goal.y),
f: 0,
parent: null,
}
startNode.f = startNode.g + startNode.h
open.set(key(start.x, start.y), startNode)
const dirs = [[1,0],[-1,0],[0,1],[0,-1]]
while (open.size > 0) {
// pick lowest f
let current: Node | null = null
for (const node of open.values()) {
if (!current || node.f < current.f) current = node
}
if (!current) break
if (current.x === goal.x && current.y === goal.y) {
const path: { x: number; y: number }[] = []
let n: Node | null = current
while (n) { path.unshift({ x: n.x, y: n.y }); n = n.parent }
return path
}
open.delete(key(current.x, current.y))
closed.add(key(current.x, current.y))
for (const [dx, dy] of dirs) {
const nx = current.x + dx
const ny = current.y + dy
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue
if (!walkable[ny][nx]) continue
if (closed.has(key(nx, ny))) continue
const g = current.g + 1
const existing = open.get(key(nx, ny))
if (!existing || g < existing.g) {
const h = heuristic(nx, ny, goal.x, goal.y)
const node: Node = { x: nx, y: ny, g, h, f: g + h, parent: current }
open.set(key(nx, ny), node)
}
}
}
return null
}
self.onmessage = (e: MessageEvent<PathRequest>) => {
const { id, grid, cols, rows, start, goal } = e.data
const path = astar(grid, cols, rows, start, goal)
self.postMessage({ id, path } satisfies PathResult)
}
+103
View File
@@ -0,0 +1,103 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
midnight: '#0E1220',
'midnight-light': '#1a2035',
'midnight-deep': '#070b14',
parchment: '#E9DCC0',
gold: '#C9A14A',
'gold-dark': '#8B6914',
'gold-bright': '#f0c060',
ember: '#E8702A',
frost: '#6ECBD5',
'frost-dark': '#3a9aa8',
arcane: '#9b4de8',
'arcane-dark': '#5a1ea0',
void: '#050810',
},
fontFamily: {
cinzel: ['Cinzel', 'serif'],
garamond: ['"EB Garamond"', 'serif'],
},
keyframes: {
'rune-glow': {
'0%, 100%': { boxShadow: '0 0 8px 2px rgba(201, 161, 74, 0.4)' },
'50%': { boxShadow: '0 0 20px 6px rgba(201, 161, 74, 0.8)' },
},
'pulse-soft': {
'0%, 100%': { opacity: '0.65' },
'50%': { opacity: '1' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-6px)' },
},
flicker: {
'0%, 100%': { opacity: '1' },
'30%': { opacity: '0.75' },
'60%': { opacity: '0.9' },
'80%': { opacity: '0.7' },
},
'spin-slow': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
},
'spin-slow-rev': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(-360deg)' },
},
shimmer: {
'0%': { backgroundPosition: '-200% center' },
'100%': { backgroundPosition: '200% center' },
},
'slide-up': {
from: { opacity: '0', transform: 'translateY(16px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'fade-in': {
from: { opacity: '0' },
to: { opacity: '1' },
},
'scale-in': {
from: { opacity: '0', transform: 'scale(0.92)' },
to: { opacity: '1', transform: 'scale(1)' },
},
'glow-pulse-rose': {
'0%, 100%': { boxShadow: '0 0 8px rgba(244,63,94,0.4)' },
'50%': { boxShadow: '0 0 22px rgba(244,63,94,0.9)' },
},
'glow-pulse-frost': {
'0%, 100%': { boxShadow: '0 0 8px rgba(110,203,213,0.4)' },
'50%': { boxShadow: '0 0 22px rgba(110,203,213,0.9)' },
},
'sweep-in': {
from: { clipPath: 'inset(0 100% 0 0)' },
to: { clipPath: 'inset(0 0% 0 0)' },
},
},
animation: {
'rune-glow': 'rune-glow 2s ease-in-out infinite',
'pulse-soft': 'pulse-soft 3s ease-in-out infinite',
float: 'float 4s ease-in-out infinite',
flicker: 'flicker 3s ease-in-out infinite',
'spin-slow': 'spin-slow 24s linear infinite',
'spin-slow-rev': 'spin-slow-rev 18s linear infinite',
shimmer: 'shimmer 2.5s linear infinite',
'slide-up': 'slide-up 0.4s ease-out',
'fade-in': 'fade-in 0.5s ease-out',
'scale-in': 'scale-in 0.35s cubic-bezier(0.16,1,0.3,1)',
'glow-pulse-rose': 'glow-pulse-rose 2s ease-in-out infinite',
'glow-pulse-frost': 'glow-pulse-frost 2s ease-in-out infinite',
'sweep-in': 'sweep-in 0.6s ease-out forwards',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
worker: {
format: 'es',
},
})