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:
+24
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
Generated
+4396
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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 |
@@ -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 |
@@ -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,
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const Time = {
|
||||
delta: 0,
|
||||
elapsed: 0,
|
||||
scale: 1,
|
||||
|
||||
get scaledDelta() {
|
||||
return Time.delta * Time.scale
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' : ''}`} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}>
|
||||
⚔ Начать поход
|
||||
</button>
|
||||
<button className="btn-rune w-full text-sm py-3.5" onClick={() => setScreen('tome')}>
|
||||
✦ Том Рун
|
||||
</button>
|
||||
<button className="btn-ghost w-full text-sm py-3" onClick={() => setScreen('settings')}>
|
||||
⚙ Настройки
|
||||
</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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user