commit ac1f348311a199665a4d8355112e2b6bc034e131 Author: Maxim Dolgolyov Date: Wed Feb 25 01:01:02 2026 +0300 Initial commit: RPG game project Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d415649 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +saves/ +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e56df9 --- /dev/null +++ b/README.md @@ -0,0 +1,770 @@ +# ⚔️ Хроники Эйдона — Изометрическая RPG + +Браузерная изометрическая RPG, написанная на чистом JavaScript + HTML5 Canvas. Без фреймворков, без зависимостей — только нативный веб. Полная игра с боевой системой, прогрессией персонажа, крафтом, зачарованиями, сюжетными квестами, лором и финальным боссом. + +--- + +## Содержание + +1. [Технологии](#технологии) +2. [Архитектура](#архитектура) +3. [Управление](#управление) +4. [Классы персонажей](#классы-персонажей) +5. [Боевая система](#боевая-система) +6. [Заклинания](#заклинания) +7. [Дерево перков](#дерево-перков) +8. [Локации и мир](#локации-и-мир) +9. [Враги и боссы](#враги-и-боссы) +10. [NPC и диалоги](#npc-и-диалоги) +11. [Квесты](#квесты) +12. [Инвентарь и экипировка](#инвентарь-и-экипировка) +13. [Сеты экипировки](#сеты-экипировки) +14. [Зачарования](#зачарования) +15. [Крафт и алхимия](#крафт-и-алхимия) +16. [Магазин](#магазин) +17. [Журнал лора](#журнал-лора) +18. [Достижения](#достижения) +19. [Бестиарий](#бестиарий) +20. [Аудиосистема](#аудиосистема) +21. [Система сохранений](#система-сохранений) +22. [Визуальные эффекты](#визуальные-эффекты) +23. [Статистика контента](#статистика-контента) + +--- + +## Технологии + +| Технология | Применение | +|---|---| +| HTML5 Canvas | Изометрический рендеринг мира и персонажей | +| Web Audio API | Процедурная музыка и SFX без аудиофайлов | +| Vanilla JS (ES6+) | Вся игровая логика | +| CSS3 | UI панели, анимации, HUD | +| localStorage | Сохранение прогресса (3 слота) | +| File System Access API | Экспорт сохранений в JSON-файлы | + +**Файловая структура:** + +``` +├── index.html — разметка UI (HUD, панели, боевой экран, меню) +├── style.css — стили всего интерфейса +├── game.js — основная игровая логика, ИИ боссов, квесты +├── renderer.js — изометрический рендерер, портреты, частицы +├── rpg.js — механики RPG: классы, заклинания, перки, формулы +├── audio.js — Web Audio API: SFX + процедурная музыка +├── saves.js — система сохранений (localStorage + JSON-файлы) +├── data-loader.js — загрузчик JSON-данных игры +├── menu.js — стартовый экран и выбор класса +└── data/ + ├── world.json — локации, спавны, NPC, декорации, погода + ├── enemies.json — все типы врагов, боссов, уникальный лут + ├── quests.json — все квесты с условиями и наградами + ├── lore.json — записки лора с координатами на карте + ├── items/ + │ ├── loot.json — все собираемые предметы и материалы + │ ├── shop.json — ассортимент магазина + │ └── recipes.json — рецепты крафта + └── story/ + └── story_quests.json — сюжетные квесты с диалогами +``` + +--- + +## Архитектура + +Игра использует паттерн «объект-синглтон» для каждого модуля: + +- **`Game`** — главный объект: игровой цикл, состояние, ввод, переходы между локациями, система квестов, достижений, бой +- **`Renderer`** — изометрический рендерер с камерой, системой глубины (depth sorting), частицами, плавающим текстом, тряской экрана +- **`RPG`** — все формулы и механики: урон, защита, крит, заклинания, перки, лут, крафт, зачарование, прокачка +- **`Audio`** — процедурная музыка через `OscillatorNode` + `GainNode`, 9 тем и 7 типов SFX +- **`Saves`** — сериализация/десериализация состояния игрока, поддержка `Set` через Array при JSON +- **`DataLoader`** — асинхронная загрузка всех JSON-файлов перед стартом + +--- + +## Управление + +| Клавиша | Действие | +|---|---| +| `WASD` / `↑↓←→` | Движение по карте | +| `I` | Инвентарь | +| `Q` | Журнал квестов | +| `T` | Дерево перков | +| `C` | Крафтинг | +| `B` | Бестиарий | +| `E` | Зачарование | +| `L` | Журнал лора | +| `H` | Достижения | +| `M` | Карта мира / быстрое путешествие | +| `P` | Сохранить | +| `1` | Атака (в бою) | +| `2` | Использовать предмет (в бою) | +| `3` | Бежать (в бою) | +| `2–9` | Заклинания (в бою, если изучены) | +| `ESC` | Закрыть все панели | + +Также поддерживается **клик мышью** по соседней клетке для перемещения. + +--- + +## Классы персонажей + +В игре 7 классов с уникальными стартовыми параметрами, бонусами за уровень, заклинаниями и деревьями перков. + +| Класс | HP | MP | STR | DEF | MAG | SPD | Описание | +|---|---|---|---|---|---|---|---| +| ⚔️ Воин | 130 | 30 | 14 | 10 | 4 | 7 | Физический боец. Высокий HP и урон, низкая магия | +| 🔮 Маг | 65 | 110 | 4 | 4 | 16 | 6 | Маг с мощными заклинаниями, хрупкий в ближнем бою | +| 🏹 Лучник | 90 | 55 | 10 | 6 | 6 | 13 | Быстрый дальний боец. Максимальная скорость | +| 🛡️ Паладин | 115 | 65 | 10 | 13 | 8 | 6 | Защитник с исцелением и святым уроном | +| 💀 Некромант | 75 | 95 | 5 | 5 | 15 | 7 | Тёмный маг, похищающий жизнь и накладывающий проклятия | +| 🪓 Берсерк | 150 | 20 | 18 | 6 | 2 | 10 | Максимальная сила и HP, почти нет магии | +| 🌿 Друид | 85 | 85 | 7 | 7 | 11 | 9 | Универсальный класс с природной магией | + +**Бонусы за каждый уровень** (HP / MP / STR / DEF / MAG / SPD): +- Воин: +15 / +3 / +3 / +2 / +1 / +1 +- Маг: +5 / +15 / +1 / +1 / +3 / +1 +- Лучник: +8 / +5 / +2 / +1 / +1 / +3 +- Паладин: +12 / +8 / +2 / +3 / +2 / +1 +- Некромант: +6 / +12 / +1 / +1 / +4 / +1 +- Берсерк: +20 / +2 / +4 / +1 / +1 / +1 +- Друид: +9 / +9 / +2 / +2 / +2 / +1 + +**Стартовое снаряжение** выдаётся автоматически по классу: оружие, реагенты, базовая броня. + +--- + +## Боевая система + +Пошаговые бои. Порядок: ход игрока → ход врага. + +### Ход игрока +- **Атака** — базовый урон = `STR + weapon.damage`, снижается защитой врага. Шанс крит-удара (×1.5–3.0 урон) зависит от перков. +- **Заклинание** — урон/лечение по формулам с учётом `MAG`. Тратит `MP`. +- **Предмет** — использовать зелье из инвентаря (лечебное или боевое). +- **Бежать** — 50% шанс успешного побега. + +### Ход врага +Враг атакует по формуле: `enemy.dmg - player.DEF` (минимум 1). Поведение зависит от AI-типа. + +### Типы урона и слабости +| Тип | Иконка | Усиление при слабости | +|---|---|---| +| Физический | ⚔️ | ×1.5 | +| Огонь | 🔥 | ×2.0 | +| Лёд | ❄️ | ×2.0 | +| Святость | ✨ | ×2.0 | +| Магия | 🔮 | ×1.8 | +| Яд | ☠️ | — (DoT) | + +### Статус-эффекты (DoT) +- **Яд** (poison) — наносит урон каждый ход, несколько ходов +- **Горение** (burn) — то же, но с огненным уроном +- **Заморозка** (freeze) — пропуск хода +- **Оглушение** (stunned) — пропуск одного хода + +### Специальные механики +- **Неуязвимость** (`_invincible`) — атаки полностью блокируются (используется некоторыми боссами) +- **Уклонение** (shadow assassin, перк) — 35% шанс полностью уклониться +- **Фазирование** (призрак) — следующая атака игрока проходит насквозь +- **Слепота** (летучая мышь) — следующий удар игрока промахивается +- **Вампиризм** (перки, Корвус, Мрак) — восстанавливает HP от нанесённого урона +- **Смертный рывок** (паладин перк) — один раз за бой выжить с 1 HP + +--- + +## Заклинания + +Всего **18 заклинаний**. Некоторые доступны сразу по классу, остальные выбираются при повышении уровня или находятся в свитках. + +| Заклинание | MP | Тип | Эффект | +|---|---|---|---| +| Огненный шар | 15 | 🔥 Огонь | 28 урона | +| Ледяная стрела | 12 | ❄️ Лёд | 20 урона + заморозка | +| Молния | 20 | ⚡ Электро | 35 урона | +| Исцеление | 10 | 💚 Лечение | +35 HP | +| Священный огонь | 22 | ✨ Святость | 30 урона | +| Похищение жизни | 18 | 💜 Тьма | 15 урона + 12 HP себе | +| Мощный удар | 12 | ⚔️ Физика | ×2.2 урон | +| Облако яда | 20 | ☠️ Яд | DoT яд на 4 хода | +| Огненный шторм | 30 | 🔥 Огонь | 55 урона (AoE) | +| Метель | 35 | ❄️ Лёд | 50 урона + заморозка | +| Цепная молния | 28 | ⚡ Электро | 45 урона | +| Берсеркерство | 8 | 💪 Бафф | STR ×1.8 на 8 сек | +| Каменная кожа | 20 | 🛡️ Бафф | DEF ×2 на 12 сек | +| Теневой шаг | 15 | 👁️ Бафф | SPD ×2 на 5 сек | +| Проклятие | 15 | 💀 Дебафф | ATK врага ×0.7 | +| Мощное исцеление | 25 | 💚 Лечение | +75 HP | +| Дождь стрел | 22 | 🏹 Физика | 40 урона | +| Землетрясение | 40 | 🌍 Физика | 60 урона | +| Вихрь | 18 | ⚔️ Физика | 25 урона | +| Воскрешение | 50 | 💚 Лечение | Полное восстановление HP | + +--- + +## Дерево перков + +Каждый класс имеет **4 ветви** по **3 перка** (Tier 1–3). Перки открываются последовательно внутри ветви. За каждый уровень — +1 очко таланта. + +### Примеры перков по классам + +**Воин** — Ветви: Мощь, Броня, Выживание, Скорость +- Сокрушающий удар T1: +15% крит-урон +- Несокрушимый T2: +20% физической защиты +- Кровавая ярость T3: вампиризм 15% от урона + +**Маг** — Ветви: Арканум, Огонь, Лёд, Внутренняя сила +- Мастер заклинаний T1: -20% стоимость MP +- Огненное сердце T2: +30% урон огнём +- Ледяная кровь T3: иммунитет к заморозке + +**Лучник** — Ветви: Меткость, Выживание, Яд, Скорость +- Острый глаз T1: +25% крит-шанс +- Яд на стрелах T2: удары ядовиты +- Ураганный залп T3: шанс двойного удара + +**Паладин** — Ветви: Святость, Щит, Благодать, Крестовый поход +- Аура защиты T1: +10 к DEF +- Смертный рывок T2: выжить с 1 HP (1×/бой) +- Святой гнев T3: +50% урон нежити + +**Некромант** — Ветви: Тьма, Нежить, Проклятие, Душа +- Тёмный пакт T1: +20% урон заклинаниями +- Личи T2: восстановление 5 MP при убийстве +- Власть над тьмой T3: шанс оглушить врага + +**Берсерк** — Ветви: Ярость, Кровь, Жестокость, Буйство +- Боевой транс T1: +20% STR при HP < 50% +- Кровожадность T2: +8% вампиризма +- Неостановимый T3: иммунитет к оглушению + +**Друид** — Ветви: Природа, Зверь, Земля, Возрождение +- Слияние с природой T1: регенерация 5 HP/ход +- Зов зверя T2: +15% к скорости и уклонению +- Перерождение T3: воскреснуть с 30% HP (1×/бой) + +--- + +## Локации и мир + +Изометрическая сетка 15×15 тайлов. Камера следует за игроком со сглаживанием. + +| Локация | Тип | Погода | Особенности | +|---|---|---|---| +| 🏘️ Деревня | Безопасная | Ясно, дождь | Стартовая. Торговец, Целитель, Стражник, Старик | +| 🌲 Лес | Опасная | Дождь, туман | Деревья, камни. Гоблины, волки, Зубастый | +| 🏚️ Подземелье | Опасная | Туман | Колонны, факелы. Скелеты, зомби, Корвус | +| 🪨 Пещера | Опасная | Туман | Кристаллы. Летучие мыши, орки, Дракон, Колосс | +| ⛰️ Горы | Опасная | Снег | Скалы. Йети, Голем, Ледяной Великан Скарр | +| 🌿 Болото | Опасная | Туман, дождь | Топи. Пауки, Ведьма, Лич, Гидра | +| 🏛️ Руины | Опасная | Туман | Столбы, факелы. Призраки, Виверны, Призрак Ирис, портал в Бездну | +| 🌑 Бездна | Опасная | Туман (всегда) | Финальная локация. Тени, Нежить, **Мрак Безликий** | + +### Система погоды +- **Дождь** (rain) — анимированные капли, снижает яркость +- **Снег** (snow) — кружащиеся снежинки +- **Туман** (fog) — анимированный туман поверх карты +- **Ясно** / **Солнечно** — нормальная видимость + +### День и ночь +Непрерывный цикл дня и ночи (настраиваемая скорость). Ночью карта темнее, источники света (факелы, кристаллы) отбрасывают динамические ореолы. + +### Система порталов +Порталы расположены на краях карт. Обозначены иконкой ⬛. Наступить на портал — мгновенный переход в локацию. Бездна доступна только из Руин. + +--- + +## Враги и боссы + +### Обычные враги (15 типов) + +| Враг | HP | DMG | DEF | EXP | Слабость | Сопр. | ИИ | +|---|---|---|---|---|---|---|---| +| 👺 Гоблин | 32 | 8 | 2 | 20 | Огонь | — | Трус (паникует при низком HP) | +| 🧌 Орк | 55 | 13 | 4 | 40 | Магия | Физика | Берсерк (+50% урон при HP<30%) | +| 💀 Скелет | 42 | 11 | 6 | 30 | Святость | Яд | Нейтральный | +| 🟢 Слизень | 22 | 5 | 0 | 10 | Огонь | Физика | Кислота (DoT при ударе) | +| 🗡️ Разбойник | 38 | 10 | 3 | 25 | Святость | — | Кража (крадёт золото) | +| 🐺 Волк | 30 | 9 | 2 | 18 | Огонь | — | Боевой клич (буст урона) | +| 🕷️ Паук | 28 | 12 | 1 | 22 | Огонь | Яд | Яд (отравляет 40%) | +| 🧟 Зомби | 48 | 8 | 8 | 28 | Огонь | Яд | Разложение (снижает DEF) | +| 🦇 Летучая мышь | 18 | 7 | 0 | 12 | Святость | — | Рой (слепота) | +| 👹 Тролль | 110 | 20 | 8 | 90 | Огонь | Физика | Регенерация (+5% HP/ход) | +| 🏔️ Йети | 95 | 18 | 10 | 80 | Огонь | Лёд | Берсерк (+50% урон при HP<30%) | +| 🧙 Ведьма | 60 | 22 | 3 | 70 | Святость | Магия | Исцеление (55% при HP<45%) | +| 🗿 Голем | 130 | 16 | 20 | 100 | Магия | Физика | Оглушение (28% шанс) | +| 👻 Призрак | 55 | 20 | 0 | 80 | Святость | Физика | Фазирование (эфирный план) | +| 🐉 Виверна | 95 | 26 | 7 | 140 | Лёд | Огонь | Пике (+55% урон при HP<60%) | + +### Боссы (2) + +| Босс | HP | DMG | DEF | EXP | Слабость | ИИ | +|---|---|---|---|---|---|---| +| 🐲 Дракон | 220 | 32 | 16 | 220 | Лёд | Ярость (двойной удар при HP<40%) | +| ☠️ Лич | 140 | 38 | 10 | 200 | Святость | Призыв скелета + саморегенерация | + +### Мини-боссы (6) — с боссбаром и уникальным лутом + +| Мини-босс | Локация | HP | EXP | Механика | Уникальный лут | +|---|---|---|---|---|---| +| 👑 Зубастый | Лес | 145 | 280 | Боевой клич +30% урон, призыв гоблина | Корона Зубастого | +| 💜 Корвус Некромант | Подземелье | 180 | 420 | Призыв зомби, филактерий (2 хода неуязв.), вампиризм 25% | Посох Корвуса | +| 🐍 Болотная Гидра | Болото | 210 | 360 | Регенерация 7%/ход, 2–3 головы (доп. атаки) | Клык Гидры | +| 🧊 Ледяной Великан Скарр | Горы | 200 | 390 | Метель (оглушение + бонус урон), заморозка | Топор Великана | +| 🗿 Каменный Колосс | Пещера | 260 | 440 | Непробиваемая броня каждые 3 хода, сокрушение ×2.5 | Щит Колосса | +| 🗡️ Призрак Ирис | Руины | 160 | 410 | Уклонение 35%, удар из тени ×3, яд клинка | Клинок Ирис | + +### Финальный мега-босс + +**🌑 Мрак Безликий** (Бездна) + +| Параметр | Значение | +|---|---| +| HP | 700 | +| DMG | 55 | +| DEF | 22 | +| EXP | 3 000 | +| Gold | 800 | +| Слабость | Святость | +| Сопротивление | Магия | + +**Три фазы боя:** +- **Фаза 1 (HP < 70%)** — призывает Призрака + Виверну +- **Фаза 2 (HP < 50%)** — принимает истинную форму (+35% DMG), становится неуязвим на 1 ход +- **Фаза 3 (HP < 30%)** — вампиризм 40% от урона + логируется «Мрак поглощает всё» + +**Дополнительно:** 25% шанс случайного дебаффа каждый ход (яд / горение / оглушение) + +**Уникальный лут:** 🌑 Корона Бездны — DEF+25, HP+80, MAG+15, STR+10 (Легендарная) + +### Поведение ИИ (17 типов) + +| ИИ | Поведение | +|---|---| +| `coward` | Паника при HP<20%, пропускает ход | +| `berserk` | +50% урон при HP<30% | +| `regen` | +5% HP регенерация каждый ход | +| `hex` | Самоисцеление при HP<45% (55% шанс) | +| `summon` | Призыв скелета при HP<50% | +| `warcry` | +30% урон + призыв гоблина при HP<60% | +| `necroboss` | Призыв зомби + филактерий (2 хода неуязвимости) + вампиризм | +| `hydra` | Регенерация + доп. атаки из голов (2 и 3 при HP<50%/30%) | +| `frost` | Метель (оглушение) + заморозка | +| `colossus` | Каменная броня каждые 3 хода + сокрушение | +| `shadow` | Уклонение 35% + удар из тени ×3 + яд | +| `fury` | Двойной удар при HP<40% | +| `acid` | Кислота (горение) 35% | +| `stun` | Оглушение 28% | +| `venom` | Яд 40% | +| `howl` | Усиление следующей атаки при HP<50% | +| `steal` | Кража золота 20% | +| `phase` | Фазирование (следующая атака промажет) | +| `dive` | Пике +55% урон при HP<60% | +| `decay` | Снижение DEF игрока | +| `swarm` | Слепота (следующая атака промажет) | +| `chaos` | 3-фазная система + случайные дебаффы | + +--- + +## NPC и диалоги + +Разветвлённая диалоговая система с историей выборов, наградами и платными опциями. + +| NPC | Локация | Функция | +|---|---|---| +| 🔵 Торговец | Деревня | Магазин с 18 предметами | +| 🔴 Стражник | Деревня | Квесты + советы о локациях (платный совет за 15 💰) | +| 🟢 Целитель | Деревня | Полное исцеление HP+MP за 20 💰 | +| ⚪ Старик | Деревня | Лор мира + благословение за 50 💰 | +| 🟩 Эльф | Лес | Квесты + информация об угрозах леса | +| 🟣 Шаман | Болото | Квесты + исцеление от яда | +| 🩵 Призрак | Подземелье | Квесты + тайна победы над Личем | +| 💙 Страж | Руины | Квесты + история замка | + +**Диалоговые ветки** поддерживают: +- Платные опции (вычитают золото) +- Награды (опыт, предметы, баффы) +- Многоуровневые ветки (`next:` переходы) +- Отображение квест-маркеров над головами NPC (❗ новый квест / ❓ сдать / ✓ выполнен) + +--- + +## Квесты + +### 26 квестов с цепочкой прогресса + +Квесты открываются последовательно по мере выполнения предыдущих (система цепочки). Одновременно активно до 3 ближайших незавершённых квестов. + +#### Квесты на убийство +| Квест | Цель | EXP | Золото | +|---|---|---|---| +| Первая кровь | 3 гоблина | 50 | 20 | +| Охота на волков | 3 волка | 60 | 30 | +| Зачистка леса | 5 врагов | 100 | 40 | +| Проблема слизней | 3 слизня | 45 | 25 | +| Убрать разбойников | 4 разбойника | 80 | 60 | +| Армия скелетов | 4 скелета | 120 | 60 | +| Пауки болота | 5 пауков | 90 | 55 | +| Орочья угроза | 5 орков | 150 | 80 | +| Зачистка болота | 3 паука + 2 зомби | 130 | 70 | +| Бой с троллем | 1 тролль | 200 | 100 | +| Убийца дракона | 1 дракон | 500 | 300 | +| Конец некромантии | 1 Лич | 450 | 250 | +| Снежный зверь | 1 йети | 160 | 100 | +| Охота на виверн | 2 виверны | 280 | 160 | +| Упокоить призраков | 3 призрака | 200 | 100 | +| Король Гоблинов | Зубастый | 450 | 250 | +| Конец Корвуса | Корвус | 550 | 300 | +| Гидра болот | Гидра | 480 | 280 | +| Ледяной Великан | Скарр | 510 | 290 | +| Каменный Колосс | Колосс | 560 | 310 | +| Призрак Ирис | Ирис | 530 | 295 | +| **Конец Тьмы** | **Мрак Безликий** | **3000** | **500** | + +#### Квесты на посещение +| Квест | Цель | EXP | Золото | +|---|---|---|---| +| Вход в подземелье | Найти подземелье | 30 | 15 | +| Вход в пещеру | Найти пещеру | 50 | 20 | +| Исследователь | Посетить Руины | 80 | 40 | + +### Сюжетные квесты +Многоэтапные квесты от именных NPC. Каждый этап имеет диалог до и после выполнения, отдельные награды за каждый этап. + +--- + +## Инвентарь и экипировка + +### Слоты экипировки (7) +`weapon` (оружие) · `shield` (щит) · `head` (шлем) · `chest` (броня) · `legs` (поножи) · `feet` (сапоги) · `acc` (украшение) + +### Предметы в инвентаре +Каждый предмет имеет: +- `rarity` — Common / Uncommon / Rare / Epic / Legendary (цветная рамка) +- `slot` — слот экипировки (если снаряжение) +- `stackable` + `qty` — стекируемые предметы (зелья, материалы) +- `enchant` — зачарование (иконка поверх предмета) +- `combatEffect` — яд/огонь при ударе (для некоторых оружий) + +### Drag & Drop +Предметы в инвентаре можно **перетаскивать**: +- Между ячейками инвентаря — меняет порядок +- На слот экипировки — автоматически надевает (если подходящий тип) + +### Материалы лута (22 типа) +Трава, ухо гоблина, клык орка, мясо, слизь, кость, волчья шкура, паучий яд, гнилая плоть, крыло летучей мыши, сердце тролля, шкура йети, зелье ведьмы, ядро голема, чешуя дракона, сердце дракона, эссенция призрака, чешуя виверны, яд виверны, чешуя гидры, сердце мороза, сердцевина колосса. + +--- + +## Сеты экипировки + +При ношении нескольких предметов одного сета активируются бонусы. + +### ⚔️ Стальной доспех (Steel) +Предметы: стальной меч, кольчуга, стальной щит, шлем воина +- 2 предмета: +4 DEF +- 4 предмета: +6 STR, +4 DEF, +20 HP + +### ✨ Посох чародея (Arcane) +Предметы: посох силы, кольцо мага, посох бури +- 1+ предмет: +5 MAG, +20 MP + +### 💀 Тёмные чары (Shadow) +Предметы: посох черепа, костяной кинжал, ночной клинок +- 2 предмета: +6 MAG, +4 STR + +### ⛪ Доспех паладина (Holy) +Предметы: нагрудник паладина (из магазина или крафта) +- 1 предмет: +5 DEF, +15 HP +- 2 предмета: +8 DEF, +30 HP, +4 MAG + +--- + +## Зачарования + +Система улучшения снаряжения с расходными материалами. Открывается клавишей `E`. + +| Зачарование | Эффект | Материал | Золото | Тип предмета | +|---|---|---|---|---| +| 🔥 Огненный | +5 огненного урона | Чешуя дракона | 150 | Оружие | +| ❄️ Ледяной | +4 защиты | Шкура йети | 130 | Броня | +| ✨ Святой | +4 урона | Сердце тролля | 180 | Оружие | +| 💚 Жизненный | +25 HP | 3× Трава | 80 | Любое | +| 🔮 Магический | +4 MAG, +15 MP | 2× Слизь | 120 | Оружие | +| ⚡ Быстрый | +4 STR | 2× Крыло мыши | 100 | Любое | +| 🛡️ Стражника | +6 DEF | 3× Кость | 110 | Броня | +| ☠️ Ядовитый | +4 ядовитого урона | 2× Паучий яд | 70 | Оружие | + +--- + +## Крафт и алхимия + +32 рецепта в 5 категориях. Открывается клавишей `C`. + +### Зелья +| Рецепт | Ингредиенты | Результат | +|---|---|---| +| Малое зелье HP | Трава + Слизь | +40 HP | +| Среднее зелье HP | 2× Трава + Слизь | +60 HP | +| Большое зелье HP | 3× Трава + Сердце тролля | +100 HP | +| Зелье маны | 2× Крыло мыши + Паучий яд | +50 MP | +| Антидот | Трава + Волчья шкура | Снятие яда | + +### Алхимия (боевые зелья) +| Рецепт | Ингредиенты | Результат | +|---|---|---| +| Зелье силы | 2× Трава + Клык орка | STR ×1.5 на 45 сек | +| Зелье камня | 2× Шкура + 2× Кость | DEF ×2 на 30 сек | +| Зелье регенерации | 3× Трава + Сердце тролля | +40 HP + рег. | +| Яд-склянка | 2× Паучий яд + Крыло | Яд на врага (3 хода) | +| Огненная колба | Чешуя дракона + Трава | 35 огненного урона | +| Оберег призраков | 2× Эссенция + 2× Трава | +30 HP, защита от нежити | +| Зелье тени | Паучий яд + Крыло мыши | Эликсир уклонения | +| Эликсир могущества | Сердце дракона + Зелье ведьмы | Легендарный усилитель всех стат | + +### Снаряжение из крафта +| Рецепт | Ингредиенты | Результат | +|---|---|---| +| Чешуйчатая броня | 2× Чешуя + 2× Шкура | DEF+14, редкое | +| Посох Смерти | Посох черепа + Ядро + 3× Ухо | DMG+22, MAG+12, эпик | +| Амулет жизни | Сердце дракона + Сердце тролля | +60 HP, +30 MP, акс. | +| Нагрудник паладина | Ядро + Сердце тролля + 3× Кость | DEF+16, HP+40 | +| Посох бури | Чешуя + Шкура йети + Ядро | DMG+12, MAG+18, эпик | +| Ледяной клинок | Сердце мороза + Чешуя | DMG+25, замораживание | +| Броня Колосса | Сердцевина + Ядро | DEF+22, HP+60, легенд. | +| Кольчуга Гидры | 2× Чешуя Гидры + Чешуя виверны | DEF+18, Яд при ударе | + +### Руны +| Рецепт | Ингредиенты | Результат | +|---|---|---| +| Руна Силы | Ядро голема + 2× Клык | +5 STR навсегда | +| Руна Магии | Чешуя + Некрономикон | +5 MAG навсегда | +| Руна Защиты | Шкура йети + 3× Кость | +4 DEF навсегда | + +--- + +## Магазин + +18 предметов у Торговца в Деревне. Цены указаны в золоте. + +| Предмет | Тип | Характеристики | Цена | +|---|---|---|---| +| ⚔️ Стальной меч | Оружие | DMG+12 | 120 | +| 🪓 Боевой топор | Оружие | DMG+14 | 160 | +| 🪄 Посох силы | Оружие | DMG+5, MAG+8 | 140 | +| 🛡️ Кольчуга | Броня | DEF+8 | 110 | +| 🛡️ Стальной щит | Щит | DEF+6 | 90 | +| ⛑️ Шлем воина | Шлем | DEF+4, HP+15 | 95 | +| 👟 Сапоги ловкости | Сапоги | DEF+2, STR+3 | 75 | +| 💍 Кольцо мага | Акс. | MAG+5, MP+20 | 200 | +| 💍 Кольцо защиты | Акс. | DEF+4, armor+3 | 180 | +| 🧪 Среднее зелье HP | Зелье | +60 HP | 35 | +| 🧪 Большое зелье HP | Зелье | +100 HP | 70 | +| 🧪 Среднее зелье MP | Зелье | +40 MP | 45 | +| 📜 Свиток огня | Свиток | Заклинание: Огненный шар | 80 | +| 📜 Свиток исцеления | Свиток | Заклинание: Исцеление | 60 | +| 🗡️ Ночной клинок | Оружие | DMG+18, STR+4 (Легенд.) | 600 | +| 🌩️ Посох бури | Оружие | DMG+8, MAG+14, MP+30 | 550 | +| 🛡️ Нагрудник паладина | Броня | DEF+14, HP+30 | 500 | +| 🧪 Зелье регенерации | Зелье | +40 HP + рег. | 90 | + +--- + +## Журнал лора + +16 записок разбросаны по всем локациям. При наступлении на клетку с запиской она автоматически собирается и попадает в журнал (`L`). В журнале записки сгруппированы по локациям. + +| Запись | Локация | Содержание | +|---|---|---| +| Старая записка | Деревня | Предупреждение о драконе | +| Объявление на столбе | Деревня | Розыск Зубастого | +| Эльфийский дневник | Лес | Изменения леса с появлением Шамана | +| Измятый пергамент | Лес | Предупреждение о Ведьме | +| Поваленное дерево | Лес | Указатель на логово Зубастого | +| Надпись на стене | Подземелье | Рисунок Лича, написано кровью | +| Дневник солдата | Подземелье | Записки заточённого бойца | +| Сожжённая страница | Подземелье | О захвате Корвусом подземелья | +| Записка исследователя | Пещера | Кристаллы поглощают магию | +| Рунный камень | Пещера | Загадка об огне и льде | +| Предостережение | Пещера | О Каменном Колоссе | +| Высеченный текст | Горы | История Первого Голема | +| Табличка на перевале | Горы | Предупреждение о Скарре | +| Страница гримуара | Болото | Рецепт зелья тени | +| Записка беглеца | Болото | Слабость Ведьмы | +| Болотный знак | Болото | Легенда о Гидре | +| Выцветший пергамент | Руины | Легенда об Ирис | +| Чёрный портал | Руины | Предчувствие чего-то тёмного | +| Записка Первого Героя | Бездна | Послание Эйдора I о Мраке | + +--- + +## Достижения + +20 достижений. Открываются автоматически при выполнении условий. Тост-уведомление с анимацией. Панель достижений открывается клавишей `H`. + +| Иконка | Название | Условие | +|---|---|---| +| 🩸 | Первая кровь | Убить первого врага | +| ⚔️ | Убийца | Убить 50 врагов | +| 👹 | Охотник за боссами | Убить любого мини-босса | +| 🏆 | Чемпион | Убить всех 6 мини-боссов | +| 💀 | Легенда | Убить Мрака Безликого | +| ⭐ | Опытный | Достигнуть 5 уровня | +| 🌟 | Ветеран | Достигнуть 10 уровня | +| 💰 | Богач | Накопить 500 золота | +| 👑 | Золотой король | Накопить 1000 золота | +| 🗺️ | Исследователь | Посетить все 8 локаций | +| 🌑 | Путь в бездну | Достигнуть локации Бездна | +| ⚗️ | Алхимик | Скрафтить 5 предметов | +| 📖 | Зоолог | Открыть 10 записей бестиария | +| 🛡️ | Непробиваемый | Выиграть бой без потери HP | +| 💥 | Снайпер | Нанести 10 критических ударов | +| ✨ | Чародей | Использовать заклинания 10 раз | +| 📜 | Летописец | Прочесть все записки на карте | +| 📋 | Герой | Выполнить 10 квестов | +| 🔮 | Зачарователь | Зачаровать предмет | +| 🎒 | Барахольщик | Собрать 20 предметов в инвентаре | + +--- + +## Бестиарий + +Открывается клавишей `B`. Содержит карточки всех типов врагов. Неизвестные враги скрыты (`???`). После первого убийства открывается: + +- Нарисованный портрет (canvas) +- Лорное описание +- Счётчик убийств +- Слабость и сопротивление + +--- + +## Аудиосистема + +Полностью процедурная генерация звука через **Web Audio API** — никаких аудиофайлов. + +### Звуковые эффекты (SFX) +| Событие | Описание | +|---|---| +| Атака / крит | Sawtooth burst с экспоненциальным затуханием. Крит — выше частота | +| Заклинание | Огонь: нарастающий свип. Лёд: нисходящий. Исцеление: аккорд. Магия: арпеджио | +| Шаг | Тихий низкочастотный клик | +| Повышение уровня | Восходящее арпеджио C-E-G-C' | +| Победа | Фанфара 3 ноты | +| Смерть | Нисходящий минорный аккорд | +| Открытие сундука / записка | Короткий джингл | + +### Фоновые темы (9 локаций) +| Тема | BPM | Характер | +|---|---|---| +| Деревня | 80 | C-мажорная пентатоника, спокойно | +| Лес | 95 | Арпеджированный минор, быстрее | +| Подземелье | 50 | Низкий мрачный дрон | +| Болото | 55 | Эерная хроматика, неравномерный ритм | +| Горы | 60 | Медленный эпический минор | +| Пещера | 55 | Тёмная пещерная атмосфера | +| Бой | 140 | Быстрый стаккато минор | +| Руины | — | Тихий атмосферный | +| **Бездна** | **40** | **Зловещий хроматический дрейф, контрабас** | + +Кнопка 🔊 в HUD переключает звук. Темы плавно меняются при переходе между локациями. + +--- + +## Система сохранений + +### 3 слота сохранений +Каждый слот показывает: класс персонажа, уровень, количество часов игры, дату сохранения. + +### Автосохранение +Происходит автоматически: после каждого боя, после путешествия, при выполнении квеста. + +### Ручное сохранение +Клавиша `P` — мгновенное сохранение с анимацией индикатора. + +### Экспорт +Кнопка «📁 Папка сохранений» + «💾 Экспорт» — сохраняет все слоты в выбранную папку как JSON-файлы через File System Access API. + +### Формат данных +Сохраняется: персонаж (стат, инвентарь, квесты, перки, бестиарий, найденный лор, достижения), текущая локация, время суток, день. + +**Важно:** `Set`-объекты (achievements, _visited) автоматически конвертируются в `Array` при JSON-сериализации и восстанавливаются при загрузке. + +--- + +## Визуальные эффекты + +### Изометрический рендерер +- Depth sorting — все объекты отрисовываются в правильном изометрическом порядке по значению `x + y` +- Плавное движение персонажа между тайлами (линейная интерполяция) +- Боб-анимация врагов (покачивание `sin(time)`) +- Эффект атаки врага (смещение вперёд при ударе) + +### Частицы +| Тип | Описание | +|---|---| +| `hit` | Красные искры при ударе | +| `heal` | Зелёные частицы при исцелении | +| `magic` | Фиолетовые звёздочки заклинаний | +| `death` | Рассеивающийся силуэт | +| `gold` | Золотые монеты | +| `holy` | Белые лучи святости | +| `fire` | Оранжевые искры | +| `ice` | Голубые кристаллы | + +### Плавающий текст +Урон, лечение, критический удар, статус-эффекты — всплывают над персонажами с анимацией подъёма и затухания. + +### Тряска экрана +Шейкер активируется при критических ударах и мощных ударах боссов (интенсивность 3–8 пикселей). + +### Динамическое освещение +Факелы и кристаллы создают анимированные световые ореолы с пульсацией. Ночью интенсивность возрастает. + +### Анимация портретов в бою +- **Дыхание**: CSS-анимация `translateY(-2px)` — игрок 3.2s, враг 2.4s +- **Моргание**: JS-интервал каждые 4–6 сек, перекрытие глаз на 120ms + +### Босс-бар +При встрече с мини-боссом или финальным боссом снизу экрана появляется расширенная полоска HP с именем и анимацией входа. + +### Меню +Анимированный canvas с изометрической сценой на фоне стартового экрана. Заголовок с пульсирующим золотым свечением. + +--- + +## Статистика контента + +| Категория | Количество | +|---|---| +| Локации | 8 | +| Классы персонажей | 7 | +| Обычных врагов | 15 | +| Боссов и мини-боссов | 8 | +| Всего типов врагов | 23 | +| Типов ИИ поведения | 21 | +| Заклинаний | 19 | +| Перков в деревьях | 84 (7 классов × 4 ветви × 3 перка) | +| Квестов | 26 | +| Сюжетных квестов | +N этапных квестов | +| NPC с диалогами | 8 | +| Записок лора | 18 | +| Достижений | 20 | +| Зачарований | 8 | +| Рецептов крафта | 32 | +| Предметов в магазине | 18 | +| Уникальных материалов лута | 22 | +| Легендарных предметов | 8 | +| Сетов экипировки | 4 | +| Музыкальных тем | 9 | +| Слотов сохранения | 3 | + +--- + +## Запуск + +Просто откройте `index.html` в браузере. Никаких сборщиков, серверов или зависимостей не требуется. Данные игры загружаются из `data/*.json` через `fetch`, поэтому при открытии с локального диска может потребоваться простой локальный сервер (например, Live Server в VS Code или `npx serve .`). + +--- + +*Разработано с помощью Claude Sonnet 4.6* diff --git a/audio.js b/audio.js new file mode 100644 index 0000000..8a7eaf7 --- /dev/null +++ b/audio.js @@ -0,0 +1,479 @@ +// ══════════════════════════════════════════════════════════════ +// Audio — процедурные SFX + фоновая музыка (Web Audio API) +// ══════════════════════════════════════════════════════════════ +const Audio = { + ctx: null, + _master: null, + _musicGain: null, // отдельный узел для музыки — глушится при смене темы + _menuBgm: null, // HTML-аудио для mainmenu.mp3 + _musicSeqId: 0, + currentTheme: null, + muted: false, + _volume: 0.6, + _lastStep: 0, + + // ── Инициализация ───────────────────────────────────────── + init() { + // Создать HTML-элемент для MP3 меню (не требует AudioContext) + if (!this._menuBgm) { + const el = document.createElement('audio'); + el.src = 'mainmenu.mp3'; + el.loop = true; + el.volume = this._volume; + el.muted = this.muted; + this._menuBgm = el; + } + if (this.ctx) return; + try { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + this._master = this.ctx.createGain(); + this._master.gain.value = this._volume; + this._master.connect(this.ctx.destination); + // Отдельный гейн для музыкальных нот (мгновенно глушится при смене темы) + this._musicGain = this.ctx.createGain(); + this._musicGain.gain.value = 1; + this._musicGain.connect(this._master); + } catch(e) { console.warn('Web Audio недоступен:', e); } + }, + + toggleMute() { + if (!this.ctx) return; + this.muted = !this.muted; + this._master.gain.value = this.muted ? 0 : this._volume; + if (this._menuBgm) this._menuBgm.muted = this.muted; + const btn = document.getElementById('btn-mute'); + if (btn) btn.textContent = this.muted ? '🔇' : '🔊'; + }, + + setVolume(v) { + this._volume = Math.max(0, Math.min(1, v)); + if (this._master && !this.muted) this._master.gain.value = this._volume; + if (this._menuBgm) this._menuBgm.volume = this._volume; + }, + + // ── Низкоуровневый синтез ────────────────────────────────── + _note(freq, startTime, dur, type, gainVal, dest, filterFreq) { + if (!this.ctx || this.muted) return; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = type || 'sine'; + osc.frequency.setValueAtTime(freq, startTime); + const atk = 0.01; + const rel = Math.min(dur * 0.4, 0.15); + g.gain.setValueAtTime(0, startTime); + g.gain.linearRampToValueAtTime(gainVal, startTime + atk); + g.gain.setValueAtTime(gainVal, startTime + dur - rel); + g.gain.exponentialRampToValueAtTime(0.0001, startTime + dur); + if (filterFreq) { + const f = this.ctx.createBiquadFilter(); + f.type = 'lowpass'; + f.frequency.value = filterFreq; + osc.connect(f); f.connect(g); + } else { + osc.connect(g); + } + g.connect(dest || this._master); + osc.start(startTime); + osc.stop(startTime + dur + 0.01); + }, + + _noise(startTime, dur, gainVal, filterFreq) { + if (!this.ctx || this.muted) return; + const bufLen = Math.ceil(this.ctx.sampleRate * dur); + const buf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buf; + const g = this.ctx.createGain(); + g.gain.setValueAtTime(gainVal, startTime); + g.gain.exponentialRampToValueAtTime(0.0001, startTime + dur); + const f = this.ctx.createBiquadFilter(); + f.type = 'bandpass'; + f.frequency.value = filterFreq || 800; + f.Q.value = 0.5; + src.connect(f); f.connect(g); g.connect(this._master); + src.start(startTime); src.stop(startTime + dur + 0.01); + }, + + // ── SFX ─────────────────────────────────────────────────── + playHit(crit) { + if (!this.ctx) return; + const t = this.ctx.currentTime; + const mul = crit ? 1.6 : 1; + // удар — нисходящий шум + низкий удар + this._noise(t, 0.08, crit ? 0.3 : 0.18, 1200 * mul); + this._note(180 * mul, t, 0.1, 'sawtooth', 0.18, null, 400); + this._note(90, t + 0.04, 0.12, 'sine', 0.25, null, 300); + if (crit) { + // дополнительный хруст для крита + this._note(440, t, 0.05, 'square', 0.12); + this._note(330, t + 0.05, 0.08, 'square', 0.1); + } + }, + + playSpell(type) { + if (!this.ctx) return; + const t = this.ctx.currentTime; + switch (type) { + case 'fire': { + // восходящий пламенный свист + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(200, t); + osc.frequency.exponentialRampToValueAtTime(900, t + 0.35); + g.gain.setValueAtTime(0.15, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.35); + osc.connect(g); g.connect(this._master); + osc.start(t); osc.stop(t + 0.36); + this._noise(t, 0.25, 0.1, 1800); + break; + } + case 'ice': { + // нисходящий кристальный звон + [1046, 784, 523, 392].forEach((f, i) => { + this._note(f, t + i * 0.06, 0.18, 'sine', 0.12); + }); + break; + } + case 'heal': { + // мажорный аккорд + [261, 329, 392, 523].forEach((f, i) => { + this._note(f, t + i * 0.04, 0.4, 'sine', 0.1); + }); + break; + } + case 'magic': + default: { + // арпеджио вверх-вниз + const notes = [261, 329, 392, 523, 392, 329]; + notes.forEach((f, i) => { + this._note(f, t + i * 0.07, 0.1, 'triangle', 0.12); + }); + break; + } + } + }, + + playStep() { + if (!this.ctx) return; + const now = Date.now(); + if (now - this._lastStep < 250) return; // дроссель + this._lastStep = now; + const t = this.ctx.currentTime; + this._noise(t, 0.04, 0.04, 300); + this._note(80, t, 0.04, 'sine', 0.06, null, 200); + }, + + playLevelUp() { + if (!this.ctx) return; + const t = this.ctx.currentTime; + // восходящее арпеджио C-E-G-C' + [261, 329, 392, 523].forEach((f, i) => { + this._note(f, t + i * 0.12, 0.2, 'triangle', 0.15); + this._note(f * 2, t + i * 0.12 + 0.06, 0.1, 'sine', 0.07); + }); + // завершение — аккорд + [523, 659, 784].forEach(f => { + this._note(f, t + 0.6, 0.5, 'sine', 0.1); + }); + }, + + playVictory() { + if (!this.ctx) return; + const t = this.ctx.currentTime; + // небольшая фанфара + const mel = [392, 392, 392, 523, 392, 523, 659]; + const durs = [0.15, 0.15, 0.15, 0.4, 0.15, 0.15, 0.6]; + let pos = 0; + mel.forEach((f, i) => { + this._note(f, t + pos, durs[i] * 0.9, 'triangle', 0.18); + this._note(f / 2, t + pos, durs[i] * 0.9, 'sine', 0.08); + pos += durs[i]; + }); + }, + + playDeath() { + if (!this.ctx) return; + const t = this.ctx.currentTime; + // нисходящий минорный аккорд + [220, 261, 311].forEach((f, i) => { + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(f, t); + osc.frequency.exponentialRampToValueAtTime(f * 0.5, t + 1.2); + g.gain.setValueAtTime(0.15, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 1.2); + osc.connect(g); g.connect(this._master); + osc.start(t); osc.stop(t + 1.25); + }); + this._noise(t, 0.3, 0.08, 200); + }, + + playOpenChest() { + if (!this.ctx) return; + const t = this.ctx.currentTime; + // позвякивание + [784, 1046, 1318, 1046, 1318, 1568].forEach((f, i) => { + this._note(f, t + i * 0.07, 0.15, 'triangle', 0.1); + }); + }, + + // ── Музыкальные темы ────────────────────────────────────── + THEMES: { + // ── Главное меню: тёмная эпическая баллада, A-минор ── + menu: { + bpm: 58, + notes: [ + // Фраза 1 — вступление (A3 → E4) + [220, 2, 0.070], // A3 + [0, 0.5, 0 ], + [196, 0.5, 0.055], // G3 + [220, 1, 0.065], // A3 + [0, 0.5, 0 ], + [261, 1.5, 0.070], // C4 + [329, 2, 0.080], // E4 + + // Фраза 2 — подъём к кульминации (G4 → A4) + [0, 0.5, 0 ], + [392, 1, 0.075], // G4 + [440, 1.5, 0.090], // A4 — кульминация + + // Фраза 3 — спуск (G4 → C4) + [0, 0.5, 0 ], + [392, 0.5, 0.065], // G4 + [349, 0.5, 0.060], // F4 + [329, 1, 0.070], // E4 + [293, 0.5, 0.060], // D4 + [261, 2, 0.070], // C4 + + // Фраза 4 — разрешение (B3 → A3) + [0, 0.5, 0 ], + [246, 0.5, 0.055], // B3 + [220, 3, 0.080], // A3 — финал + [0, 2, 0 ], + ], + bass: [ + [55, 4, 0.085], // A1 — тоника + [65, 4, 0.075], // C2 — параллельный мажор + [82, 4, 0.080], // E2 — доминанта + [73, 4, 0.075], // D2 — субдоминанта + [55, 6, 0.080], // A1 — разрешение + ], + }, + + village: { + bpm: 80, + notes: [ + // C мажорная пентатоника: C D E G A C' + [261,1,0.055],[294,0.5,0.045],[329,1,0.055],[0,0.5,0], + [392,0.5,0.045],[440,1,0.065],[523,1,0.055],[0,0.5,0], + [440,0.5,0.04],[392,0.5,0.04],[329,1,0.05],[0,0.5,0], + [294,0.5,0.04],[261,1.5,0.055],[0,2,0], + ], + bass: [ + [65,2,0.06],[65,2,0.05],[73,2,0.06],[65,2,0.05], + ] + }, + forest: { + bpm: 100, + notes: [ + // a минор арпеджио + [220,0.5,0.06],[261,0.5,0.05],[329,0.5,0.06],[261,0.5,0.05], + [247,0.5,0.06],[294,0.5,0.05],[370,0.5,0.065],[294,0.5,0.05], + [220,0.5,0.06],[261,0.5,0.055],[329,1,0.06],[0,1,0], + [196,0.5,0.055],[220,0.5,0.05],[261,0.5,0.06],[220,0.5,0.05], + [196,0.5,0.055],[174,0.5,0.05],[220,1.5,0.065],[0,1,0], + ], + bass: [ + [55,1,0.07],[55,1,0.06],[62,1,0.07],[55,1,0.06], + [49,1,0.07],[49,1,0.06],[55,2,0.065], + ] + }, + dungeon: { + bpm: 50, + notes: [ + // мрачный хроматический дрон + [130,3,0.07],[0,1,0],[116,2,0.06],[0,2,0], + [138,3,0.065],[0,1,0],[123,2,0.06],[0,3,0], + [146,2,0.07],[130,2,0.065],[0,4,0], + ], + bass: [ + [32,4,0.09],[32,4,0.08],[36,4,0.09],[32,8,0.07], + ] + }, + swamp: { + bpm: 60, + notes: [ + // жуткая хроматика, нерегулярный ритм + [185,0.5,0.05],[196,1,0.06],[0,0.5,0],[174,0.5,0.05], + [0,1.5,0],[185,0.5,0.065],[207,1,0.055],[185,0.5,0.05], + [0,2,0],[174,0.5,0.06],[164,2,0.055],[0,2,0], + [155,0.5,0.05],[0,0.5,0],[164,1.5,0.065],[0,3,0], + ], + bass: [ + [46,3,0.08],[0,1,0],[41,3,0.07],[0,2,0],[43,4,0.075],[0,3,0], + ] + }, + mountain: { + bpm: 55, + notes: [ + // медленный эпичный минор + [220,2,0.07],[196,1,0.06],[174,1,0.065],[0,1,0], + [185,2,0.07],[220,2,0.065],[0,2,0], + [261,1.5,0.075],[246,0.5,0.065],[220,2,0.07],[0,1,0], + [196,1,0.065],[174,1,0.06],[185,3,0.07],[0,2,0], + ], + bass: [ + [55,4,0.09],[46,4,0.085],[49,4,0.09],[55,4,0.085], + ] + }, + combat: { + bpm: 145, + notes: [ + // быстрый staccato минор + [220,0.5,0.08],[0,0.5,0],[261,0.5,0.075],[0,0.5,0], + [196,0.5,0.08],[220,1,0.085],[0,0.5,0], + [165,0.5,0.075],[185,0.5,0.08],[0,0.5,0],[220,0.5,0.075], + [0,0.5,0],[246,0.5,0.08],[220,1,0.085],[0,1,0], + [174,0.5,0.075],[196,0.5,0.08],[0,0.5,0],[220,0.5,0.075], + [0,0.5,0],[196,0.5,0.08],[174,1.5,0.085],[0,1,0], + ], + bass: [ + [55,0.5,0.1],[0,0.5,0],[55,0.5,0.09],[0,0.5,0], + [55,0.5,0.1],[0,0.5,0],[55,0.5,0.09],[0,0.5,0], + [49,0.5,0.1],[0,0.5,0],[49,0.5,0.09],[0,0.5,0], + [49,0.5,0.1],[0,0.5,0],[55,1,0.095],[0,1,0], + ] + }, + ruins: { + bpm: 45, + notes: [ + // мрачная эолийская гамма, редкие ноты — атмосфера разрушенного замка + [110,3,0.055],[0,2,0],[98,2,0.05],[0,3,0], + [116,3,0.06],[110,2,0.05],[0,3,0], + [92,2,0.055],[0,2,0],[104,4,0.06],[0,4,0], + [110,2,0.05],[0,2,0],[98,3,0.055],[0,3,0], + ], + bass: [ + [27,6,0.075],[0,2,0],[24,6,0.07],[0,4,0], + [29,6,0.075],[0,2,0],[27,4,0.065],[0,6,0], + ] + }, + cave: { + bpm: 55, + notes: [ + // тёмная пещерная атмосфера + [138,3,0.06],[0,2,0],[123,2,0.055],[0,3,0], + [146,3,0.065],[138,2,0.055],[0,4,0], + [116,2,0.06],[0,2,0],[130,3,0.06],[0,3,0], + ], + bass: [ + [34,5,0.08],[0,3,0],[30,5,0.075],[0,4,0], + [36,5,0.08],[0,3,0],[34,4,0.07],[0,5,0], + ] + }, + abyss: { + bpm: 40, + notes: [ + // зловещий хроматический дрейф + [41,6,0.09],[0,2,0],[37,5,0.08],[0,3,0], + [44,4,0.07],[0,4,0],[39,6,0.09],[0,2,0], + [34,5,0.075],[0,3,0],[41,4,0.08],[0,4,0], + ], + bass: [ + [20,8,0.1],[0,4,0],[18,8,0.09],[0,4,0], + [22,8,0.1],[0,4,0],[20,6,0.09],[0,6,0], + ] + }, + }, + + // ── Воспроизведение музыки ──────────────────────────────── + playTheme(name) { + if (this.currentTheme === name) return; + + // Тема 'menu' управляется через #menu-bgm в HTML напрямую + if (name === 'menu') { + // Остановить процедурную музыку при переходе в меню + if (this._musicGain) { + this._musicGain.disconnect(); + if (this.ctx) { + this._musicGain = this.ctx.createGain(); + this._musicGain.gain.value = 1; + this._musicGain.connect(this._master); + } + } + this._musicSeqId++; + this.currentTheme = 'menu'; + return; + } + + // ── Процедурные темы — требуют AudioContext ───────────── + if (!this.ctx) return; + + // Остановить MP3 меню + if (this._menuBgm && !this._menuBgm.paused) { + this._menuBgm.pause(); + this._menuBgm.currentTime = 0; + } + + // Отключить старый musicGain — мгновенно глушит все запланированные ноты + if (this._musicGain) { + this._musicGain.disconnect(); + } + this._musicGain = this.ctx.createGain(); + this._musicGain.gain.value = 1; + this._musicGain.connect(this._master); + + this.currentTheme = name; + this._musicSeqId++; + const id = this._musicSeqId; + const theme = this.THEMES[name]; + if (!theme) return; + this._scheduleMelody(theme.notes, theme.bpm, id, false); + if (theme.bass) this._scheduleMelody(theme.bass, theme.bpm, id, true); + }, + + stopMusic() { + if (this._menuBgm && !this._menuBgm.paused) { + this._menuBgm.pause(); + this._menuBgm.currentTime = 0; + } + if (this._musicGain) { + this._musicGain.disconnect(); + if (this.ctx) { + this._musicGain = this.ctx.createGain(); + this._musicGain.gain.value = 1; + this._musicGain.connect(this._master); + } + } + this._musicSeqId++; + this.currentTheme = null; + }, + + _scheduleMelody(notes, bpm, loopId, isBass) { + if (this._musicSeqId !== loopId) return; + if (!this.ctx || this.muted) { + // музыка заглушена — перепланировать через секунду + const beat = 60 / bpm; + const totalMs = notes.reduce((s, n) => s + n[1], 0) * beat * 1000; + setTimeout(() => this._scheduleMelody(notes, bpm, loopId, isBass), totalMs); + return; + } + let t = this.ctx.currentTime + 0.06; // совпадает с задержкой _musicGain восстановления + const beat = 60 / bpm; + notes.forEach(([freq, beats, gainVal]) => { + if (freq > 0 && gainVal > 0) { + const dur = beats * beat * 0.88; + // ноты идут через _musicGain, а не напрямую в _master + this._note(freq, t, dur, isBass ? 'triangle' : 'sine', gainVal, + this._musicGain || null, isBass ? 300 : null); + } + t += beats * beat; + }); + const totalMs = notes.reduce((s, n) => s + n[1], 0) * beat * 1000; + setTimeout(() => this._scheduleMelody(notes, bpm, loopId, isBass), totalMs + 30); + }, +}; diff --git a/data-loader.js b/data-loader.js new file mode 100644 index 0000000..1cafcaf --- /dev/null +++ b/data-loader.js @@ -0,0 +1,86 @@ +// ============================================================ +// DATA-LOADER.JS — Загрузка игровых данных из JSON-файлов +// ============================================================ +// Требует локальный сервер (Live Server в VS Code, или +// python -m http.server / npx serve). +// При запуске через file:// будет показана подсказка. +// ============================================================ + +const DataLoader = { + _loaded: false, + + async load() { + if (this._loaded) return; + + const FILES = [ + 'enemies', 'quests', 'recipes', 'shop', 'loot', + 'lore', 'sets', 'enchants', 'classes', 'world', + ]; + + let results; + try { + results = await Promise.all( + FILES.map(f => + fetch('data/' + f + '.json') + .then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + f + '.json'); + return r.json(); + }) + ) + ); + } catch (e) { + console.error('DataLoader.load() failed:', e); + this._showError(e); + throw e; + } + + const [enemies, quests, recipes, shop, loot, + lore, sets, enchants, classes, world] = results; + + // ── Заполнение RPG ────────────────────────────────── + RPG.ENEMY_DB = enemies; + RPG.QUEST_DB = quests; + RPG.CRAFT_RECIPES = recipes; + RPG.SHOP_ITEMS = shop; + RPG.LOOT_DB = loot; + RPG.LORE_NOTES = lore; + RPG.EQUIPMENT_SETS = sets; + RPG.ENCHANTS = enchants; + RPG.CLASSES = classes.classes; + RPG.SPELLS = classes.spells; + RPG.SKILLS = classes.skills; + RPG.PERK_TREE = classes.perkTree; + + // ── Заполнение Game ───────────────────────────────── + Game.LOCATIONS = world.locations; + Game.NPC_DIALOGS = world.dialogs; + Game._WORLD = world; // spawns, npcs, decos, weather + + this._loaded = true; + console.log('[DataLoader] Все данные загружены ✓'); + }, + + _showError(e) { + // Удалим старые баннеры если есть + const old = document.getElementById('dl-error'); + if (old) old.remove(); + + const isFileProtocol = location.protocol === 'file:'; + const msg = isFileProtocol + ? '⚠️ Игра требует локальный сервер для загрузки JSON-данных.
' + + 'Откройте через Live Server (VS Code) или запустите:
' + + 'python -m http.server и перейдите на http://localhost:8000' + : '⚠️ Ошибка загрузки данных: ' + e.message + '
Откройте консоль (F12) для подробностей.'; + + const banner = document.createElement('div'); + banner.id = 'dl-error'; + banner.style.cssText = [ + 'position:fixed', 'top:0', 'left:0', 'right:0', + 'background:#8b0000', 'color:#fff', 'padding:14px 20px', + 'font-size:13px', 'text-align:center', 'z-index:99999', + 'line-height:1.7', 'font-family:monospace', + ].join(';'); + banner.innerHTML = msg; + document.body.prepend(banner); + }, +}; diff --git a/data/classes.json b/data/classes.json new file mode 100644 index 0000000..4169e32 --- /dev/null +++ b/data/classes.json @@ -0,0 +1,241 @@ +{ + "classes": { + "warrior": { + "name":"Воин", "icon":"⚔️", "hp":130, "mp":30, "str":14, "def":10, "mag":4, "spd":7, + "desc":"Мощный боец с высоким HP", + "startSpells":[], "startSkills":[], + "lvlBonuses":{ "hp":15, "mp":3, "str":3, "def":2, "mag":1, "spd":1 } + }, + "mage": { + "name":"Маг", "icon":"🔮", "hp":65, "mp":110, "str":4, "def":4, "mag":16, "spd":6, + "desc":"Мастер заклинаний", + "startSpells":["fireball","frostbolt"], + "lvlBonuses":{ "hp":5, "mp":15, "str":1, "def":1, "mag":3, "spd":1 } + }, + "archer": { + "name":"Лучник", "icon":"🏹", "hp":90, "mp":55, "str":10, "def":6, "mag":6, "spd":13, + "desc":"Быстрый и меткий", + "startSpells":["lightning"], + "lvlBonuses":{ "hp":8, "mp":5, "str":2, "def":1, "mag":1, "spd":3 } + }, + "paladin": { + "name":"Паладин", "icon":"🛡️", "hp":115, "mp":65, "str":10, "def":13, "mag":8, "spd":6, + "desc":"Святой воин-защитник", + "startSpells":["heal","holy_fire"], + "lvlBonuses":{ "hp":12, "mp":8, "str":2, "def":3, "mag":2, "spd":1 } + }, + "necromancer": { + "name":"Некромант", "icon":"💀", "hp":75, "mp":95, "str":5, "def":5, "mag":15, "spd":7, + "desc":"Властелин тёмной магии", + "startSpells":["life_drain","curse"], + "lvlBonuses":{ "hp":6, "mp":12, "str":1, "def":1, "mag":4, "spd":1 } + }, + "berserker": { + "name":"Берсерк", "icon":"🪓", "hp":150, "mp":20, "str":18, "def":6, "mag":2, "spd":10, + "desc":"Ярость и сила превыше всего", + "startSpells":[], + "lvlBonuses":{ "hp":20, "mp":2, "str":4, "def":1, "mag":1, "spd":1 } + }, + "druid": { + "name":"Друид", "icon":"🌿", "hp":85, "mp":85, "str":7, "def":7, "mag":11, "spd":9, + "desc":"Хранитель природы", + "startSpells":["heal","poison_cloud"], + "lvlBonuses":{ "hp":9, "mp":9, "str":2, "def":2, "mag":2, "spd":1 } + } + }, + "spells": { + "fireball": { "name":"Огненный шар", "icon":"🔥", "mp":15, "dmg":28, "type":"fire", "cd":3000 }, + "frostbolt": { "name":"Ледяная стрела", "icon":"❄️", "mp":12, "dmg":20, "type":"ice", "cd":2000, "slow":true }, + "lightning": { "name":"Молния", "icon":"⚡", "mp":20, "dmg":35, "type":"lightning", "cd":3000 }, + "fireball2": { "name":"Огненный шторм", "icon":"🌋", "mp":30, "dmg":55, "type":"fire", "cd":6000 }, + "blizzard": { "name":"Метель", "icon":"🌨️", "mp":35, "dmg":50, "type":"ice", "cd":8000 }, + "chain_lightning":{ "name":"Цепная молния", "icon":"⚡", "mp":28, "dmg":45, "type":"lightning", "cd":5000 }, + "heal": { "name":"Исцеление", "icon":"💚", "mp":10, "heal":35, "cd":4000 }, + "greater_heal": { "name":"Мощное исцеление", "icon":"💖", "mp":25, "heal":75, "cd":6000 }, + "holy_fire": { "name":"Священный огонь", "icon":"✨", "mp":22, "dmg":30, "type":"holy", "cd":4000 }, + "resurrect": { "name":"Воскрешение", "icon":"🕊️", "mp":50, "heal":999, "cd":60000 }, + "life_drain": { "name":"Похищение жизни", "icon":"🖤", "mp":18, "dmg":15, "heal":12, "type":"dark", "cd":5000 }, + "curse": { "name":"Проклятие", "icon":"☠️", "mp":15, "debuff":"atk", "val":0.7, "cd":10000 }, + "power_strike": { "name":"Мощный удар", "icon":"💥", "mp":12, "dmgMult":2.2, "cd":5000 }, + "berserk": { "name":"Берсеркерство", "icon":"😡", "mp":8, "buff":"str", "val":1.8, "dur":8000, "cd":15000 }, + "whirlwind": { "name":"Вихрь", "icon":"🌀", "mp":18, "dmg":25, "type":"physical", "cd":5000 }, + "poison_cloud": { "name":"Облако яда", "icon":"🧪", "mp":20, "dot":"poison", "dotDmg":8, "dotTurns":4, "cd":6000 }, + "stone_skin": { "name":"Каменная кожа", "icon":"🪨", "mp":20, "buff":"def", "val":2, "dur":12000, "cd":20000 }, + "arrow_rain": { "name":"Дождь стрел", "icon":"🏹", "mp":22, "dmg":40, "type":"physical", "cd":5000 }, + "shadow_step": { "name":"Теневой шаг", "icon":"🌑", "mp":15, "buff":"spd", "val":2, "dur":5000, "cd":12000 }, + "earthquake": { "name":"Землетрясение", "icon":"🌍", "mp":40, "dmg":60, "type":"physical", "cd":10000 } + }, + "skills": { + "tough_skin": { "name":"Толстая кожа", "icon":"🛡️", "desc":"+15 HP навсегда", "effect":"hp", "val":15 }, + "sharp_mind": { "name":"Острый ум", "icon":"🧠", "desc":"+10 MP навсегда", "effect":"mp", "val":10 }, + "quick_feet": { "name":"Быстрые ноги", "icon":"👟", "desc":"+2 к скорости", "effect":"spd", "val":2 }, + "iron_will": { "name":"Железная воля", "icon":"⚡", "desc":"+3 к силе атаки", "effect":"str", "val":3 }, + "arcane_mastery": { "name":"Магия мастера", "icon":"✨", "desc":"+3 к магии", "effect":"mag", "val":3 }, + "fortify": { "name":"Укрепление", "icon":"🪬", "desc":"+3 к защите", "effect":"def", "val":3 }, + "learn_power_strike": { "name":"Мощный удар", "icon":"💥", "desc":"Изучить: Мощный удар", "effect":"spell", "val":"power_strike" }, + "learn_fireball2": { "name":"Огненный шторм", "icon":"🌋", "desc":"Изучить: Огненный шторм", "effect":"spell", "val":"fireball2" }, + "learn_berserk": { "name":"Берсеркерство", "icon":"😡", "desc":"Изучить: Берсеркерство", "effect":"spell", "val":"berserk" }, + "learn_greater_heal": { "name":"Мощное исцеление", "icon":"💖", "desc":"Изучить: Мощное исцеление", "effect":"spell", "val":"greater_heal" }, + "learn_blizzard": { "name":"Метель", "icon":"🌨️", "desc":"Изучить: Метель", "effect":"spell", "val":"blizzard" }, + "learn_stone_skin": { "name":"Каменная кожа", "icon":"🪨", "desc":"Изучить: Каменная кожа", "effect":"spell", "val":"stone_skin" }, + "learn_earthquake": { "name":"Землетрясение", "icon":"🌍", "desc":"Изучить: Землетрясение", "effect":"spell", "val":"earthquake" }, + "learn_arrow_rain": { "name":"Дождь стрел", "icon":"🏹", "desc":"Изучить: Дождь стрел", "effect":"spell", "val":"arrow_rain" }, + "learn_chain_lightning":{ "name":"Цепная молния", "icon":"⚡", "desc":"Изучить: Цепная молния", "effect":"spell", "val":"chain_lightning" } + }, + "perkTree": { + "warrior": { "branches": [ + { "id":"might", "name":"Мощь", "icon":"⚔️", "perks":[ + { "id":"war_str1", "tier":1, "name":"Грубая сила", "icon":"💪", "desc":"+4 СИЛ", "effect":"stat", "stat":"str", "val":4 }, + { "id":"war_crit", "tier":2, "name":"Мощный удар", "icon":"💥", "desc":"+10% к крит. урону", "effect":"critDmg", "val":0.10 }, + { "id":"war_dbl", "tier":3, "name":"Двойной удар", "icon":"⚔️", "desc":"15% шанс ударить дважды", "effect":"doubleAtk", "val":0.15 } + ]}, + { "id":"armor", "name":"Броня", "icon":"🛡️", "perks":[ + { "id":"war_def1", "tier":1, "name":"Закалка", "icon":"🛡️", "desc":"+4 ЗАЩ", "effect":"stat", "stat":"def", "val":4 }, + { "id":"war_hp", "tier":2, "name":"Несгибаемый", "icon":"🪨", "desc":"+25 макс. HP", "effect":"stat", "stat":"maxHp", "val":25 }, + { "id":"war_thorn", "tier":3, "name":"Шипы", "icon":"🌵", "desc":"10% отражение урона", "effect":"thorns", "val":0.10 } + ]}, + { "id":"survival", "name":"Выживание", "icon":"❤️", "perks":[ + { "id":"war_life", "tier":1, "name":"Жизненная сила", "icon":"🩸", "desc":"+6% вампиризм", "effect":"lifesteal", "val":0.06 }, + { "id":"war_regen", "tier":2, "name":"Регенерация", "icon":"💚", "desc":"Восст. 4 HP за ход", "effect":"regenHp", "val":4 }, + { "id":"war_save", "tier":3, "name":"Несмертельный", "icon":"🛡️", "desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]}, + { "id":"speed", "name":"Скорость", "icon":"💨", "perks":[ + { "id":"war_spd1", "tier":1, "name":"Быстрые ноги", "icon":"👟", "desc":"+3 СКР", "effect":"stat", "stat":"spd", "val":3 }, + { "id":"war_dodge", "tier":2, "name":"Уклонение", "icon":"💨", "desc":"+8% шанс уклониться", "effect":"dodge", "val":0.08 }, + { "id":"war_rage", "tier":3, "name":"Ярость", "icon":"😡", "desc":"+20% урон при <30% HP", "effect":"enrage", "val":0.20 } + ]} + ]}, + "mage": { "branches": [ + { "id":"arcane", "name":"Арканум", "icon":"🔮", "perks":[ + { "id":"mag_mag1", "tier":1, "name":"Знание рун", "icon":"📜", "desc":"+5 МАГ", "effect":"stat", "stat":"mag", "val":5 }, + { "id":"mag_mp", "tier":2, "name":"Мана-источник", "icon":"💧", "desc":"+25 макс. МА", "effect":"stat", "stat":"maxMp", "val":25 }, + { "id":"mag_spell", "tier":3, "name":"Могущество", "icon":"✨", "desc":"+18% к урону заклинаний", "effect":"spelldmg", "val":0.18 } + ]}, + { "id":"fire", "name":"Огонь", "icon":"🔥", "perks":[ + { "id":"mag_mag2", "tier":1, "name":"Жар пламени", "icon":"🔥", "desc":"+3 МАГ", "effect":"stat", "stat":"mag", "val":3 }, + { "id":"mag_crit", "tier":2, "name":"Пылающий крит", "icon":"💥", "desc":"+12% к крит. урону", "effect":"critDmg", "val":0.12 }, + { "id":"mag_spl2", "tier":3, "name":"Испепеление", "icon":"☄️", "desc":"+20% урона заклинаний", "effect":"spelldmg", "val":0.20 } + ]}, + { "id":"frost", "name":"Лёд", "icon":"❄️", "perks":[ + { "id":"mag_spd", "tier":1, "name":"Ледяная скорость", "icon":"⚡","desc":"+3 СКР", "effect":"stat", "stat":"spd", "val":3 }, + { "id":"mag_dodge", "tier":2, "name":"Морозное скольжение", "icon":"❄️","desc":"+10% уклонение", "effect":"dodge", "val":0.10 }, + { "id":"mag_mpx", "tier":3, "name":"Ледяная броня", "icon":"🧊","desc":"+30 макс. МА", "effect":"stat", "stat":"maxMp", "val":30 } + ]}, + { "id":"inner", "name":"Внутр. сила","icon":"💜", "perks":[ + { "id":"mag_def", "tier":1, "name":"Магический щит", "icon":"🛡️", "desc":"+3 ЗАЩ", "effect":"stat", "stat":"def", "val":3 }, + { "id":"mag_life", "tier":2, "name":"Маговампир", "icon":"🩸", "desc":"+5% вампиризм", "effect":"lifesteal", "val":0.05 }, + { "id":"mag_save", "tier":3, "name":"Последний резерв","icon":"💜","desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]} + ]}, + "archer": { "branches": [ + { "id":"precision", "name":"Меткость", "icon":"🎯", "perks":[ + { "id":"arc_spd1", "tier":1, "name":"Орлиный глаз", "icon":"👁️", "desc":"+4 СКР", "effect":"stat", "stat":"spd", "val":4 }, + { "id":"arc_crit", "tier":2, "name":"Смертельный выстрел","icon":"🎯","desc":"+12% крит. урон", "effect":"critDmg", "val":0.12 }, + { "id":"arc_dbl", "tier":3, "name":"Двойной выстрел", "icon":"🏹", "desc":"20% двойная атака", "effect":"doubleAtk", "val":0.20 } + ]}, + { "id":"survival", "name":"Выживание", "icon":"🌿", "perks":[ + { "id":"arc_dodge", "tier":1, "name":"Стремительный", "icon":"💨", "desc":"+10% уклонение", "effect":"dodge", "val":0.10 }, + { "id":"arc_life", "tier":2, "name":"Кровавая стрела","icon":"🩸", "desc":"+6% вампиризм", "effect":"lifesteal", "val":0.06 }, + { "id":"arc_save", "tier":3, "name":"Уйти живым", "icon":"🛡️", "desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]}, + { "id":"poison", "name":"Яд", "icon":"☠️", "perks":[ + { "id":"arc_def", "tier":1, "name":"Кожаная броня", "icon":"🛡️", "desc":"+3 ЗАЩ", "effect":"stat", "stat":"def", "val":3 }, + { "id":"arc_thorn", "tier":2, "name":"Ядовитые шипы", "icon":"🌵", "desc":"+8% отражение урона", "effect":"thorns", "val":0.08 }, + { "id":"arc_rage", "tier":3, "name":"Охотничья ярость","icon":"😡","desc":"+18% урон при <30% HP", "effect":"enrage", "val":0.18 } + ]}, + { "id":"speed", "name":"Скорость", "icon":"⚡", "perks":[ + { "id":"arc_spd2", "tier":1, "name":"Ветер", "icon":"🌬️", "desc":"+5 СКР", "effect":"stat", "stat":"spd", "val":5 }, + { "id":"arc_str", "tier":2, "name":"Твёрдая рука", "icon":"💪", "desc":"+3 СИЛ", "effect":"stat", "stat":"str", "val":3 }, + { "id":"arc_dg2", "tier":3, "name":"Тень", "icon":"🌑", "desc":"+8% уклонение", "effect":"dodge", "val":0.08 } + ]} + ]}, + "paladin": { "branches": [ + { "id":"holy", "name":"Святость", "icon":"✨", "perks":[ + { "id":"pal_mag", "tier":1, "name":"Свет веры", "icon":"☀️", "desc":"+5 МАГ", "effect":"stat", "stat":"mag", "val":5 }, + { "id":"pal_spell", "tier":2, "name":"Святой свет", "icon":"✨", "desc":"+12% урона заклинаний", "effect":"spelldmg", "val":0.12 }, + { "id":"pal_regen", "tier":3, "name":"Благодать", "icon":"💚", "desc":"Восст. 5 HP за ход", "effect":"regenHp", "val":5 } + ]}, + { "id":"shield", "name":"Щит", "icon":"🛡️", "perks":[ + { "id":"pal_def1", "tier":1, "name":"Мастер щита", "icon":"🛡️", "desc":"+5 ЗАЩ", "effect":"stat", "stat":"def", "val":5 }, + { "id":"pal_hp", "tier":2, "name":"Крепость духа", "icon":"🏰", "desc":"+25 макс. HP", "effect":"stat", "stat":"maxHp", "val":25 }, + { "id":"pal_thorn", "tier":3, "name":"Сталь и огонь", "icon":"🔥", "desc":"+12% отражение урона", "effect":"thorns", "val":0.12 } + ]}, + { "id":"divine", "name":"Благодать", "icon":"💛", "perks":[ + { "id":"pal_hpmp", "tier":1, "name":"Тело и душа", "icon":"💛", "desc":"+15 HP, +15 МА", "effect":"stat", "stat":"maxHp", "val":15 }, + { "id":"pal_save", "tier":2, "name":"Воля Небес", "icon":"🕊️", "desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 }, + { "id":"pal_life", "tier":3, "name":"Священный вампир","icon":"🩸","desc":"+5% вампиризм", "effect":"lifesteal", "val":0.05 } + ]}, + { "id":"crusade", "name":"Крестовый поход", "icon":"⚔️", "perks":[ + { "id":"pal_str", "tier":1, "name":"Кара Небес", "icon":"⚔️", "desc":"+4 СИЛ", "effect":"stat", "stat":"str", "val":4 }, + { "id":"pal_crit", "tier":2, "name":"Удар справедливости", "icon":"💥", "desc":"+10% крит. урон", "effect":"critDmg", "val":0.10 }, + { "id":"pal_dbl", "tier":3, "name":"Двойной удар", "icon":"⚔️", "desc":"+12% двойная атака","effect":"doubleAtk", "val":0.12 } + ]} + ]}, + "necromancer": { "branches": [ + { "id":"dark", "name":"Тьма", "icon":"🌑", "perks":[ + { "id":"nec_mag1", "tier":1, "name":"Тёмное знание", "icon":"📕", "desc":"+5 МАГ", "effect":"stat", "stat":"mag", "val":5 }, + { "id":"nec_spell", "tier":2, "name":"Сила смерти", "icon":"💀", "desc":"+18% урона заклинаний", "effect":"spelldmg", "val":0.18 }, + { "id":"nec_crit", "tier":3, "name":"Смертельное слово","icon":"💥","desc":"+12% крит. урон", "effect":"critDmg", "val":0.12 } + ]}, + { "id":"undead", "name":"Нежить", "icon":"🦴", "perks":[ + { "id":"nec_hp", "tier":1, "name":"Труп-тело", "icon":"🦴", "desc":"+20 макс. HP", "effect":"stat", "stat":"maxHp", "val":20 }, + { "id":"nec_life", "tier":2, "name":"Поглощение жизни","icon":"🩸","desc":"+8% вампиризм", "effect":"lifesteal", "val":0.08 }, + { "id":"nec_save", "tier":3, "name":"Личная смерть", "icon":"💀", "desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]}, + { "id":"curse", "name":"Проклятие", "icon":"🔮", "perks":[ + { "id":"nec_def", "tier":1, "name":"Тёмный покров", "icon":"🌑", "desc":"+3 ЗАЩ", "effect":"stat", "stat":"def", "val":3 }, + { "id":"nec_thorn", "tier":2, "name":"Проклятое тело", "icon":"☠️", "desc":"+8% отражение урона", "effect":"thorns", "val":0.08 }, + { "id":"nec_rage", "tier":3, "name":"Ярость тьмы", "icon":"😡", "desc":"+25% урон при <30% HP", "effect":"enrage", "val":0.25 } + ]}, + { "id":"soul", "name":"Душа", "icon":"👻", "perks":[ + { "id":"nec_mp", "tier":1, "name":"Душехранитель", "icon":"👻", "desc":"+15 макс. МА", "effect":"stat", "stat":"maxMp", "val":15 }, + { "id":"nec_regen", "tier":2, "name":"Восстановление", "icon":"💚", "desc":"Восст. 4 HP за ход", "effect":"regenHp", "val":4 }, + { "id":"nec_dodge", "tier":3, "name":"Призрачность", "icon":"💨", "desc":"+10% уклонение", "effect":"dodge", "val":0.10 } + ]} + ]}, + "berserker": { "branches": [ + { "id":"rage", "name":"Ярость", "icon":"😡", "perks":[ + { "id":"ber_str1", "tier":1, "name":"Бешеная сила", "icon":"💪", "desc":"+5 СИЛ", "effect":"stat", "stat":"str", "val":5 }, + { "id":"ber_rage", "tier":2, "name":"Берсеркерская ярость","icon":"🔥","desc":"+25% урон при <30% HP","effect":"enrage", "val":0.25 }, + { "id":"ber_dbl", "tier":3, "name":"Смертельный вихрь", "icon":"⚔️","desc":"20% двойная атака", "effect":"doubleAtk", "val":0.20 } + ]}, + { "id":"blood", "name":"Кровь", "icon":"🩸", "perks":[ + { "id":"ber_life", "tier":1, "name":"Кровожадность", "icon":"🩸", "desc":"+8% вампиризм", "effect":"lifesteal", "val":0.08 }, + { "id":"ber_regen", "tier":2, "name":"Регенерация крови", "icon":"💚","desc":"Восст. 5 HP за ход", "effect":"regenHp", "val":5 }, + { "id":"ber_save", "tier":3, "name":"Воля к жизни", "icon":"🛡️","desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]}, + { "id":"brutality", "name":"Жестокость", "icon":"🪓", "perks":[ + { "id":"ber_crit", "tier":1, "name":"Brutal удар", "icon":"💥", "desc":"+12% крит. урон", "effect":"critDmg", "val":0.12 }, + { "id":"ber_thorn", "tier":2, "name":"Кровавые шипы", "icon":"🌵", "desc":"+10% отражение урона", "effect":"thorns", "val":0.10 }, + { "id":"ber_str2", "tier":3, "name":"Безудержная сила","icon":"💪","desc":"+5 СИЛ", "effect":"stat", "stat":"str", "val":5 } + ]}, + { "id":"berserking","name":"Буйство", "icon":"⚡", "perks":[ + { "id":"ber_spd", "tier":1, "name":"Ветер смерти", "icon":"💨", "desc":"+5 СКР", "effect":"stat", "stat":"spd", "val":5 }, + { "id":"ber_dodge", "tier":2, "name":"Дикое уклонение","icon":"💨", "desc":"+10% уклонение", "effect":"dodge", "val":0.10 }, + { "id":"ber_rage2", "tier":3, "name":"Боевое безумие", "icon":"😡", "desc":"+15% урон при <30% HP", "effect":"enrage", "val":0.15 } + ]} + ]}, + "druid": { "branches": [ + { "id":"nature", "name":"Природа", "icon":"🌿", "perks":[ + { "id":"dru_mag", "tier":1, "name":"Голос леса", "icon":"🌿", "desc":"+4 МАГ", "effect":"stat", "stat":"mag", "val":4 }, + { "id":"dru_spell", "tier":2, "name":"Природная магия", "icon":"✨", "desc":"+12% урона заклинаний", "effect":"spelldmg", "val":0.12 }, + { "id":"dru_regen", "tier":3, "name":"Дыхание жизни", "icon":"💚", "desc":"Восст. 5 HP за ход", "effect":"regenHp", "val":5 } + ]}, + { "id":"beast", "name":"Зверь", "icon":"🐾", "perks":[ + { "id":"dru_spd", "tier":1, "name":"Звериная прыть", "icon":"🐾", "desc":"+5 СКР", "effect":"stat", "stat":"spd", "val":5 }, + { "id":"dru_dodge", "tier":2, "name":"Уклонение зверя","icon":"💨", "desc":"+10% уклонение", "effect":"dodge", "val":0.10 }, + { "id":"dru_str", "tier":3, "name":"Когти зверя", "icon":"🐻", "desc":"+4 СИЛ", "effect":"stat", "stat":"str", "val":4 } + ]}, + { "id":"earth", "name":"Земля", "icon":"🪨", "perks":[ + { "id":"dru_def", "tier":1, "name":"Кора дерева", "icon":"🪨", "desc":"+5 ЗАЩ", "effect":"stat", "stat":"def", "val":5 }, + { "id":"dru_hp", "tier":2, "name":"Корни земли", "icon":"🌱", "desc":"+20 макс. HP", "effect":"stat", "stat":"maxHp", "val":20 }, + { "id":"dru_thorn", "tier":3, "name":"Шипастая кора","icon":"🌵","desc":"+8% отражение урона", "effect":"thorns", "val":0.08 } + ]}, + { "id":"regrowth", "name":"Возрождение","icon":"🔄", "perks":[ + { "id":"dru_life", "tier":1, "name":"Жизненная сила","icon":"🩸", "desc":"+6% вампиризм", "effect":"lifesteal", "val":0.06 }, + { "id":"dru_rg2", "tier":2, "name":"Лесное зелье", "icon":"💚", "desc":"Восст. 4 HP за ход", "effect":"regenHp", "val":4 }, + { "id":"dru_save", "tier":3, "name":"Перерождение", "icon":"🔄", "desc":"1×/бой выжить с 1 HP", "effect":"deathSave", "val":1 } + ]} + ]} + } +} diff --git a/data/enchants.json b/data/enchants.json new file mode 100644 index 0000000..9a53be4 --- /dev/null +++ b/data/enchants.json @@ -0,0 +1,10 @@ +{ + "flame": { "name":"Огненный", "icon":"🔥", "cost":150, "mat":"dragon_scale", "matQty":1, "bonus":{"damage":5}, "desc":"+5 урона огнём", "target":"weapon" }, + "frost": { "name":"Ледяной", "icon":"❄️", "cost":130, "mat":"yeti_fur", "matQty":1, "bonus":{"defense":4}, "desc":"+4 защиты", "target":"armor" }, + "holy": { "name":"Святой", "icon":"✨", "cost":180, "mat":"troll_heart", "matQty":1, "bonus":{"damage":4}, "desc":"+4 урона", "target":"weapon" }, + "life": { "name":"Жизненный", "icon":"💚", "cost":80, "mat":"herb", "matQty":3, "bonus":{"hp":25}, "desc":"+25 HP", "target":"any" }, + "arcane": { "name":"Магический", "icon":"💜", "cost":120, "mat":"slime_gel", "matQty":2, "bonus":{"mag":4, "mp":15}, "desc":"+4 магии, +15 MP", "target":"weapon" }, + "swift": { "name":"Быстрый", "icon":"⚡", "cost":100, "mat":"bat_wing", "matQty":2, "bonus":{"str":4}, "desc":"+4 силы", "target":"any" }, + "ward": { "name":"Стражника", "icon":"🛡️", "cost":110, "mat":"bone", "matQty":3, "bonus":{"defense":6}, "desc":"+6 защиты", "target":"armor" }, + "venom": { "name":"Ядовитый", "icon":"☠️", "cost":70, "mat":"spider_venom", "matQty":2, "bonus":{"damage":4}, "desc":"+4 урона ядом", "target":"weapon" } +} diff --git a/data/enemies.json b/data/enemies.json new file mode 100644 index 0000000..c7a47dd --- /dev/null +++ b/data/enemies.json @@ -0,0 +1,109 @@ +{ + "goblin": { "name":"Гоблин", "hp":32, "dmg":8, "def":2, "exp":20, "gold":10, "loot":["herb","goblin_ear"], + "lore":"Хитрые зелёные существа, живущие стаями. Воруют всё, до чего дотянутся. Трусливы поодиночке, опасны в толпе.", + "weakness":"fire", "resist":null, "ai":"coward" }, + "orc": { "name":"Орк", "hp":55, "dmg":13, "def":4, "exp":40, "gold":25, "loot":["orc_tusk","meat"], + "lore":"Могучие воители из горных кланов. Живут по закону силы — вождём становится сильнейший боец.", + "weakness":"magic", "resist":"physical", "ai":"berserk" }, + "skeleton": { "name":"Скелет", "hp":42, "dmg":11, "def":6, "exp":30, "gold":15, "loot":["bone","bone_dagger"], + "lore":"Мертвецы, поднятые некромантами. Лишены боли и страха. Разваливаются от святого оружия.", + "weakness":"holy", "resist":"poison", "ai":null }, + "slime": { "name":"Слизень", "hp":22, "dmg":5, "def":0, "exp":10, "gold":5, "loot":["slime_gel"], + "lore":"Аморфное существо из кислотной слизи. Не имеет органов, поглощает пищу целиком. Разделяется при ударе.", + "weakness":"fire", "resist":"physical", "ai":"acid" }, + "bandit": { "name":"Разбойник", "hp":38, "dmg":10, "def":3, "exp":25, "gold":22, "loot":["money_pouch"], + "lore":"Бывшие солдаты и беглые крестьяне, избравшие путь разбоя. Опасны засадами и ядовитыми клинками.", + "weakness":"holy", "resist":null, "ai":"steal" }, + "wolf": { "name":"Волк", "hp":30, "dmg":9, "def":2, "exp":18, "gold":8, "loot":["wolf_pelt"], + "lore":"Лесной хищник с острым чутьём. Охотится стаей, загоняя жертву. Вожак стаи втрое крупнее обычного волка.", + "weakness":"fire", "resist":null, "ai":"howl" }, + "spider": { "name":"Паук", "hp":28, "dmg":12, "def":1, "exp":22, "gold":12, "loot":["spider_venom"], + "lore":"Ядовитый паук размером с собаку. Плетёт невидимые сети и вводит парализующий яд.", + "weakness":"fire", "resist":"poison", "ai":"venom" }, + "zombie": { "name":"Зомби", "hp":48, "dmg":8, "def":8, "exp":28, "gold":12, "loot":["rot_flesh"], + "lore":"Восставшие мертвецы с гниющей плотью. Медлительны, но чрезвычайно живучи. Укус передаёт заразу.", + "weakness":"fire", "resist":"poison", "ai":"decay" }, + "bat": { "name":"Летучая мышь","hp":18, "dmg":7, "def":0, "exp":12, "gold":6, "loot":["bat_wing"], + "lore":"Пещерные твари с эхолокацией. В темноте видят лучше любого существа. Нападают роем.", + "weakness":"holy", "resist":null, "ai":"swarm" }, + "troll": { "name":"Тролль", "hp":110, "dmg":20, "def":8, "exp":90, "gold":65, "loot":["troll_heart","club"], "isBoss":false, + "lore":"Регенерирующий великан из болот. Плоть зарастает на глазах. Только огонь останавливает регенерацию.", + "weakness":"fire", "resist":"physical", "ai":"regen" }, + "yeti": { "name":"Йети", "hp":95, "dmg":18, "def":10, "exp":80, "gold":55, "loot":["yeti_fur"], + "lore":"Белый великан горных вершин. Выдерживает любой мороз, но уязвим к огню. Ревниво охраняет свою территорию.", + "weakness":"fire", "resist":"ice", "ai":"berserk" }, + "witch": { "name":"Ведьма", "hp":60, "dmg":22, "def":3, "exp":70, "gold":50, "loot":["witch_brew"], "hasMp":true, + "lore":"Колдунья, заключившая договор с тёмными силами. Варит яды и проклятия. Способна проклясть весь отряд.", + "weakness":"holy", "resist":"magic", "ai":"hex" }, + "golem": { "name":"Голем", "hp":130, "dmg":16, "def":20, "exp":100, "gold":80, "loot":["golem_core"], "isBoss":false, + "lore":"Магический конструкт из камня и металла. Создан алхимиками для охраны. Разрушить можно только магией.", + "weakness":"magic", "resist":"physical", "ai":"stun" }, + "dragon": { "name":"Дракон", "hp":220, "dmg":32, "def":16, "exp":220, "gold":220, "loot":["dragon_scale","dragon_heart"], "isBoss":true, + "lore":"Древнее создание с интеллектом мага и силой армии. Живёт тысячелетиями. Огонь его дыхания плавит доспехи.", + "weakness":"ice", "resist":"fire", "ai":"fury" }, + "lich": { "name":"Лич", "hp":140, "dmg":38, "def":10, "exp":200, "gold":150, "loot":["necronomicon","skull_staff"], "isBoss":true, "hasMp":true, + "lore":"Некромант, победивший смерть ценой души. Хранит жизненную силу в филактерии. Повелевает армиями мертвецов.", + "weakness":"holy", "resist":"magic", "ai":"summon" }, + "ghost": { "name":"Призрак", "hp":55, "dmg":20, "def":0, "exp":80, "gold":35, "loot":["ghost_essence"], + "lore":"Душа воина, погибшего в Руинах. Способна уходить в эфирный план, становясь неуязвимой на мгновение.", + "weakness":"holy", "resist":"physical", "ai":"phase" }, + "wyvern": { "name":"Виверна", "hp":95, "dmg":26, "def":7, "exp":140, "gold":95, "loot":["wyvern_scale","wyvern_poison"], + "lore":"Двукрылая родственница дракона. Атакует стремительным пикированием с воздуха. Яд виверны разъедает доспехи.", + "weakness":"ice", "resist":"fire", "ai":"dive" }, + + "goblin_king": { "name":"Зубастый, Король Гоблинов", "hp":145, "dmg":20, "def":10, "exp":280, "gold":180, + "loot":["goblin_ear","herb"], + "lore":"Легендарный вожак гоблинов, носящий украденную корону убитого рыцаря. За его голову обещана награда ещё десять лет назад. Собрал крупнейшую в регионе стаю — более сотни особей.", + "weakness":"fire", "resist":null, "ai":"warcry", + "isBoss":true, "isMini":true, + "uniqueLoot":{ "id":"goblin_crown", "type":"armor", "name":"Корона Зубастого", + "opts":{ "defense":8, "bonusStr":5, "bonusDef":3, "value":280, "slot":"head", "icon":"👑", "rarity":"legendary", "desc":"Корона украденная у рыцаря. Пахнет гоблином." }}}, + + "corvus": { "name":"Корвус Некромант", "hp":180, "dmg":30, "def":9, "exp":420, "gold":260, + "loot":["necronomicon","bone"], + "lore":"Бывший придворный маг, изгнанный за опыты над мёртвыми. Корвус нашёл укрытие в подземельях под деревней и за годы изоляции лишился рассудка. Теперь он стремится поднять армию нежити и захватить регион.", + "weakness":"holy", "resist":"magic", "ai":"necroboss", + "isBoss":true, "isMini":true, "hasMp":true, + "uniqueLoot":{ "id":"corvus_staff", "type":"weapon", "name":"Посох Корвуса", + "opts":{ "damage":16, "bonusMag":22, "value":460, "slot":"weapon", "icon":"💜", "rarity":"legendary", "desc":"Навершие посоха светится мертвенным светом. Усиливает заклинания смерти." }}}, + + "hydra": { "name":"Болотная Гидра", "hp":210, "dmg":25, "def":6, "exp":360, "gold":200, + "loot":["hydra_scale","slime_gel"], + "lore":"Трёхголовое чудовище, поселившееся в глубинах болота поколения назад. Каждую отрубленную голову заменяют две новые. Только огонь не даёт ей регенерировать.", + "weakness":"fire", "resist":"physical", "ai":"hydra", + "isBoss":true, "isMini":true, + "uniqueLoot":{ "id":"hydra_fang", "type":"weapon", "name":"Клык Гидры", + "opts":{ "damage":23, "bonusMag":4, "value":340, "slot":"weapon", "icon":"🦷", "rarity":"legendary", "combatEffect":"poison", "combatDmg":9, "desc":"Полый клык, наполненный ядом Гидры. Каждый удар отравляет врага." }}}, + + "frost_giant": { "name":"Ледяной Великан Скарр", "hp":200, "dmg":30, "def":13, "exp":390, "gold":230, + "loot":["frost_heart","yeti_fur"], + "lore":"Древний великан из ледяного народа, изгнанный своим кланом за жестокость. Скарр обосновался на вершине горы и убивает любого, кто осмелится подняться на его территорию.", + "weakness":"fire", "resist":"ice", "ai":"frost", + "isBoss":true, "isMini":true, + "uniqueLoot":{ "id":"giant_axe", "type":"weapon", "name":"Топор Великана", + "opts":{ "damage":29, "bonusStr":8, "value":400, "slot":"weapon", "icon":"🪓", "rarity":"legendary", "desc":"Огромный топор из ледяного железа. Прежде чем поднять его, убедись что хватит сил." }}}, + + "stone_colossus": { "name":"Каменный Колосс", "hp":260, "dmg":24, "def":22, "exp":440, "gold":270, + "loot":["titan_core","golem_core"], + "lore":"Прото-голем, созданный магами Первой Эпохи для охраны пещерного святилища. Тысячелетиями он стоял неподвижно — пока не почуял чужака. Разрушить физически невозможно, только магией.", + "weakness":"magic", "resist":"physical", "ai":"colossus", + "isBoss":true, "isMini":true, + "uniqueLoot":{ "id":"colossus_shield", "type":"armor", "name":"Щит Колосса", + "opts":{ "defense":18, "bonusHp":40, "value":380, "slot":"shield", "icon":"🛡️", "rarity":"legendary", "desc":"Отколотая рука Колосса. Невероятно прочна." }}}, + + "shadow_assassin": { "name":"Призрак Ирис", "hp":160, "dmg":36, "def":7, "exp":410, "gold":240, + "loot":["ghost_essence","wyvern_poison"], + "lore":"Легендарный убийца, некогда служивший тайной службе королевства. После гибели в руинах её душа не ушла — она осталась охранять тайны, которые унесла с собой. Атакует из темноты.", + "weakness":"holy", "resist":"physical", "ai":"shadow", + "isBoss":true, "isMini":true, + "uniqueLoot":{ "id":"shadow_blade", "type":"weapon", "name":"Клинок Ирис", + "opts":{ "damage":26, "bonusDef":4, "value":420, "slot":"weapon", "icon":"🗡️", "rarity":"legendary", "combatEffect":"poison", "combatDmg":7, "desc":"Клинок, выкованный из теневого металла. Слабо светится в темноте." }}}, + + "chaos_lord": { "name":"Мрак Безликий", "hp":700, "dmg":55, "def":22, "exp":3000, "gold":800, + "loot":["dragon_heart","necronomicon","titan_core"], + "lore":"Первобытное существо из Бездны. Источник всего зла в Эйдоне. Не имеет лица, не имеет имени — только жажда хаоса.", + "weakness":"holy", "resist":"magic", "ai":"chaos", + "isBoss":true, "isMini":true, "hasMp":true, + "uniqueLoot":{ "id":"void_crown", "type":"armor", "name":"Корона Бездны", + "opts":{ "defense":25, "bonusHp":80, "bonusMag":15, "bonusStr":10, "value":2000, "slot":"head", "icon":"🌑", "rarity":"legendary", "desc":"Корона первобытного хаоса. Все стихии склоняются перед её владельцем." }}} +} diff --git a/data/loot.json b/data/loot.json new file mode 100644 index 0000000..d5de0ae --- /dev/null +++ b/data/loot.json @@ -0,0 +1,29 @@ +{ + "herb": { "n":"Трава", "t":"material", "v":6 }, + "goblin_ear": { "n":"Ухо гоблина", "t":"material", "v":5 }, + "orc_tusk": { "n":"Клык орка", "t":"material", "v":12 }, + "meat": { "n":"Мясо", "t":"food", "v":10, "heal":20 }, + "slime_gel": { "n":"Слизь", "t":"material", "v":5 }, + "bone": { "n":"Кость", "t":"material", "v":4 }, + "bone_dagger": { "n":"Костяной кинжал", "t":"weapon", "v":28, "dmg":8, "slot":"weapon", "icon":"🗡️", "rarity":"uncommon", "setId":"shadow" }, + "money_pouch": { "n":"Кошелёк", "t":"gold", "v":18 }, + "wolf_pelt": { "n":"Волчья шкура", "t":"material", "v":15 }, + "spider_venom": { "n":"Паучий яд", "t":"material", "v":20 }, + "rot_flesh": { "n":"Гнилая плоть", "t":"material", "v":3 }, + "bat_wing": { "n":"Крыло летучей мыши", "t":"material", "v":8 }, + "troll_heart": { "n":"Сердце тролля", "t":"material", "v":55, "rarity":"rare" }, + "club": { "n":"Дубина", "t":"weapon", "v":18, "dmg":7, "slot":"weapon", "icon":"🏏" }, + "yeti_fur": { "n":"Шкура йети", "t":"material", "v":40, "rarity":"rare" }, + "witch_brew": { "n":"Зелье ведьмы", "t":"potion", "v":60, "heal":80, "rarity":"rare" }, + "golem_core": { "n":"Ядро голема", "t":"material", "v":90, "rarity":"epic" }, + "dragon_scale": { "n":"Чешуя дракона", "t":"material", "v":110,"rarity":"epic" }, + "dragon_heart": { "n":"Сердце дракона", "t":"material", "v":300,"rarity":"legendary" }, + "necronomicon": { "n":"Некрономикон", "t":"scroll", "v":200,"spell":"life_drain", "rarity":"rare" }, + "skull_staff": { "n":"Посох черепа", "t":"weapon", "v":90, "dmg":18, "bonusMag":5, "slot":"weapon", "icon":"💀", "rarity":"rare", "setId":"shadow" }, + "ghost_essence": { "n":"Эссенция призрака", "t":"material", "v":40, "rarity":"rare" }, + "wyvern_scale": { "n":"Чешуя виверны", "t":"material", "v":60, "rarity":"rare" }, + "wyvern_poison": { "n":"Яд виверны", "t":"material", "v":50, "rarity":"rare" }, + "hydra_scale": { "n":"Чешуя Гидры", "t":"material", "v":75, "rarity":"epic" }, + "frost_heart": { "n":"Сердце Мороза", "t":"material", "v":90, "rarity":"epic" }, + "titan_core": { "n":"Сердцевина Колосса", "t":"material", "v":100, "rarity":"epic" } +} diff --git a/data/lore.json b/data/lore.json new file mode 100644 index 0000000..e4318fb --- /dev/null +++ b/data/lore.json @@ -0,0 +1,59 @@ +[ + { "id":"ln_v1", "title":"Старая записка", "mapId":"village", "gx":11, "gy":11, "icon":"📜", + "text":"Говорят, в пещерах к востоку обитает дракон. Никто из ушедших туда не вернулся. Будь осторожен, путник.", + "reveals":{ "enemy":"dragon", "hint":"Дракон живёт в пещерах. Готовься к огненному бою." } }, + { "id":"ln_v2", "title":"Объявление на столбе", "mapId":"village", "gx":5, "gy":5, "icon":"📋", + "text":"РАЗЫСКИВАЕТСЯ: вожак гоблинов по прозвищу «Зубастый». Видели в лесу к северу. Награда: 100 золота. Обращаться к Стражнику.", + "reveals":{ "enemy":"goblin_king", "hint":"Зубастый прячется в лесу. Огонь — его слабость." } }, + { "id":"ln_f1", "title":"Эльфийский дневник", "mapId":"forest", "gx":4, "gy":4, "icon":"📖", + "text":"4-й день. Лес изменился. Существа стали агрессивнее с тех пор, как в болоте появился Шаман. Старые тропы больше не безопасны.", + "reveals":{ "enemy":"wolf", "hint":"Волки контролируют лесные тропы. Огонь отпугивает стаю." } }, + { "id":"ln_f2", "title":"Измятый пергамент", "mapId":"forest", "gx":10, "gy":9, "icon":"📜", + "text":"...Не ходите в болото ночью. Ведьма собирает души потерявшихся. Говорят, она слаба против святого пламени...", + "reveals":{ "enemy":"witch", "hint":"Ведьма уязвима к святой магии, но устойчива к обычным чарам." } }, + { "id":"ln_d1", "title":"Надпись на стене", "mapId":"dungeon", "gx":3, "gy":3, "icon":"🪨", + "text":"«Тот, кто слышит шорох в стенах — не одинок. Мы всегда рядом.» — написано кровью. Под надписью — рисунок Лича.", + "reveals":{ "enemy":"lich", "hint":"Лич повелевает нежитью. Святое оружие прожигает его магию." } }, + { "id":"ln_d2", "title":"Дневник солдата", "mapId":"dungeon", "gx":10, "gy":10, "icon":"📔", + "text":"12-й день в подземелье. Еды нет. Скелеты не дают пройти. Лич слаб против святого огня. Надеюсь, кто-то это найдёт.", + "reveals":{ "enemy":"skeleton", "hint":"Скелеты неуязвимы к яду, но святой свет разрушает их." } }, + { "id":"ln_c1", "title":"Записка исследователя","mapId":"cave", "gx":5, "gy":5, "icon":"📝", + "text":"Кристаллы в этой пещере поглощают магическую энергию. Я чувствую, как слабею. Дракон охраняет нечто в глубине — не ходите туда.", + "reveals":{ "enemy":"dragon", "hint":"Дракон устойчив к огню, но уязвим ко льду." } }, + { "id":"ln_c2", "title":"Рунный камень", "mapId":"cave", "gx":10, "gy":8, "icon":"🔮", + "text":"«Огонь рождён из холода, лёд — из пламени. Дракон, что дышит огнём, падёт от стрел мороза.»", + "reveals":{ "enemy":"dragon", "hint":"Ледяные заклинания наносят дракону полуторный урон!" } }, + { "id":"ln_m1", "title":"Высеченный текст", "mapId":"mountain", "gx":7, "gy":7, "icon":"⛏️", + "text":"«Здесь покоится Первый Голем, созданный Академией. Магия разрушает его — физический урон отскакивает от него, как от скалы.»", + "reveals":{ "enemy":"golem", "hint":"Голем устойчив к физическим атакам. Используй магию!" } }, + { "id":"ln_s1", "title":"Страница гримуара", "mapId":"swamp", "gx":5, "gy":9, "icon":"📖", + "text":"Рецепт зелья тени: 3 части паучьего яда, крыло летучей мыши, лепесток ночного цветка. Смешать в полночь. Осторожно — вдыхать нельзя.", + "reveals":{ "enemy":"spider", "hint":"Пауки устойчивы к яду, но огонь сжигает их паутину." } }, + { "id":"ln_s2", "title":"Записка беглеца", "mapId":"swamp", "gx":9, "gy":4, "icon":"📜", + "text":"Ведьма слабее у восточного огня. Принесите святую воду и она не сможет применить исцеление. Я ухожу. Прощайте.", + "reveals":{ "enemy":"witch", "hint":"Святая магия блокирует исцеление Ведьмы." } }, + { "id":"ln_f3", "title":"Поваленное дерево", "mapId":"forest", "gx":8, "gy":7, "icon":"📜", + "text":"Нашёл старый указатель: «Зубастый обитает в центре леса. Его стая — сотня гоблинов. Один не ходи — вернись с оружием и смелостью.» Дата: десять лет назад.", + "reveals":{ "enemy":"goblin", "hint":"Гоблины боятся огня. Используй огненные заклинания." } }, + { "id":"ln_d3", "title":"Сожжённая страница", "mapId":"dungeon", "gx":6, "gy":6, "icon":"📔", + "text":"«Корвус захватил нижний ярус. Бывший придворный маг — теперь безумный некромант. Его свита — армия нежити. Мы не смогли пробиться. Пишу это, чтобы предупредить следующих.»", + "reveals":{ "enemy":"corvus", "hint":"Корвус — святая магия его единственная слабость." } }, + { "id":"ln_s3", "title":"Болотный знак", "mapId":"swamp", "gx":3, "gy":8, "icon":"📖", + "text":"Шаман предупреждал: в глубинах болота спит трёхголовая Гидра. Она проснулась год назад. Регенерирует раненые головы — только огонь останавливает её. Не ходите туда.", + "reveals":{ "enemy":"hydra", "hint":"Гидра регенерирует, но огонь останавливает рост голов!" } }, + { "id":"ln_m2", "title":"Табличка на перевале", "mapId":"mountain", "gx":9, "gy":9, "icon":"⛏️", + "text":"СТОЙ! Выше — территория Скарра. Изгнанный ледяной великан убивает любого, кто поднимется на вершину. Огонь — единственное, чего он боится. Не говори, что тебя не предупреждали.", + "reveals":{ "enemy":"frost_giant", "hint":"Ледяной Великан боится огня. Лёд бесполезен против него." } }, + { "id":"ln_c3", "title":"Предостережение", "mapId":"cave", "gx":3, "gy":9, "icon":"🔮", + "text":"Каменный Колосс — тысячелетний страж. Он почуял чужака. Магия разрушает его — физическое оружие не берёт. Каждые несколько ходов он закрывается непробиваемой бронёй. Жди и атакуй в уязвимый момент.", + "reveals":{ "enemy":"stone_colossus", "hint":"Колосс устойчив к физике. Магия — единственный путь." } }, + { "id":"ln_r1", "title":"Выцветший пергамент", "mapId":"ruins", "gx":5, "gy":7, "icon":"📜", + "text":"«Ирис — легенда среди убийц. Она мертва, но не ушла. Её призрак охраняет тайны руин, которые унесла с собой. Нападает из темноты. Святое оружие — единственная защита от неё.»", + "reveals":{ "enemy":"shadow_assassin", "hint":"Призрак Ирис уязвима к святой магии." } }, + { "id":"ln_r2", "title":"Чёрный портал", "mapId":"ruins", "gx":7, "gy":2, "icon":"🌑", + "text":"Что-то пульсирует за этим порталом. Воздух холоднее, чем должен быть. Первый Герой запечатал это место — печать ослабла. Это начало конца, или его конец?", + "reveals":{ "enemy":"chaos_lord", "hint":"За порталом — древнее зло. Готовь святое оружие." } }, + { "id":"ln_a1", "title":"Записка Первого Героя","mapId":"abyss", "gx":4, "gy":4, "icon":"📜", + "text":"«Я запечатал его здесь тысячу лет назад. Если ты читаешь это — печать разрушена. Мрак Безликий — это не демон и не зверь. Это сама тьма, принявшая форму. Только святое оружие может причинить ему настоящий вред. Удачи, герой.» — Эйдор I", + "reveals":{ "enemy":"chaos_lord", "hint":"Мрак Безликий уязвим ТОЛЬКО к святой магии!" } } +] diff --git a/data/quests.json b/data/quests.json new file mode 100644 index 0000000..d2fcbcb --- /dev/null +++ b/data/quests.json @@ -0,0 +1,27 @@ +[ + { "id":"q_first", "name":"Первая кровь", "desc":"Убей 3 гоблина", "type":"kill", "target":"goblin", "need":3, "reward":{ "exp":50, "gold":20 }}, + { "id":"q_wolves", "name":"Охота на волков", "desc":"Убей 3 волка", "type":"kill", "target":"wolf", "need":3, "reward":{ "exp":60, "gold":30 }}, + { "id":"q_forest", "name":"Зачистка леса", "desc":"Убей 5 любых монстров", "type":"kill", "target":"any", "need":5, "reward":{ "exp":100, "gold":40 }}, + { "id":"q_slime", "name":"Проблема слизней", "desc":"Убей 3 слизня", "type":"kill", "target":"slime", "need":3, "reward":{ "exp":45, "gold":25 }}, + { "id":"q_bandit", "name":"Убрать разбойников", "desc":"Убей 4 разбойника", "type":"kill", "target":"bandit", "need":4, "reward":{ "exp":80, "gold":60 }}, + { "id":"q_dungeon","name":"Вход в подземелье", "desc":"Доберись до подземелья", "type":"visit","target":"dungeon", "need":1, "reward":{ "exp":30, "gold":15 }}, + { "id":"q_cave", "name":"Вход в пещеру", "desc":"Найди пещеру", "type":"visit","target":"cave", "need":1, "reward":{ "exp":50, "gold":20 }}, + { "id":"q_skel", "name":"Армия скелетов", "desc":"Убей 4 скелета", "type":"kill", "target":"skeleton", "need":4, "reward":{ "exp":120, "gold":60 }}, + { "id":"q_troll", "name":"Бой с троллем", "desc":"Убей тролля", "type":"kill", "target":"troll", "need":1, "reward":{ "exp":200, "gold":100}}, + { "id":"q_spider", "name":"Пауки болота", "desc":"Убей 5 пауков", "type":"kill", "target":"spider", "need":5, "reward":{ "exp":90, "gold":55 }}, + { "id":"q_dragon", "name":"Убийца дракона", "desc":"Убей дракона", "type":"kill", "target":"dragon", "need":1, "reward":{ "exp":500, "gold":300}}, + { "id":"q_lich", "name":"Конец некромантии", "desc":"Уничтожь Лича", "type":"kill", "target":"lich", "need":1, "reward":{ "exp":450, "gold":250}}, + { "id":"q_ruins", "name":"Исследователь", "desc":"Посети Руины", "type":"visit","target":"ruins", "need":1, "reward":{ "exp":80, "gold":40 }}, + { "id":"q_ghost", "name":"Упокоить призраков", "desc":"Убей 3 призрака в Руинах", "type":"kill", "target":"ghost", "need":3, "reward":{ "exp":200, "gold":100}}, + { "id":"q_wyvern", "name":"Охота на виверн", "desc":"Убей 2 виверны", "type":"kill", "target":"wyvern", "need":2, "reward":{ "exp":280, "gold":160}}, + { "id":"q_orc5", "name":"Орочья угроза", "desc":"Убей 5 орков", "type":"kill", "target":"orc", "need":5, "reward":{ "exp":150, "gold":80 }}, + { "id":"q_yeti", "name":"Снежный зверь", "desc":"Убей йети", "type":"kill", "target":"yeti", "need":1, "reward":{ "exp":160, "gold":100}}, + { "id":"q_swamp_c", "name":"Зачистка болота", "desc":"Убей 3 паука и 2 зомби", "type":"kill","target":"spider", "need":3, "reward":{"exp":130, "gold":70 }}, + { "id":"q_goblin_king", "name":"Король Гоблинов", "desc":"Убей Зубастого в лесу", "type":"kill","target":"goblin_king", "need":1, "reward":{"exp":450, "gold":250 }}, + { "id":"q_corvus", "name":"Конец Корвуса", "desc":"Уничтожь Корвуса в подземелье", "type":"kill","target":"corvus", "need":1, "reward":{"exp":550, "gold":300 }}, + { "id":"q_hydra", "name":"Гидра болот", "desc":"Убей Болотную Гидру", "type":"kill","target":"hydra", "need":1, "reward":{"exp":480, "gold":280 }}, + { "id":"q_frost_giant", "name":"Ледяной Великан", "desc":"Сразись с Ледяным Великаном Скарр", "type":"kill","target":"frost_giant", "need":1, "reward":{"exp":510, "gold":290 }}, + { "id":"q_colossus", "name":"Каменный Колосс", "desc":"Уничтожь Каменного Колосса в пещере", "type":"kill","target":"stone_colossus", "need":1, "reward":{"exp":560, "gold":310 }}, + { "id":"q_shadow", "name":"Призрак Ирис", "desc":"Упокой Призрак Ирис в Руинах", "type":"kill","target":"shadow_assassin", "need":1, "reward":{"exp":530, "gold":295 }}, + { "id":"q_chaos_lord", "name":"Конец Тьмы", "desc":"Убей Мрака Безликого в Бездне", "type":"kill","target":"chaos_lord", "need":1, "reward":{"exp":3000,"gold":500 }} +] diff --git a/data/recipes.json b/data/recipes.json new file mode 100644 index 0000000..f6c5025 --- /dev/null +++ b/data/recipes.json @@ -0,0 +1,92 @@ +[ + { "id":"r_heal_sm", "name":"Малое зелье HP", "icon":"🧪", "category":"potions", + "ingredients":[{"id":"herb","qty":1},{"id":"slime_gel","qty":1}], + "result":{ "type":"potion", "name":"Малое зелье HP", "opts":{ "healAmount":40, "value":30, "stackable":true, "qty":1, "icon":"🧪" }}}, + { "id":"r_heal_md", "name":"Среднее зелье HP", "icon":"🧪", "category":"potions", + "ingredients":[{"id":"herb","qty":2},{"id":"slime_gel","qty":1}], + "result":{ "type":"potion", "name":"Среднее зелье HP", "opts":{ "healAmount":70, "value":55, "stackable":true, "qty":1, "icon":"🧪" }}}, + { "id":"r_heal_lg", "name":"Большое зелье HP", "icon":"🍶", "category":"potions", + "ingredients":[{"id":"herb","qty":3},{"id":"troll_heart","qty":1}], + "result":{ "type":"potion", "name":"Большое зелье HP", "opts":{ "healAmount":120, "value":95, "stackable":true, "qty":1, "icon":"🍶" }}}, + { "id":"r_mp_md", "name":"Зелье маны", "icon":"💧", "category":"potions", + "ingredients":[{"id":"bat_wing","qty":2},{"id":"spider_venom","qty":1}], + "result":{ "type":"potion", "name":"Зелье маны", "opts":{ "restoreMp":50, "value":60, "stackable":true, "qty":1, "icon":"💧" }}}, + { "id":"r_antidote", "name":"Антидот", "icon":"🩺", "category":"potions", + "ingredients":[{"id":"herb","qty":1},{"id":"wolf_pelt","qty":1}], + "result":{ "type":"potion", "name":"Антидот", "opts":{ "healAmount":15, "value":35, "stackable":true, "qty":1, "icon":"🩺", "desc":"Снимает яд и горение" }}}, + { "id":"r_stew", "name":"Мясное рагу", "icon":"🍲", "category":"potions", + "ingredients":[{"id":"meat","qty":2},{"id":"herb","qty":1}], + "result":{ "type":"food", "name":"Сытное рагу", "opts":{ "healAmount":50, "restoreMp":25, "value":40, "stackable":true, "qty":1, "icon":"🍲" }}}, + { "id":"r_smoke", "name":"Дымовая бомба", "icon":"💨", "category":"potions", + "ingredients":[{"id":"bat_wing","qty":1},{"id":"rot_flesh","qty":1}], + "result":{ "type":"potion", "name":"Дымовая бомба", "opts":{ "value":45, "stackable":true, "qty":1, "icon":"💨", "desc":"Гарантированный побег из боя", "healAmount":0 }}}, + { "id":"r_rune_str", "name":"Руна Силы", "icon":"🔴", "category":"runes", + "ingredients":[{"id":"golem_core","qty":1},{"id":"orc_tusk","qty":2}], + "result":{ "type":"material", "name":"Руна Силы", "opts":{ "bonusStr":5, "value":200, "icon":"🔴", "desc":"+5 СИЛ к персонажу", "rarity":"rare" }}}, + { "id":"r_rune_mag", "name":"Руна Магии", "icon":"🔵", "category":"runes", + "ingredients":[{"id":"dragon_scale","qty":1},{"id":"necronomicon","qty":1}], + "result":{ "type":"material", "name":"Руна Магии", "opts":{ "bonusMag":5, "value":260, "icon":"🔵", "desc":"+5 МАГ к персонажу", "rarity":"rare" }}}, + { "id":"r_rune_def", "name":"Руна Защиты", "icon":"🟢", "category":"runes", + "ingredients":[{"id":"yeti_fur","qty":1},{"id":"bone","qty":3}], + "result":{ "type":"material", "name":"Руна Защиты", "opts":{ "bonusDef":4, "value":185, "icon":"🟢", "desc":"+4 ЗАЩ к персонажу", "rarity":"uncommon" }}}, + { "id":"r_sharpen", "name":"Камень заточки", "icon":"⚙️", "category":"enhance", + "ingredients":[{"id":"bone","qty":2},{"id":"slime_gel","qty":1}], + "result":{ "type":"material", "name":"Камень заточки", "opts":{ "damage":3, "value":65, "icon":"⚙️", "desc":"+3 урона (применить к оружию)" }}}, + { "id":"r_poison_c", "name":"Яд на оружие", "icon":"☠️", "category":"enhance", + "ingredients":[{"id":"spider_venom","qty":2},{"id":"herb","qty":1}], + "result":{ "type":"scroll", "name":"Яд на оружие", "opts":{ "spell":"poison_cloud", "value":85, "icon":"☠️", "stackable":true, "qty":1 }}}, + { "id":"r_scale_a", "name":"Чешуйчатая броня", "icon":"🐉", "category":"equipment", + "ingredients":[{"id":"dragon_scale","qty":2},{"id":"wolf_pelt","qty":2}], + "result":{ "type":"armor", "name":"Чешуйчатая броня", "opts":{ "defense":20, "bonusHp":30, "value":500, "slot":"chest", "icon":"🐉", "rarity":"epic" }}}, + { "id":"r_death_s", "name":"Посох Смерти", "icon":"💀", "category":"equipment", + "ingredients":[{"id":"skull_staff","qty":1},{"id":"golem_core","qty":1},{"id":"goblin_ear","qty":3}], + "result":{ "type":"weapon", "name":"Посох Смерти", "opts":{ "damage":22, "bonusMag":8, "value":420, "slot":"weapon", "icon":"💀", "rarity":"epic" }}}, + { "id":"r_life_gem", "name":"Амулет жизни", "icon":"💎", "category":"equipment", + "ingredients":[{"id":"dragon_heart","qty":1},{"id":"troll_heart","qty":1}], + "result":{ "type":"armor", "name":"Амулет жизни", "opts":{ "bonusHp":60, "bonusMp":30, "value":850, "slot":"acc", "icon":"💎", "rarity":"legendary" }}}, + { "id":"r_alch_str", "name":"Зелье силы", "icon":"💪", "category":"alchemy", + "ingredients":[{"id":"herb","qty":2},{"id":"orc_tusk","qty":1}], + "result":{ "type":"potion", "name":"Зелье силы 💪", "opts":{ "buffStat":"str", "buffVal":1.5, "buffDur":45000, "value":80, "stackable":true, "qty":1, "icon":"💪", "desc":"Урон ×1.5 на 45 сек" }}}, + { "id":"r_alch_def", "name":"Зелье камня", "icon":"🪨", "category":"alchemy", + "ingredients":[{"id":"wolf_pelt","qty":2},{"id":"bone","qty":2}], + "result":{ "type":"potion", "name":"Зелье камня 🪨", "opts":{ "buffStat":"def", "buffVal":2.0, "buffDur":30000, "value":70, "stackable":true, "qty":1, "icon":"🪨", "desc":"Защита ×2 на 30 сек" }}}, + { "id":"r_alch_mp", "name":"Эликсир маны", "icon":"🔮", "category":"alchemy", + "ingredients":[{"id":"slime_gel","qty":2},{"id":"bat_wing","qty":2}], + "result":{ "type":"potion", "name":"Эликсир маны 🔮", "opts":{ "restoreMp":80, "value":65, "stackable":true, "qty":1, "icon":"🔮" }}}, + { "id":"r_alch_regen","name":"Зелье регенерации", "icon":"💚", "category":"alchemy", + "ingredients":[{"id":"herb","qty":3},{"id":"troll_heart","qty":1}], + "result":{ "type":"potion", "name":"Зелье регенерации 💚", "opts":{ "healAmount":40, "buffStat":"regen", "buffVal":8, "buffDur":50000, "value":110, "stackable":true, "qty":1, "icon":"💚", "desc":"+40 HP сейчас, регенерация 8 HP/ход 5 ходов" }}}, + { "id":"r_alch_poison","name":"Яд-склянка", "icon":"☠️", "category":"alchemy", + "ingredients":[{"id":"spider_venom","qty":2},{"id":"bat_wing","qty":1}], + "result":{ "type":"potion", "name":"Яд-склянка ☠️", "opts":{ "combatEffect":"poison", "value":55, "stackable":true, "qty":1, "icon":"☠️", "desc":"Боевое: наносит яд врагу (3 хода)" }}}, + { "id":"r_alch_fire","name":"Огненная колба", "icon":"🔥", "category":"alchemy", + "ingredients":[{"id":"dragon_scale","qty":1},{"id":"herb","qty":1}], + "result":{ "type":"potion", "name":"Огненная колба 🔥", "opts":{ "combatEffect":"fire", "combatDmg":35, "value":90, "stackable":true, "qty":1, "icon":"🔥", "desc":"Боевое: 35 урона огнём", "rarity":"rare" }}}, + { "id":"r_alch_antidote","name":"Сильный антидот", "icon":"🩺", "category":"alchemy", + "ingredients":[{"id":"herb","qty":2},{"id":"slime_gel","qty":1},{"id":"bat_wing","qty":1}], + "result":{ "type":"potion", "name":"Сильный антидот 🩺", "opts":{ "healAmount":20, "cureStatus":true, "value":50, "stackable":true, "qty":1, "icon":"🩺", "desc":"Снимает яд, горение, все статусы" }}}, + { "id":"r_alch_elixir","name":"Эликсир могущества", "icon":"⚗️", "category":"alchemy", + "ingredients":[{"id":"dragon_heart","qty":1},{"id":"witch_brew","qty":1}], + "result":{ "type":"potion", "name":"Эликсир могущества ⚗️", "opts":{ "healAmount":80, "restoreMp":60, "buffStat":"str", "buffVal":1.4, "buffDur":60000, "value":350, "stackable":true, "qty":1, "icon":"⚗️", "desc":"HP+80 MP+60 Сила×1.4 на 60 сек", "rarity":"epic" }}}, + { "id":"r_ghost_ward","name":"Оберег от призраков", "icon":"🕯️", "category":"alchemy", + "ingredients":[{"id":"ghost_essence","qty":2},{"id":"herb","qty":2}], + "result":{ "type":"potion", "name":"Оберег от призраков 🕯️", "opts":{ "buffStat":"def", "buffVal":1.3, "buffDur":60000, "value":120, "stackable":true, "qty":1, "icon":"🕯️", "desc":"Защита ×1.3 и +30 HP против нежити", "healAmount":30 }}}, + { "id":"r_wyvern_venom","name":"Яд виверны", "icon":"☠️", "category":"alchemy", + "ingredients":[{"id":"wyvern_poison","qty":1},{"id":"spider_venom","qty":1}], + "result":{ "type":"potion", "name":"Яд виверны ☠️", "opts":{ "combatEffect":"poison", "value":95, "stackable":true, "qty":1, "icon":"☠️", "desc":"Боевое: сильный яд на врага (4 хода)", "rarity":"rare" }}}, + { "id":"r_holy_plate","name":"Нагрудник паладина", "icon":"⛪", "category":"equipment", + "ingredients":[{"id":"golem_core","qty":1},{"id":"troll_heart","qty":1},{"id":"bone","qty":3}], + "result":{ "type":"armor", "name":"Нагрудник паладина ⛪", "opts":{ "defense":16, "bonusHp":40, "value":480, "slot":"chest", "icon":"⛪", "rarity":"epic", "setId":"holy" }}}, + { "id":"r_storm_staff","name":"Посох бури", "icon":"⚡", "category":"equipment", + "ingredients":[{"id":"dragon_scale","qty":1},{"id":"yeti_fur","qty":1},{"id":"golem_core","qty":1}], + "result":{ "type":"weapon", "name":"Посох бури ⚡", "opts":{ "damage":10, "bonusMag":16, "bonusMp":35, "value":580, "slot":"weapon", "icon":"⚡", "rarity":"epic", "setId":"arcane" }}}, + { "id":"r_frost_blade","name":"Ледяной клинок", "icon":"❄️", "category":"equipment", + "ingredients":[{"id":"frost_heart","qty":1},{"id":"dragon_scale","qty":1}], + "result":{ "type":"weapon", "name":"Ледяной клинок ❄️", "opts":{ "damage":27, "bonusStr":6, "value":620, "slot":"weapon", "icon":"❄️", "rarity":"legendary", "desc":"Выкован из ледяного сердца великана. Морозит врагов." }}}, + { "id":"r_titan_armor","name":"Броня Колосса", "icon":"🪨", "category":"equipment", + "ingredients":[{"id":"titan_core","qty":1},{"id":"golem_core","qty":1}], + "result":{ "type":"armor", "name":"Броня Колосса 🪨", "opts":{ "defense":24, "bonusHp":55, "value":650, "slot":"chest", "icon":"🪨", "rarity":"legendary", "desc":"Выкована из осколков Каменного Колосса. Невероятно прочна." }}}, + { "id":"r_hydra_mail", "name":"Кольчуга Гидры", "icon":"🐍", "category":"equipment", + "ingredients":[{"id":"hydra_scale","qty":2},{"id":"wyvern_scale","qty":1}], + "result":{ "type":"armor", "name":"Кольчуга Гидры 🐍", "opts":{ "defense":17, "bonusHp":30, "bonusMp":20, "value":490, "slot":"chest", "icon":"🐍", "rarity":"epic", "desc":"Сделана из чешуи Болотной Гидры. Обладает природной регенерацией." }}} +] diff --git a/data/sets.json b/data/sets.json new file mode 100644 index 0000000..40474f2 --- /dev/null +++ b/data/sets.json @@ -0,0 +1,32 @@ +{ + "steel": { + "name": "Стальной доспех", "icon": "⚔️", + "pieces": ["s_sw2","s_ar2","s_sh2","s_hm2"], + "bonuses": { + "2": { "def":4, "desc":"2 предмета: +4 защиты" }, + "4": { "def":4, "str":6, "hp":20, "desc":"4 предмета: +6 силы, +4 защиты, +20 HP" } + } + }, + "arcane": { + "name": "Посох чародея", "icon": "✨", + "pieces": ["s_st3"], + "bonuses": { + "1": { "mag":5, "mp":20, "desc":"Набор: +5 магии, +20 MP" } + } + }, + "shadow": { + "name": "Тёмные чары", "icon": "💀", + "pieces": ["skull_staff","bone_dagger"], + "bonuses": { + "2": { "mag":6, "str":4, "desc":"2 предмета: +6 магии, +4 силы" } + } + }, + "holy": { + "name": "Доспех паладина", "icon": "⛪", + "pieces": ["s_ar3","r_holy_plate"], + "bonuses": { + "1": { "def":5, "hp":15, "desc":"1 предмет: +5 защиты, +15 HP" }, + "2": { "def":8, "hp":30, "mag":4, "desc":"2 предмета: +8 защиты, +30 HP, +4 магии" } + } + } +} diff --git a/data/shop.json b/data/shop.json new file mode 100644 index 0000000..52ccd2e --- /dev/null +++ b/data/shop.json @@ -0,0 +1,19 @@ +[ + { "id":"s_hp2", "type":"potion", "name":"Среднее зелье HP", "opts":{ "healAmount":60, "value":50, "stackable":true, "qty":1, "icon":"🧪" }}, + { "id":"s_hp3", "type":"potion", "name":"Большое зелье HP", "opts":{ "healAmount":100, "value":90, "stackable":true, "qty":1, "icon":"🧪" }}, + { "id":"s_mp2", "type":"potion", "name":"Среднее зелье MP", "opts":{ "restoreMp":40, "value":55, "stackable":true, "qty":1, "icon":"💧" }}, + { "id":"s_sw2", "type":"weapon", "name":"Стальной меч", "opts":{ "damage":12, "value":160, "slot":"weapon", "icon":"⚔️", "rarity":"uncommon", "setId":"steel" }}, + { "id":"s_ax2", "type":"weapon", "name":"Боевой топор", "opts":{ "damage":14, "value":200, "slot":"weapon", "icon":"🪓", "rarity":"uncommon" }}, + { "id":"s_st3", "type":"weapon", "name":"Посох силы", "opts":{ "damage":5, "bonusMag":8, "value":220, "slot":"weapon", "icon":"✨", "rarity":"uncommon", "setId":"arcane" }}, + { "id":"s_ar2", "type":"armor", "name":"Кольчуга", "opts":{ "defense":8, "value":200, "slot":"chest", "icon":"🔗", "rarity":"uncommon", "setId":"steel" }}, + { "id":"s_sh2", "type":"armor", "name":"Стальной щит", "opts":{ "defense":6, "value":150, "slot":"shield", "icon":"🛡️", "rarity":"uncommon", "setId":"steel" }}, + { "id":"s_hm2", "type":"armor", "name":"Шлем воина", "opts":{ "defense":4, "bonusHp":15, "value":130, "slot":"head", "icon":"⛑️", "rarity":"uncommon", "setId":"steel" }}, + { "id":"s_sc1", "type":"scroll", "name":"Свиток огня", "opts":{ "spell":"fireball", "value":80, "icon":"📜" }}, + { "id":"s_sc2", "type":"scroll", "name":"Свиток исцеления", "opts":{ "spell":"heal", "value":60, "icon":"📜" }}, + { "id":"s_ring1", "type":"armor", "name":"Кольцо мага", "opts":{ "bonusMag":5, "bonusMp":20, "value":280, "slot":"acc", "icon":"💍", "rarity":"rare", "setId":"arcane" }}, + { "id":"s_boot1", "type":"armor", "name":"Сапоги ловкости", "opts":{ "bonusStr":3, "defense":2, "value":200, "slot":"feet", "icon":"👟", "rarity":"uncommon" }}, + { "id":"s_sw3", "type":"weapon", "name":"Ночной клинок", "opts":{ "damage":18, "bonusStr":4, "value":450, "slot":"weapon", "icon":"🗡️", "rarity":"legendary", "setId":"shadow" }}, + { "id":"s_st4", "type":"weapon", "name":"Посох бури", "opts":{ "damage":8, "bonusMag":14, "bonusMp":30, "value":500, "slot":"weapon", "icon":"⚡", "rarity":"epic", "setId":"arcane" }}, + { "id":"s_ar3", "type":"armor", "name":"Нагрудник паладина","opts":{ "defense":14, "bonusHp":30, "value":400, "slot":"chest", "icon":"⛪", "rarity":"epic", "setId":"holy" }}, + { "id":"s_ring2", "type":"armor", "name":"Кольцо защиты", "opts":{ "bonusDef":4, "defense":3, "value":220, "slot":"acc", "icon":"🔮", "rarity":"rare" }} +] diff --git a/data/world.json b/data/world.json new file mode 100644 index 0000000..3913dfc --- /dev/null +++ b/data/world.json @@ -0,0 +1,351 @@ +{ + "locations": { + "village": { "name":"Деревня", "safe":true }, + "tavern": { "name":"Таверна", "safe":true }, + "forest": { "name":"Лес", "safe":false }, + "dungeon": { "name":"Подземелье", "safe":false }, + "cave": { "name":"Пещера", "safe":false }, + "mountain": { "name":"Горы", "safe":false }, + "swamp": { "name":"Болото", "safe":false }, + "ruins": { "name":"Руины", "safe":false }, + "abyss": { "name":"Бездна", "safe":false } + }, + "spawns": { + "forest": [ + {"t":"goblin", "lOff":0, "x":3, "y":3 }, + {"t":"wolf", "lOff":0, "x":10, "y":2 }, + {"t":"goblin", "lOff":1, "x":5, "y":9 }, + {"t":"slime", "lOff":0, "x":2, "y":10}, + {"t":"bandit", "lOff":1, "x":11, "y":8 }, + {"t":"wolf", "lOff":1, "x":7, "y":4 }, + {"t":"goblin_king", "lOff":5, "x":8, "y":7 } + ], + "dungeon": [ + {"t":"skeleton", "lOff":2, "x":2, "y":2 }, + {"t":"skeleton", "lOff":2, "x":11, "y":3 }, + {"t":"zombie", "lOff":2, "x":6, "y":10}, + {"t":"troll", "lOff":4, "x":10, "y":11}, + {"t":"skeleton", "lOff":2, "x":3, "y":8 }, + {"t":"zombie", "lOff":3, "x":8, "y":5 }, + {"t":"corvus", "lOff":6, "x":5, "y":6 } + ], + "cave": [ + {"t":"bat", "lOff":2, "x":3, "y":3 }, + {"t":"slime", "lOff":1, "x":8, "y":5 }, + {"t":"orc", "lOff":3, "x":10, "y":8 }, + {"t":"dragon", "lOff":7, "x":12, "y":2 }, + {"t":"bat", "lOff":2, "x":5, "y":11}, + {"t":"orc", "lOff":4, "x":11, "y":11}, + {"t":"stone_colossus", "lOff":8, "x":6, "y":7 } + ], + "mountain":[ + {"t":"yeti", "lOff":3, "x":3, "y":3 }, + {"t":"golem", "lOff":5, "x":11, "y":4 }, + {"t":"yeti", "lOff":4, "x":6, "y":10}, + {"t":"golem", "lOff":6, "x":10, "y":10}, + {"t":"wolf", "lOff":2, "x":4, "y":7 }, + {"t":"frost_giant", "lOff":7, "x":8, "y":7 } + ], + "swamp": [ + {"t":"spider", "lOff":2, "x":3, "y":4 }, + {"t":"spider", "lOff":2, "x":11, "y":3 }, + {"t":"witch", "lOff":4, "x":7, "y":11}, + {"t":"slime", "lOff":1, "x":2, "y":9 }, + {"t":"zombie", "lOff":3, "x":10, "y":9 }, + {"t":"lich", "lOff":8, "x":12, "y":2 }, + {"t":"hydra", "lOff":7, "x":5, "y":6 } + ], + "ruins": [ + {"t":"ghost", "lOff":3, "x":3, "y":3 }, + {"t":"ghost", "lOff":3, "x":10, "y":4 }, + {"t":"ghost", "lOff":4, "x":5, "y":10}, + {"t":"wyvern", "lOff":5, "x":11, "y":10}, + {"t":"wyvern", "lOff":6, "x":12, "y":3 }, + {"t":"skeleton", "lOff":2, "x":2, "y":9 }, + {"t":"zombie", "lOff":3, "x":9, "y":11}, + {"t":"shadow_assassin", "lOff":8, "x":7, "y":7 } + ], + "abyss": [ + {"t":"shadow_assassin", "lOff":7, "x":3, "y":3 }, + {"t":"lich", "lOff":8, "x":11, "y":3 }, + {"t":"ghost", "lOff":6, "x":2, "y":11}, + {"t":"chaos_lord", "lOff":12,"x":7, "y":5 } + ] + }, + "npcs": { + "village": [ + {"name":"Торговец", "x":2, "y":5, "color":"#3498db", "type":"shop"}, + {"name":"Стражник", "x":5, "y":1, "color":"#8b0000", "type":"quest"}, + {"name":"Целитель", "x":10, "y":3, "color":"#27ae60", "type":"healer"}, + {"name":"Старик", "x":8, "y":8, "color":"#aaa", "type":"quest"} + ], + "tavern": [ + {"name":"Трактирщик", "x":7, "y":5, "color":"#c8a060", "type":"branch"} + ], + "forest": [{"name":"Эльф", "x":10, "y":10, "color":"#2ecc71", "type":"quest"}], + "dungeon": [{"name":"Призрак", "x":7, "y":7, "color":"#aaffff", "type":"quest"}], + "swamp": [{"name":"Шаман", "x":3, "y":3, "color":"#8e44ad", "type":"quest"}], + "mountain": [{"name":"Старик", "x":5, "y":5, "color":"#aaa", "type":"quest"}], + "ruins": [{"name":"Страж", "x":6, "y":6, "color":"#88aacc", "type":"quest"}] + }, + "decos": { + "village": [ + {"x":2, "y":2, "type":"house"}, + {"x":11, "y":2, "type":"house"}, + {"x":2, "y":9, "type":"house"}, + {"x":4, "y":6, "type":"tree"}, + {"x":10, "y":6, "type":"tree"}, + {"x":12, "y":8, "type":"tree"}, + {"x":3, "y":4, "type":"fountain"}, + {"x":8, "y":2, "type":"well"}, + {"x":7, "y":9, "type":"tavern", "name":"Таверна"}, + {"x":7, "y":9, "type":"portal", "destination":"tavern", "name":"🍺 Таверна"}, + {"x":13, "y":7, "type":"portal", "destination":"forest", "name":"Лес"}, + {"x":7, "y":13, "type":"portal", "destination":"dungeon", "name":"Подземелье"}, + {"x":1, "y":7, "type":"portal", "destination":"cave", "name":"Пещера"}, + {"x":13, "y":1, "type":"portal", "destination":"mountain", "name":"Горы"}, + {"x":1, "y":13, "type":"portal", "destination":"swamp", "name":"Болото"}, + {"x":13, "y":13, "type":"portal", "destination":"ruins", "name":"Руины"} + ], + "tavern": [ + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"🚪 Выход"}, + {"x":3, "y":3, "type":"torch"}, + {"x":11, "y":3, "type":"torch"}, + {"x":3, "y":10, "type":"torch"}, + {"x":11, "y":10, "type":"torch"}, + {"x":4, "y":6, "type":"table"}, + {"x":4, "y":8, "type":"table"}, + {"x":10, "y":6, "type":"table"}, + {"x":10, "y":8, "type":"table"} + ], + "forest": [ + {"x":2, "y":2, "type":"tree"}, + {"x":4, "y":3, "type":"tree"}, + {"x":6, "y":4, "type":"tree"}, + {"x":8, "y":5, "type":"tree"}, + {"x":10, "y":6, "type":"tree"}, + {"x":12, "y":7, "type":"tree"}, + {"x":4, "y":4, "type":"rock"}, + {"x":10, "y":10, "type":"rock"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"} + ], + "dungeon": [ + {"x":1, "y":1, "type":"pillar"}, + {"x":13, "y":1, "type":"pillar"}, + {"x":1, "y":13, "type":"pillar"}, + {"x":13, "y":13, "type":"pillar"}, + {"x":4, "y":4, "type":"torch"}, + {"x":9, "y":4, "type":"torch"}, + {"x":4, "y":9, "type":"torch"}, + {"x":9, "y":9, "type":"torch"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"} + ], + "cave": [ + {"x":2, "y":2, "type":"crystal"}, + {"x":11, "y":3, "type":"crystal"}, + {"x":8, "y":8, "type":"crystal"}, + {"x":3, "y":11, "type":"crystal"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"} + ], + "mountain": [ + {"x":3, "y":3, "type":"rock"}, + {"x":10, "y":3, "type":"rock"}, + {"x":5, "y":8, "type":"rock"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"} + ], + "swamp": [ + {"x":2, "y":2, "type":"rock"}, + {"x":11, "y":4, "type":"rock"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"} + ], + "ruins": [ + {"x":2, "y":2, "type":"pillar"}, + {"x":11, "y":2, "type":"pillar"}, + {"x":2, "y":11, "type":"pillar"}, + {"x":11, "y":11, "type":"pillar"}, + {"x":5, "y":5, "type":"rock"}, + {"x":8, "y":3, "type":"rock"}, + {"x":3, "y":8, "type":"rock"}, + {"x":10, "y":9, "type":"rock"}, + {"x":4, "y":4, "type":"torch"}, + {"x":9, "y":4, "type":"torch"}, + {"x":4, "y":9, "type":"torch"}, + {"x":7, "y":13, "type":"portal", "destination":"village", "name":"Деревня"}, + {"x":7, "y":1, "type":"portal", "destination":"abyss", "name":"Бездна"} + ], + "abyss": [ + {"x":2, "y":2, "type":"pillar"}, + {"x":12, "y":2, "type":"pillar"}, + {"x":2, "y":12, "type":"pillar"}, + {"x":12, "y":12, "type":"pillar"}, + {"x":4, "y":4, "type":"torch"}, + {"x":10, "y":4, "type":"torch"}, + {"x":4, "y":10, "type":"torch"}, + {"x":10, "y":10, "type":"torch"}, + {"x":7, "y":13, "type":"portal", "destination":"ruins", "name":"Руины"} + ] + }, + "weather": { + "village": ["none","none","rain","sunny"], + "tavern": ["none"], + "forest": ["rain","rain","fog","none"], + "dungeon": ["none","fog"], + "cave": ["none","fog"], + "mountain": ["snow","snow","none"], + "swamp": ["fog","fog","rain"], + "ruins": ["fog","fog","none"], + "abyss": ["fog","fog","fog"] + }, + "dialogs": { + "Трактирщик": { + "start": { + "text": "Добро пожаловать в «Золотой Кубок»! Что желаете?", + "opts": [ + { "label": "Отдохнуть (20 🪙)", "next": "rest", "cost": 20 }, + { "label": "Купить напиток (10 🪙)", "next": "drink", "cost": 10 }, + { "label": "Послушать слухи", "next": "rumors" }, + { "label": "До свидания", "next": null } + ] + }, + "rest": { + "text": "Хорошо, комната готова. Ложитесь — утром будете как новенький!", + "opts": [{ "label": "Спасибо!", "next": null, "reward": { "hp": 9999, "mp": 9999 } }] + }, + "drink": { + "text": "Вот кружка доброго эля. Придаст сил на бой!", + "opts": [{ "label": "За удачу!", "next": null, "reward": { "buff": "str" } }] + }, + "rumors": { + "text": "Говорят, в Руинах видели странный свет по ночам... А ещё что Бездна всё расширяется. Путники туда заходят — и не возвращаются.", + "opts": [{ "label": "Занятно...", "next": null }] + } + }, + "Стражник": { + "start": { "text":"Путник! Тут неспокойно. Гоблины снова шалят в лесу. Чем могу помочь?", + "opts": [ + { "label":"📍 Расскажи о локациях", "next":"lore" }, + { "label":"💰 Заплатить за совет (15 💰)", "next":"tip", "cost":15 }, + { "label":"❌ Ничего, спасибо", "next":null } + ]}, + "lore": { "text":"Лес к северу опасен — там волки и гоблины. В подземелье орудует нежить. В пещере — дракон. На болоте засела Ведьма. Горы охраняет Голем.", + "opts": [ + { "label":"Понятно, спасибо", "next":null } + ]}, + "tip": { "text":"Тайный совет: Паук ядовит — возьми антидот. Голем неуязвим к физике, только магия берёт. Ну и не суйся на болото ночью.", + "opts": [ + { "label":"Ценная информация!", "next":null, "reward":{ "exp":30 } } + ]} + }, + "Старик": { + "start": { "text":"Хм... Давненько не видел таких юных искателей приключений. Что тебя интересует?", + "opts": [ + { "label":"📖 История Эйдона", "next":"history" }, + { "label":"🧙 Тайны магии", "next":"magic" }, + { "label":"🎁 Благословение (50 💰)", "next":"bless", "cost":50 }, + { "label":"❌ Ничего", "next":null } + ]}, + "history": { "text":"Эйдон — земля, рождённая из хаоса. Когда-то здесь жили древние маги. Они создали Голема и Лича в качестве стражей. Но амбиции взяли верх, и мир погрузился в войну...", + "opts": [ + { "label":"Что случилось дальше?", "next":"history2" }, + { "label":"Понятно", "next":null } + ]}, + "history2": { "text":"Войну остановил Первый Герой — он запечатал Лича в болоте, а Голем заснул в горах. Но печать слабеет... Лич снова собирает силы.", + "opts": [ + { "label":"Мне нужно остановить его", "next":null, "reward":{ "exp":50 } } + ]}, + "magic": { "text":"Магия идёт из земли. Кристаллы пещер — её источники. Но у каждой стихии есть противоположность: огонь слаб против льда, яд — против антидота.", + "opts": [ + { "label":"Полезно знать", "next":null, "reward":{ "exp":25 } } + ]}, + "bless": { "text":"Пусть удача сопутствует тебе. Прими моё благословение — пусть следующий бой будет в твою пользу.", + "opts": [ + { "label":"Благодарю, мудрец", "next":null, "reward":{ "exp":40, "buff":"def" } } + ]} + }, + "Эльф": { + "start": { "text":"О, смертный... Ты пришёл в лес в неспокойное время. Что тебе нужно?", + "opts": [ + { "label":"🌿 Собрать травы", "next":"herbs" }, + { "label":"⚔️ Где самые опасные враги?", "next":"danger" }, + { "label":"🎁 Дар природы", "next":"gift" }, + { "label":"❌ Просто проходил мимо", "next":null } + ]}, + "herbs": { "text":"В этом лесу много целебных трав. Возьми — пригодятся для зелий.", + "opts": [ + { "label":"Спасибо!", "next":null, "reward":{ "item":"herb", "qty":3 } } + ]}, + "danger": { "text":"Глубже в лес — волки и бандиты. Осторожнее с пауками — их яд силён. А тролль у болота регенерирует — нужен огонь.", + "opts": [ + { "label":"Буду осторожен", "next":null } + ]}, + "gift": { "text":"Природа даёт дары тем, кто уважает её. Прими этот амулет леса.", + "opts": [ + { "label":"Прекрасно!", "next":null, "reward":{ "exp":60, "item":"slime_gel", "qty":2 } } + ]} + }, + "Шаман": { + "start": { "text":"Я чувствую силу в тебе... и тьму вокруг. Болото охраняет тайны. Что ты ищешь?", + "opts": [ + { "label":"🧪 Исцели меня", "next":"heal" }, + { "label":"📜 Секрет болота", "next":"secret" }, + { "label":"🔮 Предсказание", "next":"predict" }, + { "label":"❌ Ухожу", "next":null } + ]}, + "heal": { "text":"Болото очищает. Постой на этой земле... готово, яды ушли.", + "opts": [ + { "label":"Благодарю шаман", "next":null, "reward":{ "cure":true, "hp":30 } } + ]}, + "secret": { "text":"Ведьма черпает силу из болотных трясин. Вытащи её на твёрдую землю — и она ослабнет. Святой огонь сожжёт её договор с тьмой.", + "opts": [ + { "label":"Это важно", "next":null, "reward":{ "exp":45 } } + ]}, + "predict": { "text":"Я вижу... тебя окружают враги. Один из них обманет тебя. Но ты победишь. Цена предсказания — немного твоей силы.", + "opts": [ + { "label":"Принять предсказание", "next":null, "reward":{ "exp":35 } } + ]} + }, + "Призрак": { + "start": { "text":"...ты слышишь меня? Хорошо. Я был солдатом. Погиб здесь давно. Чего ты хочешь, живой?", + "opts": [ + { "label":"Кто ты?", "next":"who" }, + { "label":"Как победить Лича?", "next":"lich" }, + { "label":"Покойся с миром", "next":null } + ]}, + "who": { "text":"Рядовой Эрик из армии Короля. Мы пришли зачистить подземелье от нежити. Никто не вернулся. Лич поглотил наши души... кроме моей. Я слишком упрямый.", + "opts": [ + { "label":"Мне жаль", "next":"lich" }, + { "label":"Покойся с миром", "next":null } + ]}, + "lich": { "text":"Лич хранит свою душу в филактерии — магическом сосуде. Пока он цел — Лич не умрёт. Разбей его святым заклинанием... или просто бей достаточно сильно. Удачи тебе, живой.", + "opts": [ + { "label":"Я уничтожу Лича", "next":null, "reward":{ "exp":80 } } + ]} + }, + "Страж": { + "start": { "text":"Стоп! Ты живой? Странно видеть здесь живых... Руины опасны. Духи воинов и виверны охраняют эти камни. Что тебе нужно?", + "opts": [ + { "label":"Расскажи об этом месте", "next":"history" }, + { "label":"Где найти виверн?", "next":"wyvern" }, + { "label":"Дай задание", "next":"quest" }, + { "label":"Ничего, спасибо", "next":null } + ]}, + "history": { "text":"Когда-то здесь стоял великий замок короля Эйдора. Армия тьмы уничтожила его за одну ночь. Я был среди защитников. Теперь мы здесь навсегда — ждём героя, который упокоит проклятие.", + "opts": [ + { "label":"Как снять проклятие?", "next":"curse" }, + { "label":"Я помогу", "next":null, "reward":{ "exp":60 } } + ]}, + "curse": { "text":"Убей Виверну-Матриарха в северо-восточном углу. Она — источник проклятия. Её смерть ослабит духов. Это единственный путь.", + "opts": [ + { "label":"Я её найду", "next":null, "reward":{ "exp":90, "item":"ghost_essence" } } + ]}, + "wyvern": { "text":"Виверны гнездятся в разрушенных башнях — смотри на северо-восток. Яд виверны — ценный алхимический компонент. Удача тебе нужна больше, чем смелость.", + "opts": [ + { "label":"Спасибо за совет", "next":null, "reward":{ "exp":40 } } + ]}, + "quest": { "text":"Упокой трёх призраков в этих руинах. Они страдают. Их имена я знаю, но произнести не могу. Просто уничтожь их — это лучшее, что ты можешь сделать.", + "opts": [ + { "label":"Хорошо, займусь этим", "next":null, "reward":{ "exp":50 } } + ]} + } + } +} diff --git a/game.js b/game.js new file mode 100644 index 0000000..fa8c20a --- /dev/null +++ b/game.js @@ -0,0 +1,2785 @@ +// ============================================================ +// GAME.JS — Основная логика игры +// ============================================================ + +const Game = { + + // ── Состояние ── + state: 'menu', // menu | playing | combat | levelup + player: null, + map: [], + mapId: 'village', + maps: {}, + enemies: [], + npcs: [], + decorations: [], + groundItems: [], + weather: 'none', + weatherParts: [], + timeOfDay: 12, // 0–24 + dayCount: 1, + daySpeed: 0.00014, // скорость смены дня (1/мс) + combatEnemy: null, + pendingLevelUp: false, + mouse: { x:0, y:0, tx:-1, ty:-1 }, + time: 0, + lastTime: 0, + openPanels: new Set(), + _keysDown: new Set(), + saveSlot: 0, + _sessionStart: 0, + loreNotes: [], + _combatLog: [], + _blinkInterval: null, + _abyssParticles: null, + _paused: false, + _invTab: 'equip', + _exitingToMenu: false, + + LOCATIONS: {}, // заполняется DataLoader из data/world.json + NPC_DIALOGS: {}, // заполняется DataLoader из data/world.json + _WORLD: {}, // заполняется DataLoader из data/world.json + + // ══════════════════════════════════════════ + // ЗАПУСК ИГРЫ + // ══════════════════════════════════════════ + start(classId, slot = 0) { + this.saveSlot = slot; + this._sessionStart = Date.now(); + document.getElementById('start-screen').style.display = 'none'; + Renderer.init('gameCanvas'); + this.buildAllMaps(); + this.player = RPG.createCharacter(classId); + // Стартовые предметы + const kit = RPG.getStarterItems(classId); + kit.forEach(it => RPG.addToInventory(this.player, it)); + // Экипировать оружие и щит если есть + const weapon = this.player.inventory.find(i => i.slot === 'weapon'); + if (weapon) RPG.equip(this.player, weapon); + const shield = this.player.inventory.find(i => i.slot === 'shield'); + if (shield) RPG.equip(this.player, shield); + // Первый квест + this.giveQuest('q_first'); + this.loadMap('village'); + this.state = 'playing'; + this._initCamera(); + this.setupInput(); + this.updateHUD(); + this.showMsg('Добро пожаловать в Хроники Эйдона!', '#ffd700'); + _stopMenuBgm(); + Audio.init(); Audio.playTheme('village'); + requestAnimationFrame(t => this.loop(t)); + }, + + loadAndStart(slot = 0) { + const data = RPG.load(slot); + if (!data) return; + this.saveSlot = slot; + this._sessionStart = Date.now(); + document.getElementById('start-screen').style.display = 'none'; + Renderer.init('gameCanvas'); + this.buildAllMaps(); + this.player = data.player; + this.dayCount = data.dayCount || 1; + this.timeOfDay= data.timeOfDay|| 12; + this.loadMap(data.mapId || 'village'); + this.state = 'playing'; + this._initCamera(); + this.setupInput(); + this.updateHUD(); + this.showMsg('Игра загружена! День '+this.dayCount, '#4f4'); + _stopMenuBgm(); + Audio.init(); Audio.playTheme(data.mapId || 'village'); + requestAnimationFrame(t => this.loop(t)); + }, + + // ══════════════════════════════════════════ + // ГЕНЕРАЦИЯ КАРТ + // ══════════════════════════════════════════ + buildAllMaps() { + ['village','tavern','forest','dungeon','cave','mountain','swamp','ruins','abyss'].forEach(id => { + this.maps[id] = this.genMap(id); + }); + }, + + genMap(id) { + const S = 15; + const m = Array.from({length:S}, () => Array(S).fill(0)); + + // Границы — стена + for (let y=0;y Math.random() < n; + for (let y=1;y this.enemies.push(RPG.createEnemy(s.t, lvl + (s.lOff || 0), s.x, s.y))); + }, + + spawnNPCs() { + this.npcs = []; + const list = ((this._WORLD.npcs || {})[this.mapId]) || []; + list.forEach(n => this.npcs.push({ ...n })); + }, + + spawnDecos() { + this.decorations = []; + const list = ((this._WORLD.decos || {})[this.mapId]) || []; + list.forEach(d => this.decorations.push({ ...d })); + }, + + spawnLoreNotes() { + const found = this.player ? (this.player.foundNotes || []) : []; + this.loreNotes = RPG.LORE_NOTES + .filter(n => n.mapId === this.mapId) + .map(n => ({ ...n, collected: found.includes(n.id) })); + }, + + spawnGroundItems() { + this.groundItems = []; + if (Math.random() < 0.4) { + this.groundItems.push({ + x: 4 + Math.floor(Math.random()*7), + y: 4 + Math.floor(Math.random()*7), + collected: false, + ...RPG.createItem('rnd_gi', 'potion', 'Зелье HP', + { healAmount:30, value:20, stackable:true, qty:1, icon:'🧪' }) + }); + } + if (Math.random() < 0.3) { + this.groundItems.push({ + x: 3 + Math.floor(Math.random()*9), + y: 3 + Math.floor(Math.random()*9), + collected: false, + ...RPG.createItem('rnd_gold','gold','Золото', + { value: 10+Math.floor(Math.random()*20), stackable:true, qty:1, icon:'💰' }) + }); + } + }, + + // ══════════════════════════════════════════ + // ИГРОВОЙ ЦИКЛ + // ══════════════════════════════════════════ + loop(ts) { + if (this._exitingToMenu) { this._exitingToMenu = false; return; } + if (this._paused) { requestAnimationFrame(t => this.loop(t)); return; } + const dt = Math.min(ts - this.lastTime, 50); + this.lastTime = ts; + this.time = ts; + + this.update(dt); + this.render(); + requestAnimationFrame(t => this.loop(t)); + }, + + update(dt) { + if (this.state !== 'playing') return; + + // День/ночь + this.timeOfDay += dt * this.daySpeed * 24; + if (this.timeOfDay >= 24) { + this.timeOfDay -= 24; + this.dayCount++; + this.showMsg('День '+this.dayCount, '#ffd700'); + this.spawnEnemies(); // Враги возрождаются каждый день + } + + // Погода + this.updateWeather(dt); + + // Движение игрока + if (this.player.isMoving) { + this.player.mp_move += dt / 260; + if (this.player.mp_move >= 1) { + this.player.mp_move = 0; + this.player.isMoving = false; + this.player.x = this.player.tx; + this.player.y = this.player.ty; + this.onPlayerLanded(); + this._updateInteractHint(); + this._tryMoveFromKeys(); + } + } + + Renderer.updateParticles(dt); + Renderer.updateFloatingTexts(dt); + Renderer.updateShake(); + + // Плавное следование камеры за игроком + const drawX = this.player.isMoving + ? this.player.x + (this.player.tx - this.player.x) * this.player.mp_move + : this.player.x; + const drawY = this.player.isMoving + ? this.player.y + (this.player.ty - this.player.y) * this.player.mp_move + : this.player.y; + const camTargX = -((drawX - drawY) * Renderer.TW / 2); + const camTargY = -((drawX + drawY) * Renderer.TH / 2) - Renderer.OY + Renderer.canvas.height * 0.5; + const lf = Math.min(1, dt * 0.009); + Renderer.camera.x += (camTargX - Renderer.camera.x) * lf; + Renderer.camera.y += (camTargY - Renderer.camera.y) * lf; + }, + + updateWeather(dt) { + if (this.weather === 'rain') { + for (let i=0; i<4; i++) { + this.weatherParts.push({ x:Math.random()*900, y:-5, speed:4+Math.random()*5 }); + } + this.weatherParts.forEach(p => p.y += p.speed); + this.weatherParts = this.weatherParts.filter(p => p.y < 620); + } else if (this.weather === 'snow') { + for (let i=0; i<2; i++) { + this.weatherParts.push({ x:Math.random()*900, y:-5, speed:0.8+Math.random()*1.5, r:1+Math.random()*2 }); + } + this.weatherParts.forEach(p => { p.y += p.speed; p.x += Math.sin(this.time/800+p.y)*0.5; }); + this.weatherParts = this.weatherParts.filter(p => p.y < 620); + } + }, + + onPlayerLanded() { + const px = this.player.x, py = this.player.y; + + // Порталы + const portal = this.decorations.find(d => d.type==='portal' && d.x===px && d.y===py); + if (portal) { this.travelTo(portal.destination); return; } + + // Подбор предметов + const gi = this.groundItems.find(i => !i.collected && i.x===px && i.y===py); + if (gi) { + gi.collected = true; + if (gi.type === 'gold') { + this.player.gold += gi.value; + this.showMsg('+'+gi.value+' золота 💰', '#ffd700'); + Renderer.addParticle(px, py, 'gold'); + this.checkAchievements('gold'); + } else { + RPG.addToInventory(this.player, gi); + this.showMsg('Найдено: '+gi.name, '#4f4'); + this.checkAchievements('inv_full'); + } + this.updateHUD(); + } + + // Сбор записок лора + const note = this.loreNotes && this.loreNotes.find(n => !n.collected && n.gx===px && n.gy===py); + if (note) { + note.collected = true; + this.player.foundNotes = this.player.foundNotes || []; + if (!this.player.foundNotes.includes(note.id)) { + this.player.foundNotes.push(note.id); + this.showMsg('📖 Найдена запись: '+note.title, '#88aaff'); + Audio.playOpenChest(); + // Показать подсказку о слабости врага + if (note.reveals && note.reveals.hint) { + setTimeout(() => this.showMsg('💡 ' + note.reveals.hint, '#ffdd44'), 1500); + } + // Проверка: собраны все записки локации → бонус + this._checkLoreLocationBonus(note.mapId); + this.checkAchievements('lore_read'); + } + } + + // Столкновение с врагом + const enemy = this.enemies.find(e => Math.round(e.x)===px && Math.round(e.y)===py); + if (enemy) this.startCombat(enemy); + + // NPC + const npc = this.npcs.find(n => n.x===px && n.y===py); + if (npc) this.interactNPC(npc); + }, + + _initCamera() { + const px = this.player.x, py = this.player.y; + Renderer.camera.x = -((px - py) * Renderer.TW / 2); + Renderer.camera.y = -((px + py) * Renderer.TH / 2) - Renderer.OY + Renderer.canvas.height * 0.5; + }, + + travelTo(dest) { + if (!this.maps[dest]) return; + this.closeAllPanels(); + this.loadMap(dest); + this._initCamera(); + const locName = (this.LOCATIONS[dest] || {}).name || dest; + this.showMsg('Переход: ' + locName, '#88aaff'); + this.updateQuestProgress('visit', dest); + this.checkAchievements('visit', dest); + Audio.playTheme(dest); + // Void-частицы в Бездне + clearInterval(this._abyssParticles); + if (dest === 'abyss') { + this._abyssParticles = setInterval(() => { + if (this.mapId !== 'abyss') { clearInterval(this._abyssParticles); return; } + const rx = 1 + Math.floor(Math.random()*13); + const ry = 1 + Math.floor(Math.random()*13); + Renderer.addParticle(rx, ry, 'void', 2); + }, 900); + } + this.autoSave(); + }, + + // ══════════════════════════════════════════ + // ДВИЖЕНИЕ + // ══════════════════════════════════════════ + _tryMoveFromKeys() { + const k = this._keysDown; + if (k.has('ArrowUp') || k.has('w') || k.has('W')) { this.movePlayer( 0,-1); return; } + if (k.has('ArrowDown') || k.has('s') || k.has('S')) { this.movePlayer( 0, 1); return; } + if (k.has('ArrowLeft') || k.has('a') || k.has('A')) { this.movePlayer(-1, 0); return; } + if (k.has('ArrowRight') || k.has('d') || k.has('D')) { this.movePlayer( 1, 0); return; } + }, + + movePlayer(dx, dy) { + if (this.player.isMoving || this.state !== 'playing') return; + if (this.openPanels.size > 0) return; + const nx = Math.round(this.player.x) + dx; + const ny = Math.round(this.player.y) + dy; + if (!RPG.isPassable(this.map, nx, ny)) return; + // Не идти туда, где стоит враг — бой начнётся по клику + const enemy = this.enemies.find(e => Math.round(e.x)===nx && Math.round(e.y)===ny); + if (enemy) { this.startCombat(enemy); return; } + const npc = this.npcs.find(n => n.x===nx && n.y===ny); + if (npc) { this.interactNPC(npc); return; } + this.player.tx = nx; this.player.ty = ny; + this.player.isMoving = true; this.player.mp_move = 0; + Audio.playStep(); + if (dx > 0) this.player.facing = 'right'; + else if (dx < 0) this.player.facing = 'left'; + else if (dy > 0) this.player.facing = 'down'; + else this.player.facing = 'up'; + }, + + // ══════════════════════════════════════════ + // ВВОД + // ══════════════════════════════════════════ + setupInput() { + const canvas = document.getElementById('gameCanvas'); + document.addEventListener('keydown', e => { this._keysDown.add(e.key); this.onKey(e.key); }); + document.addEventListener('keyup', e => this._keysDown.delete(e.key)); + canvas.addEventListener('mousemove', e => { + const r = canvas.getBoundingClientRect(); + this.mouse.x = e.clientX - r.left; + this.mouse.y = e.clientY - r.top; + const iso = Renderer.fromIso(this.mouse.x, this.mouse.y); + this.mouse.tx = iso.x; this.mouse.ty = iso.y; + }); + canvas.addEventListener('click', () => { + if (this.openPanels.size > 0 || this.state !== 'playing') return; + if (this.mouse.tx < 0) return; + const px = Math.round(this.player.x), py = Math.round(this.player.y); + const dx = this.mouse.tx - px, dy = this.mouse.ty - py; + if (Math.abs(dx) + Math.abs(dy) === 1 && !this.player.isMoving) { + this.movePlayer(dx, dy); + } + }); + }, + + onKey(key) { + // Движение + if (this.state === 'playing' && !this.player.isMoving) { + if (key==='ArrowUp' ||key==='w'||key==='W') this.movePlayer(0,-1); + if (key==='ArrowDown' ||key==='s'||key==='S') this.movePlayer(0,1); + if (key==='ArrowLeft' ||key==='a'||key==='A') this.movePlayer(-1,0); + if (key==='ArrowRight'||key==='d'||key==='D') this.movePlayer(1,0); + } + // Панели + if (key==='i'||key==='I') this.togglePanel('inventory'); + if (key==='q'||key==='Q') this.togglePanel('quest'); + if (key==='t'||key==='T') this.togglePanel('perk'); + if (key==='c'||key==='C') this.togglePanel('craft'); + if (key==='b'||key==='B') this.togglePanel('bestiary'); + if (key==='h'||key==='H') this.togglePanel('achiev'); + if (key==='e'||key==='E') this.togglePanel('enchant'); + if (key==='l'||key==='L') this.togglePanel('lore'); + if (key==='m'||key==='M') this.togglePanel('worldmap'); + if (key==='p'||key==='P') this.saveGame(); + if (key==='f'||key==='F') { if (this.state==='playing' && this.openPanels.size===0) this._interactNearest(); } + if (key==='Escape') { + if (this.openPanels.size > 0 && !this.openPanels.has('pause')) { + this.closeAllPanels(); + } else { + this._togglePause(); + } + } + // Бой + if (this.state==='combat') { + if (key==='1') this.combatAct('attack'); + if (key==='2') this.combatAct('item'); + if (key==='3') this.combatAct('flee'); + } + }, + + // ══════════════════════════════════════════ + // ВЗАИМОДЕЙСТВИЕ С ОКРУЖЕНИЕМ (F) + // ══════════════════════════════════════════ + _getNearbyInteractable() { + const px = this.player.x, py = this.player.y; + // Сначала проверяем соседние тайлы, потом текущий (портал на текущем уже auto-trigger) + const tiles = [{x:px,y:py-1},{x:px,y:py+1},{x:px-1,y:py},{x:px+1,y:py},{x:px,y:py}]; + for (const {x,y} of tiles) { + const portal = this.decorations.find(d => d.type==='portal' && d.x===x && d.y===y); + if (portal) return { kind:'portal', obj:portal, label: portal.name || 'Портал' }; + const npc = this.npcs.find(n => n.x===x && n.y===y); + if (npc) return { kind:'npc', obj:npc, label: npc.name }; + } + return null; + }, + + _updateInteractHint() { + const hint = document.getElementById('interact-hint'); + if (!hint) return; + if (this.state !== 'playing' || this.openPanels.size > 0 || this._paused) { + hint.classList.remove('visible'); + return; + } + const nearby = this._getNearbyInteractable(); + if (nearby) { + const icon = nearby.kind === 'portal' ? '🚪' : '💬'; + const verb = nearby.kind === 'portal' ? 'Войти' : 'Говорить'; + hint.innerHTML = `F${icon} ${verb}: ${nearby.label}`; + hint.classList.add('visible'); + } else { + hint.classList.remove('visible'); + } + }, + + _interactNearest() { + const nearby = this._getNearbyInteractable(); + if (!nearby) return; + if (nearby.kind === 'portal') { + this.travelTo(nearby.obj.destination); + } else { + this.interactNPC(nearby.obj); + } + }, + + // ══════════════════════════════════════════ + // РЕНДЕР + // ══════════════════════════════════════════ + render() { + const brightness = this.getDayBrightness(); + Renderer._currentMapId = this.mapId; + + Renderer.clear(brightness); + Renderer.drawStars(this.time, brightness); + + // Карта + const hover = { x: this.mouse.tx, y: this.mouse.ty }; + Renderer.drawMap(this.map, hover, this.time); + + // Сортировка объектов по глубине (изометрический порядок) + const objs = []; + + this.decorations.forEach(d => objs.push({ depth: d.x+d.y, draw: () => Renderer.drawDecoration(d, this.time) })); + this.groundItems.filter(i=>!i.collected).forEach(i => objs.push({ depth: i.x+i.y-0.1, draw: () => Renderer.drawGroundItem(i, this.time) })); + if (this.loreNotes) this.loreNotes.filter(n=>!n.collected).forEach(n => objs.push({ depth: n.gx+n.gy-0.05, draw: () => Renderer.drawLoreNote(n, this.time) })); + this.enemies.forEach(e => objs.push({ depth: e.x+e.y, draw: () => Renderer.drawEnemy(e, this.time) })); + this.npcs.forEach(n => objs.push({ depth: n.x+n.y, draw: () => Renderer.drawNPC(n, this.time) })); + + const pd = this.player.isMoving + ? this.player.x + (this.player.tx-this.player.x)*this.player.mp_move + + this.player.y + (this.player.ty-this.player.y)*this.player.mp_move + : this.player.x + this.player.y; + objs.push({ depth: pd, draw: () => Renderer.drawPlayer(this.player, this.time) }); + + objs.sort((a,b) => a.depth - b.depth).forEach(o => o.draw()); + + // Маркеры квестов (! / ? / ✓ над NPC) + Renderer.drawQuestMarkers(this.npcs, this._getQuestMarkerData(), this.time); + + // Частицы + Renderer.drawParticles(); + + // Погода + if (this.weather==='rain') Renderer.drawRain(this.weatherParts); + if (this.weather==='snow') Renderer.drawSnow(this.weatherParts); + if (this.weather==='fog') Renderer.drawFog(this.time); + + // Динамическое освещение — собираем источники света + const lights = []; + this.decorations.forEach(d => { + if (d.type === 'torch') lights.push({ x: d.x, y: d.y, radius: 100, flicker: true }); + if (d.type === 'crystal') lights.push({ x: d.x, y: d.y, radius: 75, flicker: false }); + if (d.type === 'portal') lights.push({ x: d.x, y: d.y, radius: 60, flicker: false }); + }); + // Игрок тоже излучает свет (маги — больше) + const plrLightX = this.player.isMoving ? this.player.x + (this.player.tx - this.player.x) * this.player.mp_move : this.player.x; + const plrLightY = this.player.isMoving ? this.player.y + (this.player.ty - this.player.y) * this.player.mp_move : this.player.y; + const plrR = (this.player.class === 'mage' || this.player.class === 'necromancer') ? 75 : 48; + lights.push({ x: plrLightX, y: plrLightY, radius: plrR, flicker: false }); + Renderer.drawLightMask(brightness, lights, this.time); + + // Всплывающие числа урона (поверх темноты — всегда видны) + Renderer.drawFloatingTexts(); + + // Атмосфера Бездны + if (this.mapId === 'abyss') Renderer.drawAbyssAtmosphere(this.time); + + // Вспышка экрана (при ударах и т.д.) + Renderer.drawFlash(); + + // HUD canvas-часть: название локации, день, время + this.renderCanvasHUD(); + + // Миникарта + Renderer.drawMinimap(this.map, this.player, this.enemies, this._getMinimapQuestDots()); + }, + + renderCanvasHUD() { + const ctx = Renderer.ctx; + const loc = this.LOCATIONS[this.mapId]; + ctx.fillStyle = '#ffd700'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'left'; + ctx.fillText(loc ? loc.name : '', 10, 62); + ctx.fillStyle = '#888'; ctx.font = '11px Arial'; + ctx.fillText('День '+this.dayCount+' · '+Math.floor(this.timeOfDay)+':00', 10, 76); + if (this.weather!=='none') { + const w = {rain:'🌧️',snow:'❄️',fog:'🌫️',sunny:'☀️'}[this.weather]||''; + ctx.fillText(w, 130, 76); + } + }, + + getDayBrightness() { + const h = this.timeOfDay; + if (h>=6&&h<18) return 1; + if (h>=18&&h<20) return 1 - (h-18)*0.3; + if (h>=4&&h<6) return 0.4 + (h-4)*0.3; + return 0.4; + }, + + // ══════════════════════════════════════════ + // ПАНЕЛИ (HTML-UI) + // ══════════════════════════════════════════ + togglePanel(name) { + const el = document.getElementById(name+'-panel'); + if (!el) return; + if (this.openPanels.has(name)) { + el.classList.remove('open'); + this.openPanels.delete(name); + } else { + this.openPanels.add(name); + el.classList.add('open'); + if (name==='inventory') this.renderInventoryPanel(); + if (name==='quest') this.renderQuestPanel(); + if (name==='shop') this.renderShopPanel(); + if (name==='perk') this.renderPerkPanel(); + if (name==='craft') this.renderCraftPanel(); + if (name==='bestiary') this.renderBestiaryPanel(); + if (name==='achiev') this.renderAchievPanel(); + if (name==='enchant') this.renderEnchantPanel(); + if (name==='lore') this.renderLorePanel(); + if (name==='worldmap') this.renderWorldMapPanel(); + } + }, + + openPanel(name) { + if (this.openPanels.has(name)) return; + const el = document.getElementById(name+'-panel'); + if (!el) return; + this.openPanels.add(name); + el.classList.add('open'); + this._updateInteractHint(); + }, + + closePanel(name) { + const el = document.getElementById(name+'-panel'); + if (el) { el.classList.remove('open'); } + this.openPanels.delete(name); + this._updateInteractHint(); + }, + + closeAllPanels() { + ['inventory','shop','quest','dialog','combat','skill','perk','craft', + 'bestiary','achiev','enchant','lore','worldmap','pause'].forEach(n => this.closePanel(n)); + this._paused = false; + if (this.state==='combat') this.state = 'playing'; + }, + + _togglePause() { + if (this.state !== 'playing' && this.state !== 'combat') return; + if (this.openPanels.has('pause')) { + this.resumeGame(); + } else { + this._paused = true; + const el = document.getElementById('pause-panel'); + if (el) el.classList.add('open'); + this.openPanels.add('pause'); + const sl = document.getElementById('vol-slider'); + if (sl && Audio._master) sl.value = Math.round(Audio._master.gain.value * 100); + } + }, + + resumeGame() { + this._paused = false; + this.closePanel('pause'); + }, + + exitToMenu() { + this._paused = false; + this._exitingToMenu = true; + this.closeAllPanels(); + // Сплэш при возврате из игры не показываем + const splash = document.getElementById('splash-screen'); + if (splash) splash.style.display = 'none'; + document.getElementById('start-screen').style.display = ''; + document.getElementById('menu-main').style.display = 'flex'; + document.getElementById('menu-class').style.display = 'none'; + menuBuildSlots(); + menuStartAnim(); + // Вернуть музыку меню + Audio.stopMusic(); + const bgm = document.getElementById('menu-bgm'); + if (bgm) { bgm.currentTime = 0; bgm.play().catch(() => {}); } + }, + + // ──── Инвентарь ──── + renderInventoryPanel() { + this.switchInvTab(this._invTab || 'equip'); + }, + + switchInvTab(tab) { + this._invTab = tab; + document.querySelectorAll('.inv-tab').forEach((btn, i) => { + btn.classList.toggle('active', ['equip','items','stats'][i] === tab); + }); + document.querySelectorAll('.inv-tab-pane').forEach(pane => { + pane.classList.toggle('active', pane.id === 'inv-tab-' + tab); + }); + if (tab === 'equip') this._renderPaperDoll(); + if (tab === 'items') this._renderItemsGrid(); + if (tab === 'stats') this._renderStatsDetail(); + }, + + _renderPaperDoll() { + const p = this.player; + // Портрет персонажа + const cvs = document.getElementById('portrait-inv'); + if (cvs) Renderer.drawPlayerPortrait(p, cvs); + // Слоты экипировки + const slotLabels = { head:'Шлем', weapon:'Оружие', chest:'Броня', + shield:'Щит', legs:'Поножи', feet:'Сапоги', acc:'Украшение' }; + Object.entries(slotLabels).forEach(([slot, label]) => { + const el = document.getElementById('pd-' + slot); + if (!el) return; + const item = p.equipment[slot]; + if (item) { + el.classList.add('filled'); + const enchTag = item.enchant && RPG.ENCHANTS[item.enchant] + ? ' ' + RPG.ENCHANTS[item.enchant].icon : ''; + let stat = ''; + if (item.damage) stat = '⚔️+'+item.damage; + if (item.defense) stat = '🛡️+'+item.defense; + el.innerHTML = `
${item.icon||'📦'}
+
${item.name}${enchTag}
+
${stat}
`; + el.onclick = () => { + RPG.unequip(p, slot); + this.updateHUD(); this._renderPaperDoll(); + this.showMsg('Снято: ' + item.name); + }; + } else { + el.classList.remove('filled'); + el.innerHTML = `${label}`; + el.onclick = null; + } + // DnD: принять предмет из вкладки «Предметы» + el.ondragover = e => { e.preventDefault(); el.classList.add('drag-over'); }; + el.ondragleave = () => el.classList.remove('drag-over'); + el.ondrop = e => { + e.preventDefault(); el.classList.remove('drag-over'); + const idx = parseInt(e.dataTransfer.getData('invIdx')); + if (isNaN(idx)) return; + const dragged = p.inventory[idx]; + if (!dragged || dragged.slot !== slot) return; + const r = RPG.equip(p, dragged); + this.showMsg(r.msg); this.updateHUD(); this._renderPaperDoll(); + }; + }); + }, + + _renderItemsGrid() { + const p = this.player; + const invGrid = document.getElementById('inv-grid'); + if (!invGrid) return; + invGrid.innerHTML = ''; + p.inventory.forEach((item, idx) => { + const div = document.createElement('div'); + const rc = RPG.RARITY_COLORS[item.rarity||'common']; + div.className = 'inv-slot r-'+(item.rarity||'common'); + div.style.borderColor = rc; + let statStr = ''; + if (item.damage) statStr = '⚔️+'+item.damage; + else if (item.defense) statStr = '🛡️+'+item.defense; + else if (item.healAmount) statStr = '❤️+'+item.healAmount; + else if (item.restoreMp) statStr = '💧+'+item.restoreMp; + div.innerHTML = ` +
${item.icon||'📦'}
+
${item.name.substring(0,14)}
+
${statStr}
+ ${item.stackable && item.qty > 1 ? `
×${item.qty}
` : ''}`; + div.onclick = () => { + if (item.slot) { + const r = RPG.equip(p, item); + this.showMsg(r.msg); + } else { + const r = RPG.useItem(p, item); + this.showMsg(r.msg); + } + this.updateHUD(); + this.renderInventoryPanel(); + }; + // Drag & Drop + div.draggable = true; + div.addEventListener('dragstart', e => { + e.dataTransfer.setData('invIdx', idx); + e.dataTransfer.effectAllowed = 'move'; + setTimeout(() => div.classList.add('dragging'), 0); + }); + div.addEventListener('dragend', () => div.classList.remove('dragging')); + div.addEventListener('dragover', e => { e.preventDefault(); div.classList.add('drag-over'); }); + div.addEventListener('dragleave', () => div.classList.remove('drag-over')); + div.addEventListener('drop', e => { + e.preventDefault(); + div.classList.remove('drag-over'); + const fromIdx = parseInt(e.dataTransfer.getData('invIdx')); + if (isNaN(fromIdx) || fromIdx === idx) return; + [p.inventory[fromIdx], p.inventory[idx]] = [p.inventory[idx], p.inventory[fromIdx]]; + this._renderItemsGrid(); + }); + if (item.slot && p.equipment[item.slot] === item) div.classList.add('equipped'); + invGrid.appendChild(div); + }); + }, + + _renderStatsDetail() { + const el = document.getElementById('inv-stats-detail'); + if (!el) return; + const p = this.player; + const s = RPG.getTotalStats(p); + const eq = RPG.getEqBonus(p); + const sb = RPG.getSetBonus(p); + + const eqStr = (eq.str||0) + (eq.damage||0); + const eqDef = (eq.def||0) + (eq.defense||0); + const eqMag = eq.mag || 0; + const eqHp = eq.hp || 0; + const eqMp = eq.mp || 0; + + const baseStr = p.baseStr || p.str; + const grStr = Math.max(0, p.str - baseStr); + const baseDef = p.baseDef || p.def; + const grDef = Math.max(0, p.def - baseDef); + const baseMag = p.baseMag || p.mag; + const grMag = Math.max(0, p.mag - baseMag); + + const critPct = Math.round((0.10 + (p.spd||0)*0.008)*100); + const dodge = RPG._sumPerkVal ? Math.round(RPG._sumPerkVal(p,'dodge')*100) : 0; + const dblAtk = RPG._sumPerkVal ? Math.round(RPG._sumPerkVal(p,'doubleAtk')*100) : 0; + const lifesteal = RPG._sumPerkVal ? Math.round(RPG._sumPerkVal(p,'lifesteal')*100) : 0; + + const row = (icon, label, val, breakdown, barMax) => { + const pct = barMax ? Math.min(100, Math.round(val/barMax*100)) : 0; + return `
+ ${icon} ${label} + ${val} + ${breakdown} +
${barMax ? `
` : ''}`; + }; + + const fmtBreak = (base, growth, equip, set) => { + let parts = [`база ${base}`]; + if (growth > 0) parts.push(`рост +${growth}`); + if (equip > 0) parts.push(`экип +${equip}`); + if (set > 0) parts.push(`набор +${set}`); + return parts.join(' '); + }; + + el.innerHTML = ` +
+
Основные
+ ${row('⚔️','Урон', s.damage, fmtBreak(baseStr, grStr, eqStr, sb.str||0), 100)} + ${row('🛡️','Защита', s.defense, fmtBreak(baseDef, grDef, eqDef, sb.def||0), 80)} + ${row('✨','Магия', s.magic, fmtBreak(baseMag, grMag, eqMag, sb.mag||0), 80)} +
+
+
Живучесть
+ ${row('❤️','HP макс', p.maxHp, eqHp||sb.hp ? `экип +${eqHp+(sb.hp||0)}` : '—', 0)} + ${row('💧','MP макс', p.maxMp, eqMp||sb.mp ? `экип +${eqMp+(sb.mp||0)}` : '—', 0)} + ${row('⭐','Уровень', p.level, `опыт: ${p.exp}/${p.expNext}`, 0)} +
+
+
Боевые
+ ${row('💥','Крит', critPct+'%', `база 10% + спд×0.8% (спд: ${p.spd||0})`, 50)} + ${row('⚡','Скорость', p.spd||0, `база ${p.baseSpd||p.spd||0} + рост +${Math.max(0,(p.spd||0)-(p.baseSpd||p.spd||0))}`, 20)} + ${dodge >0 ? row('👤','Уклонение', dodge+'%', `из перков`, 50) : ''} + ${dblAtk >0 ? row('🗡️','Двойной удар', dblAtk+'%', `из перков`, 50) : ''} + ${lifesteal>0? row('🩸','Вампиризм', lifesteal+'%', `из перков`, 30) : ''} +
`; + }, + + // ──── Магазин ──── + renderShopPanel() { + document.getElementById('shop-gold-val').textContent = this.player.gold; + const grid = document.getElementById('shop-grid'); + grid.innerHTML = ''; + RPG.SHOP_ITEMS.forEach(raw => { + const item = RPG.createItem(raw.id, raw.type, raw.name, raw.opts); + const div = document.createElement('div'); + div.className = 'shop-item'; + let statStr = ''; + if (item.damage) statStr += '⚔️ '+item.damage+' '; + if (item.defense) statStr += '🛡️ '+item.defense+' '; + if (item.healAmount)statStr += '❤️ +'+item.healAmount; + if (item.bonusMag) statStr += '✨ +'+item.bonusMag; + div.innerHTML = ` +
${item.icon||''} ${item.name}
+
💰 ${item.value}
+
${statStr}
`; + div.onclick = () => this.buyItem(item); + if (this.player.gold < item.value) div.classList.add('cant-afford'); + grid.appendChild(div); + }); + }, + + buyItem(item) { + if (this.player.gold < item.value) { this.showMsg('Недостаточно золота!', '#f44'); return; } + this.player.gold -= item.value; + const copy = { ...item, id: item.id+'_'+Date.now() }; + RPG.addToInventory(this.player, copy); + this.showMsg('Куплено: '+item.name, '#4f4'); + this.updateHUD(); + document.getElementById('shop-gold-val').textContent = this.player.gold; + }, + + // ──── Квесты ──── + renderQuestPanel() { + const ql = document.getElementById('quest-list'); + ql.innerHTML = ''; + + // ── Сюжетные квесты (активные) ── + const activeStory = this.player.quests.filter(q => q.isStory && !q.done); + if (activeStory.length) { + const t = document.createElement('div'); t.className = 'q-sec'; t.textContent = '📖 Сюжетные задания'; + ql.appendChild(t); + activeStory.forEach(pq => { + const sq = RPG.getStoryQuest(pq.id); + if (!sq) return; + const stage = sq.stages[pq.stageIdx]; + if (!stage) return; + + const stageChecks = sq.stages.map((st, idx) => { + const isDone = pq.completedStages.includes(idx); + const isCurrent = idx === pq.stageIdx; + const clr = isDone ? '#27ae60' : isCurrent ? '#ffd700' : '#333'; + const pfx = isDone ? '✓' : isCurrent ? '▶' : '○'; + const prog = isCurrent && st.need > 1 ? ` (${pq.progress}/${st.need})` : ''; + return `
${pfx} ${st.title}${prog}
`; + }).join(''); + + const pct = stage.need > 0 ? Math.min(pq.progress / stage.need, 1) * 100 : 0; + const div = document.createElement('div'); + div.className = 'q-card active'; + div.style.borderLeft = '3px solid #ffaa44'; + div.innerHTML = ` +
+ ${sq.icon} +
${sq.name}
+ от: ${sq.giverNpc} +
+
${stage.desc}
+
${stageChecks}
+ ${stage.need > 1 ? `
` : ''} +
Этап: +${stage.reward.exp} XP · +${stage.reward.gold} золота
`; + ql.appendChild(div); + }); + } + + // ── Обычные квесты (активные) ── + const activeSimple = this.player.quests.filter(q => !q.isStory && !q.done); + if (activeSimple.length) { + const t = document.createElement('div'); t.className = 'q-sec'; t.textContent = 'Задания'; + ql.appendChild(t); + activeSimple.forEach(q => { + const qdb = RPG.QUEST_DB.find(d=>d.id===q.id); + const div = document.createElement('div'); div.className = 'q-card active'; + const pct = q.need > 0 ? Math.min(q.progress/q.need,1)*100 : 100; + div.innerHTML = ` +
${qdb?qdb.name:q.id}
+
${qdb?qdb.desc:''}
+
+
+${q.reward.exp} XP · +${q.reward.gold} золота
`; + ql.appendChild(div); + }); + } + + // ── Выполненные ── + const completed = this.player.quests.filter(q => q.done); + if (completed.length) { + const t = document.createElement('div'); t.className = 'q-sec'; t.textContent = 'Выполненные'; + ql.appendChild(t); + completed.slice(-10).forEach(q => { + const name = q.isStory + ? (RPG.getStoryQuest(q.id) || {}).name || q.id + : (RPG.QUEST_DB.find(d=>d.id===q.id) || {}).name || q.id; + const div = document.createElement('div'); div.className = 'q-card completed'; + div.innerHTML = `
✓ ${name}
`; + ql.appendChild(div); + }); + } + + if (!this.player.quests.length) { + ql.innerHTML = '
Нет квестов. Поговори с NPC!
'; + } + }, + + // ──── Диалог ──── + showDialog(npcName, text, options) { + document.getElementById('dlg-npc-name').textContent = npcName; + document.getElementById('dlg-text').textContent = text; + const optEl = document.getElementById('dlg-options'); + optEl.innerHTML = ''; + (options||[]).forEach(opt => { + const btn = document.createElement('button'); + btn.className = 'dlg-opt'; + btn.textContent = opt.label; + btn.onclick = () => { opt.action(); }; + optEl.appendChild(btn); + }); + this.openPanel('dialog'); + }, + + // ──── Меню путешествия ──── + showTravelMenu() { + const opts = Object.entries(this.LOCATIONS).map(([id,loc]) => ({ + label: (id===this.mapId?'✦ ':'')+loc.name+(id===this.mapId?' (здесь)':''), + action: () => { this.closePanel('dialog'); if (id!==this.mapId) this.travelTo(id); } + })); + opts.push({ label:'❌ Закрыть', action:()=>this.closePanel('dialog') }); + this.showDialog('🗺️ Карта мира', 'Выберите локацию для путешествия:', opts); + }, + + // ══════════════════════════════════════════ + // NPC ВЗАИМОДЕЙСТВИЕ + // ══════════════════════════════════════════ + interactNPC(npc) { + if (npc.type === 'shop') { + this.renderShopPanel(); + this.openPanel('shop'); + return; + } + if (npc.type === 'healer') { + this.showDialog(npc.name, 'Могу вас исцелить за 20 золота. Хотите?', [ + { label:'💚 Исцелить (-20 💰)', action:()=>{ + if (this.player.gold >= 20) { + this.player.gold -= 20; + this.player.hp = this.player.maxHp; + this.player.mp = this.player.maxMp; + this.updateHUD(); this.showMsg('Исцелён!', '#4f4'); + Renderer.addParticle(this.player.x, this.player.y, 'heal', 10); + } else { this.showMsg('Нужно 20 золота!', '#f44'); } + this.closePanel('dialog'); + }}, + { label:'❌ Нет', action:()=>this.closePanel('dialog') } + ]); + return; + } + if (npc.type === 'quest') { + this._handleQuestNPC(npc); + return; + } + this._startBranchDialog(npc); + }, + + // ══════════════════════════════════════════ + // КВЕСТЫ + // ══════════════════════════════════════════ + giveQuest(id) { + if (this.player.quests.find(q=>q.id===id)) return; + const qdb = RPG.QUEST_DB.find(q=>q.id===id); + if (!qdb) return; + this.player.quests.push({ id, progress:0, need:qdb.need, reward:qdb.reward, done:false }); + this.showMsg('Новый квест: '+qdb.name, '#ffd700'); + }, + + getUnlockedQuests() { + const done = this.player.quests.filter(q=>q.done).map(q=>q.id); + // Квесты открываются по мере выполнения предыдущих + const chain = ['q_first','q_wolves','q_forest','q_slime','q_bandit','q_dungeon','q_skel','q_troll','q_cave','q_spider','q_dragon','q_lich', + 'q_goblin_king','q_corvus','q_hydra','q_frost_giant','q_colossus','q_shadow','q_chaos_lord']; + const idx = chain.findIndex(id => !done.includes(id)); + if (idx < 0) return []; + return RPG.QUEST_DB.filter(q => chain.slice(0, Math.min(idx+3, chain.length)).includes(q.id) && !done.includes(q.id)); + }, + + updateQuestProgress(type, target) { + let anyCompleted = false; + // Обычные квесты + this.player.quests.forEach(q => { + if (q.done || q.isStory) return; + const qdb = RPG.QUEST_DB.find(d=>d.id===q.id); + if (!qdb) return; + if (qdb.type === type && (qdb.target === target || qdb.target === 'any')) { + q.progress++; + if (q.progress >= q.need) { + q.done = true; + this.player.exp += q.reward.exp; + this.player.gold += q.reward.gold; + this.showMsg('✓ Квест выполнен: '+qdb.name+' · +'+q.reward.exp+' XP', '#ffd700'); + anyCompleted = true; + this.checkAchievements('quest_done'); + } + } + }); + // Сюжетные квесты + const storyResults = RPG.updateStoryQuestProgress(this.player, type, target); + if (storyResults && storyResults.length > 0) { + storyResults.forEach(r => { + this.showMsg('📜 Этап выполнен: ' + r.stage.title + '! Поговори с ' + r.sq.giverNpc, '#ffaa44'); + anyCompleted = true; + }); + } + if (anyCompleted) { + if (RPG.checkLevelUp(this.player)) this.triggerLevelUp(); + this.updateHUD(); + this.renderQuestPanel(); + } + }, + + // ══════════════════════════════════════════ + // БОЙ + // ══════════════════════════════════════════ + startCombat(enemy) { + this.state = 'combat'; + this.combatEnemy = enemy; + this.player.isMoving = false; + this.openPanel('combat'); + this.refreshCombatPanel(); + if (enemy.isMini) { + const bar = document.getElementById('boss-bar'); + bar.style.display = 'block'; + document.getElementById('boss-bar-name').textContent = '⚔️ ' + enemy.name.toUpperCase(); + document.getElementById('boss-bar-fill').style.width = '100%'; + document.getElementById('boss-bar-text').textContent = 'HP: ' + enemy.hp + ' / ' + enemy.maxHp; + this.showMsg('⚠️ МИНИ-БОСС: ' + enemy.name + '!', '#ff4400'); + } else { + this.showMsg('Бой с ' + enemy.name + '!', '#e74c3c'); + } + Audio.playTheme('combat'); + clearInterval(this._blinkInterval); + this._blinkInterval = setInterval(() => this._doPortraitBlink(), 4000 + Math.random() * 2000); + }, + + refreshCombatPanel() { + const e = this.combatEnemy; + if (!e) return; + // Портреты + Renderer.drawEnemyPortrait(e, document.getElementById('portrait-enemy')); + Renderer.drawPlayerPortrait(this.player, document.getElementById('portrait-player')); + // Враг + document.getElementById('cf-ename').textContent = e.name + ' Lv.'+e.level + (e.isBoss?' 👹':''); + const ehpPct = e.hp / e.maxHp; + const efill = document.getElementById('cf-ehp'); + efill.style.width = Math.max(0, ehpPct*100)+'%'; + if (ehpPct < 0.3) efill.style.background = 'linear-gradient(to right,#6a0000,#ff2200)'; + else if (ehpPct < 0.6) efill.style.background = 'linear-gradient(to right,#6a4400,#e67e00)'; + else efill.style.background = ''; + document.getElementById('cf-ehpt').textContent = 'HP: '+Math.max(0,e.hp)+'/'+e.maxHp; + document.getElementById('cf-estatus').textContent = e.status ? '⚠️ '+e.status : ''; + // Игрок + document.getElementById('cf-pname').textContent = RPG.CLASSES[this.player.class].name+' Lv.'+this.player.level; + const phpPct = this.player.hp / this.player.maxHp; + const pfill = document.getElementById('cf-php'); + pfill.style.width = Math.max(0, phpPct*100)+'%'; + if (phpPct < 0.3) pfill.style.background = 'linear-gradient(to right,#3a0000,#ff2200)'; + else if (phpPct < 0.6) pfill.style.background = 'linear-gradient(to right,#5a4400,#e6a000)'; + else pfill.style.background = ''; + document.getElementById('cf-phpt').textContent = 'HP: '+Math.floor(this.player.hp)+'/'+this.player.maxHp+' | MP: '+Math.floor(this.player.mp)+'/'+this.player.maxMp; + document.getElementById('cf-pstatus').textContent = this.player.status ? '⚠️ '+this.player.status : ''; + + // Boss bar update + if (e.isMini) { + const pct = Math.max(0, e.hp / e.maxHp * 100); + document.getElementById('boss-bar-fill').style.width = pct + '%'; + document.getElementById('boss-bar-text').textContent = 'HP: ' + Math.max(0, e.hp) + ' / ' + e.maxHp; + } + + // Кнопки действий + const acts = document.getElementById('cbt-actions'); + acts.innerHTML = ''; + const addBtn = (label, cls, cb) => { + const b = document.createElement('button'); + b.className = 'cbt-btn ' + cls; + b.textContent = label; + b.onclick = cb; + acts.appendChild(b); + }; + addBtn('⚔️ Атака (1)', 'b-atk', ()=>this.combatAct('attack')); + // Заклинания + this.player.learnedSpells.forEach((spId, i) => { + const sp = RPG.SPELLS[spId]; + if (!sp) return; + const disabled = this.player.mp < sp.mp; + const b = document.createElement('button'); + b.className = 'cbt-btn b-spl' + (disabled?' disabled':''); + b.textContent = sp.icon+' '+sp.name+' ('+sp.mp+'MP)'; + if (!disabled) b.onclick = ()=>this.combatCastSpell(spId); + acts.appendChild(b); + }); + // Зелья + const potions = this.player.inventory.filter(i=>i.type==='potion'&&i.healAmount); + if (potions.length) addBtn('🧪 Зелье (2)', 'b-itm', ()=>this.combatAct('item')); + addBtn('🏃 Бежать (3)', 'b-fle', ()=>this.combatAct('flee')); + }, + + addCombatLog(msg, color) { + this._combatLog.unshift({ msg, color: color || '#aaaaaa' }); + if (this._combatLog.length > 5) this._combatLog.pop(); + const log = document.getElementById('cbt-log'); + if (!log) return; + log.innerHTML = this._combatLog + .map((e, i) => `
${e.msg}
`) + .join(''); + }, + + combatAct(action) { + if (this.state !== 'combat') return; + const enemy = this.combatEnemy; + let msg = ''; + let particleType = 'hit'; + + // Перк: регенерация HP в начале каждого хода + const regenHp = RPG._sumPerkVal(this.player, 'regenHp'); + if (regenHp > 0 && this.player.hp < this.player.maxHp) { + const actual = Math.min(regenHp, this.player.maxHp - this.player.hp); + this.player.hp += actual; + Renderer.addFloatingText(this.player.x, this.player.y, '+' + actual, '#44ff88', 13); + } + + if (action === 'attack') { + // Тик статуса врага + const dotDmg = RPG.tickStatus(enemy); + if (dotDmg > 0) this.addCombatLog(`${enemy.name} получает ${dotDmg} урона от ${enemy.status}!`); + + // Неуязвимость мини-босса + if (enemy._invincible > 0) { + enemy._invincible--; + this.addCombatLog('🛡️ ' + enemy.name + ' неуязвим! Атака отражена!'); + Renderer.addFloatingText(enemy.x, enemy.y, 'БЛОК', '#888888', 16); + this.refreshCombatPanel(); this.updateHUD(); + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + return; + } + + // Уклонение в тень (Shadow Assassin) + if (enemy.ai === 'shadow' && Math.random() < 0.35) { + this.addCombatLog('👤 ' + enemy.name + ' уклоняется в тень!'); + Renderer.addFloatingText(enemy.x, enemy.y, 'УКЛОН', '#9988cc', 15); + this.refreshCombatPanel(); this.updateHUD(); + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + return; + } + + // Сдвиг в пустоту (Мрак Безликий — фаза 2+) + if (enemy.ai === 'chaos' && enemy._chaosPhase2 && Math.random() < 0.20) { + this.addCombatLog('🌑 ' + enemy.name + ' сдвигается в пустоту!'); + Renderer.addFloatingText(enemy.x, enemy.y, 'ПУСТОТА', '#660066', 16); + this.refreshCombatPanel(); this.updateHUD(); + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + return; + } + + // Призрак в эфирном плане — атака проходит насквозь + if (enemy._phased) { + enemy._phased = false; + this.addCombatLog('👻 Призрак уходит в эфир! Атака проходит насквозь!'); + this.refreshCombatPanel(); this.updateHUD(); + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + return; + } + + const r = RPG.attackEnemy(this.player, enemy); + const elemTag = r.elemType === 'weak' ? ' ⚡СЛАБОСТЬ!' : r.elemType === 'resist' ? ' 🛡️УСТОЙЧИВ' : ''; + msg = `Атака: ${r.dmg} урона${r.crit?' 💥 КРИТ!':''}${elemTag}`; + Renderer.addParticle(enemy.x, enemy.y, 'hit'); + Renderer.shakeScreen(r.crit ? 8 : 4); + Renderer.flashScreen(r.crit ? '#ff6600' : '#ff2200', r.crit ? 0.25 : 0.18); + const _pe = document.getElementById('portrait-enemy'); + if (_pe) { _pe.classList.add('portrait-hit'); setTimeout(() => _pe.classList.remove('portrait-hit'), 280); } + Audio.playHit(r.crit); + Renderer.addFloatingText(enemy.x, enemy.y, '-' + r.dmg, + r.crit ? '#ff8800' : '#ff4444', r.crit ? 22 : 17); + // Плавающий текст слабости/сопротивления + if (r.elemType === 'weak') { + setTimeout(() => Renderer.addFloatingText(enemy.x, enemy.y, 'СЛАБОСТЬ!', '#ffdd00', 14), 200); + } else if (r.elemType === 'resist') { + setTimeout(() => Renderer.addFloatingText(enemy.x, enemy.y, 'УСТОЙЧИВ', '#8888aa', 14), 200); + } + if (r.crit) this.checkAchievements('crit'); + this.player.stats.kills = this.player.stats.kills || 0; + + if (enemy.hp <= 0) { this.endCombat(true); return; } + + } else if (action === 'item') { + // Сначала ищем боевое зелье (яд/огонь), потом лечебное + const combatPot = this.player.inventory.find(i => i.type==='potion' && i.combatEffect); + const healPot = this.player.inventory.find(i => i.type==='potion' && i.healAmount && !i.combatEffect); + const pot = combatPot || healPot; + if (pot) { + const r = RPG.useItem(this.player, pot, enemy); + msg = r.msg; + if (r.combatUsed) { + // боевое зелье — эффект на врага + Renderer.addParticle(enemy.x, enemy.y, 'hit'); + Audio.playSpell('fire'); + if (r.dmg) Renderer.addFloatingText(enemy.x, enemy.y, '-'+r.dmg, '#ff6600', 18); + if (enemy.hp <= 0) { this.endCombat(true); return; } + } else { + Renderer.addParticle(this.player.x, this.player.y, 'heal'); + if (pot.healAmount) Renderer.addFloatingText(this.player.x, this.player.y, '+'+pot.healAmount, '#44ff88', 17); + } + } else { + msg = 'Нет зелий!'; + } + } else if (action === 'flee') { + if (Math.random() < 0.5) { + this.endCombatFlee(); + return; + } + msg = 'Не удалось сбежать!'; + } + + this.addCombatLog(msg); + this.refreshCombatPanel(); + this.updateHUD(); + + if (action !== 'flee' || msg === 'Не удалось сбежать!') { + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + } + }, + + combatCastSpell(spellId) { + if (this.state !== 'combat') return; + const enemy = this.combatEnemy; + const r = RPG.castSpell(this.player, spellId, enemy); + if (!r.ok) { this.showMsg(r.msg, '#f44'); return; } + Audio.playSpell(r.particleType || 'magic'); + this.checkAchievements('spell'); + + let msg = r.spellName + ': '; + if (r.dmg) { + const elemTag = r.elemType === 'weak' ? ' ⚡СЛАБОСТЬ!' : r.elemType === 'resist' ? ' 🛡️УСТОЙЧИВ' : ''; + msg += r.dmg+' урона' + elemTag; + Renderer.addParticle(enemy.x, enemy.y, r.particleType); + Renderer.shakeScreen(r.elemType === 'weak' ? 8 : 5); + const dmgCol = r.particleType === 'fire' ? '#ff6600' : + r.particleType === 'ice' ? '#88ccff' : + r.particleType === 'holy' ? '#ffdd44' : '#aa44ff'; + Renderer.addFloatingText(enemy.x, enemy.y, '-' + r.dmg, dmgCol, r.elemType === 'weak' ? 22 : 19); + if (r.elemType === 'weak') { + setTimeout(() => Renderer.addFloatingText(enemy.x, enemy.y, 'СЛАБОСТЬ!', '#ffdd00', 14), 200); + } else if (r.elemType === 'resist') { + setTimeout(() => Renderer.addFloatingText(enemy.x, enemy.y, 'УСТОЙЧИВ', '#8888aa', 14), 200); + } + } + if (r.heal) { + msg += '+'+r.heal+' HP'; + Renderer.addParticle(this.player.x, this.player.y, 'heal'); + Renderer.addFloatingText(this.player.x, this.player.y, '+' + r.heal, '#44ff88', 18); + } + if (r.msg) msg = r.msg; + + this.addCombatLog(msg); + this.refreshCombatPanel(); + this.updateHUD(); + + if (enemy.hp <= 0) { this.endCombat(true); return; } + document.getElementById('cbt-status').textContent = 'Ход врага...'; + setTimeout(() => this.enemyTurn(), 700); + }, + + enemyTurn() { + if (this.state !== 'combat') return; + const e = this.combatEnemy; + const p = this.player; + const hpPct = e.hp / e.maxHp; + + // Тик статуса игрока + const pdot = RPG.tickStatus(p); + if (pdot > 0) this.addCombatLog(`Вы получаете ${pdot} урона от ${p.status}!`); + + // Оглушение (пропускаем ход врага? нет — оглушён игрок) + if (p.stunned) { + p.stunned = false; + this.addCombatLog('💫 Вы оглушены и пропускаете ход!'); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + if (p.hp <= 0) { this.endCombat(false); return; } + return; + } + + // ── AI: регенерация тролля ──────────────────────── + if (e.ai === 'regen' && hpPct < 0.9) { + const regen = Math.max(4, Math.floor(e.maxHp * 0.05)); + e.hp = Math.min(e.hp + regen, e.maxHp); + Renderer.addFloatingText(e.x, e.y, '+'+regen, '#44ff88', 14); + this.addCombatLog(`🔄 ${e.name} регенерирует +${regen} HP!`); + } + + // ── AI: самоисцеление ведьмы/лича ──────────────── + if (e.ai === 'hex' && hpPct < 0.45 && e.mp >= 20 && Math.random() < 0.55) { + const heal = Math.floor(e.maxHp * 0.18); + e.hp = Math.min(e.hp + heal, e.maxHp); + e.mp -= 20; + Renderer.addFloatingText(e.x, e.y, '✨+'+heal, '#cc88ff', 16); + this.addCombatLog(`✨ ${e.name} произносит заклинание исцеления! +${heal} HP`); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + return; // тратит ход на лечение + } + + // ── AI: призыв нежити личем ────────────────────── + if (e.ai === 'summon' && hpPct < 0.5 && !e._summoned) { + e._summoned = true; + const summon = RPG.createEnemy('skeleton', p.level + 1, e.x, e.y); + if (!e._activeSummons) e._activeSummons = []; + e._activeSummons.push(summon); + this.addCombatLog(`⚠️ ${e.name} призывает ${summon.name}!`); + Audio.playSpell('magic'); + } + + // ── AI: Военный клич Короля Гоблинов ───────────── + if (e.ai === 'warcry' && hpPct < 0.60 && !e._warcryed) { + e._warcryed = true; + e.dmg = Math.floor(e.dmg * 1.30); + const minion = RPG.createEnemy('goblin', Math.max(1, p.level - 1), e.x, e.y); + if (!e._activeSummons) e._activeSummons = []; + e._activeSummons.push(minion); + this.addCombatLog(`📣 ${e.name} издаёт военный клич! Призван гоблин! Урон +30%!`); + Audio.playSpell('magic'); + } + + // ── AI: Некромант Корвус — призыв и неуязвимость ── + if (e.ai === 'necroboss') { + if (hpPct < 0.65 && !e._necroSummoned) { + e._necroSummoned = true; + const z = RPG.createEnemy('zombie', p.level + 1, e.x, e.y); + if (!e._activeSummons) e._activeSummons = []; + e._activeSummons.push(z); + this.addCombatLog(`💀 ${e.name} призывает Зомби из тьмы!`); + Audio.playSpell('magic'); + } + if (hpPct < 0.40 && !e._phylactery) { + e._phylactery = true; + e._invincible = 2; + this.addCombatLog(`💜 ${e.name} заряжает филактерий! Неуязвим 2 хода!`); + Audio.playSpell('magic'); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + return; + } + } + + // ── AI: Болотная Гидра — регенерация голов ──────── + if (e.ai === 'hydra' && hpPct < 0.80) { + const regen = Math.floor(e.maxHp * 0.07); + e.hp = Math.min(e.hp + regen, e.maxHp); + Renderer.addFloatingText(e.x, e.y, '+'+regen, '#44ff88', 14); + this.addCombatLog(`🐍 ${e.name} отращивает голову! +${regen} HP`); + } + + // ── AI: Каменный Колосс — счётчик брони ────────── + if (e.ai === 'colossus') { + e._colTurn = (e._colTurn || 0) + 1; + if (e._colTurn % 3 === 0) { + e._invincible = 1; + this.addCombatLog(`🗿 ${e.name} закрывается каменной бронёй! (1 ход)`); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + return; + } + } + + // ── AI: Призрак Ирис — счётчик тени ────────────── + if (e.ai === 'shadow') { + e._shadowTurn = (e._shadowTurn || 0) + 1; + } + + // ── AI: Мрак Безликий — фаза 1 (призыв теней) ──── + if (e.ai === 'chaos') { + if (hpPct < 0.70 && !e._chaosSummon1) { + e._chaosSummon1 = true; + const s1 = RPG.createEnemy('ghost', p.level + 2, e.x, e.y); + const s2 = RPG.createEnemy('wyvern', p.level + 1, e.x, e.y); + if (!e._activeSummons) e._activeSummons = []; + e._activeSummons.push(s1, s2); + this.addCombatLog(`🌑 ${e.name} призывает тени!`); + Audio.playSpell('magic'); + } + // Фаза 2: истинная форма при HP < 50% + if (hpPct < 0.50 && !e._chaosPhase2) { + e._chaosPhase2 = true; + e.dmg = Math.floor(e.dmg * 1.35); + e._invincible = 1; + this.addCombatLog(`⚠️ ФАЗА 2: ${e.name} принимает истинную форму! Урон +35%!`); + Audio.playSpell('magic'); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + return; + } + } + + // ── Атака врага ────────────────────────────────── + const r = RPG.enemyAttackPlayer(e, p); + let extraLog = ''; + + // AI: берсерк (орк, йети) при HP < 30% + if ((e.ai === 'berserk') && hpPct < 0.30) { + if (!e._berserked) { e._berserked = true; this.addCombatLog(`😡 ${e.name} впадает в ярость!`); } + const bonus = Math.floor(r.dmg * 0.5); + p.hp = Math.max(0, p.hp - bonus); + r.dmg += bonus; + extraLog += ' 😡БЕРСЕРК'; + } + + // AI: ярость дракона — двойной удар при HP < 40% + if (e.ai === 'fury' && hpPct < 0.40 && Math.random() < 0.55) { + const r2 = RPG.enemyAttackPlayer(e, p); + extraLog += ` + второй удар (${r2.dmg})`; + Renderer.addFloatingText(p.x, p.y, '-'+r2.dmg, '#ff4444', 15); + } + + // AI: яд паука + if (e.ai === 'venom' && !p.status && Math.random() < 0.40) { + p.status = 'poison'; p.statusTurns = 3; p.dotDmg = 6; + extraLog += ' ☠️Яд!'; + } + + // AI: гниение зомби — снижает защиту + if (e.ai === 'decay' && Math.random() < 0.30 && !p._decayed) { + p._decayed = true; + p.def = Math.max(0, p.def - 2); + extraLog += ' 🦠-2 защиты'; + } + + // AI: ослепление летучей мыши — следующий удар игрока промахивается + if (e.ai === 'swarm' && Math.random() < 0.35) { + p._blinded = true; + extraLog += ' 👁️Слепота!'; + } + + // AI: оглушение голема + if (e.ai === 'stun' && Math.random() < 0.28) { + p.stunned = true; + extraLog += ' 💫Оглушение!'; + } + + // AI: вой волка — усиление следующей атаки + if (e.ai === 'howl' && hpPct < 0.50 && !e._howled) { + e._howled = true; + e._dmgBonus = Math.floor(e.dmg * 0.4); + this.addCombatLog(`🐺 ${e.name} воет, усиливая следующий удар!`); + } + + // AI: разбойник крадёт золото + if (e.ai === 'steal' && Math.random() < 0.20 && p.gold > 0) { + const stolen = Math.min(Math.floor(8 + Math.random()*12), p.gold); + p.gold -= stolen; + extraLog += ` 💰-${stolen} укр.`; + } + + // AI: трус-гоблин паникует при низком HP + if (e.ai === 'coward' && hpPct < 0.20 && Math.random() < 0.40) { + this.addCombatLog(`💨 ${e.name} паникует и не может атаковать!`); + this.updateHUD(); this.refreshCombatPanel(); + document.getElementById('cbt-status').textContent = 'Ваш ход'; + return; + } + + // AI: кислота слизня — небольшое DOT + if (e.ai === 'acid' && !p.status && Math.random() < 0.35) { + p.status = 'burn'; p.statusTurns = 2; p.dotDmg = 4; + extraLog += ' 🟢Кислота!'; + } + + // AI: фазирование призрака — следующая атака игрока промажет + if (e.ai === 'phase' && !e._phased && Math.random() < 0.32) { + e._phased = true; + extraLog += ' 👻Эфир!'; + } + + // AI: пикирование виверны — усиленный удар при HP < 60% + if (e.ai === 'dive' && hpPct < 0.60 && Math.random() < 0.38) { + const bonus = Math.floor(r.dmg * 0.55); + p.hp = Math.max(0, p.hp - bonus); + r.dmg += bonus; + extraLog += ' 🦅ПИКЕ!'; + } + + // ── AI: Болотная Гидра — доп. удары ────────────── + if (e.ai === 'hydra') { + if (hpPct < 0.50) { + const r2 = RPG.enemyAttackPlayer(e, p); + p.hp = Math.max(0, p.hp - 0); // уже вычтено в enemyAttackPlayer + Renderer.addFloatingText(p.x, p.y, '-'+r2.dmg, '#ff4444', 15); + extraLog += ` 🐍 вторая голова (${r2.dmg})`; + } + if (hpPct < 0.30) { + const r3 = RPG.enemyAttackPlayer(e, p); + Renderer.addFloatingText(p.x, p.y, '-'+r3.dmg, '#ff4444', 15); + extraLog += ` 🐍 третья голова (${r3.dmg})`; + } + } + + // ── AI: Ледяной Великан — метель/заморозка ──────── + if (e.ai === 'frost') { + if (hpPct < 0.50 && Math.random() < 0.45) { + const frostBonus = Math.floor(r.dmg * 0.6); + p.hp = Math.max(0, p.hp - frostBonus); + r.dmg += frostBonus; + p.stunned = true; + extraLog += ` ❄️МЕТЕЛЬ!`; + } else if (!p.status && Math.random() < 0.30) { + p.status = 'freeze'; p.statusTurns = 1; p.dotDmg = 0; + p.stunned = true; + extraLog += ' ❄️Заморозка!'; + } + } + + // ── AI: Каменный Колосс — сокрушительный удар ──── + if (e.ai === 'colossus' && hpPct < 0.40 && !e._shattered) { + e._shattered = true; + const shatBonus = Math.floor(r.dmg * 1.5); + p.hp = Math.max(0, p.hp - shatBonus); + r.dmg += shatBonus; + extraLog += ` 💥СОКРУШЕНИЕ!`; + } + + // ── AI: Призрак Ирис — удар из тени ────────────── + if (e.ai === 'shadow') { + if ((e._shadowTurn || 0) % 3 === 0) { + const shadowBonus = Math.floor(r.dmg * 2); + p.hp = Math.max(0, p.hp - shadowBonus); + r.dmg += shadowBonus; + extraLog += ' 🗡️ИЗ ТЕНИ!'; + } + if (hpPct < 0.50 && !p.status && Math.random() < 0.40) { + p.status = 'poison'; p.statusTurns = 4; p.dotDmg = 8; + extraLog += ' ☠️Яд клинка!'; + } + } + + // ── AI: Вампиризм Корвуса ───────────────────────── + if (e.ai === 'necroboss') { + const vamp = Math.floor(r.dmg * 0.25); + if (vamp > 0) { + e.hp = Math.min(e.hp + vamp, e.maxHp); + Renderer.addFloatingText(e.x, e.y, '🩸+'+vamp, '#cc44ff', 13); + extraLog += ` 🩸+${vamp}`; + } + } + + // ── AI: Мрак Безликий — дебаффы и фаза 3 (вампиризм) ── + if (e.ai === 'chaos') { + // Случайный дебафф (25% шанс) + if (!p.status && Math.random() < 0.25) { + const pick = Math.floor(Math.random() * 3); + if (pick === 0) { p.status = 'poison'; p.statusTurns = 3; p.dotDmg = 12; extraLog += ' ☠️Хаос-яд!'; } + if (pick === 1) { p.status = 'burn'; p.statusTurns = 2; p.dotDmg = 14; extraLog += ' 🔥Хаос-огонь!'; } + if (pick === 2) { p.stunned = true; extraLog += ' 💫Оглушение хаоса!'; } + } + // Фаза 3: вампиризм при HP < 30% + if (hpPct < 0.30) { + if (!e._chaosPhase3) { e._chaosPhase3 = true; this.addCombatLog(`⚠️ ФАЗА 3: Мрак поглощает всё вокруг!`); } + const vamp3 = Math.floor(r.dmg * 0.40); + if (vamp3 > 0) { + e.hp = Math.min(e.hp + vamp3, e.maxHp); + Renderer.addFloatingText(e.x, e.y, '🌑+'+vamp3, '#660066', 14); + extraLog += ` 🌑+${vamp3}HP`; + } + } + } + + const msg = `${e.name}: ${r.dmg} урона${r.crit?' 💥':''}${extraLog}`; + this.addCombatLog(msg); + Audio.playHit(r.crit); + e.isAtk = true; + setTimeout(() => { if (e) e.isAtk = false; }, 420); + Renderer.addParticle(p.x, p.y, 'hit', 4); + Renderer.shakeScreen(r.crit ? 5 : 3); + Renderer.flashScreen('#8800ff', r.crit ? 0.25 : 0.18); + const _pp = document.getElementById('portrait-player'); + if (_pp) { _pp.classList.add('portrait-hit'); setTimeout(() => _pp.classList.remove('portrait-hit'), 280); } + Renderer.addFloatingText(p.x, p.y, '-' + r.dmg, + r.crit ? '#ff6600' : '#ffaa44', r.crit ? 20 : 16); + this.updateHUD(); + this.refreshCombatPanel(); + + // ── Атаки призванных существ ──────────────────── + if (e._activeSummons && e._activeSummons.length > 0) { + for (const summon of e._activeSummons) { + const sDmg = Math.max(1, Math.floor(summon.dmg * (0.5 + Math.random() * 0.5)) - Math.floor(p.def * 0.3)); + p.hp = Math.max(0, p.hp - sDmg); + this.addCombatLog(`⚔️ ${summon.name} атакует! ${sDmg} урона`); + Renderer.addFloatingText(p.x, p.y, '-'+sDmg, '#ff8844', 13); + } + this.updateHUD(); + this.refreshCombatPanel(); + } + + document.getElementById('cbt-status').textContent = 'Ваш ход'; + + if (p.hp <= 0) { this.endCombat(false); return; } + }, + + endCombat(won) { + clearInterval(this._blinkInterval); + const e = this.combatEnemy; + const hpBefore = this.player.hp; + this.state = 'playing'; + this.closePanel('combat'); + + // Скрыть boss bar + document.getElementById('boss-bar').style.display = 'none'; + + if (won) { + // Лут + const loot = RPG.generateLoot(e); + let goldGained = 0; + loot.forEach(it => { + if (it.type==='gold') { this.player.gold += it.value; goldGained += it.value; } + else RPG.addToInventory(this.player, it); + }); + // Уникальный лут мини-босса + if (e.uniqueLoot) { + const ul = e.uniqueLoot; + const item = Object.assign({ id: ul.id, type: ul.type, name: ul.name }, ul.opts); + RPG.addToInventory(this.player, item); + setTimeout(() => this.showMsg('⚜️ ЛЕГЕНДАРНЫЙ: ' + ul.name + '!', '#ffd700'), 800); + } + // Опыт + this.player.exp += e.exp; + this.player.stats.kills = (this.player.stats.kills||0) + 1; + if (e.isMini) { + this.showMsg(`⚔️ Мини-босс повержен! +${e.exp} XP +${goldGained} 💰`, '#ff6644'); + } else { + this.showMsg(`Победа! +${e.exp} XP +${goldGained} 💰`, '#ffd700'); + } + Renderer.addParticle(e.x, e.y, 'death', 12); + Renderer.addParticle(e.x, e.y, 'gold', 5); + this.enemies = this.enemies.filter(en => en !== e); + // Квест + this.updateQuestProgress('kill', e.type); + // Бестиарий + this.player.bestiary = this.player.bestiary || {}; + const prevKills = this.player.bestiary[e.type] || 0; + this.player.bestiary[e.type] = prevKills + 1; + if (prevKills === 0) this.showMsg('📖 Бестиарий: ' + e.name + ' открыт!', '#88aaff'); + // Уровень + if (RPG.checkLevelUp(this.player)) this.triggerLevelUp(); + // Достижения + this.checkAchievements('kill'); + if (e.isBoss || e.isMini) this.checkAchievements('kill_boss'); + if (e.isMini) this.checkAchievements('mini_boss_kill'); + if (e.type === 'chaos_lord') this.checkAchievements('mega_boss'); + if (hpBefore >= this.player.maxHp) this.checkAchievements('no_damage'); + this.checkAchievements('gold'); + this.checkAchievements('bestiary'); + Audio.playVictory(); + Audio.playTheme(this.mapId); + } else { + this.player.hp = Math.max(1, Math.floor(this.player.maxHp * 0.3)); + this.player.gold = Math.max(0, this.player.gold - 15); + this.showMsg('Поражение! Потеряно 15 💰', '#e74c3c'); + Audio.playDeath(); + Audio.playTheme(this.mapId); + } + + this.combatEnemy = null; + if (this.player) { + this.player.deathSaveUsed = false; // сброс смертного рывка + this.player._decayed = false; + this.player.stunned = false; + this.player._blinded = false; + } + // Сбросить флаги врага + if (e) { e._phased = false; e._berserked = false; e._activeSummons = null; } + this.updateHUD(); + this.autoSave(); + }, + + endCombatFlee() { + clearInterval(this._blinkInterval); + this.state = 'playing'; + this.closePanel('combat'); + document.getElementById('boss-bar').style.display = 'none'; + this.combatEnemy = null; + if (this.player) this.player.deathSaveUsed = false; + this.showMsg('Сбежали!', '#aaa'); + Audio.playTheme(this.mapId); + }, + + // ══════════════════════════════════════════ + // ПОВЫШЕНИЕ УРОВНЯ + // ══════════════════════════════════════════ + triggerLevelUp() { + this.player.perkPoints = (this.player.perkPoints || 0) + 1; + this.showMsg('🌟 Уровень ' + this.player.level + '! Получено +1 очко таланта', '#ffd700'); + Renderer.addParticle(this.player.x, this.player.y, 'holy', 15); + Renderer.shakeScreen(3); + Audio.playLevelUp(); + this.checkAchievements('level'); + // Открыть дерево перков с небольшой задержкой + setTimeout(() => { + this.renderPerkPanel(); + this.openPanel('perk'); + }, 400); + }, + + // ══════════════════════════════════════════ + // HUD ОБНОВЛЕНИЕ + // ══════════════════════════════════════════ + updateHUD() { + const p = this.player; + if (!p) return; + const s = RPG.getTotalStats(p); + document.getElementById('h-hp').textContent = Math.floor(p.hp)+'/'+p.maxHp; + document.getElementById('h-mp').textContent = Math.floor(p.mp)+'/'+p.maxMp; + document.getElementById('h-lv').textContent = p.level; + document.getElementById('h-gold').textContent = p.gold; + document.getElementById('h-atk').textContent = s.damage; + document.getElementById('h-def').textContent = s.defense; + document.getElementById('b-hp').style.width = (p.hp/p.maxHp*100)+'%'; + document.getElementById('b-mp').style.width = (p.mp/p.maxMp*100)+'%'; + document.getElementById('b-exp').style.width = (p.exp/p.expNext*100)+'%'; + }, + + // ══════════════════════════════════════════ + // СООБЩЕНИЯ + // ══════════════════════════════════════════ + showMsg(text, color) { + const overlay = document.getElementById('msg-overlay'); + const div = document.createElement('div'); + div.className = 'msg-pop'; + div.textContent = text; + if (color) div.style.color = color; + overlay.appendChild(div); + setTimeout(() => { if (div.parentNode) div.parentNode.removeChild(div); }, 3000); + }, + + // ══════════════════════════════════════════ + // СОХРАНЕНИЕ / ЗАГРУЗКА + // ══════════════════════════════════════════ + _updatePlayTime() { + const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000); + this.player._playTime = (this.player._playTime || 0) + elapsed; + this._sessionStart = Date.now(); + }, + + saveGame() { + this._updatePlayTime(); + const ok = RPG.save({ + player: this.player, + mapId: this.mapId, + dayCount: this.dayCount, + timeOfDay: this.timeOfDay, + }, this.saveSlot); + this.showMsg(ok ? '💾 Сохранено!' : 'Ошибка!', ok ? '#4f4' : '#f44'); + // Индикатор + const ind = document.getElementById('save-ind'); + if (ind) { + ind.style.opacity = '1'; + clearTimeout(ind._t); + ind._t = setTimeout(() => { ind.style.opacity = '0'; }, 2000); + } + }, + + autoSave() { + this._updatePlayTime(); + RPG.save({ + player: this.player, + mapId: this.mapId, + dayCount: this.dayCount, + timeOfDay: this.timeOfDay, + }, this.saveSlot); + }, + + // ══════════════════════════════════════════ + // ДЕРЕВО ПЕРКОВ + // ══════════════════════════════════════════ + renderPerkPanel() { + const p = this.player; + const tree = RPG.PERK_TREE[p.class]; + if (!tree) return; + + const pts = p.perkPoints || 0; + document.getElementById('perk-points-display').textContent = + pts > 0 ? `⭐ Очков: ${pts}` : 'Очков: 0'; + document.getElementById('perk-class-name').textContent = + RPG.CLASSES[p.class].name + ' · Изучено: ' + (p.perks || []).length + ' перков'; + + const container = document.getElementById('perk-branches'); + container.innerHTML = ''; + + tree.branches.forEach(branch => { + const col = document.createElement('div'); + col.className = 'perk-branch'; + col.innerHTML = `
${branch.icon} ${branch.name}
`; + + branch.perks.forEach((perk, idx) => { + if (idx > 0) { + const prevPerk = branch.perks[idx - 1]; + const prevLearned = (p.perks || []).includes(prevPerk.id); + const curLearned = (p.perks || []).includes(perk.id); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '100%'); svg.setAttribute('height', '18'); + svg.style.cssText = 'display:block;margin:2px 0'; + const ln = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + ln.setAttribute('x1', '50%'); ln.setAttribute('y1', '0'); + ln.setAttribute('x2', '50%'); ln.setAttribute('y2', '18'); + ln.setAttribute('stroke', (prevLearned && curLearned) ? '#ffd700' : '#2a2a4a'); + ln.setAttribute('stroke-width', (prevLearned && curLearned) ? '2' : '1.5'); + if (!(prevLearned && curLearned)) ln.setAttribute('stroke-dasharray', '5,3'); + svg.appendChild(ln); + col.appendChild(svg); + } + + const learned = (p.perks || []).includes(perk.id); + const req = RPG.getPerkPrereq(p.class, perk.id); + const prereqOk = !req || (p.perks || []).includes(req); + const available = prereqOk && !learned && pts > 0; + + const card = document.createElement('div'); + card.className = 'perk-card ' + (learned ? 'learned' : available ? 'available' : 'locked'); + card.innerHTML = ` +
T${perk.tier}
+
${perk.icon}
+
${perk.name}
+
${perk.desc}
`; + + if (available) { + card.onclick = () => { + const r = RPG.applyPerk(p, perk.id); + this.showMsg(r.ok ? '✨ ' + r.msg : r.msg, r.ok ? '#ffd700' : '#f44'); + if (r.ok) { + this.updateHUD(); + this.renderPerkPanel(); + Renderer.addParticle(p.x, p.y, 'holy', 8); + } + }; + } + col.appendChild(card); + }); + + container.appendChild(col); + }); + }, + + // ══════════════════════════════════════════ + // КРАФТИНГ + // ══════════════════════════════════════════ + _craftActiveCategory: 'potions', + _craftSelectedRecipe: null, + + renderCraftPanel() { + const cats = { potions:'🧪 Зелья', alchemy:'⚗️ Алхимия', runes:'🔮 Руны', enhance:'⚙️ Улучшения', equipment:'⚔️ Снаряжение' }; + const tabs = document.getElementById('craft-tabs'); + tabs.innerHTML = ''; + Object.entries(cats).forEach(([key, label]) => { + const btn = document.createElement('button'); + btn.className = 'craft-tab' + (this._craftActiveCategory === key ? ' active' : ''); + btn.textContent = label; + btn.onclick = () => { this._craftActiveCategory = key; this._craftSelectedRecipe = null; this.renderCraftPanel(); }; + tabs.appendChild(btn); + }); + + const list = document.getElementById('craft-recipe-list'); + list.innerHTML = ''; + RPG.CRAFT_RECIPES.filter(r => r.category === this._craftActiveCategory).forEach(recipe => { + const canCraft = RPG.canCraft(this.player, recipe.id); + const btn = document.createElement('button'); + btn.className = 'craft-recipe-btn' + (canCraft ? ' can-craft' : '') + (this._craftSelectedRecipe === recipe.id ? ' selected' : ''); + btn.textContent = recipe.icon + ' ' + recipe.name; + btn.onclick = () => { this._craftSelectedRecipe = recipe.id; this._renderCraftDetail(recipe); }; + list.appendChild(btn); + }); + + if (this._craftSelectedRecipe) { + const rec = RPG.CRAFT_RECIPES.find(r => r.id === this._craftSelectedRecipe); + if (rec) this._renderCraftDetail(rec); + } + }, + + _renderCraftDetail(recipe) { + const detail = document.getElementById('craft-detail'); + const canCraft = RPG.canCraft(this.player, recipe.id); + + const ingRows = recipe.ingredients.map(ing => { + const playerQty = this.player.inventory + .filter(i => i.id === ing.id || i.id.startsWith(ing.id + '_')) + .reduce((sum, i) => sum + (i.qty || 1), 0); + const have = playerQty >= ing.qty; + const ld = RPG.LOOT_DB[ing.id]; + const name = ld ? ld.n : ing.id; + return `
+ ${name}${playerQty}/${ing.qty} ${have ? '✓' : '✗'} +
`; + }).join(''); + + const r = recipe.result; + const statStr = [ + r.opts.damage ? `⚔️ +${r.opts.damage} урон` : '', + r.opts.defense ? `🛡️ +${r.opts.defense} защита` : '', + r.opts.healAmount ? `❤️ +${r.opts.healAmount} HP` : '', + r.opts.restoreMp ? `💧 +${r.opts.restoreMp} МА` : '', + r.opts.bonusStr ? `💪 +${r.opts.bonusStr} СИЛ` : '', + r.opts.bonusDef ? `🛡️ +${r.opts.bonusDef} ЗАЩ` : '', + r.opts.bonusMag ? `✨ +${r.opts.bonusMag} МАГ` : '', + r.opts.bonusHp ? `❤️ +${r.opts.bonusHp} HP` : '', + r.opts.bonusMp ? `💧 +${r.opts.bonusMp} МА` : '', + ].filter(Boolean).join(' · ') || (r.opts.desc || ''); + + const rarityColor = RPG.RARITY_COLORS[r.opts.rarity] || '#888'; + const rarityStr = r.opts.rarity ? `${r.opts.rarity} · ` : ''; + + detail.innerHTML = ` +
${recipe.icon}
+
${recipe.name}
+
Ингредиенты:
+ ${ingRows} +
Результат:
+
+ ${r.opts.icon || ''} ${r.name}
+ ${rarityStr}${statStr} +
+ `; + }, + + _doCraft(recipeId) { + const r = RPG.craft(this.player, recipeId); + this.showMsg(r.msg, r.ok ? '#4f4' : '#f44'); + if (r.ok) { + Renderer.addParticle(this.player.x, this.player.y, 'magic', 8); + this._craftSelectedRecipe = recipeId; + this.renderCraftPanel(); + this.updateHUD(); + this.checkAchievements('craft'); + this.checkAchievements('inv_full'); + } + }, + + // ══════════════════════════════════════════ + // СЮЖЕТНЫЕ КВЕСТЫ — ДИАЛОГ С NPC + // ══════════════════════════════════════════ + _handleQuestNPC(npc) { + const p = this.player; + const opts = []; + + // Сюжетные квесты от этого NPC + RPG.STORY_QUESTS.filter(sq => sq.giverNpc === npc.name).forEach(sq => { + const pq = RPG.getPlayerStoryQuest(p, sq.id); + + if (!pq) { + // Предложить взять квест + opts.push({ + label: '📋 ' + sq.icon + ' ' + sq.name, + action: () => { + const stage0 = sq.stages[0]; + this.showDialog(npc.name, stage0.dialogBefore, [ + { label: '✅ Принять задание', action: () => { + RPG.giveStoryQuest(p, sq.id); + this.closePanel('dialog'); + this.showMsg('📜 Новый квест: ' + sq.name, '#ffd700'); + this.renderQuestPanel(); + }}, + { label: '❌ Не сейчас', action: () => this.closePanel('dialog') } + ]); + } + }); + } else if (!pq.done) { + // Есть ли завершённый неотмеченный этап? + const lastNum = pq.completedStages.filter(s => typeof s === 'number'); + const lastIdx = lastNum.length > 0 ? lastNum[lastNum.length - 1] : -1; + const hasUnack = lastIdx >= 0 && !pq.completedStages.includes('ack_' + lastIdx); + + if (hasUnack) { + const completedStage = sq.stages[lastIdx]; + const nextStage = sq.stages[pq.stageIdx]; + opts.push({ + label: '📜 ' + sq.name + ' — отчитаться', + action: () => { + // Выдать награду за этап + p.exp += completedStage.reward.exp; + p.gold += completedStage.reward.gold; + pq.completedStages.push('ack_' + lastIdx); + this.showMsg(`✓ Этап "${completedStage.title}" · +${completedStage.reward.exp} XP · +${completedStage.reward.gold} зол.`, '#ffd700'); + if (RPG.checkLevelUp(p)) this.triggerLevelUp(); + this.updateHUD(); + this.renderQuestPanel(); + + const afterText = completedStage.dialogAfter + (nextStage && !pq.done ? '\n\n► ' + nextStage.title + ': ' + nextStage.desc : ''); + this.showDialog(npc.name, afterText, [ + { label: pq.done ? '🎉 Завершить' : '► Продолжить', action: () => this.closePanel('dialog') } + ]); + } + }); + } else { + // Напомнить текущий этап + const cur = sq.stages[pq.stageIdx]; + opts.push({ + label: '📜 ' + sq.name + ' (текущее задание)', + action: () => { + const text = (cur.dialogBefore || cur.desc) + '\n\nПрогресс: ' + pq.progress + '/' + cur.need; + this.showDialog(npc.name, text, [ + { label: 'Понял, иду', action: () => this.closePanel('dialog') } + ]); + } + }); + } + } else { + opts.push({ + label: '✓ ' + sq.name + ' (завершён)', + action: () => { + this.showDialog(npc.name, 'Ты уже выполнил это задание. Да пребудет с тобой удача, герой!', [ + { label: 'До свидания', action: () => this.closePanel('dialog') } + ]); + } + }); + } + }); + + // Обычные квесты от любого quest-NPC + const unlocked = this.getUnlockedQuests(); + unlocked.forEach(qdb => { + const has = p.quests.find(q => q.id === qdb.id); + if (!has) { + opts.push({ label: '📋 ' + qdb.name, action: () => { + this.giveQuest(qdb.id); + this.closePanel('dialog'); + this.renderQuestPanel(); + }}); + } + }); + + opts.push({ label: '❌ Уйти', action: () => this.closePanel('dialog') }); + + const hasNew = opts.some(o => o.label.startsWith('📋')); + const hasReport = opts.some(o => o.label.includes('отчитаться')); + const msg = hasReport ? 'Жду твоего доклада, странник!' : + hasNew ? 'Есть задания для тебя, странник!' : + 'Нет новых заданий. Возвращайся позже.'; + this.showDialog(npc.name, msg, opts); + }, + + // ══════════════════════════════════════════ + // МАРКЕРЫ КВЕСТОВ НА КАРТЕ + // ══════════════════════════════════════════ + _getQuestMarkerData() { + const p = this.player; + const markers = {}; + + RPG.STORY_QUESTS.forEach(sq => { + const pq = RPG.getPlayerStoryQuest(p, sq.id); + if (!pq) { + // Квест ещё не взят — показать "!" у выдающего NPC + if (!markers[sq.giverNpc]) markers[sq.giverNpc] = 'give'; + } else if (!pq.done) { + const lastNum = pq.completedStages.filter(s => typeof s === 'number'); + const lastIdx = lastNum.length > 0 ? lastNum[lastNum.length - 1] : -1; + const hasUnack = lastIdx >= 0 && !pq.completedStages.includes('ack_' + lastIdx); + if (hasUnack) { + markers[sq.giverNpc] = 'advance'; // мигающий "?" — надо отчитаться + } + } else { + if (!markers[sq.giverNpc]) markers[sq.giverNpc] = 'complete'; + } + }); + + // Обычные квесты — ставить "!" у quest-NPC если есть незанятые квесты + if (this.getUnlockedQuests().some(q => !p.quests.find(pq => pq.id === q.id))) { + this.npcs.forEach(npc => { + if (npc.type === 'quest' && !markers[npc.name]) markers[npc.name] = 'give'; + }); + } + + return markers; + }, + + _getMinimapQuestDots() { + const p = this.player; + const dots = []; + const markers = this._getQuestMarkerData(); + + this.npcs.forEach(npc => { + if (markers[npc.name]) { + dots.push({ x: Math.round(npc.x), y: Math.round(npc.y), type: markers[npc.name] }); + } + }); + + // Цели visit-этапов — показать на миникарте + p.quests.filter(q => q.isStory && !q.done).forEach(pq => { + const sq = RPG.getStoryQuest(pq.id); + if (!sq) return; + const stage = sq.stages[pq.stageIdx]; + if (stage && stage.type === 'visit') { + const portal = this.decorations.find(d => d.type === 'portal' && d.destination === stage.target); + if (portal) dots.push({ x: Math.round(portal.x), y: Math.round(portal.y), type: 'target' }); + } + }); + + return dots; + }, + + // ══════════════════════════════════════════ + // БЕСТИАРИЙ + // ══════════════════════════════════════════ + renderBestiaryPanel() { + const grid = document.getElementById('bestiary-grid'); + if (!grid) return; + grid.innerHTML = ''; + const p = this.player; + const bestiary = p.bestiary || {}; + + const WEAK_ICONS = { fire:'🔥', ice:'❄️', holy:'✨', magic:'🔮', physical:'⚔️', poison:'☠️' }; + const WEAK_NAMES = { fire:'Огонь', ice:'Лёд', holy:'Святость', magic:'Магия', physical:'Физика', poison:'Яд' }; + + Object.entries(RPG.ENEMY_DB).forEach(([type, db]) => { + const kills = bestiary[type] || 0; + const seen = kills > 0; + + const card = document.createElement('div'); + card.className = 'beast-card ' + (seen ? 'seen' : 'unseen'); + + // Мини-canvas с портретом + const cvs = document.createElement('canvas'); + cvs.className = 'beast-canvas'; + cvs.width = 64; + cvs.height = 72; + card.appendChild(cvs); + + // Рисуем портрет через Renderer (масштаб ~0.7 от 90x100) + // Создаём временный объект с нужными полями + Renderer.drawEnemyPortrait({ type }, cvs); + + const nameEl = document.createElement('div'); + nameEl.className = 'beast-name'; + nameEl.textContent = seen ? db.name : '???'; + card.appendChild(nameEl); + + if (seen) { + const loreEl = document.createElement('div'); + loreEl.className = 'beast-lore'; + loreEl.textContent = db.lore || ''; + card.appendChild(loreEl); + + const killsEl = document.createElement('div'); + killsEl.className = 'beast-kills'; + killsEl.textContent = '⚔ Убито: ' + kills; + card.appendChild(killsEl); + + // Слабость/сопротивление видны если: нашёл лор-записку или убил 3+ врагов + const foundNotes = p.foundNotes || []; + const hasLoreHint = RPG.LORE_NOTES.some(n => n.reveals && n.reveals.enemy === type && foundNotes.includes(n.id)); + const knowsWeakness = hasLoreHint || kills >= 3; + + if (db.weakness) { + const weakEl = document.createElement('div'); + weakEl.className = 'beast-weak'; + if (knowsWeakness) { + weakEl.textContent = '⚡ Слабость: ' + (WEAK_ICONS[db.weakness]||'') + ' ' + (WEAK_NAMES[db.weakness]||db.weakness); + } else { + weakEl.textContent = '⚡ Слабость: ???'; + weakEl.style.opacity = '0.5'; + } + card.appendChild(weakEl); + } + if (db.resist) { + const resEl = document.createElement('div'); + resEl.className = 'beast-resist'; + if (knowsWeakness) { + resEl.textContent = '🛡 Устойчив: ' + (WEAK_ICONS[db.resist]||'') + ' ' + (WEAK_NAMES[db.resist]||db.resist); + } else { + resEl.textContent = '🛡 Устойчив: ???'; + resEl.style.opacity = '0.5'; + } + card.appendChild(resEl); + } + } + + grid.appendChild(card); + }); + }, + + // NPC_DIALOGS заполняется DataLoader из data/world.json + + _startBranchDialog(npc) { + const tree = this.NPC_DIALOGS[npc.name]; + if (!tree) { + this.showDialog(npc.name, 'Привет, путник!', + [{ label:'Пока', action:()=>this.closePanel('dialog') }]); + return; + } + this._showDialogNode(npc.name, tree, 'start'); + }, + + _showDialogNode(npcName, tree, nodeKey) { + const node = tree[nodeKey]; + if (!node) { this.closePanel('dialog'); return; } + const opts = (node.opts || []).map(opt => ({ + label: opt.label, + action: () => { + // Стоимость + if (opt.cost) { + if (this.player.gold < opt.cost) { + this.showMsg('Недостаточно золота!', '#f44'); + this.closePanel('dialog'); + return; + } + this.player.gold -= opt.cost; + } + // Награды + const rew = opt.reward || (node.reward); + if (rew) { + if (rew.exp) { this.player.exp += rew.exp; this.showMsg('+'+rew.exp+' опыта', '#ffd700'); if (RPG.checkLevelUp(this.player)) this.triggerLevelUp(); } + if (rew.hp) { this.player.hp = Math.min(this.player.maxHp, this.player.hp + rew.hp); this.showMsg('HP восстановлено!', '#4f4'); } + if (rew.mp) { this.player.mp = Math.min(this.player.maxMp, this.player.mp + rew.mp); this.showMsg('MP восстановлено!', '#88f'); } + if (rew.cure) { this.player.status = null; this.player.statusTurns = 0; } + if (rew.item) { + const ld = RPG.LOOT_DB[rew.item]; + if (ld) { + const it = RPG.createItem(rew.item+'_gift', ld.t, ld.n, { value:ld.v, qty:rew.qty||1, stackable:true, icon:ld.icon||'📦' }); + RPG.addToInventory(this.player, it); + this.showMsg('Получено: '+it.name+(rew.qty>1?' ×'+rew.qty:''), '#4f4'); + } + } + if (rew.buff) { + this.player.buffs = this.player.buffs || []; + this.player.buffs.push({ stat:rew.buff, val:1.5, expires: Date.now()+30000 }); + this.showMsg('Бафф активен!', '#88f'); + } + this.updateHUD(); + } + if (opt.next) this._showDialogNode(npcName, tree, opt.next); + else this.closePanel('dialog'); + } + })); + this.showDialog(npcName, node.text, opts); + }, + + // ══════════════════════════════════════════ + // ЖУРНАЛ ЛОРА + // ══════════════════════════════════════════ + renderLorePanel() { + const container = document.getElementById('lore-list'); + if (!container) return; + container.innerHTML = ''; + const found = this.player.foundNotes || []; + const all = RPG.LORE_NOTES; + const total = all.length; + const cnt = found.length; + const header = document.getElementById('lore-count'); + if (header) header.textContent = `Найдено: ${cnt} / ${total}`; + + if (cnt === 0) { + container.innerHTML = '
Вы ещё не нашли ни одной записи.
Исследуйте локации!
'; + return; + } + + const MAP_NAMES = { village:'Деревня', forest:'Лес', dungeon:'Подземелье', cave:'Пещера', mountain:'Горы', swamp:'Болото', ruins:'Руины', abyss:'Бездна' }; + // Группируем по локациям + const byMap = {}; + all.filter(n => found.includes(n.id)).forEach(n => { + if (!byMap[n.mapId]) byMap[n.mapId] = []; + byMap[n.mapId].push(n); + }); + + Object.entries(byMap).forEach(([mapId, notes]) => { + const groupEl = document.createElement('div'); + groupEl.className = 'lore-group'; + const titleEl = document.createElement('div'); + titleEl.className = 'lore-group-title'; + titleEl.textContent = MAP_NAMES[mapId] || mapId; + groupEl.appendChild(titleEl); + + notes.forEach(note => { + const card = document.createElement('div'); + card.className = 'lore-card'; + const hintHtml = note.reveals && note.reveals.hint + ? `
💡 ${note.reveals.hint}
` : ''; + card.innerHTML = ` +
${note.icon}${note.title}
+
${note.text}
${hintHtml}`; + groupEl.appendChild(card); + }); + + // Показать статус сбора записок локации + const totalInMap = all.filter(n => n.mapId === mapId).length; + const foundInMap = all.filter(n => n.mapId === mapId && found.includes(n.id)).length; + const statusEl = document.createElement('div'); + statusEl.className = 'lore-map-status'; + if (foundInMap >= totalInMap) { + statusEl.textContent = `✅ Все записки собраны! (${foundInMap}/${totalInMap})`; + statusEl.style.color = '#44ff88'; + } else { + statusEl.textContent = `📜 ${foundInMap}/${totalInMap} записок`; + statusEl.style.color = '#888'; + } + groupEl.appendChild(statusEl); + container.appendChild(groupEl); + }); + }, + + // Бонус за сбор всех записок в локации + _checkLoreLocationBonus(mapId) { + const all = RPG.LORE_NOTES.filter(n => n.mapId === mapId); + const found = this.player.foundNotes || []; + const allCollected = all.every(n => found.includes(n.id)); + if (!allCollected) return; + + // Уже получал бонус за эту локацию? + this.player._loreBonus = this.player._loreBonus || []; + if (this.player._loreBonus.includes(mapId)) return; + this.player._loreBonus.push(mapId); + + // Бонус: +3 к случайному стату + золото + const bonuses = [ + { stat: 'baseStr', label: 'СИЛ' }, + { stat: 'baseDef', label: 'ЗАЩ' }, + { stat: 'baseMag', label: 'МАГ' }, + { stat: 'baseSpd', label: 'СКР' }, + ]; + const pick = bonuses[Math.floor(Math.random() * bonuses.length)]; + this.player[pick.stat] += 2; + if (pick.stat === 'baseStr') this.player.str = this.player.baseStr; + if (pick.stat === 'baseDef') this.player.def = this.player.baseDef; + if (pick.stat === 'baseMag') this.player.mag = this.player.baseMag; + if (pick.stat === 'baseSpd') this.player.spd = this.player.baseSpd; + this.player.gold += 50; + + const MAP_NAMES = { village:'Деревню', forest:'Лес', dungeon:'Подземелье', cave:'Пещеру', mountain:'Горы', swamp:'Болото', ruins:'Руины', abyss:'Бездну' }; + setTimeout(() => { + this.showMsg(`📚 Все записки собраны: ${MAP_NAMES[mapId] || mapId}!`, '#ffdd44'); + }, 2500); + setTimeout(() => { + this.showMsg(`🎁 Бонус знаний: +2 ${pick.label}, +50 золота`, '#44ff88'); + }, 4000); + this.updateHUD(); + }, + + // ══════════════════════════════════════════ + // ЗАЧАРОВАНИЕ + // ══════════════════════════════════════════ + _enchantSelectedItem: null, + + renderEnchantPanel(selectedItem) { + const p = this.player; + const leftEl = document.getElementById('enchant-item-list'); + const rightEl = document.getElementById('enchant-detail'); + if (!leftEl || !rightEl) return; + + // Собираем зачаруемые предметы: экипированные + в инвентаре с slot + const enchantable = []; + Object.entries(p.equipment).forEach(([slot, it]) => { + if (it) enchantable.push({ item:it, source:'eq', slot }); + }); + p.inventory.forEach(it => { + if (it.slot) enchantable.push({ item:it, source:'inv', slot:it.slot }); + }); + + if (!selectedItem && this._enchantSelectedItem) { + // проверить что он ещё существует + const found = enchantable.find(e => e.item.id === this._enchantSelectedItem.id); + if (found) selectedItem = found.item; + } + if (!selectedItem && enchantable.length > 0) selectedItem = enchantable[0].item; + this._enchantSelectedItem = selectedItem || null; + + // Левая колонка — список предметов + leftEl.innerHTML = ''; + enchantable.forEach(({ item, source }) => { + const btn = document.createElement('div'); + btn.className = 'enchant-item-btn' + (item === selectedItem ? ' active' : ''); + const enchIcon = item.enchant && RPG.ENCHANTS[item.enchant] ? RPG.ENCHANTS[item.enchant].icon : ''; + btn.innerHTML = `${item.icon||'📦'}${item.name}${enchIcon ? `${enchIcon}` : ''}${source==='eq'?'👕экип.':'🎒инв.'}`; + btn.onclick = () => this.renderEnchantPanel(item); + leftEl.appendChild(btn); + }); + + // Правая колонка — список зачарований + rightEl.innerHTML = ''; + if (!selectedItem) { + rightEl.innerHTML = '
Нет предметов для зачарования
'; + return; + } + + const header = document.createElement('div'); + header.className = 'ench-item-header'; + header.innerHTML = `${selectedItem.icon||'📦'}${selectedItem.name}`; + if (selectedItem.enchant && RPG.ENCHANTS[selectedItem.enchant]) { + const cur = RPG.ENCHANTS[selectedItem.enchant]; + header.innerHTML += `Текущее: ${cur.icon} ${cur.name}`; + } + rightEl.appendChild(header); + + const enchants = RPG.getAvailableEnchants(p, selectedItem); + if (enchants.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ench-empty'; + empty.textContent = 'Нет доступных зачарований для этого предмета'; + rightEl.appendChild(empty); + return; + } + + enchants.forEach(en => { + const card = document.createElement('div'); + card.className = 'enchant-card' + (en.canDo ? '' : ' disabled') + (selectedItem.enchant === en.id ? ' current' : ''); + const matOk = en.hasMat ? '✅' : '❌'; + const goldOk = en.hasGold ? '✅' : '❌'; + card.innerHTML = ` +
${en.icon}${en.name}${en.desc}
+
${goldOk} 💰 ${en.cost}   ${matOk} ${en.matName} ×${en.matQty} (есть: ${en.matCount})
`; + if (en.canDo) { + card.onclick = () => { + const result = RPG.enchantItem(p, selectedItem, en.id); + this.showMsg(result.msg, result.ok ? '#adf' : '#f88'); + if (result.ok) { + this.updateHUD(); + this.renderEnchantPanel(selectedItem); + this.checkAchievements('enchant'); + } + }; + } + rightEl.appendChild(card); + }); + }, + + // ══════════════════════════════════════════ + // ДОСТИЖЕНИЯ + // ══════════════════════════════════════════ + ACHIEVEMENTS_DB: { + 'first_blood': { icon:'🩸', name:'Первая кровь', desc:'Убей первого врага' }, + 'kill50': { icon:'⚔️', name:'Убийца', desc:'Убей 50 врагов', maxProgress:50, getProgress: p => p.stats?.kills||0 }, + 'kill_boss': { icon:'👹', name:'Охотник за боссами', desc:'Убей любого мини-босса' }, + 'boss_all_mini': { icon:'🏆', name:'Чемпион', desc:'Убей всех 6 мини-боссов', maxProgress:6, getProgress: p => { const ids=['goblin_king','corvus','hydra','frost_giant','stone_colossus','shadow_assassin']; return ids.filter(t=>(p.bestiary||{})[t]>0).length; } }, + 'boss_mega': { icon:'💀', name:'Легенда', desc:'Убей Мрака Безликого' }, + 'lvl5': { icon:'⭐', name:'Опытный', desc:'Достигни 5 уровня', maxProgress:5, getProgress: p => Math.min(p.level||1, 5) }, + 'lvl10': { icon:'🌟', name:'Ветеран', desc:'Достигни 10 уровня', maxProgress:10, getProgress: p => Math.min(p.level||1, 10) }, + 'rich': { icon:'💰', name:'Богач', desc:'Накопи 500 золота', maxProgress:500, getProgress: p => Math.min(p.gold||0, 500) }, + 'gold1000': { icon:'👑', name:'Золотой король', desc:'Накопи 1000 золота', maxProgress:1000, getProgress: p => Math.min(p.gold||0, 1000) }, + 'explorer': { icon:'🗺️', name:'Исследователь', desc:'Посети все 8 локаций', maxProgress:8, getProgress: p => { const v=p._visited; return v instanceof Set?v.size:Array.isArray(v)?v.length:0; } }, + 'abyss': { icon:'🌑', name:'Путь в бездну', desc:'Достигни локации Бездна' }, + 'crafter': { icon:'⚗️', name:'Алхимик', desc:'Скрафти 5 предметов', maxProgress:5, getProgress: p => p._craftCount||0 }, + 'bestiary10': { icon:'📖', name:'Зоолог', desc:'Открой 10 записей бестиария', maxProgress:10, getProgress: p => Object.keys(p.bestiary||{}).length }, + 'no_damage': { icon:'🛡️', name:'Непробиваемый', desc:'Выиграй бой без потери HP' }, + 'crit10': { icon:'💥', name:'Снайпер', desc:'Нанеси 10 критических ударов', maxProgress:10, getProgress: p => Math.min(p._critCount||0, 10) }, + 'spells10': { icon:'✨', name:'Чародей', desc:'Используй заклинания 10 раз', maxProgress:10, getProgress: p => Math.min(p._spellCount||0, 10) }, + 'lore_all': { icon:'📜', name:'Летописец', desc:'Прочти все записки на карте' }, + 'quests10': { icon:'📋', name:'Герой', desc:'Выполни 10 квестов', maxProgress:10, getProgress: p => Math.min((p.quests||[]).filter(q=>q.done).length, 10) }, + 'enchanter': { icon:'🔮', name:'Зачарователь', desc:'Зачаруй предмет' }, + 'inv_full': { icon:'🎒', name:'Барахольщик', desc:'Собери 20 предметов в инвентаре', maxProgress:20, getProgress: p => Math.min((p.inventory||[]).length, 20) }, + }, + + checkAchievements(trigger, value) { + const p = this.player; + if (!p) return; + // Восстановить Set из любого формата (Array после загрузки, {} из старых сохранений, или уже Set) + if (!p.achievements || !(p.achievements instanceof Set)) { + p.achievements = new Set(Array.isArray(p.achievements) ? p.achievements : []); + } + const unlock = id => { + if (p.achievements.has(id)) return; + const a = this.ACHIEVEMENTS_DB[id]; if (!a) return; + p.achievements.add(id); + this.showAchievement(a); + }; + if (trigger === 'kill') { + unlock('first_blood'); + if ((p.stats.kills || 0) >= 50) unlock('kill50'); + } + if (trigger === 'kill_boss') unlock('kill_boss'); + if (trigger === 'mini_boss_kill') { + const miniBossIds = ['goblin_king','corvus','hydra','frost_giant','stone_colossus','shadow_assassin']; + const killed = miniBossIds.filter(t => (p.bestiary || {})[t] > 0); + if (killed.length >= 6) unlock('boss_all_mini'); + } + if (trigger === 'mega_boss') unlock('boss_mega'); + if (trigger === 'level') { + if (p.level >= 5) unlock('lvl5'); + if (p.level >= 10) unlock('lvl10'); + } + if (trigger === 'gold') { + if (p.gold >= 500) unlock('rich'); + if (p.gold >= 1000) unlock('gold1000'); + } + if (trigger === 'visit') { + if (!(p._visited instanceof Set)) + p._visited = new Set(Array.isArray(p._visited) ? p._visited : []); + p._visited.add(value); + if (p._visited.size >= 8) unlock('explorer'); + if (value === 'abyss') unlock('abyss'); + } + if (trigger === 'craft') { + p._craftCount = (p._craftCount || 0) + 1; + if (p._craftCount >= 5) unlock('crafter'); + } + if (trigger === 'bestiary') { + if (Object.keys(p.bestiary || {}).length >= 10) unlock('bestiary10'); + } + if (trigger === 'no_damage') unlock('no_damage'); + if (trigger === 'crit') { + p._critCount = (p._critCount || 0) + 1; + if (p._critCount >= 10) unlock('crit10'); + } + if (trigger === 'spell') { + p._spellCount = (p._spellCount || 0) + 1; + if (p._spellCount >= 10) unlock('spells10'); + } + if (trigger === 'lore_read') { + p._loreRead = (p._loreRead || 0) + 1; + if (p._loreRead >= (RPG.LORE_NOTES ? RPG.LORE_NOTES.length : 999)) unlock('lore_all'); + } + if (trigger === 'quest_done') { + const done = (p.quests || []).filter(q => q.done).length; + if (done >= 10) unlock('quests10'); + } + if (trigger === 'enchant') unlock('enchanter'); + if (trigger === 'inv_full') { + if (p.inventory.length >= 20) unlock('inv_full'); + } + }, + + showAchievement(a) { + document.getElementById('ach-text').textContent = a.icon + ' ' + a.name + ' — ' + a.desc; + const toast = document.getElementById('ach-toast'); + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 3500); + }, + + renderAchievPanel() { + const p = this.player; + if (!p) return; + const unlocked = (p.achievements instanceof Set) ? p.achievements : + new Set(Array.isArray(p.achievements) ? p.achievements : []); + const grid = document.getElementById('achiev-grid'); + if (!grid) return; + grid.innerHTML = ''; + Object.entries(this.ACHIEVEMENTS_DB).forEach(([id, a]) => { + const isUnlocked = unlocked.has(id); + const div = document.createElement('div'); + div.className = 'ach-card ' + (isUnlocked ? 'unlocked' : 'locked'); + div.innerHTML = `${a.icon} +
${a.name}
+
${a.desc}
`; + if (a.maxProgress && !isUnlocked) { + const prog = Math.min(a.maxProgress, a.getProgress(p)); + const pct = Math.round(prog / a.maxProgress * 100); + div.innerHTML += `
+
${prog}/${a.maxProgress}
`; + } + grid.appendChild(div); + }); + }, + + // ══════════════════════════════════════════ + // АНИМАЦИЯ ПОРТРЕТОВ + // ══════════════════════════════════════════ + _doPortraitBlink() { + ['player', 'enemy'].forEach(who => { + const el = document.getElementById('blink-' + who); + if (!el) return; + el.classList.add('blinking'); + setTimeout(() => el.classList.remove('blinking'), 120); + }); + }, + + // ══════════════════════════════════════════ + // КАРТА МИРА + // ══════════════════════════════════════════ + MAP_GRAPH: { + village: ['forest','dungeon','swamp','tavern'], + tavern: ['village'], + forest: ['village','cave','mountain','dungeon'], + dungeon: ['village','forest'], + cave: ['forest','mountain'], + mountain: ['forest','cave','ruins'], + swamp: ['village'], + ruins: ['mountain','abyss'], + abyss: ['ruins'], + }, + MAP_NODES: { + village: { x:240, y:195, icon:'🏘️', name:'Деревня' }, + tavern: { x:290, y:205, icon:'🍺', name:'Таверна' }, + forest: { x:155, y:140, icon:'🌲', name:'Лес' }, + dungeon: { x:170, y:250, icon:'🏰', name:'Подземелье' }, + cave: { x: 75, y:170, icon:'⛰️', name:'Пещера' }, + mountain: { x: 80, y: 90, icon:'🏔️', name:'Горы' }, + swamp: { x:330, y:250, icon:'🌿', name:'Болото' }, + ruins: { x:345, y:100, icon:'🗿', name:'Руины' }, + abyss: { x:440, y: 50, icon:'🌑', name:'Бездна' }, + }, + + renderWorldMapPanel() { + const cvs = document.getElementById('worldmap-canvas'); + if (!cvs) return; + const ctx = cvs.getContext('2d'); + const p = this.player; + const visited = p._visited instanceof Set ? p._visited : new Set(Array.isArray(p._visited) ? p._visited : []); + const cur = this.mapId; + const t = Date.now(); + + // Фон + ctx.fillStyle = '#07070f'; + ctx.fillRect(0, 0, cvs.width, cvs.height); + // Сетка + ctx.strokeStyle = '#0e0e22'; ctx.lineWidth = 1; + for (let i=0;i { + const n1 = this.MAP_NODES[from]; + tos.forEach(to => { + const key = [from,to].sort().join('-'); + if (drawn.has(key)) return; + drawn.add(key); + const n2 = this.MAP_NODES[to]; + const v1 = visited.has(from)||from===cur, v2 = visited.has(to)||to===cur; + ctx.beginPath(); ctx.moveTo(n1.x, n1.y); ctx.lineTo(n2.x, n2.y); + ctx.strokeStyle = (v1&&v2) ? '#2a2a5a' : '#12122a'; + ctx.lineWidth = (v1&&v2) ? 2 : 1; + ctx.setLineDash((v1&&v2) ? [] : [5,4]); + ctx.stroke(); ctx.setLineDash([]); + }); + }); + + // Узлы + Object.entries(this.MAP_NODES).forEach(([id, node]) => { + const isCur = id === cur; + const isVisited = visited.has(id); + const isNeighbor = (this.MAP_GRAPH[cur]||[]).includes(id); + const pulse = Math.sin(t/700) * 3; + const r = isCur ? 22 + pulse : isNeighbor ? 19 : 17; + + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI*2); + if (isCur) { + const g = ctx.createRadialGradient(node.x,node.y,0,node.x,node.y,r); + g.addColorStop(0,'#ffd700'); g.addColorStop(1,'#a06000'); + ctx.fillStyle = g; ctx.shadowColor = '#ffd700'; ctx.shadowBlur = 16; + } else if (isVisited) { + ctx.fillStyle = '#1a1a3a'; ctx.shadowBlur = 0; + } else { + ctx.fillStyle = '#0a0a18'; ctx.shadowBlur = 0; + } + ctx.fill(); + + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI*2); + ctx.strokeStyle = isCur ? '#ffd700' : isNeighbor ? '#3a6a9a' : isVisited ? '#2a2a5a' : '#151530'; + ctx.lineWidth = isCur ? 3 : isNeighbor ? 2 : 1; + ctx.stroke(); ctx.shadowBlur = 0; + + ctx.font = `${isCur?17:13}px serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.globalAlpha = (isVisited||isCur) ? 1 : 0.25; + ctx.fillText(node.icon, node.x, node.y); + ctx.font = `bold ${isCur?11:9}px Arial`; + ctx.fillStyle = isCur ? '#ffd700' : isVisited ? '#aaaacc' : '#2a2a5a'; + ctx.fillText(node.name, node.x, node.y + r + 11); + ctx.globalAlpha = 1; + }); + + // Обработчик клика + cvs.onclick = (ev) => { + const rect = cvs.getBoundingClientRect(); + const mx = (ev.clientX - rect.left) * (cvs.width / rect.width); + const my = (ev.clientY - rect.top) * (cvs.height / rect.height); + Object.entries(this.MAP_NODES).forEach(([id, node]) => { + if (Math.hypot(mx - node.x, my - node.y) < 24 && id !== cur) { + if ((this.MAP_GRAPH[cur]||[]).includes(id)) { + this.togglePanel('worldmap'); + this.travelTo(id); + } else { + this.showMsg('⛔ Сначала доберитесь туда пешком', '#f44'); + } + } + }); + }; + }, +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..23441a8 --- /dev/null +++ b/index.html @@ -0,0 +1,317 @@ + + + + + + ⚔️ Хроники Эйдона — RPG + + + + +
+ + + +
+
❤️ 100/100
+
+
💧 50/50
+
+
⭐ Ур.1
+
+
+
💰 50
+
⚔️ 10 🛡️ 5
+ 🔊 +
+
+ + +
+ + + +
🏆
+ + +
+
+ 🎒 Инвентарь + +
+ +
+ + + +
+
+ +
+
+
+
🪖
Шлем
+
⚔️
Оружие
+
🥼
Броня
+
+ +
+
🛡️
Щит
+
👖
Поножи
+
👟
Сапоги
+
+
+
+
💍 Украшение
+
+
+ +
+
+
+ +
+
+
+
+
+ + +
+
+ 🏪 Магазин + +
+
💰 Ваше золото: 0
+
+
+ + +
+
+ 📜 Квесты + +
+
+
+ + +
+
NPC
+
...
+
+
+ + +
+
+ ⚔️ БОЙ + Ваш ход +
+
+
+
+ + +
+
Враг
+
+
HP: 0/0
+
+
+
+
+ + +
+
Герой
+
+
HP: 0/0
+
+
+
+
+
+
+ + +
+
✨ Повышение уровня!
+
Выберите новый навык:
+
+
+ + +
+
+ 🌳 Дерево умений + + +
+
+
+
+ + +
+
+ 📖 Журнал лора + + +
+
+
+ + +
+
+ ✨ Зачарование + +
+
+
+
Предмет
+
+
+
+
Зачарование
+
+
+
+
+ + +
+
+ 📖 Бестиарий + +
+
+
+ + +
+
+ 🏆 Достижения + +
+
+
+ + +
+
+ ⚒️ Крафтинг + +
+
+
+
+
+
+
+
Выберите рецепт
+
+
+
+ + + + + +
+ +
+
— Хроники Эйдона —
+
ХРОНИКИ
ЭЙДОНА
+
Изометрическая ролевая игра
+ +
нажмите для продолжения
+
+
+ + + + +
+
+ 🗺️ Карта мира + +
+ +
Нажми на соседнюю локацию чтобы переместиться
+
+ + +
+
+
⏸ ПАУЗА
+ + +
+ + +
+ +
+
+ + +
+ + +
💾 Сохранено
+
+ + +
+ WASD/↑↓←→ движение   + I инвентарь   + Q квесты   + T таланты   + C крафтинг   + L журнал   + E зачарование   + B бестиарий   + H достижения   + M карта/путь   + P сохранить   + 1-5 бой   + F взаимодействие   + ESC закрыть +
+ + + + + + + + + + diff --git a/mainmenu.mp3 b/mainmenu.mp3 new file mode 100644 index 0000000..449beac Binary files /dev/null and b/mainmenu.mp3 differ diff --git a/menu.js b/menu.js new file mode 100644 index 0000000..54e99a5 --- /dev/null +++ b/menu.js @@ -0,0 +1,409 @@ +// ============================================================ +// MENU.JS — Стартовый экран: анимация, слоты, выбор класса +// ============================================================ + +let _menuSlot = null; + +window.onload = async function () { + try { + await DataLoader.load(); + } catch (e) { + // Ошибка уже показана баннером в DataLoader._showError() + return; + } + Audio.init(); + menuBuildClassGrid(); + menuBuildSlots(); + // Показываем сплэш-экран; start-screen скрыт через style="display:none" в HTML + splashStartAnim(); +}; + +// ── Переход со сплэша в главное меню ────────────────────── +function splashEnter() { + const splash = document.getElementById('splash-screen'); + if (!splash) return; + // Запускаем музыку (первый клик пользователя — браузер разрешает) + const bgm = document.getElementById('menu-bgm'); + if (bgm) bgm.play().catch(() => {}); + // Сначала показываем главное меню позади сплэша — чтобы не мелькал игровой интерфейс + const ss = document.getElementById('start-screen'); + if (ss) ss.style.display = ''; + menuStartAnim(); + // Затем плавно убираем сплэш поверх уже готового меню + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + setTimeout(() => { splash.style.display = 'none'; }, 750); +} + +// ── Анимация сплэш-экрана ───────────────────────────────── +function splashStartAnim() { + const mc = document.getElementById('splash-canvas'); + if (!mc) return; + const ctx = mc.getContext('2d'); + mc.width = 900; mc.height = 600; + + const RUNES = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚷ','ᚹ','ᛗ','ᛟ','ᚾ','ᛁ','ᛃ','ᚲ','ᛏ','ᛚ']; + + // Три слоя звёзд (параллакс) + const starLayers = [ + Array.from({length: 110}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*0.7+0.1, v: 0.04, a: Math.random()*0.4+0.1 })), + Array.from({length: 55}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*1.1+0.3, v: 0.10, a: Math.random()*0.5+0.2 })), + Array.from({length: 22}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*1.6+0.5, v: 0.18, a: Math.random()*0.6+0.3 })), + ]; + + const runes = []; + let lastRune = 0; + let angle = 0; + + function frame(ts) { + const el = document.getElementById('splash-screen'); + if (!el || el.style.display === 'none' || el.style.opacity === '0') return; + requestAnimationFrame(frame); + + // Фон + ctx.fillStyle = '#02020a'; + ctx.fillRect(0, 0, 900, 600); + + // Центральное свечение + const cx = 450, cy = 288; + const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, 420); + glow.addColorStop(0, 'rgba(35,8,75,0.55)'); + glow.addColorStop(0.45,'rgba(15,4,38,0.28)'); + glow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, 900, 600); + + // Звёзды + starLayers.forEach((layer, li) => { + layer.forEach(s => { + s.y -= s.v; + if (s.y < -2) { s.y = 602; s.x = Math.random() * 900; } + const tw = 0.65 + Math.sin(ts / 900 + s.x * 0.05) * 0.35; + ctx.fillStyle = li === 2 + ? `rgba(255,220,140,${s.a * tw})` + : `rgba(190,190,255,${s.a * tw})`; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); + }); + }); + + // Магический круг + angle += 0.0015; + ctx.save(); + ctx.translate(cx, cy); + + // Внешнее кольцо + ctx.rotate(angle); + ctx.beginPath(); ctx.arc(0, 0, 215, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(80,40,160,0.18)'; ctx.lineWidth = 1; ctx.stroke(); + + // Внутреннее кольцо + ctx.rotate(-angle * 2.3); + ctx.beginPath(); ctx.arc(0, 0, 155, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(100,55,185,0.14)'; ctx.lineWidth = 1; ctx.stroke(); + + // Шестиугольник + ctx.rotate(angle * 1.5); + ctx.strokeStyle = 'rgba(110,65,195,0.10)'; ctx.lineWidth = 0.8; + for (let i = 0; i < 6; i++) { + const a1 = (i / 6) * Math.PI * 2; + const a2 = ((i + 2) / 6) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(Math.cos(a1) * 155, Math.sin(a1) * 155); + ctx.lineTo(Math.cos(a2) * 155, Math.sin(a2) * 155); + ctx.stroke(); + } + + // Точки на кольце + ctx.rotate(-angle * 0.5); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2 + angle * 3; + const px = Math.cos(a) * 215, py = Math.sin(a) * 215; + ctx.beginPath(); ctx.arc(px, py, 2.5, 0, Math.PI * 2); + ctx.fillStyle = `rgba(150,80,220,${0.4 + Math.sin(ts/600 + i) * 0.2})`; + ctx.fill(); + } + ctx.restore(); + + // Лучи света + ctx.save(); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2 + angle * 1.8; + const pulse = 0.06 + Math.sin(ts / 1100 + i) * 0.02; + const grad = ctx.createLinearGradient(cx, cy, + cx + Math.cos(a) * 380, cy + Math.sin(a) * 380); + grad.addColorStop(0, `rgba(110,50,210,${pulse})`); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(cx, cy); + const w = 0.07; + ctx.arc(cx, cy, 380, a - w, a + w); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + + // Пульсирующая сфера в центре + const orbP = Math.sin(ts / 800); + const orb = ctx.createRadialGradient(cx, cy, 0, cx, cy, 65 + orbP * 6); + orb.addColorStop(0, `rgba(190,130,255,${0.14 + orbP * 0.05})`); + orb.addColorStop(0.5, `rgba(90,40,190,${0.07 + orbP * 0.02})`); + orb.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = orb; + ctx.beginPath(); ctx.arc(cx, cy, 65 + orbP * 6, 0, Math.PI * 2); ctx.fill(); + + // Силуэты мечей + ctx.save(); + ctx.globalAlpha = 0.09 + Math.sin(ts / 3200) * 0.02; + ctx.strokeStyle = '#c8a020'; ctx.lineWidth = 1.6; + + // Левый меч + ctx.save(); + ctx.translate(185, 295); + ctx.rotate(-0.28 + Math.sin(ts / 4000) * 0.015); + ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(0, 110); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-22, -55); ctx.lineTo(22, -55); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(-6, -118); ctx.moveTo(0, -140); ctx.lineTo(6, -118); ctx.stroke(); + ctx.restore(); + + // Правый меч + ctx.save(); + ctx.translate(715, 295); + ctx.rotate(0.28 - Math.sin(ts / 4000) * 0.015); + ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(0, 110); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-22, -55); ctx.lineTo(22, -55); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(-6, -118); ctx.moveTo(0, -140); ctx.lineTo(6, -118); ctx.stroke(); + ctx.restore(); + ctx.restore(); + + // Руны + if (ts - lastRune > 420) { + lastRune = ts; + const col = Math.random() < 0.35 ? '#c8a020' : Math.random() < 0.5 ? '#9966cc' : '#4a4aaa'; + runes.push({ + x: 40 + Math.random() * 820, + y: 620, + ch: RUNES[Math.floor(Math.random() * RUNES.length)], + a: 0.75, + vy: -(0.38 + Math.random() * 0.52), + sz: 10 + Math.random() * 15, + col, + dx: (Math.random() - 0.5) * 0.28, + }); + } + runes.forEach(r => { r.y += r.vy; r.x += r.dx; r.a -= 0.0022; }); + for (let i = runes.length - 1; i >= 0; i--) { + const r = runes[i]; + if (r.a <= 0 || r.y < -10) { runes.splice(i, 1); continue; } + ctx.globalAlpha = r.a; + ctx.fillStyle = r.col; + ctx.font = `${r.sz}px serif`; + ctx.textAlign = 'left'; + ctx.fillText(r.ch, r.x, r.y); + } + ctx.globalAlpha = 1; + ctx.textAlign = 'left'; + } + requestAnimationFrame(frame); +} + +// Вызывается из любой точки взаимодействия с меню (браузер разрешает play() только после клика) +function _menuStartMusic() { + const el = document.getElementById('menu-bgm'); + if (el && el.paused) el.play().catch(() => {}); +} + +// Вызывается из game.js при старте игры +function _stopMenuBgm() { + const el = document.getElementById('menu-bgm'); + if (el && !el.paused) { el.pause(); el.currentTime = 0; } +} + +// ── Анимация фона главного меню ─────────────────────────── +function menuStartAnim() { + const mc = document.getElementById('menu-canvas'); + if (!mc) return; + const ctx = mc.getContext('2d'); + mc.width = 900; mc.height = 600; + + const stars = Array.from({ length: 180 }, () => ({ + x: Math.random() * 900, + y: Math.random() * 600, + r: Math.random() * 1.4 + 0.2, + vx: (Math.random() - .5) * 0.1, + vy: (Math.random() - .5) * 0.05, + a: Math.random() * 0.7 + 0.2, + })); + + const runes = []; + const RUNES = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚷ','ᚹ','ᛗ','ᛟ','ᚾ','ᛁ','ᛃ','ᚲ','ᛏ','ᛚ']; + let _lr = 0; + + function frame(ts) { + const ss = document.getElementById('start-screen'); + if (!ss || ss.style.display === 'none') return; + requestAnimationFrame(frame); + + ctx.fillStyle = '#03030b'; + ctx.fillRect(0, 0, 900, 600); + + const grd = ctx.createRadialGradient(450, 300, 0, 450, 300, 430); + grd.addColorStop(0, 'rgba(50,20,90,0.28)'); + grd.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, 900, 600); + + // Звёзды + stars.forEach(s => { + s.x = (s.x + s.vx + 900) % 900; + s.y = (s.y + s.vy + 600) % 600; + ctx.fillStyle = `rgba(200,200,255,${s.a})`; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); + }); + + // Руны + if (ts - _lr > 650) { + _lr = ts; + runes.push({ + x: 80 + Math.random() * 740, + y: 590 + Math.random() * 20, + ch: RUNES[Math.floor(Math.random() * RUNES.length)], + a: 0.65, + vy: -(0.35 + Math.random() * 0.35), + sz: 11 + Math.random() * 13, + col: Math.random() < 0.3 ? '#c8a020' : '#4848a8', + }); + } + runes.forEach(r => { r.y += r.vy; r.a -= 0.0025; }); + for (let i = runes.length - 1; i >= 0; i--) { + const r = runes[i]; + if (r.a <= 0) { runes.splice(i, 1); continue; } + ctx.globalAlpha = r.a; + ctx.fillStyle = r.col; + ctx.font = `${r.sz}px serif`; + ctx.fillText(r.ch, r.x, r.y); + } + ctx.globalAlpha = 1; + } + requestAnimationFrame(frame); +} + +// ── Слоты сохранений ────────────────────────────────────── +function menuBuildSlots() { + const cont = document.getElementById('s-slots'); + if (!cont) return; + cont.innerHTML = ''; + for (let sl = 0; sl < 3; sl++) { + const meta = RPG.getSaveMeta(sl); + const card = document.createElement('div'); + card.className = 'slot-card ' + (meta ? 'filled' : 'empty'); + card.id = 'slot-card-' + sl; + + if (meta) { + card.innerHTML = ` +
Слот ${sl + 1}
+
${meta.icon}
+
${meta.className}
+
+ ⭐ Уровень ${meta.level}
+ 📍 ${meta.mapName} · День ${meta.days}
+ ⚔️ Убийств: ${meta.kills}
+ 🕐 ${meta.playTime} +
+
${meta.date} ${meta.saveTime}
+
▶ Играть
+
🗑 Удалить
`; + card.onclick = e => { + if (e.target.classList.contains('sc-del')) return; + _menuStartMusic(); + Game.loadAndStart(sl); + }; + } else { + card.innerHTML = ` +
Слот ${sl + 1}
+
📂
+
Пусто
+
Нажмите чтобы
начать новую игру
`; + card.onclick = () => menuShowClassSelect(sl); + } + cont.appendChild(card); + } +} + +// ── Выбор класса ────────────────────────────────────────── +function menuBuildClassGrid() { + const grid = document.getElementById('cls-grid'); + if (!grid) return; + Object.entries(RPG.CLASSES).forEach(([id, cls]) => { + const btn = document.createElement('button'); + btn.className = 'cls-btn'; + btn.innerHTML = ` +
${cls.icon}
+
${cls.name}
+
${cls.desc}
+
HP:${cls.hp} MP:${cls.mp} СИЛ:${cls.str} ЗАЩ:${cls.def}
`; + btn.onclick = () => { + const slot = _menuSlot !== null ? _menuSlot : _pickFreeSlot(); + Game.start(id, slot); + }; + grid.appendChild(btn); + }); +} + +function _pickFreeSlot() { + for (let i = 0; i < 3; i++) if (!RPG.hasSave(i)) return i; + return 0; +} + +// ── Навигация меню ──────────────────────────────────────── +function menuShowClassSelect(slot) { + _menuStartMusic(); + _menuSlot = slot; + document.getElementById('menu-main').style.display = 'none'; + document.getElementById('menu-class').style.display = 'flex'; + const hint = document.getElementById('cls-slot-hint'); + if (hint) hint.textContent = slot !== null ? `Новая игра — Слот ${slot + 1}` : 'Выберите класс'; +} + +function menuBack() { + _menuStartMusic(); + document.getElementById('menu-class').style.display = 'none'; + document.getElementById('menu-main').style.display = 'flex'; + _menuSlot = null; +} + +function menuDeleteSlot(slot, event) { + event.stopPropagation(); + if (!confirm(`Удалить сохранение в слоте ${slot + 1}?`)) return; + RPG.deleteSave(slot); + menuBuildSlots(); +} + +// ── Выбор папки сохранений ──────────────────────────────── +async function menuSelectSaveFolder() { + if (typeof SaveFS === 'undefined') { + alert('File System API недоступен в этом браузере.'); + return; + } + if (!SaveFS.isSupported()) { + alert('Ваш браузер не поддерживает сохранение в файлы. Используйте Chrome/Edge.'); + return; + } + const ok = await SaveFS.selectDir(); + if (ok) { + const name = SaveFS.getDirName(); + const btn = document.getElementById('btn-save-folder'); + if (btn) btn.textContent = '📁 ' + name; + menuBuildSlots(); // обновить слоты из файлов + } +} + +// Экспортировать сохранения в уже выбранную папку +async function menuExportSaves() { + if (typeof SaveFS === 'undefined' || !SaveFS.hasDir()) { + alert('Сначала выберите папку!'); + return; + } + await SaveFS.exportAll(); + alert('Сохранения экспортированы в папку: ' + SaveFS.getDirName()); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6c38faa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3407 @@ +{ + "name": "chronicles-of-eydon", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chronicles-of-eydon", + "version": "1.0.0", + "devDependencies": { + "live-server": "^1.2.2", + "serve": "^14.2.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "license": "ISC", + "dependencies": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "node_modules/anymatch/node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/apache-crypt": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/apache-crypt/-/apache-crypt-1.2.6.tgz", + "integrity": "sha512-072WetlM4blL8PREJVeY+WHiUh1R5VNt2HfceGS8aKqttPHcmqE5pkKuXPz/ULmJOFkc8Hw3kfKl6vy7Qka6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unix-crypt-td-js": "^1.1.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/apache-md5": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/apache-md5/-/apache-md5-1.1.8.tgz", + "integrity": "sha512-FCAJojipPn0bXjuEpjOOOMN8FZDkxfWWp4JGN9mifU2IhxvKyXZYqpzPHdnTSUpmPDy+tsslB6Z1g+Vg6nVbYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async-each": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", + "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + }, + "optionalDependencies": { + "fsevents": "^1.2.7" + } + }, + "node_modules/class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extglob/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-cache": "^0.2.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + } + }, + "node_modules/glob-parent/node_modules/is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-auth": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/http-auth/-/http-auth-3.1.3.tgz", + "integrity": "sha512-Jbx0+ejo2IOx+cRUYAGS1z6RGc6JfYUNkysZM4u4Sfk1uLlGv814F7/PIjQQAuThLdAWxb74JMGd5J8zex1VQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "apache-crypt": "^1.1.2", + "apache-md5": "^1.0.6", + "bcryptjs": "^2.3.0", + "uuid": "^3.0.0" + }, + "engines": { + "node": ">=4.6.1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-accessor-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-data-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-descriptor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/live-server": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/live-server/-/live-server-1.2.2.tgz", + "integrity": "sha512-t28HXLjITRGoMSrCOv4eZ88viHaBVIjKjdI5PO92Vxlu+twbk6aE0t7dVIaz6ZWkjPilYFV6OSdMYl9ybN2B4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^2.0.4", + "colors": "1.4.0", + "connect": "^3.6.6", + "cors": "latest", + "event-stream": "3.3.4", + "faye-websocket": "0.11.x", + "http-auth": "3.1.x", + "morgan": "^1.9.1", + "object-assign": "latest", + "opn": "latest", + "proxy-middleware": "latest", + "send": "latest", + "serve-index": "^1.9.1" + }, + "bin": { + "live-server": "live-server.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, + "node_modules/map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-6.0.0.tgz", + "integrity": "sha512-I9PKfIZC+e4RXZ/qr1RhgyCnGgYX0UEIlXgWnCOVACIvFgaC9rz6Won7xbdhoHrd8IIhV7YEpHjreNUNkqCGkQ==", + "deprecated": "The package has been renamed to `open`", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-middleware": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/proxy-middleware/-/proxy-middleware-0.15.0.tgz", + "integrity": "sha512-EGCG8SeoIRVMhsqHQUdDigB2i7qU7fCsWASwn54+nPutYO8n4q6EiwMzyfWlC+dzRFExP+kvcnDFdBDHoZBU7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex-not/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex-not/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", + "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.12.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.6", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.2", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "license": "MIT", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "node_modules/source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "deprecated": "See https://github.com/lydell/source-map-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unix-crypt-td-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", + "deprecated": "Please see https://github.com/lydell/urix#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..30b9ba2 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "chronicles-of-eydon", + "version": "1.0.0", + "description": "Хроники Эйдона — изометрическая RPG на HTML5 Canvas", + "private": true, + "scripts": { + "start": "serve . -p 8080 --no-clipboard", + "dev": "live-server --port=8080 --open=index.html" + }, + "devDependencies": { + "live-server": "^1.2.2", + "serve": "^14.2.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..e3681e2 --- /dev/null +++ b/renderer.js @@ -0,0 +1,2173 @@ +// ============================================================ +// RENDERER.JS — Изометрический рендерер с частицами +// ============================================================ + +const Renderer = { + canvas: null, + ctx: null, + TW: 64, // ширина тайла + TH: 32, // высота тайла + TD: 18, // глубина тайла (боковые грани) + OY: 85, // смещение по Y от верха + + particles: [], + floatingTexts: [], + camera: { x: 0, y: 0 }, + lightCanvas: null, + lightCtx: null, + shake: { x: 0, y: 0, power: 0 }, + patterns: {}, + _flash: null, + _currentMapId: 'village', + + // ────────────────────────────────── + // Типы тайлов + // ────────────────────────────────── + TILES: { + 0: { name: 'Трава', top: '#3d7a3c', l: '#2d5a2c', r: '#1d4020', pat: 'grass' }, + 1: { name: 'Вода', top: '#2a5a8c', l: '#1a4a7c', r: '#0d3560', anim: true }, + 2: { name: 'Камень', top: '#5a5a5a', l: '#484848', r: '#383838', pat: 'stone' }, + 3: { name: 'Песок', top: '#c2a55e', l: '#b09550', r: '#9e8540', pat: 'sand' }, + 4: { name: 'Стена', top: '#4a3828', l: '#3a2818', r: '#2a1808', pat: 'brick', solid: true }, + 5: { name: 'Дерево', top: '#7a5010', l: '#6a4000', r: '#5a3000', pat: 'wood' }, + 6: { name: 'Лава', top: '#cc4400', l: '#bb3300', r: '#aa2200', anim: true }, + 7: { name: 'Снег', top: '#dde0ee', l: '#cdd0de', r: '#bdc0ce', pat: 'snow' }, + 8: { name: 'Земля', top: '#7a4a20', l: '#6a3a12', r: '#5a2a08', pat: 'dirt' }, + 9: { name: 'Булыжник', top: '#606060', l: '#505050', r: '#404040', pat: 'cobble' }, + 10: { name: 'Болото', top: '#3e5a30', l: '#2e4a20', r: '#1e3a10' }, + 11: { name: 'Лёд', top: '#90b8d8', l: '#80a8c8', r: '#7098b8' }, + 12: { name: 'Бездна', top: '#060008', l: '#040006', r: '#020004', pat: 'void' }, + }, + + // ────────────────────────────────── + // Инициализация + // ────────────────────────────────── + init(canvasId) { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + this.ctx.imageSmoothingEnabled = false; + this.buildPatterns(); + this.initLightCanvas(); + }, + + // ────────────────────────────────── + // Координатные преобразования + // ────────────────────────────────── + toIso(gx, gy) { + return { + x: (gx - gy) * (this.TW / 2) + this.canvas.width / 2 + this.shake.x + this.camera.x, + y: (gx + gy) * (this.TH / 2) + this.OY + this.shake.y + this.camera.y + }; + }, + fromIso(sx, sy) { + const ax = sx - this.canvas.width / 2 - this.shake.x - this.camera.x; + const ay = sy - this.OY - this.shake.y - this.camera.y; + const hw = this.TW / 2, hh = this.TH / 2; + return { + x: Math.floor((ax / hw + ay / hh) / 2), + y: Math.floor((ay / hh - ax / hw) / 2) + }; + }, + + // ────────────────────────────────── + // Процедурные текстуры + // ────────────────────────────────── + initLightCanvas() { + this.lightCanvas = document.createElement('canvas'); + this.lightCanvas.width = this.canvas.width; + this.lightCanvas.height = this.canvas.height; + this.lightCtx = this.lightCanvas.getContext('2d'); + }, + + buildPatterns() { + this.patterns.grass = this._makeGrass(); + this.patterns.stone = this._makeStone(); + this.patterns.sand = this._makeSand(); + this.patterns.brick = this._makeBrick(); + this.patterns.wood = this._makeWood(); + this.patterns.snow = this._makeSnow(); + this.patterns.dirt = this._makeDirt(); + this.patterns.cobble = this._makeCobble(); + this.patterns.void = this._makeVoid(); + }, + _pat(w, h, fn) { + const c = document.createElement('canvas'); c.width = w; c.height = h; + const x = c.getContext('2d'); fn(x, w, h); + return this.ctx.createPattern(c, 'repeat'); + }, + _makeGrass() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#3d7a3c'; p.fillRect(0,0,32,32); + p.strokeStyle = '#4a9048'; p.lineWidth = 1; + for (let i=0;i<18;i++) { + const x=Math.random()*32, y=Math.random()*32, h=3+Math.random()*5; + p.beginPath(); p.moveTo(x,y); p.lineTo(x+(Math.random()*4-2),y-h); p.stroke(); + } + p.fillStyle = '#2d5a2c'; + for (let i=0;i<4;i++) { p.beginPath(); p.arc(Math.random()*32,Math.random()*32,1+Math.random()*2,0,Math.PI*2); p.fill(); } + }); + }, + _makeStone() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#5a5a5a'; p.fillRect(0,0,32,32); + p.strokeStyle = '#484848'; p.lineWidth = 1; + p.strokeRect(0,0,16,16); p.strokeRect(16,0,16,16); + p.strokeRect(0,16,16,16); p.strokeRect(16,16,16,16); + p.fillStyle = '#4a4a4a'; + p.fillRect(4,4,3,2); p.fillRect(20,9,2,3); p.fillRect(7,22,4,2); + }); + }, + _makeSand() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#c2a55e'; p.fillRect(0,0,32,32); + p.fillStyle = '#d4b870'; + for (let i=0;i<30;i++) p.fillRect(Math.random()*32,Math.random()*32,1,1); + p.fillStyle = '#b09550'; + for (let i=0;i<20;i++) p.fillRect(Math.random()*32,Math.random()*32,1,1); + }); + }, + _makeBrick() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#4a3828'; p.fillRect(0,0,32,32); + p.strokeStyle = '#2a1808'; p.lineWidth = 2; + p.beginPath(); + p.moveTo(0,8); p.lineTo(32,8); p.moveTo(0,24); p.lineTo(32,24); + p.moveTo(16,0); p.lineTo(16,8); p.moveTo(8,8); p.lineTo(8,24); + p.moveTo(24,8); p.lineTo(24,24); p.moveTo(16,24); p.lineTo(16,32); + p.stroke(); + }); + }, + _makeWood() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#7a5010'; p.fillRect(0,0,32,32); + p.strokeStyle = '#6a4000'; p.lineWidth = 1; + for (let i=0;i<8;i++) { p.beginPath(); p.moveTo(i*4,0); p.lineTo(i*4,32); p.stroke(); } + }); + }, + _makeSnow() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#dde0ee'; p.fillRect(0,0,32,32); + p.fillStyle = '#c8cce0'; + for (let i=0;i<12;i++) { p.beginPath(); p.arc(Math.random()*32,Math.random()*32,1+Math.random()*1.5,0,Math.PI*2); p.fill(); } + }); + }, + _makeDirt() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#7a4a20'; p.fillRect(0,0,32,32); + p.fillStyle = '#6a3a12'; + for (let i=0;i<12;i++) { p.beginPath(); p.arc(Math.random()*32,Math.random()*32,1+Math.random()*2,0,Math.PI*2); p.fill(); } + }); + }, + _makeCobble() { + return this._pat(32, 32, (p) => { + p.fillStyle = '#606060'; p.fillRect(0,0,32,32); + const stones = [[8,8,7],[24,8,6],[8,24,6],[24,24,7],[16,16,5]]; + p.fillStyle = '#505050'; + stones.forEach(([x,y,r]) => { p.beginPath(); p.arc(x,y,r,0,Math.PI*2); p.fill(); }); + p.strokeStyle = '#383838'; p.lineWidth = 1; + stones.forEach(([x,y,r]) => { p.beginPath(); p.arc(x,y,r,0,Math.PI*2); p.stroke(); }); + }); + }, + _makeVoid() { + return this._pat(16, 16, (p) => { + p.fillStyle = '#060008'; p.fillRect(0,0,16,16); + p.fillStyle = '#1a0028'; + [[3,3],[11,7],[6,12],[13,2],[1,10]].forEach(([px,py]) => { + p.beginPath(); p.arc(px,py,1.2,0,Math.PI*2); p.fill(); + }); + p.fillStyle = '#0d0015'; + [[8,4],[4,11],[12,9]].forEach(([px,py]) => { + p.beginPath(); p.arc(px,py,0.8,0,Math.PI*2); p.fill(); + }); + }); + }, + + // ────────────────────────────────── + // Рисование тайла + // ────────────────────────────────── + drawTile(gx, gy, type, hover, time) { + const p = this.toIso(gx, gy); + const t = this.TILES[type] || this.TILES[0]; + const hw = this.TW / 2, hh = this.TH / 2; + + let topColor = t.top; + if (t.anim) { + const wave = Math.sin(time / 500 + gx + gy) * 12; + topColor = this._adj(t.top, wave); + } + + // Верхняя грань + this.ctx.beginPath(); + this.ctx.moveTo(p.x, p.y - hh); + this.ctx.lineTo(p.x + hw, p.y); + this.ctx.lineTo(p.x, p.y + hh); + this.ctx.lineTo(p.x - hw, p.y); + this.ctx.closePath(); + + if (t.pat && this.patterns[t.pat]) { + this.ctx.save(); + this.ctx.clip(); + this.ctx.save(); + this.ctx.translate(p.x, p.y); + this.ctx.scale(1, 0.5); + this.ctx.rotate(Math.PI / 4); + this.ctx.fillStyle = this.patterns[t.pat]; + this.ctx.fillRect(-60, -60, 120, 120); + this.ctx.restore(); + this.ctx.restore(); + } else { + this.ctx.fillStyle = topColor; + this.ctx.fill(); + } + + this.ctx.strokeStyle = hover ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.35)'; + this.ctx.lineWidth = hover ? 2 : 0.8; + this.ctx.beginPath(); + this.ctx.moveTo(p.x, p.y - hh); + this.ctx.lineTo(p.x + hw, p.y); + this.ctx.lineTo(p.x, p.y + hh); + this.ctx.lineTo(p.x - hw, p.y); + this.ctx.closePath(); + this.ctx.stroke(); + + // Левая грань + this.ctx.beginPath(); + this.ctx.moveTo(p.x - hw, p.y); + this.ctx.lineTo(p.x, p.y + hh); + this.ctx.lineTo(p.x, p.y + hh + this.TD); + this.ctx.lineTo(p.x - hw, p.y + this.TD); + this.ctx.closePath(); + this.ctx.fillStyle = t.l; + this.ctx.fill(); + this.ctx.strokeStyle = 'rgba(0,0,0,0.4)'; + this.ctx.lineWidth = 0.8; + this.ctx.stroke(); + + // Правая грань + this.ctx.beginPath(); + this.ctx.moveTo(p.x + hw, p.y); + this.ctx.lineTo(p.x, p.y + hh); + this.ctx.lineTo(p.x, p.y + hh + this.TD); + this.ctx.lineTo(p.x + hw, p.y + this.TD); + this.ctx.closePath(); + this.ctx.fillStyle = t.r; + this.ctx.fill(); + this.ctx.stroke(); + + // Украшения + this._tileDeco(gx, gy, type, p, time); + + if (hover) { + this.ctx.fillStyle = 'rgba(255,255,255,0.7)'; + this.ctx.font = '10px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.fillText(t.name, p.x, p.y + 4); + } + }, + + _tileDeco(gx, gy, type, p, time) { + const seed = (gx * 7 + gy * 13) % 10; + if (type === 0 && seed < 2) this._drawTree(p.x, p.y, 0.55 + seed * 0.12); + if (type === 0 && seed >= 2 && seed < 4) this._drawFlower(p.x, p.y, seed); + if (type === 1) { // вода — рябь + const ctx = this.ctx; + const phase = time/300 + gx*0.9 + gy*1.1; + // первое кольцо ряби + const ox1 = Math.sin(phase)*4; + ctx.fillStyle = `rgba(255,255,255,${0.07 + 0.05*Math.sin(phase)})`; + ctx.beginPath(); ctx.ellipse(p.x + ox1, p.y, 8, 3, 0, 0, Math.PI*2); ctx.fill(); + // второе кольцо (сдвинуто по фазе и позиции) + const phase2 = time/240 + gx*1.3 + gy*0.7 + 2.1; + const ox2 = Math.cos(phase2)*3; + ctx.fillStyle = `rgba(180,220,255,${0.05 + 0.04*Math.abs(Math.sin(phase2))})`; + ctx.beginPath(); ctx.ellipse(p.x + ox2, p.y - 2, 5, 2, 0, 0, Math.PI*2); ctx.fill(); + // блик-искра + if (seed < 3) { + const sparkA = 0.3 + 0.3*Math.abs(Math.sin(time/180 + gx*2.5)); + ctx.fillStyle = `rgba(255,255,255,${sparkA})`; + ctx.beginPath(); ctx.arc(p.x + (seed-1)*5, p.y - 1, 1.2, 0, Math.PI*2); ctx.fill(); + } + } + if (type === 6) { // лава — пузыри + свечение + дрожание + const ctx = this.ctx; + // тепловое свечение (halo) + const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 14); + glow.addColorStop(0, `rgba(255,80,0,${0.18 + 0.08*Math.abs(Math.sin(time/350+gx))})`); + glow.addColorStop(1, 'rgba(255,80,0,0)'); + ctx.fillStyle = glow; + ctx.beginPath(); ctx.ellipse(p.x, p.y, 14, 6, 0, 0, Math.PI*2); ctx.fill(); + // несколько пузырей по детерминированным смещениям + for (let b = 0; b < 3; b++) { + const bPhase = time/200 + gx*3.1 + gy*2.3 + b*2.09; + const bx = p.x + (seed%3)*5 - 4 + b*4; + const by = p.y - 4 + Math.sin(bPhase)*3; + const br = 1.5 + Math.abs(Math.sin(bPhase*1.3))*1.5; + const ba = 0.35 + 0.3*Math.abs(Math.sin(bPhase)); + if (Math.abs(Math.sin(bPhase)) > 0.4) { // пузырь виден часть цикла + ctx.fillStyle = `rgba(255,${120 + Math.floor(60*Math.abs(Math.sin(bPhase)))},0,${ba})`; + ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI*2); ctx.fill(); + } + } + } + }, + + _drawTree(x, y, s = 1) { + const th = 14 * s, cr = 18 * s; + this.ctx.fillStyle = '#4a2808'; + this.ctx.fillRect(x - 3*s, y - th, 6*s, th + 4); + this.ctx.fillStyle = '#1a4818'; + this.ctx.beginPath(); + this.ctx.moveTo(x, y - th - cr); + this.ctx.lineTo(x - cr*0.8, y - th*0.4); + this.ctx.lineTo(x + cr*0.8, y - th*0.4); + this.ctx.closePath(); + this.ctx.fill(); + this.ctx.fillStyle = '#2a6028'; + this.ctx.beginPath(); + this.ctx.moveTo(x, y - th - cr*1.2); + this.ctx.lineTo(x - cr*0.6, y - th - cr*0.3); + this.ctx.lineTo(x + cr*0.6, y - th - cr*0.3); + this.ctx.closePath(); + this.ctx.fill(); + }, + + _drawFlower(x, y, seed) { + const cols = ['#ff5555','#ffff44','#ff55ff','#55ffff']; + this.ctx.strokeStyle = '#2a4a2a'; this.ctx.lineWidth = 1; + this.ctx.beginPath(); this.ctx.moveTo(x,y); this.ctx.lineTo(x,y-8); this.ctx.stroke(); + this.ctx.fillStyle = cols[seed % cols.length]; + for (let i=0;i<5;i++) { + const a = i/5*Math.PI*2; + this.ctx.beginPath(); this.ctx.arc(x+Math.cos(a)*3, y-8+Math.sin(a)*3, 2, 0, Math.PI*2); this.ctx.fill(); + } + this.ctx.fillStyle = '#ffff00'; + this.ctx.beginPath(); this.ctx.arc(x,y-8,1.5,0,Math.PI*2); this.ctx.fill(); + }, + + // ────────────────────────────────── + // Рисование всей карты + // ────────────────────────────────── + drawMap(map, hoverTile, time) { + const tiles = []; + for (let y=0; y (a.x+a.y)-(b.x+b.y)); + tiles.forEach(tile => { + const hover = hoverTile && hoverTile.x===tile.x && hoverTile.y===tile.y; + this.drawTile(tile.x, tile.y, tile.t, hover, time); + }); + }, + + // ────────────────────────────────── + // Игрок (спрайтовая анимация) + // ────────────────────────────────── + drawPlayer(player, time) { + // ИСПРАВЛЕНО: mp_move (прогресс шага 0→1), а НЕ mp (мана) + const wx = player.isMoving + ? player.x + (player.tx - player.x) * player.mp_move + : player.x; + const wy = player.isMoving + ? player.y + (player.ty - player.y) * player.mp_move + : player.y; + const p = this.toIso(wx, wy); + + // Анимация ходьбы + const wc = player.isMoving ? time / 130 : 0; + const legSwing = Math.sin(wc) * 5; // левая нога вперёд → правая назад + const armSwing = -Math.sin(wc) * 6; // руки противофазны ногам + const bodyRise = player.isMoving ? Math.abs(Math.sin(wc)) * 2 : 0; // подъём тела при шаге + const breathe = player.isMoving ? 0 : Math.sin(time / 2200) * 1.2; // дыхание в покое + + const CLASS_COLORS = { + warrior:'#e74c3c', mage:'#3498db', archer:'#27ae60', + paladin:'#f1c40f', necromancer:'#8e44ad', berserker:'#e67e22', druid:'#2ecc71' + }; + const bodyCol = CLASS_COLORS[player.class] || '#e74c3c'; + const darkCol = this._adj(bodyCol, -35); + const bx = p.x; + const by = p.y + bodyRise; // Y тела с учётом шага + const hb = breathe; // смещение головы (дыхание) + + // ── ТЕНЬ ── + this.ctx.beginPath(); + this.ctx.ellipse(bx, p.y + 8, 18, 9, 0, 0, Math.PI * 2); + this.ctx.fillStyle = 'rgba(0,0,0,0.28)'; + this.ctx.fill(); + + // ── ЛЕВАЯ НОГА ── + this.ctx.fillStyle = darkCol; + this.ctx.fillRect(bx - 7, by - 4 + legSwing, 5, 13); + this.ctx.fillStyle = '#2a1808'; + this.ctx.fillRect(bx - 8, by + 7 + legSwing, 7, 4); + + // ── ПРАВАЯ НОГА ── + this.ctx.fillStyle = darkCol; + this.ctx.fillRect(bx + 2, by - 4 - legSwing, 5, 13); + this.ctx.fillStyle = '#2a1808'; + this.ctx.fillRect(bx + 1, by + 7 - legSwing, 7, 4); + + // ── ТЕЛО ── + this.ctx.fillStyle = bodyCol; + this.ctx.fillRect(bx - 9, by - 24 + hb, 18, 20); + this.ctx.fillStyle = this._adj(darkCol, -10); + this.ctx.fillRect(bx - 9, by - 8 + hb, 18, 3); // пояс + this.ctx.strokeStyle = darkCol; + this.ctx.lineWidth = 1.5; + this.ctx.strokeRect(bx - 9, by - 24 + hb, 18, 20); + + // ── ЛЕВАЯ РУКА ── + this.ctx.fillStyle = this._adj(bodyCol, -15); + this.ctx.fillRect(bx - 14, by - 23 + hb - armSwing, 5, 12); + this.ctx.fillStyle = '#ffcc99'; + this.ctx.fillRect(bx - 14, by - 13 + hb - armSwing, 5, 5); + + // ── ПРАВАЯ РУКА (оружие) ── + this.ctx.fillStyle = this._adj(bodyCol, -15); + this.ctx.fillRect(bx + 9, by - 23 + hb + armSwing, 5, 12); + this.ctx.fillStyle = '#ffcc99'; + this.ctx.fillRect(bx + 9, by - 13 + hb + armSwing, 5, 5); + + // ── ОРУЖИЕ В РУКЕ (по классу) ── + this._drawPlayerWeapon(bx, by + hb, player.class, armSwing, time); + + // ── ГОЛОВА ── + const hy = by - 38 + hb; + this.ctx.beginPath(); + this.ctx.arc(bx, hy, 10, 0, Math.PI * 2); + this.ctx.fillStyle = '#ffcc99'; + this.ctx.fill(); + this.ctx.strokeStyle = '#cc9966'; + this.ctx.lineWidth = 1.5; + this.ctx.stroke(); + + // ── ГОЛОВНОЙ УБОР / ПРИЧЁСКА (зависит от класса) ── + this._drawPlayerHead(bx, hy, player.class, bodyCol, player.hairColor || '#3a2510', time); + + // ── ГЛАЗА (смотрят в сторону последнего движения) ── + const facing = player.facing || 'down'; + const eox = facing === 'right' ? 2 : facing === 'left' ? -2 : 0; + this.ctx.fillStyle = '#1a1a2a'; + this.ctx.beginPath(); + this.ctx.arc(bx - 3.5 + eox, hy + 1, 1.8, 0, Math.PI * 2); + this.ctx.arc(bx + 3.5 + eox, hy + 1, 1.8, 0, Math.PI * 2); + this.ctx.fill(); + this.ctx.fillStyle = 'rgba(255,255,255,0.7)'; + this.ctx.beginPath(); + this.ctx.arc(bx - 2.5 + eox, hy, 0.7, 0, Math.PI * 2); + this.ctx.arc(bx + 4.5 + eox, hy, 0.7, 0, Math.PI * 2); + this.ctx.fill(); + + // ── УРОВЕНЬ ── + this.ctx.fillStyle = '#ffd700'; + this.ctx.font = 'bold 10px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('Lv.' + player.level, bx, by - 53 + hb); + + // ── АУРА (маг / некромант) ── + if (player.class === 'mage' || player.class === 'necromancer') { + const auraCol = player.class === 'mage' ? 'rgba(52,152,219,0.35)' : 'rgba(142,68,173,0.35)'; + this.ctx.strokeStyle = auraCol; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + this.ctx.arc(bx, by - 30 + hb, 18 + Math.sin(time / 400) * 2, 0, Math.PI * 2); + this.ctx.stroke(); + } + + // ── HP БАР ── + const barW = 28; + const hpPct = player.hp / player.maxHp; + this.ctx.fillStyle = '#222'; + this.ctx.fillRect(bx - barW / 2, by - 60 + hb, barW, 4); + this.ctx.fillStyle = hpPct > 0.5 ? '#27ae60' : hpPct > 0.25 ? '#e67e22' : '#e74c3c'; + this.ctx.fillRect(bx - barW / 2, by - 60 + hb, barW * hpPct, 4); + }, + + // Головные уборы / причёски по классу + _drawPlayerHead(bx, hy, cls, bodyCol, hairColor, time) { + const ctx = this.ctx; + switch (cls) { + case 'warrior': + ctx.fillStyle = '#909090'; + ctx.beginPath(); ctx.arc(bx, hy - 2, 11, Math.PI, 0); ctx.fill(); + ctx.fillStyle = '#707070'; + ctx.fillRect(bx - 11, hy - 2, 22, 4); + ctx.strokeStyle = '#505050'; ctx.lineWidth = 1; + ctx.strokeRect(bx - 11, hy - 2, 22, 4); + ctx.fillStyle = '#b8b8b8'; + ctx.fillRect(bx - 7, hy, 14, 2); // забрало + break; + case 'mage': + ctx.fillStyle = bodyCol; + ctx.beginPath(); + ctx.moveTo(bx, hy - 27); ctx.lineTo(bx - 11, hy - 3); ctx.lineTo(bx + 11, hy - 3); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = this._adj(bodyCol, -25); ctx.lineWidth = 1.2; ctx.stroke(); + ctx.fillStyle = this._adj(bodyCol, -15); + ctx.fillRect(bx - 13, hy - 5, 26, 5); // ободок + ctx.fillStyle = `rgba(255,220,50,${0.7 + 0.3 * Math.sin(time / 300)})`; + ctx.font = 'bold 9px Arial'; ctx.textAlign = 'center'; + ctx.fillText('✦', bx, hy - 12); + break; + case 'archer': + ctx.fillStyle = bodyCol; + ctx.beginPath(); ctx.arc(bx, hy - 1, 12, Math.PI * 1.08, Math.PI * 1.92); ctx.fill(); + ctx.fillStyle = this._adj(bodyCol, -15); + ctx.fillRect(bx - 12, hy - 1, 24, 4); + break; + case 'paladin': + ctx.fillStyle = '#c8a810'; + ctx.beginPath(); ctx.arc(bx, hy - 2, 11, Math.PI, 0); ctx.fill(); + ctx.fillStyle = '#a88808'; ctx.fillRect(bx - 11, hy - 2, 22, 4); + ctx.fillStyle = '#ffffff'; // крест + ctx.fillRect(bx - 1.5, hy - 14, 3, 14); + ctx.fillRect(bx - 6, hy - 9, 12, 3); + break; + case 'necromancer': + ctx.fillStyle = '#1a0a2a'; + ctx.beginPath(); + ctx.moveTo(bx, hy - 28); ctx.lineTo(bx - 12, hy - 2); ctx.lineTo(bx + 12, hy - 2); + ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.arc(bx, hy, 13, Math.PI * 1.03, Math.PI * 1.97); ctx.fill(); + ctx.fillStyle = `rgba(142,68,173,${0.22 + 0.18 * Math.sin(time / 400)})`; + ctx.beginPath(); ctx.arc(bx, hy, 11, 0, Math.PI * 2); ctx.fill(); + break; + case 'berserker': + ctx.fillStyle = '#5a3a20'; + ctx.beginPath(); ctx.arc(bx, hy - 2, 11, Math.PI, 0); ctx.fill(); + ctx.fillStyle = '#4a2a10'; ctx.fillRect(bx - 11, hy - 2, 22, 4); + ctx.fillStyle = '#c8b090'; // рога + ctx.beginPath(); ctx.moveTo(bx - 8, hy - 9); ctx.lineTo(bx - 16, hy - 25); ctx.lineTo(bx - 4, hy - 9); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.moveTo(bx + 8, hy - 9); ctx.lineTo(bx + 16, hy - 25); ctx.lineTo(bx + 4, hy - 9); ctx.closePath(); ctx.fill(); + break; + case 'druid': + ctx.fillStyle = hairColor; + ctx.beginPath(); ctx.arc(bx, hy - 3, 9, Math.PI, 0); ctx.fill(); + for (let i = 0; i < 7; i++) { + const a = (i / 7) * Math.PI + Math.PI; + ctx.fillStyle = ['#2ecc71','#27ae60','#1abc9c'][i % 3]; + ctx.save(); + ctx.translate(bx + Math.cos(a) * 10, hy - 3 + Math.sin(a) * 5); + ctx.rotate(a + Math.PI / 2); + ctx.beginPath(); ctx.ellipse(0, 0, 4, 2.5, 0, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + break; + default: + ctx.fillStyle = hairColor; + ctx.beginPath(); ctx.arc(bx, hy - 4, 9, Math.PI, 0); ctx.fill(); + } + }, + + // Оружие в правой руке (по классу) + _drawPlayerWeapon(bx, by, cls, armSwing, time) { + const ctx = this.ctx; + ctx.save(); + ctx.translate(bx + 14, by - 20 + armSwing); + switch (cls) { + case 'warrior': + case 'paladin': + ctx.rotate(-0.25); + ctx.fillStyle = '#c8c8d8'; ctx.fillRect(-2, -22, 4, 24); // клинок + ctx.fillStyle = '#c8a810'; ctx.fillRect(-6, 0, 12, 3); // гарда + ctx.fillStyle = '#5a3010'; ctx.fillRect(-2, 3, 4, 8); // рукоять + ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillRect(0, -20, 1.5, 17); // блик + break; + case 'mage': { + ctx.rotate(-0.1); + ctx.fillStyle = '#7a5010'; ctx.fillRect(-2, -34, 4, 40); // посох + const hue = (time / 25) % 360; + ctx.fillStyle = `hsl(${hue},85%,65%)`; + ctx.beginPath(); ctx.arc(0, -35, 5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = `hsla(${hue},85%,75%,${0.15 + 0.1 * Math.sin(time / 200)})`; + ctx.beginPath(); ctx.arc(0, -35, 12, 0, Math.PI * 2); ctx.fill(); + break; + } + case 'archer': + ctx.rotate(0.1); + ctx.strokeStyle = '#7a5010'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(0, -10, 14, -0.9, 0.9); ctx.stroke(); + ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(Math.cos(-0.9) * 14, Math.sin(-0.9) * 14 - 10); + ctx.lineTo(Math.cos(0.9) * 14, Math.sin(0.9) * 14 - 10); + ctx.stroke(); + break; + case 'berserker': + ctx.rotate(0.3); + ctx.fillStyle = '#5a3010'; ctx.fillRect(-2.5, -28, 5, 32); + ctx.fillStyle = '#acacac'; + ctx.beginPath(); + ctx.moveTo(2, -24); ctx.lineTo(15, -17); ctx.lineTo(15, -7); ctx.lineTo(2, -4); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#888'; ctx.lineWidth = 1; ctx.stroke(); + break; + case 'necromancer': { + ctx.rotate(-0.1); + ctx.fillStyle = '#1a0a2a'; ctx.fillRect(-2, -34, 4, 40); + ctx.fillStyle = '#d0d0c0'; + ctx.beginPath(); ctx.arc(0, -35, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#1a0a2a'; + ctx.fillRect(-2.5, -32, 2, 3); ctx.fillRect(0.5, -32, 2, 3); + const glow = 0.3 + 0.2 * Math.sin(time / 300); + ctx.fillStyle = `rgba(142,68,173,${glow})`; + ctx.beginPath(); ctx.arc(0, -35, 9, 0, Math.PI * 2); ctx.fill(); + break; + } + case 'druid': + ctx.rotate(-0.1); + ctx.fillStyle = '#5a3a10'; ctx.fillRect(-2, -32, 4, 38); + ctx.fillStyle = '#2ecc71'; + ctx.beginPath(); ctx.arc(0, -33, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = `rgba(46,204,113,${0.2 + 0.15 * Math.sin(time / 400)})`; + ctx.beginPath(); ctx.arc(0, -33, 10, 0, Math.PI * 2); ctx.fill(); + break; + } + ctx.restore(); + }, + + // ────────────────────────────────── + // Враг + // ────────────────────────────────── + drawEnemy(enemy, time) { + const p = this.toIso(enemy.x, enemy.y); + const bob = Math.sin(time/200) * 2; + const atk = enemy.isAtk ? Math.sin(time/60) * 6 : 0; + + const CFG = { + goblin: { body:'#3a6a2a', head:'#4a7a3a', eye:'#ff2200', h:40 }, + orc: { body:'#4a5a2a', head:'#5a6a3a', eye:'#ff4400', h:44 }, + skeleton: { body:'#c8c8b0', head:'#dcdcc8', eye:'#00ff44', h:42 }, + slime: { body:'#00aa55', head:'#00cc66', eye:'#fff', h:28 }, + bandit: { body:'#6a5540', head:'#7a6550', eye:'#ff6600', h:42 }, + wolf: { body:'#5a4840', head:'#6a5850', eye:'#ffcc00', h:36 }, + spider: { body:'#1a1a2a', head:'#2a2a3a', eye:'#ff0000', h:34 }, + troll: { body:'#4a5a3a', head:'#5a6a4a', eye:'#ff3300', h:50 }, + dragon: { body:'#8b0000', head:'#a00000', eye:'#ffff00', h:55 }, + zombie: { body:'#3a5a30', head:'#4a6a40', eye:'#88ff44', h:42 }, + bat: { body:'#2a1a3a', head:'#3a2a4a', eye:'#ff44ff', h:30 }, + yeti: { body:'#ccccdd', head:'#ddddee', eye:'#00ccff', h:52 }, + witch: { body:'#2a1a4a', head:'#3a2a5a', eye:'#ff00ff', h:44 }, + golem: { body:'#606050', head:'#707060', eye:'#ff6600', h:56 }, + ghost: { body:'rgba(160,200,255,0.55)', head:'rgba(180,220,255,0.65)', eye:'#aaddff', h:44 }, + wyvern: { body:'#2a5a30', head:'#3a7a40', eye:'#ffee00', h:50 }, + chaos_lord: { body:'#1a0020', head:'#2e0040', eye:'#cc00ff', h:62 }, + }; + const cfg = CFG[enemy.type] || CFG.goblin; + const dy = p.y + atk; + + // Тень + this.ctx.beginPath(); + this.ctx.ellipse(p.x, dy + 8, 16, 8, 0, 0, Math.PI*2); + this.ctx.fillStyle = 'rgba(0,0,0,0.25)'; + this.ctx.fill(); + + if (enemy.type === 'slime') { + // Слизень — каплевидная форма + this.ctx.beginPath(); + this.ctx.ellipse(p.x, dy - 10 + bob, 14, 10, 0, 0, Math.PI*2); + this.ctx.fillStyle = cfg.body; this.ctx.fill(); + this.ctx.strokeStyle = '#00885a'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + this.ctx.fillStyle = '#fff'; // глаза + this.ctx.beginPath(); this.ctx.arc(p.x-4, dy-12+bob, 3, 0, Math.PI*2); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.arc(p.x+4, dy-12+bob, 3, 0, Math.PI*2); this.ctx.fill(); + this.ctx.fillStyle = '#000'; + this.ctx.beginPath(); this.ctx.arc(p.x-4, dy-12+bob, 1.5, 0, Math.PI*2); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.arc(p.x+4, dy-12+bob, 1.5, 0, Math.PI*2); this.ctx.fill(); + } else if (enemy.type === 'bat') { + // Летучая мышь + this.ctx.fillStyle = cfg.body; + this.ctx.beginPath(); + const wingFlap = Math.sin(time/80) * 8; + this.ctx.ellipse(p.x, dy - 15 + bob, 7, 5, 0, 0, Math.PI*2); this.ctx.fill(); + this.ctx.beginPath(); + this.ctx.moveTo(p.x-6, dy-15+bob); this.ctx.lineTo(p.x-18, dy-20+wingFlap); this.ctx.lineTo(p.x-8, dy-13+bob); this.ctx.fill(); + this.ctx.beginPath(); + this.ctx.moveTo(p.x+6, dy-15+bob); this.ctx.lineTo(p.x+18, dy-20+wingFlap); this.ctx.lineTo(p.x+8, dy-13+bob); this.ctx.fill(); + } else if (enemy.type === 'ghost') { + // Призрак — полупрозрачная парящая фигура + const glow = 0.3 + 0.2 * Math.sin(time / 400); + this.ctx.shadowColor = '#88ccff'; this.ctx.shadowBlur = 14; + this.ctx.globalAlpha = 0.55 + 0.15 * Math.sin(time / 500); + // Мантия / тело + this.ctx.fillStyle = cfg.body; + this.ctx.beginPath(); + this.ctx.arc(p.x, dy - 28 + bob, 10, Math.PI, 0); // верх + this.ctx.lineTo(p.x + 14, dy - 8 + bob); + // рваный низ + for (let i = 3; i >= 0; i--) { + const wx = p.x + 14 - i * 7; + this.ctx.lineTo(wx, dy - 8 + bob + (i % 2 === 0 ? 8 : 0)); + } + this.ctx.closePath(); this.ctx.fill(); + // Голова + this.ctx.beginPath(); + this.ctx.arc(p.x, dy - 38 + bob, 10, 0, Math.PI * 2); + this.ctx.fillStyle = cfg.head; this.ctx.fill(); + // Глаза — светящиеся + this.ctx.fillStyle = '#aaddff'; + this.ctx.shadowBlur = 10; + this.ctx.beginPath(); this.ctx.ellipse(p.x - 3.5, dy - 39 + bob, 3, 2, 0, 0, Math.PI * 2); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.ellipse(p.x + 3.5, dy - 39 + bob, 3, 2, 0, 0, Math.PI * 2); this.ctx.fill(); + this.ctx.globalAlpha = 1; this.ctx.shadowBlur = 0; + } else if (enemy.type === 'wyvern') { + // Виверна — дракон с крыльями + const wf = Math.sin(time / 110) * 10; + // Крылья + this.ctx.fillStyle = 'rgba(30,80,40,0.75)'; + this.ctx.beginPath(); + this.ctx.moveTo(p.x - 7, dy - 28 + bob); this.ctx.lineTo(p.x - 30, dy - 48 + wf); this.ctx.lineTo(p.x - 7, dy - 10 + bob); this.ctx.fill(); + this.ctx.beginPath(); + this.ctx.moveTo(p.x + 7, dy - 28 + bob); this.ctx.lineTo(p.x + 30, dy - 48 + wf); this.ctx.lineTo(p.x + 7, dy - 10 + bob); this.ctx.fill(); + // Тело + this.ctx.fillStyle = cfg.body; + this.ctx.fillRect(p.x - 7, dy - cfg.h * 0.4 + bob, 14, cfg.h * 0.45); + this.ctx.strokeStyle = '#1a4a20'; this.ctx.lineWidth = 1.5; + this.ctx.strokeRect(p.x - 7, dy - cfg.h * 0.4 + bob, 14, cfg.h * 0.45); + // Хвост + this.ctx.beginPath(); + this.ctx.moveTo(p.x + 7, dy - 15 + bob); this.ctx.quadraticCurveTo(p.x + 22, dy - 5 + bob, p.x + 18, dy + 8 + bob); + this.ctx.strokeStyle = cfg.body; this.ctx.lineWidth = 4; this.ctx.stroke(); + // Голова + this.ctx.beginPath(); this.ctx.arc(p.x, dy - cfg.h + bob, 10, 0, Math.PI * 2); + this.ctx.fillStyle = cfg.head; this.ctx.fill(); + this.ctx.strokeStyle = '#1a4a20'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + // Глаза + this.ctx.fillStyle = cfg.eye; + this.ctx.beginPath(); this.ctx.arc(p.x - 3, dy - cfg.h - 1 + bob, 2.5, 0, Math.PI * 2); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.arc(p.x + 3, dy - cfg.h - 1 + bob, 2.5, 0, Math.PI * 2); this.ctx.fill(); + } else { + // Стандартная гуманоидная фигура + this.ctx.fillStyle = cfg.body; + this.ctx.fillRect(p.x-7, dy - cfg.h*0.4 + bob, 14, cfg.h*0.45); + this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 1.5; + this.ctx.strokeRect(p.x-7, dy - cfg.h*0.4 + bob, 14, cfg.h*0.45); + // Ноги + this.ctx.fillRect(p.x-6, dy - cfg.h*0.02 + bob, 5, 12); + this.ctx.fillRect(p.x+1, dy - cfg.h*0.02 + bob, 5, 12); + // Голова + this.ctx.beginPath(); + this.ctx.arc(p.x, dy - cfg.h + bob, 10, 0, Math.PI*2); + this.ctx.fillStyle = cfg.head; this.ctx.fill(); + this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + // Глаза + this.ctx.fillStyle = cfg.eye; + this.ctx.beginPath(); this.ctx.arc(p.x-3, dy-cfg.h-1+bob, 2.5, 0, Math.PI*2); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.arc(p.x+3, dy-cfg.h-1+bob, 2.5, 0, Math.PI*2); this.ctx.fill(); + // Клыки у орков/гоблинов + if (enemy.type==='orc'||enemy.type==='goblin') { + this.ctx.fillStyle = '#fff'; + this.ctx.beginPath(); this.ctx.moveTo(p.x-2,dy-cfg.h+5+bob); this.ctx.lineTo(p.x-1,dy-cfg.h+9+bob); this.ctx.lineTo(p.x,dy-cfg.h+5+bob); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.moveTo(p.x+2,dy-cfg.h+5+bob); this.ctx.lineTo(p.x+3,dy-cfg.h+9+bob); this.ctx.lineTo(p.x+4,dy-cfg.h+5+bob); this.ctx.fill(); + } + // Дракон — крылья + if (enemy.type==='dragon') { + this.ctx.fillStyle = 'rgba(100,0,0,0.6)'; + this.ctx.beginPath(); this.ctx.moveTo(p.x-7, dy-cfg.h*0.3+bob); this.ctx.lineTo(p.x-28, dy-cfg.h*0.55+bob); this.ctx.lineTo(p.x-7, dy-cfg.h*0.05+bob); this.ctx.fill(); + this.ctx.beginPath(); this.ctx.moveTo(p.x+7, dy-cfg.h*0.3+bob); this.ctx.lineTo(p.x+28, dy-cfg.h*0.55+bob); this.ctx.lineTo(p.x+7, dy-cfg.h*0.05+bob); this.ctx.fill(); + } + } + + // Аура босса + if (enemy.isBoss) { + this.ctx.strokeStyle = `rgba(220,0,0,${0.3+Math.sin(time/200)*0.15})`; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + this.ctx.arc(p.x, dy - cfg.h*0.5, 26, 0, Math.PI*2); + this.ctx.stroke(); + } + + // Статус-эффект: кольцо вокруг врага + if (enemy.status) { + const statusCols = { + poison: 'rgba(30,230,80,', + burn: 'rgba(255,110,0,', + slow: 'rgba(80,160,255,', + }; + const sc = statusCols[enemy.status] || 'rgba(220,220,220,'; + const pulse = 0.5 + 0.35 * Math.sin(time / 180); + this.ctx.strokeStyle = sc + pulse + ')'; + this.ctx.lineWidth = 2.5; + this.ctx.beginPath(); + this.ctx.arc(p.x, dy - cfg.h * 0.52, 22, 0, Math.PI * 2); + this.ctx.stroke(); + // Мерцающие точки по окружности + for (let i = 0; i < 4; i++) { + const a = (time / 600 + i / 4) * Math.PI * 2; + const dotAlpha = 0.4 + 0.5 * Math.abs(Math.sin(time / 200 + i)); + this.ctx.fillStyle = sc + dotAlpha + ')'; + this.ctx.beginPath(); + this.ctx.arc(p.x + Math.cos(a) * 22, dy - cfg.h * 0.52 + Math.sin(a) * 22, 2.5, 0, Math.PI * 2); + this.ctx.fill(); + } + } + + // HP бар + const barW = 32, hp = enemy.hp / enemy.maxHp; + this.ctx.fillStyle = '#1a1a1a'; + this.ctx.fillRect(p.x-barW/2, dy - cfg.h - 18 + bob, barW, 5); + this.ctx.fillStyle = hp > 0.5 ? '#27ae60' : hp > 0.25 ? '#e67e22' : '#e74c3c'; + this.ctx.fillRect(p.x-barW/2, dy - cfg.h - 18 + bob, barW*hp, 5); + this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 0.5; + this.ctx.strokeRect(p.x-barW/2, dy - cfg.h - 18 + bob, barW, 5); + + // Имя + this.ctx.fillStyle = enemy.isBoss ? '#ff4444' : '#dddddd'; + this.ctx.font = (enemy.isBoss ? 'bold ' : '') + '9px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.fillText(enemy.name, p.x, dy - cfg.h - 22 + bob); + }, + + // ────────────────────────────────── + // NPC + // ────────────────────────────────── + drawNPC(npc, time) { + const p = this.toIso(npc.x, npc.y); + const bob = Math.sin(time/300) * 2; + const c = npc.color || '#8b6914'; + + this.ctx.beginPath(); + this.ctx.ellipse(p.x, p.y+8, 16, 8, 0, 0, Math.PI*2); + this.ctx.fillStyle = 'rgba(0,0,0,0.22)'; + this.ctx.fill(); + + // Тело + this.ctx.fillStyle = c; + this.ctx.fillRect(p.x-7, p.y-28+bob, 14, 18); + this.ctx.fillRect(p.x-5, p.y-10+bob, 4, 10); + this.ctx.fillRect(p.x+1, p.y-10+bob, 4, 10); + + // Голова + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y-40+bob, 10, 0, Math.PI*2); + this.ctx.fillStyle = '#ffcc99'; this.ctx.fill(); + this.ctx.strokeStyle = '#cc9966'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + + // Индикатор диалога + const bounce2 = Math.sin(time/180) * 4; + this.ctx.fillStyle = '#ffd700'; + this.ctx.font = 'bold 14px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('💬', p.x, p.y - 55 + bounce2); + + // Имя + this.ctx.fillStyle = '#88ffaa'; + this.ctx.font = 'bold 10px Arial'; + this.ctx.fillText(npc.name, p.x, p.y - 70); + }, + + // ────────────────────────────────── + // Предметы на земле + // ────────────────────────────────── + drawGroundItem(item, time) { + const p = this.toIso(item.x, item.y); + const bounce = Math.sin(time/300) * 4; + + // Свечение редких предметов + const RGLOW = { + uncommon: 'rgba(39,174,96,', + rare: 'rgba(41,128,185,', + epic: 'rgba(142,68,173,', + legendary: 'rgba(230,126,34,', + }; + const gc = RGLOW[item.rarity]; + if (gc) { + const pulse = 0.22 + 0.18 * Math.sin(time / 350 + item.x); + const gr = this.ctx.createRadialGradient(p.x, p.y - 12, 0, p.x, p.y - 12, 24); + gr.addColorStop(0, gc + (pulse * 1.4) + ')'); + gr.addColorStop(1, gc + '0)'); + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y - 12, 24, 0, Math.PI * 2); + this.ctx.fillStyle = gr; + this.ctx.fill(); + // Дополнительный ореол для легендарных + if (item.rarity === 'legendary') { + this.ctx.strokeStyle = gc + (0.5 + 0.4 * Math.sin(time / 200)) + ')'; + this.ctx.lineWidth = 1.5; + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y - 12 - bounce, 14, 0, Math.PI * 2); + this.ctx.stroke(); + } + } + + if (item.type === 'gold') { + this.ctx.save(); + this.ctx.translate(p.x, p.y - 12 - bounce); + this.ctx.beginPath(); this.ctx.arc(0,0,8,0,Math.PI*2); + this.ctx.fillStyle = '#ffd700'; this.ctx.fill(); + this.ctx.strokeStyle = '#aa8800'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + this.ctx.fillStyle = '#aa8800'; this.ctx.font = 'bold 9px Arial'; this.ctx.textAlign = 'center'; + this.ctx.fillText('$', 0, 3); + for (let i=0;i<3;i++) { + const a = time/200+i*2; + this.ctx.fillStyle = '#fff'; this.ctx.fillRect(Math.cos(a)*10, Math.sin(a)*10, 2, 2); + } + this.ctx.restore(); + } else if (item.type === 'potion') { + const col = item.healAmount ? '#e74c3c' : '#3498db'; + this._drawPotionIcon(p.x, p.y - 12 - bounce, col); + } else if (item.type === 'weapon') { + this.ctx.save(); + this.ctx.translate(p.x, p.y - 14 - bounce); + this.ctx.rotate(Math.PI/4); + this.ctx.fillStyle = '#c0c0c0'; + this.ctx.fillRect(-2, -12, 4, 18); + this.ctx.fillStyle = '#8b4513'; + this.ctx.fillRect(-2, 6, 4, 6); + this.ctx.fillStyle = '#ffd700'; + this.ctx.fillRect(-6, 4, 12, 3); + this.ctx.restore(); + } else if (item.type === 'armor') { + this.ctx.save(); + this.ctx.translate(p.x, p.y - 14 - bounce); + this.ctx.beginPath(); this.ctx.arc(0,0,9,0,Math.PI*2); + this.ctx.fillStyle = '#4a4a6a'; this.ctx.fill(); + this.ctx.strokeStyle = '#6a6a9a'; this.ctx.lineWidth = 1.5; this.ctx.stroke(); + this.ctx.restore(); + } else { + // Квестовый / неизвестный + this.ctx.fillStyle = '#ff44ff'; + this.ctx.font = '16px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('❓', p.x, p.y - 10 - bounce); + } + }, + + drawLoreNote(note, time) { + const p = this.toIso(note.gx, note.gy); + const ctx = this.ctx; + const bounce = Math.sin(time / 280) * 3; + const pulse = 0.5 + 0.4 * Math.abs(Math.sin(time / 500 + note.gx)); + // мягкое свечение + const glow = ctx.createRadialGradient(p.x, p.y - 14, 0, p.x, p.y - 14, 20); + glow.addColorStop(0, `rgba(140,120,255,${pulse * 0.4})`); + glow.addColorStop(1, 'rgba(140,120,255,0)'); + ctx.fillStyle = glow; + ctx.beginPath(); ctx.arc(p.x, p.y - 14, 20, 0, Math.PI * 2); ctx.fill(); + // пергамент + ctx.save(); + ctx.translate(p.x, p.y - 16 - bounce); + ctx.fillStyle = '#d4c49a'; + ctx.fillRect(-7, -9, 14, 16); + ctx.strokeStyle = '#8a6a20'; ctx.lineWidth = 1.2; ctx.strokeRect(-7, -9, 14, 16); + // строчки + ctx.strokeStyle = '#8a6a3080'; ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + ctx.beginPath(); ctx.moveTo(-5, -4 + i*4); ctx.lineTo(5, -4 + i*4); ctx.stroke(); + } + ctx.restore(); + // иконка сверху + ctx.font = '11px Arial'; ctx.textAlign = 'center'; + ctx.fillText('📜', p.x, p.y - 26 - bounce); + }, + + _drawPotionIcon(x, y, color) { + this.ctx.fillStyle = '#4a2a10'; + this.ctx.fillRect(x-3, y-18, 6, 5); + this.ctx.beginPath(); + this.ctx.moveTo(x, y-13); this.ctx.lineTo(x-7, y+3); this.ctx.lineTo(x+7, y+3); this.ctx.closePath(); + this.ctx.fillStyle = color; this.ctx.fill(); + this.ctx.strokeStyle = this._adj(color,-30); this.ctx.lineWidth = 1.5; this.ctx.stroke(); + this.ctx.fillStyle = 'rgba(255,255,255,0.3)'; + this.ctx.beginPath(); this.ctx.ellipse(x-2, y-6, 2, 4, 0, 0, Math.PI*2); this.ctx.fill(); + }, + + // ────────────────────────────────── + // Декорации + // ────────────────────────────────── + drawDecoration(dec, time) { + const p = this.toIso(dec.x, dec.y); + switch(dec.type) { + case 'portal': this._drawPortal(p, dec, time); break; + case 'tree': this._drawTree(p.x, p.y, 0.8); break; + case 'house': this._drawHouse(p); break; + case 'tavern': this._drawTavern(p, dec); break; + case 'table': this._drawTable(p); break; + case 'torch': this._drawTorch(p, time); break; + case 'crystal': this._drawCrystal(p, time); break; + case 'pillar': this._drawPillar(p); break; + case 'rock': this._drawRock(p); break; + case 'well': this._drawWell(p); break; + case 'fountain':this._drawFountain(p, time); break; + } + }, + + _drawPortal(p, dec, time) { + const isVoid = dec.destination === 'abyss' || this._currentMapId === 'abyss'; + const pulse = Math.sin(time/200)*4; + const ctx = this.ctx; + ctx.beginPath(); + ctx.ellipse(p.x, p.y-20, 14+pulse/2, 22+pulse, 0, 0, Math.PI*2); + const g = ctx.createRadialGradient(p.x,p.y-20,0,p.x,p.y-20,24); + if (isVoid) { + g.addColorStop(0,'rgba(180,0,255,0.95)'); + g.addColorStop(0.4,'rgba(60,0,120,0.7)'); + g.addColorStop(1,'rgba(10,0,30,0.2)'); + ctx.fillStyle = g; ctx.fill(); + ctx.strokeStyle = '#6600aa'; ctx.lineWidth = 2.5; ctx.stroke(); + // Вращающийся ореол из частиц + const rot = time/1200; + for (let i=0;i<6;i++) { + const a = rot + i/6*Math.PI*2; + ctx.beginPath(); + ctx.arc(p.x+Math.cos(a)*18, p.y-20+Math.sin(a)*26, 2.5, 0, Math.PI*2); + ctx.fillStyle = `rgba(${100+Math.floor(Math.sin(a+time/400)*50)},0,255,0.7)`; + ctx.fill(); + } + ctx.fillStyle = '#cc00ff'; + } else { + g.addColorStop(0,'rgba(255,255,255,0.9)'); + g.addColorStop(0.4,'rgba(150,0,255,0.7)'); + g.addColorStop(1,'rgba(50,0,150,0.2)'); + ctx.fillStyle = g; ctx.fill(); + ctx.strokeStyle = '#cc88ff'; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = '#ffd700'; + } + ctx.font = 'bold 9px Arial'; ctx.textAlign = 'center'; + ctx.fillText(dec.name || '?', p.x, p.y - 46); + }, + + _drawHouse(p) { + const ctx = this.ctx; + ctx.fillStyle = '#8b6914'; ctx.fillRect(p.x-18, p.y-28, 36, 28); + ctx.strokeStyle = '#5a4000'; ctx.lineWidth = 1.5; ctx.strokeRect(p.x-18, p.y-28, 36, 28); + ctx.fillStyle = '#c0392b'; + ctx.beginPath(); ctx.moveTo(p.x-20, p.y-28); ctx.lineTo(p.x, p.y-50); ctx.lineTo(p.x+20, p.y-28); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#922b21'; ctx.stroke(); + ctx.fillStyle = '#4a2a0a'; ctx.fillRect(p.x-5, p.y-18, 10, 18); + ctx.fillStyle = '#88ccff'; ctx.fillRect(p.x-14, p.y-24, 10, 8); + ctx.strokeStyle = '#aaddff'; ctx.lineWidth = 1; ctx.strokeRect(p.x-14, p.y-24, 10, 8); + }, + + _drawTavern(p, dec) { + const ctx = this.ctx; + // Стены (тёплый коричневый, шире обычного дома) + ctx.fillStyle = '#5a3a1a'; ctx.fillRect(p.x-22, p.y-32, 44, 32); + ctx.strokeStyle = '#3a2010'; ctx.lineWidth = 1.5; ctx.strokeRect(p.x-22, p.y-32, 44, 32); + // Крыша (тёмно-коричневая) + ctx.fillStyle = '#8B4513'; + ctx.beginPath(); ctx.moveTo(p.x-24, p.y-32); ctx.lineTo(p.x, p.y-58); ctx.lineTo(p.x+24, p.y-32); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#5a2d0c'; ctx.lineWidth = 1; ctx.stroke(); + // Дверь (двойная) + ctx.fillStyle = '#2a1a08'; ctx.fillRect(p.x-7, p.y-22, 14, 22); + ctx.strokeStyle = '#4a3018'; ctx.lineWidth = 1; ctx.strokeRect(p.x-7, p.y-22, 14, 22); + ctx.strokeStyle = '#3a2010'; ctx.beginPath(); ctx.moveTo(p.x, p.y-22); ctx.lineTo(p.x, p.y); ctx.stroke(); + // Два окна + ctx.fillStyle = '#ffddaa'; ctx.fillRect(p.x-20, p.y-26, 9, 7); + ctx.fillStyle = '#ffddaa'; ctx.fillRect(p.x+11, p.y-26, 9, 7); + ctx.strokeStyle = '#8b6914'; ctx.lineWidth = 1; + ctx.strokeRect(p.x-20, p.y-26, 9, 7); ctx.strokeRect(p.x+11, p.y-26, 9, 7); + // Вывеска + ctx.fillStyle = '#c8a020'; ctx.fillRect(p.x-18, p.y-52, 36, 14); + ctx.strokeStyle = '#7a6010'; ctx.lineWidth = 1; ctx.strokeRect(p.x-18, p.y-52, 36, 14); + ctx.font = '10px serif'; ctx.textAlign = 'center'; ctx.fillStyle = '#2a1a00'; + ctx.fillText('🍺', p.x, p.y - 41); + // Название + ctx.fillStyle = '#ffd700'; ctx.font = 'bold 9px Arial'; + ctx.fillText(dec && dec.name ? dec.name : 'Таверна', p.x, p.y - 64); + }, + + _drawTable(p) { + const ctx = this.ctx; + // Столешница + ctx.fillStyle = '#8B5A2B'; ctx.fillRect(p.x-12, p.y-12, 24, 12); + ctx.strokeStyle = '#5a3a10'; ctx.lineWidth = 1; ctx.strokeRect(p.x-12, p.y-12, 24, 12); + // Верхний кант + ctx.fillStyle = '#a06030'; ctx.fillRect(p.x-12, p.y-14, 24, 4); + ctx.strokeStyle = '#6a4020'; ctx.strokeRect(p.x-12, p.y-14, 24, 4); + // Кружка + ctx.font = '10px serif'; ctx.textAlign = 'center'; + ctx.fillText('🍺', p.x, p.y - 9); + }, + + _drawTorch(p, time) { + const ctx = this.ctx; + ctx.fillStyle = '#5a3000'; ctx.fillRect(p.x-2, p.y-20, 4, 20); + // составное мерцание из двух частот + const flicker = Math.sin(time/80)*2 + Math.cos(time/55)*1; + const radius = 11 + Math.abs(Math.sin(time/120))*2; + const cx = p.x + Math.sin(time/95)*1.2; + const cy = p.y - 24 + flicker; + // внешнее свечение + const outer = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius*2.2); + outer.addColorStop(0,'rgba(255,150,0,0.18)'); + outer.addColorStop(1,'rgba(255,50,0,0)'); + ctx.fillStyle = outer; + ctx.beginPath(); ctx.arc(cx, cy, radius*2.2, 0, Math.PI*2); ctx.fill(); + // основное пламя + const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + g.addColorStop(0,'rgba(255,220,60,0.95)'); + g.addColorStop(0.55,'rgba(255,110,0,0.6)'); + g.addColorStop(1,'rgba(255,40,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI*2); ctx.fill(); + // искра (одна, летит вверх) + const sparkPhase = (time/600) % 1; + if (sparkPhase < 0.55) { + const sy = cy - sparkPhase * 18; + const sa = 0.9 - sparkPhase * 1.6; + ctx.fillStyle = `rgba(255,220,80,${Math.max(0,sa)})`; + ctx.beginPath(); ctx.arc(cx + Math.sin(sparkPhase*8)*2, sy, 1.2, 0, Math.PI*2); ctx.fill(); + } + }, + + _drawCrystal(p, time) { + const ctx = this.ctx; + const pulse = Math.sin(time/400)*3; + const hue = (time/30)%360; + ctx.fillStyle = `hsla(${hue},80%,60%,0.8)`; + ctx.beginPath(); + ctx.moveTo(p.x, p.y-26-pulse); ctx.lineTo(p.x-8, p.y-14); ctx.lineTo(p.x, p.y-4); ctx.lineTo(p.x+8, p.y-14); ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = `hsla(${hue},100%,80%,0.6)`; ctx.lineWidth = 1.5; ctx.stroke(); + }, + + _drawPillar(p) { + const ctx = this.ctx; + const isAbyss = this._currentMapId === 'abyss'; + ctx.fillStyle = isAbyss ? '#1a0028' : '#909090'; ctx.fillRect(p.x-6, p.y-30, 12, 30); + ctx.fillStyle = isAbyss ? '#110018' : '#808080'; ctx.fillRect(p.x-8, p.y-32, 16, 6); + ctx.fillStyle = isAbyss ? '#220033' : '#a0a0a0'; ctx.fillRect(p.x-8, p.y-4, 16, 6); + ctx.strokeStyle = isAbyss ? '#330044' : '#606060'; ctx.lineWidth = 1; + ctx.strokeRect(p.x-6, p.y-30, 12, 30); + if (isAbyss) { + ctx.shadowColor = '#6600aa'; ctx.shadowBlur = 10; + ctx.strokeStyle = '#440066'; ctx.lineWidth = 2; + ctx.strokeRect(p.x-5, p.y-29, 10, 28); + ctx.shadowBlur = 0; + } + }, + + _drawRock(p) { + const ctx = this.ctx; + ctx.fillStyle = '#555'; ctx.beginPath(); ctx.ellipse(p.x, p.y-8, 12, 7, 0, 0, Math.PI*2); ctx.fill(); + ctx.fillStyle = '#666'; ctx.beginPath(); ctx.ellipse(p.x-2, p.y-10, 7, 4, 0, 0, Math.PI*2); ctx.fill(); + }, + + _drawWell(p) { + const ctx = this.ctx; + ctx.fillStyle = '#7a7a7a'; ctx.fillRect(p.x-10, p.y-16, 20, 16); + ctx.fillStyle = '#3a3a5a'; ctx.beginPath(); ctx.arc(p.x, p.y-16, 10, 0, Math.PI*2); ctx.fill(); + ctx.strokeStyle = '#5a5a8a'; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.fillStyle = '#5a3a10'; ctx.fillRect(p.x-2, p.y-28, 4, 14); ctx.fillRect(p.x-12, p.y-28, 4, 8); ctx.fillRect(p.x+8, p.y-28, 4, 8); + ctx.strokeStyle = '#3a2000'; ctx.lineWidth = 1; ctx.strokeRect(p.x-12, p.y-28, 24, 4); + }, + + _drawFountain(p, time) { + const ctx = this.ctx; + ctx.fillStyle = '#7a7a9a'; ctx.beginPath(); ctx.arc(p.x, p.y, 14, 0, Math.PI*2); ctx.fill(); + ctx.fillStyle = '#2a5a8c'; ctx.beginPath(); ctx.arc(p.x, p.y, 10, 0, Math.PI*2); ctx.fill(); + const spray = Math.sin(time/200)*2; + ctx.strokeStyle = 'rgba(100,180,255,0.7)'; ctx.lineWidth = 1.5; + for (let i=0;i<5;i++) { + const a = i/5*Math.PI*2; + ctx.beginPath(); ctx.moveTo(p.x, p.y-8); ctx.lineTo(p.x+Math.cos(a)*10+spray, p.y-18+Math.sin(a)*4); ctx.stroke(); + } + }, + + // ────────────────────────────────── + // Частицы + // ────────────────────────────────── + addParticle(worldX, worldY, type, count) { + count = count || 6; + const pos = this.toIso(worldX, worldY); + const palettes = { + hit: ['#ff4444','#ff8800','#ffcc00'], + magic: ['#4488ff','#aa44ff','#ffffff'], + heal: ['#44ff88','#88ff44','#ffffff'], + fire: ['#ff6600','#ff9900','#ffcc00'], + ice: ['#88ccff','#aaddff','#ffffff'], + poison: ['#44cc44','#88dd44','#ccff44'], + gold: ['#ffd700','#ffaa00','#ffffaa'], + death: ['#666','#888','#aaa'], + holy: ['#ffee44','#ffffff','#fff0aa'], + void: ['#4a0080','#6600aa','#330055'], + }; + const cols = palettes[type] || palettes.hit; + for (let i = 0; i < count; i++) { + this.particles.push({ + x: pos.x + (Math.random()-0.5)*20, + y: pos.y - 15 + (Math.random()-0.5)*20, + vx: (Math.random()-0.5)*3.5, + vy: -1.5 - Math.random()*3, + life: 1, + decay: 0.025 + Math.random()*0.03, + size: 2 + Math.random()*3, + col: cols[Math.floor(Math.random()*cols.length)] + }); + } + }, + + updateParticles(dt) { + this.particles.forEach(p => { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.12; + p.life -= p.decay; + }); + this.particles = this.particles.filter(p => p.life > 0); + }, + + drawParticles() { + this.particles.forEach(p => { + this.ctx.save(); + this.ctx.globalAlpha = p.life; + this.ctx.fillStyle = p.col; + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI*2); + this.ctx.fill(); + this.ctx.restore(); + }); + }, + + // ────────────────────────────────── + // Встряска экрана + // ────────────────────────────────── + shakeScreen(power) { + this.shake.power = Math.max(this.shake.power, power); + }, + updateShake() { + if (this.shake.power > 0.1) { + this.shake.x = (Math.random()-0.5) * this.shake.power; + this.shake.y = (Math.random()-0.5) * this.shake.power; + this.shake.power *= 0.82; + } else { + this.shake.power = 0; + this.shake.x = 0; + this.shake.y = 0; + } + }, + + // ────────────────────────────────── + // Вспышка экрана + // ────────────────────────────────── + flashScreen(color, alpha, dur) { + this._flash = { color: color||'#ff0000', alpha: alpha||0.30, life: 1, speed: 1/(dur||7) }; + }, + drawFlash() { + if (!this._flash || this._flash.life <= 0) return; + this.ctx.save(); + this.ctx.globalAlpha = this._flash.life * this._flash.alpha; + this.ctx.fillStyle = this._flash.color; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.restore(); + this._flash.life -= this._flash.speed; + }, + + // ────────────────────────────────── + // Атмосфера Бездны + // ────────────────────────────────── + drawAbyssAtmosphere(time) { + const ctx = this.ctx; + const pulse = Math.sin(time / 1400) * 0.04; + ctx.save(); + ctx.globalAlpha = 0.25 + pulse; + const g = ctx.createRadialGradient( + this.canvas.width*0.5, this.canvas.height*0.4, 0, + this.canvas.width*0.5, this.canvas.height*0.4, this.canvas.width*0.75 + ); + g.addColorStop(0, 'rgba(20,0,40,0)'); + g.addColorStop(0.5,'rgba(10,0,28,0.5)'); + g.addColorStop(1, 'rgba(0,0,8,0.95)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + // Полосы тумана + ctx.globalAlpha = 0.07 + Math.sin(time/900)*0.03; + for (let i=0;i<3;i++) { + const fy = ((time/3200 + i*0.33) % 1) * this.canvas.height; + const fg = ctx.createLinearGradient(0, fy-22, 0, fy+22); + fg.addColorStop(0,'transparent'); + fg.addColorStop(0.5,'rgba(40,0,70,0.55)'); + fg.addColorStop(1,'transparent'); + ctx.fillStyle = fg; + ctx.fillRect(0, fy-22, this.canvas.width, 44); + } + ctx.restore(); + }, + + // ────────────────────────────────── + // Всплывающие числа урона/лечения + // ────────────────────────────────── + addFloatingText(worldX, worldY, text, color, size) { + const pos = this.toIso(worldX, worldY); + this.floatingTexts.push({ + x: pos.x, + y: pos.y - 30, + text: String(text), + color: color || '#ff4444', + size: size || 16, + life: 1, + decay: 0.016, + vy: -1.1, + }); + }, + + updateFloatingTexts(dt) { + this.floatingTexts.forEach(t => { + t.y += t.vy; + t.vy += 0.025; // замедление подъёма + t.life -= t.decay; + }); + this.floatingTexts = this.floatingTexts.filter(t => t.life > 0); + }, + + drawFloatingTexts() { + const ctx = this.ctx; + this.floatingTexts.forEach(t => { + ctx.save(); + ctx.globalAlpha = Math.min(1, t.life * 2.2); + ctx.font = `bold ${t.size}px Arial`; + ctx.textAlign = 'center'; + // Тень + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillText(t.text, t.x + 1, t.y + 1); + // Текст + ctx.fillStyle = t.color; + ctx.fillText(t.text, t.x, t.y); + ctx.restore(); + }); + }, + + // ────────────────────────────────── + // Миникарта + // ────────────────────────────────── + drawMinimap(map, player, enemies, questDots) { + const ctx = this.ctx; + const mx = this.canvas.width - 110, my = 52, ms = 95; + const mw = map[0].length, mh = map.length; + const tw = ms / mw, th = ms / mh; + + // Фон + ctx.fillStyle = 'rgba(4,4,12,0.82)'; + ctx.fillRect(mx-2, my-2, ms+4, ms+4); + ctx.strokeStyle = '#1e1e38'; ctx.lineWidth = 1; + ctx.strokeRect(mx-2, my-2, ms+4, ms+4); + + const MCOLS = { 0:'#2d5a1a',1:'#1a4a7c',2:'#4a4a4a',3:'#8a7a4a', + 4:'#2a1808',5:'#5a4020',6:'#8a3a00',7:'#b0b0c0', + 8:'#5a3a1a',9:'#505050',10:'#2a4a2a',11:'#7090b0' }; + for (let y=0; y { + const col = d.type === 'give' ? '#ffd700' : + d.type === 'advance' ? '#ffaa00' : + d.type === 'complete'? '#27ae60' : + d.type === 'target' ? '#88aaff' : '#fff'; + const r = d.type === 'target' ? tw * 1.6 : tw * 1.2; + ctx.fillStyle = col + '88'; + ctx.strokeStyle = col; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.arc(mx + d.x * tw + tw/2, my + d.y * th + th/2, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + }); + } + + // Враги + enemies.forEach(e => { + ctx.fillStyle = '#e74c3c'; + ctx.fillRect(mx + e.x*tw, my + e.y*th, tw+1, th+1); + }); + + // Игрок + const px = Math.round(player.x), py = Math.round(player.y); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(mx + px*tw - 1, my + py*th - 1, tw+2, th+2); + }, + + // ────────────────────────────────── + // Маркеры квестов над NPC + // ────────────────────────────────── + drawQuestMarkers(npcs, questData, time) { + if (!questData || !npcs || !npcs.length) return; + const ctx = this.ctx; + const bounce = Math.sin(time / 300) * 3; + + npcs.forEach(npc => { + const mtype = questData[npc.name]; + if (!mtype) return; + + const p = this.toIso(npc.x, npc.y); + const cx = p.x; + const cy = p.y - 80 + bounce; + const r = 9; + + ctx.save(); + + if (mtype === 'give') { + // Жёлтый "!" — новый квест + ctx.fillStyle = 'rgba(255,215,0,0.18)'; + ctx.strokeStyle = '#ffd700'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#ffd700'; + ctx.font = 'bold 13px Arial'; ctx.textAlign = 'center'; + ctx.fillText('!', cx, cy + 5); + + } else if (mtype === 'advance') { + // Мигающий оранжевый "?" — нужно отчитаться + const pulse = 0.55 + 0.45 * Math.sin(time / 180); + ctx.globalAlpha = pulse; + ctx.fillStyle = 'rgba(255,170,0,0.22)'; + ctx.strokeStyle = '#ffaa00'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#ffaa00'; + ctx.font = 'bold 12px Arial'; ctx.textAlign = 'center'; + ctx.fillText('?', cx, cy + 4); + + } else if (mtype === 'complete') { + // Зелёный "✓" + ctx.fillStyle = 'rgba(39,174,96,0.18)'; + ctx.strokeStyle = '#27ae60'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#27ae60'; + ctx.font = 'bold 11px Arial'; ctx.textAlign = 'center'; + ctx.fillText('✓', cx, cy + 4); + } + + ctx.restore(); + }); + }, + + // ────────────────────────────────── + // Очистка и наложение день/ночь + // ────────────────────────────────── + clear(brightness) { + const ctx = this.ctx; + ctx.fillStyle = '#07070f'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + const grad = ctx.createRadialGradient(450, 300, 0, 450, 300, 500); + grad.addColorStop(0, '#12122a'); + grad.addColorStop(1, '#07070f'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + }, + + // Динамическое освещение: тьма с "дырками" от источников света + drawLightMask(brightness, lights, time) { + if (brightness >= 1) return; + const lc = this.lightCtx; + const darkness = Math.max(0, 1 - brightness) * 0.80; + + lc.clearRect(0, 0, this.lightCanvas.width, this.lightCanvas.height); + lc.fillStyle = `rgba(0,0,40,${darkness})`; + lc.fillRect(0, 0, this.lightCanvas.width, this.lightCanvas.height); + + if (lights && lights.length > 0) { + lc.globalCompositeOperation = 'destination-out'; + lights.forEach(light => { + const pos = this.toIso(light.x, light.y); + const flicker = light.flicker ? Math.sin(time / 80 + (light.x || 0) * 3.3) * 9 : 0; + const r = (light.radius || 90) + flicker; + const cy = pos.y - 18; + const g = lc.createRadialGradient(pos.x, cy, 0, pos.x, cy, r); + g.addColorStop(0, 'rgba(0,0,0,0.90)'); + g.addColorStop(0.45, 'rgba(0,0,0,0.55)'); + g.addColorStop(0.80, 'rgba(0,0,0,0.15)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + lc.fillStyle = g; + lc.beginPath(); + lc.arc(pos.x, cy, r, 0, Math.PI * 2); + lc.fill(); + }); + lc.globalCompositeOperation = 'source-over'; + } + + this.ctx.drawImage(this.lightCanvas, 0, 0); + }, + + // Дождь + drawRain(particles) { + const ctx = this.ctx; + ctx.strokeStyle = 'rgba(130,160,255,0.45)'; + ctx.lineWidth = 1; + particles.forEach(p => { + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - 2, p.y + 10); + ctx.stroke(); + }); + }, + + // Снег + drawSnow(particles) { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(220,220,255,0.6)'; + particles.forEach(p => { + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); + ctx.fill(); + }); + }, + + // Туман + drawFog(time) { + const ctx = this.ctx; + ctx.fillStyle = `rgba(180,190,200,${0.08+0.04*Math.sin(time/2000)})`; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + }, + + // Звёздное небо (ночью) + drawStars(time, brightness) { + if (brightness > 0.6) return; + const alpha = (1 - brightness / 0.6) * 0.7; + const ctx = this.ctx; + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + // Постоянный seed для позиций + for (let i = 0; i < 60; i++) { + const sx = (((i * 1619 + 7) * 9301 + 49297) % 233280) / 233280 * 900; + const sy = (((i * 3491 + 13) * 9301 + 49297) % 233280) / 233280 * 120; + const twinkle = 0.3 + 0.7 * Math.abs(Math.sin(time/800 + i)); + const r = 0.5 + (i%3)*0.4; + ctx.globalAlpha = alpha * twinkle; + ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2); ctx.fill(); + } + ctx.globalAlpha = 1; + }, + + // ────────────────────────────────── + // Утилиты + // ────────────────────────────────── + _adj(hex, amt) { + const n = parseInt(hex.replace('#',''), 16); + const R = Math.max(0, Math.min(255, (n>>16)+amt)); + const G = Math.max(0, Math.min(255, ((n>>8)&0xff)+amt)); + const B = Math.max(0, Math.min(255, (n&0xff)+amt)); + return '#'+(0x1000000+R*0x10000+G*0x100+B).toString(16).slice(1); + }, + + drawText(text, x, y, color, size='13px', align='left') { + this.ctx.font = `${size} Arial`; + this.ctx.textAlign = align; + this.ctx.fillStyle = '#000'; this.ctx.fillText(text, x+1, y+1); + this.ctx.fillStyle = color; this.ctx.fillText(text, x, y); + }, + + // ══════════════════════════════════════════ + // ПОРТРЕТЫ — отдельный canvas для боя + // ══════════════════════════════════════════ + drawPlayerPortrait(player, canvas) { + if (!canvas) return; + const c = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + c.clearRect(0, 0, W, H); + // фон + c.fillStyle = '#06060e'; + c.fillRect(0, 0, W, H); + + const cls = player.cls || 'warrior'; + const cx = W / 2; + + // ── Базовое тело (общее) ────────────────── + const _body = (torsoColor, shoulderColor) => { + // тело + c.fillStyle = torsoColor; + c.fillRect(cx - 14, 58, 28, 26); + // плечи + c.fillStyle = shoulderColor; + c.fillRect(cx - 19, 55, 10, 10); + c.fillRect(cx + 9, 55, 10, 10); + // руки + c.fillStyle = torsoColor; + c.fillRect(cx - 21, 65, 8, 14); + c.fillRect(cx + 13, 65, 8, 14); + }; + + const _head = (faceColor, eyeColor, pupilColor) => { + // голова + c.fillStyle = faceColor; + c.beginPath(); c.arc(cx, 40, 14, 0, Math.PI * 2); c.fill(); + // глаза + c.fillStyle = eyeColor; + c.beginPath(); c.arc(cx - 5, 38, 3.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 38, 3.5, 0, Math.PI * 2); c.fill(); + // зрачки + c.fillStyle = pupilColor; + c.beginPath(); c.arc(cx - 5, 38, 1.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 38, 1.5, 0, Math.PI * 2); c.fill(); + // рот + c.strokeStyle = '#8b5e3c'; c.lineWidth = 1.5; + c.beginPath(); c.arc(cx, 44, 4, 0.15 * Math.PI, 0.85 * Math.PI); c.stroke(); + }; + + switch (cls) { + case 'warrior': { + _body('#4a6080', '#607090'); + // стальной шлем + c.fillStyle = '#7080a0'; + c.fillRect(cx - 14, 24, 28, 22); + c.beginPath(); c.arc(cx, 26, 14, Math.PI, 0); c.fill(); + // визор + c.fillStyle = '#30404a'; + c.fillRect(cx - 10, 34, 20, 8); + // красный плащ + c.fillStyle = '#8a1a1a'; + c.beginPath(); c.moveTo(cx - 16, 60); c.lineTo(cx - 22, 90); c.lineTo(cx + 22, 90); c.lineTo(cx + 16, 60); c.fill(); + break; + } + case 'mage': { + _body('#2a2a6a', '#1a1a5a'); + _head('#c8a882', '#88aaff', '#2233aa'); + // синяя роба + c.fillStyle = '#1a1a6a'; + c.fillRect(cx - 14, 58, 28, 30); + // остроконечная шляпа + c.fillStyle = '#1a1a8a'; + c.beginPath(); c.moveTo(cx, 2); c.lineTo(cx - 18, 38); c.lineTo(cx + 18, 38); c.fill(); + c.fillStyle = '#2828aa'; + c.fillRect(cx - 20, 36, 40, 6); + // светящиеся глаза + c.fillStyle = '#88aaff'; c.shadowColor = '#88aaff'; c.shadowBlur = 8; + c.beginPath(); c.arc(cx - 5, 38, 3, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 38, 3, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + break; + } + case 'archer': { + _body('#2a4a1a', '#3a5a28'); + _head('#c8a882', '#8a6a2a', '#2a1a00'); + // зелёный капюшон + c.fillStyle = '#2a4a1a'; + c.beginPath(); c.arc(cx, 36, 18, Math.PI, 0); c.fill(); + c.fillRect(cx - 18, 36, 36, 12); + c.fillStyle = '#3a5a28'; + c.fillRect(cx - 10, 26, 20, 14); + // лук за спиной + c.strokeStyle = '#6a4a1a'; c.lineWidth = 3; + c.beginPath(); c.arc(cx + 22, 55, 20, -0.7 * Math.PI, 0.4 * Math.PI); c.stroke(); + c.strokeStyle = '#b8a060'; c.lineWidth = 1; + c.beginPath(); c.moveTo(cx + 22, 41); c.lineTo(cx + 22, 82); c.stroke(); + break; + } + case 'paladin': { + _body('#b8902a', '#d4a832'); + _head('#c8a882', '#3a3a8a', '#0a0a3a'); + // золотой шлем + c.fillStyle = '#c8a020'; + c.beginPath(); c.arc(cx, 28, 15, Math.PI, 0); c.fill(); + c.fillRect(cx - 15, 28, 30, 18); + // крест на шлеме + c.fillStyle = '#ffffff'; + c.fillRect(cx - 2, 20, 4, 14); + c.fillRect(cx - 8, 26, 16, 4); + // белый плащ + c.fillStyle = '#e0e0d0'; + c.beginPath(); c.moveTo(cx - 16, 60); c.lineTo(cx - 22, 90); c.lineTo(cx + 22, 90); c.lineTo(cx + 16, 60); c.fill(); + break; + } + case 'necromancer': { + _body('#181828', '#0e0e1e'); + _head('#9098a8', '#aa44cc', '#5500aa'); + // тёмный капюшон + c.fillStyle = '#0e0e22'; + c.beginPath(); c.arc(cx, 36, 19, Math.PI, 0); c.fill(); + c.fillRect(cx - 19, 36, 38, 14); + c.beginPath(); c.moveTo(cx, 4); c.lineTo(cx - 16, 38); c.lineTo(cx + 16, 38); c.fill(); + // пурпурные глаза + c.fillStyle = '#cc44ff'; c.shadowColor = '#aa00ff'; c.shadowBlur = 10; + c.beginPath(); c.arc(cx - 5, 38, 3.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 38, 3.5, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + break; + } + case 'berserker': { + _body('#5a3020', '#6a3828'); + _head('#c8a078', '#cc4422', '#660000'); + // кожаный шлем с рогами + c.fillStyle = '#4a2810'; + c.beginPath(); c.arc(cx, 34, 15, Math.PI, 0); c.fill(); + c.fillRect(cx - 15, 34, 30, 10); + // рога + c.fillStyle = '#8a7050'; + c.beginPath(); c.moveTo(cx - 14, 26); c.lineTo(cx - 22, 10); c.lineTo(cx - 8, 28); c.fill(); + c.beginPath(); c.moveTo(cx + 14, 26); c.lineTo(cx + 22, 10); c.lineTo(cx + 8, 28); c.fill(); + // шрамы + c.strokeStyle = '#8a4030'; c.lineWidth = 1.5; + c.beginPath(); c.moveTo(cx - 8, 35); c.lineTo(cx - 2, 42); c.stroke(); + c.beginPath(); c.moveTo(cx + 4, 34); c.lineTo(cx + 8, 43); c.stroke(); + break; + } + case 'druid': { + _body('#2a4a18', '#1e3810'); + _head('#c8a882', '#3a7a20', '#0a3a00'); + // венок из листьев + c.fillStyle = '#2a6a10'; + c.beginPath(); c.arc(cx, 34, 17, Math.PI, 0); c.fill(); + c.fillRect(cx - 17, 34, 34, 6); + // листочки + [cx - 16, cx - 8, cx, cx + 8, cx + 16].forEach((lx, i) => { + c.fillStyle = i % 2 === 0 ? '#2a8020' : '#3a6a18'; + c.beginPath(); c.ellipse(lx, 26, 5, 8, (i - 2) * 0.3, 0, Math.PI * 2); c.fill(); + }); + // зелёное одеяние + c.fillStyle = '#1a3a10'; + c.fillRect(cx - 14, 58, 28, 30); + break; + } + default: { + _body('#4a4a6a', '#5a5a7a'); + _head('#c8a882', '#888888', '#333333'); + } + } + }, + + drawEnemyPortrait(enemy, canvas) { + if (!canvas || !enemy) return; + const c = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + c.clearRect(0, 0, W, H); + c.fillStyle = '#06060e'; + c.fillRect(0, 0, W, H); + + const type = enemy.type || 'goblin'; + const cx = W / 2; + + switch (type) { + case 'goblin': { + // голова + c.fillStyle = '#4a7a2a'; + c.beginPath(); c.arc(cx, 42, 18, 0, Math.PI * 2); c.fill(); + // уши + c.beginPath(); c.arc(cx - 17, 38, 7, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 17, 38, 7, 0, Math.PI * 2); c.fill(); + // глаза + c.fillStyle = '#ff4400'; + c.beginPath(); c.arc(cx - 6, 40, 4, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 40, 4, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#000'; + c.beginPath(); c.arc(cx - 6, 40, 1.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 40, 1.5, 0, Math.PI * 2); c.fill(); + // нос + c.fillStyle = '#3a6a1a'; + c.beginPath(); c.ellipse(cx, 47, 3, 2.5, 0, 0, Math.PI * 2); c.fill(); + // зубы + c.fillStyle = '#f0e890'; + c.fillRect(cx - 6, 52, 4, 5); + c.fillRect(cx + 2, 52, 4, 5); + // тело + c.fillStyle = '#3a6018'; + c.fillRect(cx - 12, 60, 24, 22); + break; + } + case 'slime': { + c.fillStyle = '#30c060'; + c.beginPath(); c.ellipse(cx, 62, 28, 22, 0, 0, Math.PI * 2); c.fill(); + // пузырьки + c.fillStyle = '#50e880'; + c.beginPath(); c.arc(cx - 8, 55, 6, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 52, 4, 0, Math.PI * 2); c.fill(); + // глаза + c.fillStyle = '#fff'; + c.beginPath(); c.arc(cx - 7, 62, 5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 7, 62, 5, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#000'; + c.beginPath(); c.arc(cx - 7, 62, 2.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 7, 62, 2.5, 0, Math.PI * 2); c.fill(); + break; + } + case 'bat': { + // крылья + c.fillStyle = '#3a1a4a'; + c.beginPath(); c.moveTo(cx, 50); c.lineTo(cx - 40, 30); c.lineTo(cx - 30, 60); c.fill(); + c.beginPath(); c.moveTo(cx, 50); c.lineTo(cx + 40, 30); c.lineTo(cx + 30, 60); c.fill(); + // тело + c.fillStyle = '#2a1a3a'; + c.beginPath(); c.ellipse(cx, 55, 14, 18, 0, 0, Math.PI * 2); c.fill(); + // голова + c.beginPath(); c.arc(cx, 38, 12, 0, Math.PI * 2); c.fill(); + // уши + c.beginPath(); c.moveTo(cx - 9, 30); c.lineTo(cx - 14, 14); c.lineTo(cx - 2, 28); c.fill(); + c.beginPath(); c.moveTo(cx + 9, 30); c.lineTo(cx + 14, 14); c.lineTo(cx + 2, 28); c.fill(); + // глаза + c.fillStyle = '#ff2222'; c.shadowColor = '#ff0000'; c.shadowBlur = 6; + c.beginPath(); c.arc(cx - 5, 37, 3, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 37, 3, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + break; + } + case 'wolf': { + // туловище + c.fillStyle = '#606060'; + c.beginPath(); c.ellipse(cx, 68, 22, 14, 0, 0, Math.PI * 2); c.fill(); + // голова + c.beginPath(); c.ellipse(cx - 6, 44, 16, 13, -0.3, 0, Math.PI * 2); c.fill(); + // морда + c.fillStyle = '#888'; + c.beginPath(); c.ellipse(cx - 18, 48, 9, 6, -0.4, 0, Math.PI * 2); c.fill(); + // уши + c.fillStyle = '#505050'; + c.beginPath(); c.moveTo(cx - 4, 34); c.lineTo(cx - 12, 20); c.lineTo(cx + 2, 32); c.fill(); + c.beginPath(); c.moveTo(cx + 4, 34); c.lineTo(cx + 10, 20); c.lineTo(cx + 8, 33); c.fill(); + // глаза + c.fillStyle = '#ffaa00'; + c.beginPath(); c.arc(cx - 9, 42, 3, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#000'; + c.beginPath(); c.arc(cx - 9, 42, 1.5, 0, Math.PI * 2); c.fill(); + // нос + c.fillStyle = '#222'; + c.beginPath(); c.ellipse(cx - 22, 47, 4, 2.5, 0, 0, Math.PI * 2); c.fill(); + break; + } + case 'spider': { + // тело + c.fillStyle = '#2a1a0a'; + c.beginPath(); c.ellipse(cx, 60, 16, 20, 0, 0, Math.PI * 2); c.fill(); + // голова + c.beginPath(); c.arc(cx, 38, 12, 0, Math.PI * 2); c.fill(); + // ноги (8 штук) + c.strokeStyle = '#1a0a00'; c.lineWidth = 2.5; + [[-1, -35, -1, -55], [1, -30, 1, -50], [-1, -20, -1, -42], [1, -15, 1, -38]].forEach(([sx, sy, ex, ey], i) => { + const side = i % 2 === 0 ? -1 : 1; + c.beginPath(); c.moveTo(cx + side * 15, 58); c.lineTo(cx + side * (15 + Math.abs(sx) * 18), 58 + sy); c.stroke(); + c.beginPath(); c.moveTo(cx + side * 15, 65); c.lineTo(cx + side * (15 + Math.abs(sx) * 18), 65 + sy + 10); c.stroke(); + }); + // глаза + c.fillStyle = '#ff4400'; + for (let i = 0; i < 4; i++) { + c.beginPath(); c.arc(cx - 9 + i * 6, 36, 2, 0, Math.PI * 2); c.fill(); + } + break; + } + case 'bandit': { + // тело + c.fillStyle = '#3a3028'; + c.fillRect(cx - 13, 58, 26, 24); + // голова + c.fillStyle = '#c8a078'; + c.beginPath(); c.arc(cx, 42, 14, 0, Math.PI * 2); c.fill(); + // маска/повязка + c.fillStyle = '#1a1010'; + c.fillRect(cx - 12, 38, 24, 8); + // шляпа + c.fillStyle = '#2a2018'; + c.fillRect(cx - 16, 26, 32, 4); + c.fillRect(cx - 11, 16, 22, 14); + // глаза + c.fillStyle = '#cc8800'; + c.beginPath(); c.arc(cx - 5, 42, 2.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 42, 2.5, 0, Math.PI * 2); c.fill(); + // кинжал + c.strokeStyle = '#aaa'; c.lineWidth = 2; + c.beginPath(); c.moveTo(cx + 18, 58); c.lineTo(cx + 18, 82); c.stroke(); + c.fillStyle = '#6a5030'; + c.fillRect(cx + 15, 58, 6, 5); + break; + } + case 'skeleton': { + // кости тела + c.fillStyle = '#d0c8a0'; + c.fillRect(cx - 8, 58, 16, 28); + c.fillStyle = '#e8e0b8'; + c.fillRect(cx - 3, 62, 6, 5); + c.fillRect(cx - 3, 71, 6, 5); + c.fillRect(cx - 3, 80, 6, 5); + // руки-кости + c.fillRect(cx - 20, 62, 8, 18); + c.fillRect(cx + 12, 62, 8, 18); + // череп + c.beginPath(); c.arc(cx, 40, 16, 0, Math.PI * 2); c.fill(); + // глазницы + c.fillStyle = '#1a1a0a'; + c.beginPath(); c.arc(cx - 6, 38, 5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 38, 5, 0, Math.PI * 2); c.fill(); + // нос + c.beginPath(); c.arc(cx, 46, 2, 0, Math.PI * 2); c.fill(); + // зубы + c.fillStyle = '#d0c8a0'; + for (let i = 0; i < 5; i++) c.fillRect(cx - 9 + i * 5, 50, 4, 4); + break; + } + case 'orc': { + // тело + c.fillStyle = '#3a5a1a'; + c.fillRect(cx - 18, 56, 36, 30); + // плечи + c.beginPath(); c.arc(cx - 18, 58, 10, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 18, 58, 10, 0, Math.PI * 2); c.fill(); + // голова + c.fillStyle = '#4a6a28'; + c.beginPath(); c.arc(cx, 38, 18, 0, Math.PI * 2); c.fill(); + // клыки + c.fillStyle = '#e8e0a0'; + c.fillRect(cx - 8, 50, 5, 8); + c.fillRect(cx + 3, 50, 5, 8); + // глаза + c.fillStyle = '#ff6600'; + c.beginPath(); c.arc(cx - 6, 36, 5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 36, 5, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#000'; + c.beginPath(); c.arc(cx - 6, 36, 2, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 36, 2, 0, Math.PI * 2); c.fill(); + break; + } + case 'zombie': { + // тело (гниющее) + c.fillStyle = '#3a5a28'; + c.fillRect(cx - 13, 58, 26, 28); + // пятна гнили + c.fillStyle = '#2a4018'; + c.beginPath(); c.arc(cx - 4, 65, 5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 74, 4, 0, Math.PI * 2); c.fill(); + // голова + c.fillStyle = '#7a9a5a'; + c.beginPath(); c.arc(cx, 42, 15, 0, Math.PI * 2); c.fill(); + // раны + c.fillStyle = '#4a0a0a'; + c.beginPath(); c.arc(cx + 6, 36, 4, 0, Math.PI * 2); c.fill(); + // глаза мёртвые + c.fillStyle = '#b8c8a0'; + c.beginPath(); c.arc(cx - 5, 40, 4, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 40, 4, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#888a70'; + c.beginPath(); c.arc(cx - 5, 40, 2, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 40, 2, 0, Math.PI * 2); c.fill(); + // рот открыт + c.fillStyle = '#1a0a08'; + c.beginPath(); c.arc(cx, 48, 5, 0.1 * Math.PI, 0.9 * Math.PI); c.fill(); + break; + } + case 'troll': { + // огромное тело + c.fillStyle = '#4a6a30'; + c.beginPath(); c.ellipse(cx, 68, 28, 22, 0, 0, Math.PI * 2); c.fill(); + // плечи широкие + c.beginPath(); c.arc(cx - 26, 60, 14, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 26, 60, 14, 0, Math.PI * 2); c.fill(); + // голова + c.beginPath(); c.arc(cx, 38, 20, 0, Math.PI * 2); c.fill(); + // нос крупный + c.fillStyle = '#3a5a20'; + c.beginPath(); c.ellipse(cx, 40, 7, 8, 0, 0, Math.PI * 2); c.fill(); + // глаза маленькие + c.fillStyle = '#ff8800'; + c.beginPath(); c.arc(cx - 8, 34, 4, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 8, 34, 4, 0, Math.PI * 2); c.fill(); + break; + } + case 'yeti': { + // белая шерсть + c.fillStyle = '#d8e0e8'; + c.beginPath(); c.ellipse(cx, 66, 26, 22, 0, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx - 24, 58, 13, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 24, 58, 13, 0, Math.PI * 2); c.fill(); + // голова + c.beginPath(); c.arc(cx, 38, 20, 0, Math.PI * 2); c.fill(); + // тёмная морда + c.fillStyle = '#b0b8c0'; + c.beginPath(); c.ellipse(cx, 44, 12, 10, 0, 0, Math.PI * 2); c.fill(); + // нос чёрный + c.fillStyle = '#202830'; + c.beginPath(); c.ellipse(cx, 42, 5, 3.5, 0, 0, Math.PI * 2); c.fill(); + // глаза + c.fillStyle = '#88aacc'; + c.beginPath(); c.arc(cx - 9, 33, 4.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 9, 33, 4.5, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#000'; + c.beginPath(); c.arc(cx - 9, 33, 2, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 9, 33, 2, 0, Math.PI * 2); c.fill(); + break; + } + case 'witch': { + // тело в чёрном + c.fillStyle = '#181820'; + c.fillRect(cx - 13, 58, 26, 28); + // голова + c.fillStyle = '#b0a080'; + c.beginPath(); c.arc(cx, 42, 14, 0, Math.PI * 2); c.fill(); + // шляпа + c.fillStyle = '#101018'; + c.fillRect(cx - 18, 26, 36, 5); + c.fillRect(cx - 10, 10, 20, 18); + // зелёные глаза + c.fillStyle = '#22cc44'; c.shadowColor = '#00ff44'; c.shadowBlur = 8; + c.beginPath(); c.arc(cx - 5, 40, 3.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 5, 40, 3.5, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + // нос крючковатый + c.fillStyle = '#988060'; + c.beginPath(); c.moveTo(cx, 46); c.lineTo(cx - 5, 54); c.lineTo(cx + 2, 53); c.fill(); + break; + } + case 'golem': { + // каменное тело + c.fillStyle = '#606870'; + c.fillRect(cx - 20, 54, 40, 32); + // детали камня + c.fillStyle = '#505860'; + c.fillRect(cx - 18, 60, 14, 10); + c.fillRect(cx + 4, 64, 14, 8); + // плечи кубические + c.fillRect(cx - 28, 50, 14, 14); + c.fillRect(cx + 14, 50, 14, 14); + // голова + c.fillStyle = '#686e78'; + c.fillRect(cx - 16, 24, 32, 32); + // кристалл в груди + c.fillStyle = '#22aaff'; c.shadowColor = '#0088ff'; c.shadowBlur = 10; + c.beginPath(); c.moveTo(cx, 58); c.lineTo(cx - 7, 68); c.lineTo(cx, 76); c.lineTo(cx + 7, 68); c.fill(); + c.shadowBlur = 0; + // глаза-кристаллы + c.fillStyle = '#44ccff'; c.shadowColor = '#0088ff'; c.shadowBlur = 8; + c.fillRect(cx - 10, 32, 8, 6); + c.fillRect(cx + 2, 32, 8, 6); + c.shadowBlur = 0; + break; + } + case 'dragon': { + // туловище + c.fillStyle = '#8a1a1a'; + c.beginPath(); c.ellipse(cx + 4, 66, 26, 18, 0.2, 0, Math.PI * 2); c.fill(); + // шея + c.fillStyle = '#9a2020'; + c.fillRect(cx - 6, 44, 16, 24); + // голова + c.beginPath(); c.ellipse(cx - 2, 36, 18, 14, -0.3, 0, Math.PI * 2); c.fill(); + // морда + c.beginPath(); c.ellipse(cx - 16, 40, 10, 7, -0.4, 0, Math.PI * 2); c.fill(); + // рога + c.fillStyle = '#6a1010'; + c.beginPath(); c.moveTo(cx + 6, 26); c.lineTo(cx + 14, 8); c.lineTo(cx + 10, 28); c.fill(); + c.beginPath(); c.moveTo(cx - 2, 24); c.lineTo(cx + 4, 8); c.lineTo(cx + 2, 26); c.fill(); + // глаза огненные + c.fillStyle = '#ffaa00'; c.shadowColor = '#ff6600'; c.shadowBlur = 10; + c.beginPath(); c.arc(cx - 4, 34, 5, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + // крыло + c.fillStyle = '#6a1010'; + c.beginPath(); c.moveTo(cx + 18, 52); c.lineTo(cx + 42, 26); c.lineTo(cx + 40, 60); c.lineTo(cx + 22, 68); c.fill(); + break; + } + case 'lich': { + // мантия + c.fillStyle = '#0e0818'; + c.beginPath(); c.moveTo(cx - 20, 58); c.lineTo(cx - 28, 96); c.lineTo(cx + 28, 96); c.lineTo(cx + 20, 58); c.fill(); + c.fillRect(cx - 13, 56, 26, 8); + // руки-кости + c.fillStyle = '#c8c0a8'; + c.fillRect(cx - 22, 62, 6, 20); + c.fillRect(cx + 16, 62, 6, 20); + // череп + c.beginPath(); c.arc(cx, 38, 17, 0, Math.PI * 2); c.fill(); + // глазницы с огнём + c.fillStyle = '#0a0818'; + c.beginPath(); c.arc(cx - 6, 36, 6, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 36, 6, 0, Math.PI * 2); c.fill(); + // пурпурный огонь в глазах + c.fillStyle = '#cc22ff'; c.shadowColor = '#aa00ff'; c.shadowBlur = 12; + c.beginPath(); c.arc(cx - 6, 36, 3.5, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.arc(cx + 6, 36, 3.5, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + // зубы черепа + c.fillStyle = '#c8c0a8'; + for (let i = 0; i < 5; i++) c.fillRect(cx - 9 + i * 5, 48, 4, 5); + // корона тёмная + c.fillStyle = '#3a0060'; + c.fillRect(cx - 14, 18, 28, 6); + [cx - 12, cx - 4, cx + 4, cx + 12].forEach(kx => { + c.fillRect(kx - 2, 10, 5, 10); + }); + // самоцветы + c.fillStyle = '#cc22ff'; + [cx - 10, cx + 2, cx + 14].forEach(kx => { + c.beginPath(); c.arc(kx, 14, 2.5, 0, Math.PI * 2); c.fill(); + }); + break; + } + case 'ghost': { + // Полупрозрачная фигура призрака + c.globalAlpha = 0.75; + // Свечение + const grd = c.createRadialGradient(cx, 55, 0, cx, 55, 40); + grd.addColorStop(0, 'rgba(140,200,255,0.5)'); + grd.addColorStop(1, 'rgba(80,140,255,0)'); + c.fillStyle = grd; + c.beginPath(); c.arc(cx, 55, 40, 0, Math.PI * 2); c.fill(); + // Мантия-тело + c.fillStyle = 'rgba(160,210,255,0.8)'; + c.beginPath(); + c.moveTo(cx - 14, 46); c.lineTo(cx - 18, 85); c.lineTo(cx - 8, 78); + c.lineTo(cx, 85); c.lineTo(cx + 8, 78); c.lineTo(cx + 18, 85); c.lineTo(cx + 14, 46); + c.closePath(); c.fill(); + // Голова + c.fillStyle = 'rgba(200,230,255,0.9)'; + c.beginPath(); c.arc(cx, 36, 16, 0, Math.PI * 2); c.fill(); + // Светящиеся глаза + c.fillStyle = '#88ccff'; c.shadowColor = '#66aaff'; c.shadowBlur = 12; + c.beginPath(); c.ellipse(cx - 5, 35, 4.5, 3, 0, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.ellipse(cx + 5, 35, 4.5, 3, 0, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + c.globalAlpha = 1; + break; + } + case 'wyvern': { + // Виверна — небольшой дракон с крыльями + // Крыло слева + c.fillStyle = '#1a5028'; + c.beginPath(); c.moveTo(cx - 8, 52); c.lineTo(cx - 42, 30); c.lineTo(cx - 30, 60); c.lineTo(cx - 10, 68); c.fill(); + // Туловище + c.fillStyle = '#2a6032'; + c.beginPath(); c.ellipse(cx + 2, 68, 22, 16, 0.15, 0, Math.PI * 2); c.fill(); + // Шея + c.fillRect(cx - 5, 48, 14, 22); + // Голова + c.beginPath(); c.ellipse(cx, 36, 14, 11, -0.2, 0, Math.PI * 2); c.fill(); + // Морда + c.beginPath(); c.ellipse(cx - 14, 40, 8, 5, -0.3, 0, Math.PI * 2); c.fill(); + // Хвост + c.strokeStyle = '#2a6032'; c.lineWidth = 6; + c.beginPath(); c.moveTo(cx + 20, 68); c.quadraticCurveTo(cx + 38, 72, cx + 40, 88); c.stroke(); + // Рог/гребень + c.fillStyle = '#1a4020'; + c.beginPath(); c.moveTo(cx + 4, 28); c.lineTo(cx + 10, 14); c.lineTo(cx + 8, 30); c.fill(); + c.beginPath(); c.moveTo(cx - 2, 26); c.lineTo(cx + 2, 12); c.lineTo(cx + 2, 28); c.fill(); + // Глаза + c.fillStyle = '#eecc00'; c.shadowColor = '#ffaa00'; c.shadowBlur = 8; + c.beginPath(); c.arc(cx - 4, 35, 4, 0, Math.PI * 2); c.fill(); + c.shadowBlur = 0; + break; + } + case 'chaos_lord': { + // Тёмный аморфный силуэт — тело + c.fillStyle = '#0d0018'; + c.beginPath(); c.ellipse(cx, 68, 26, 22, 0, 0, Math.PI * 2); c.fill(); + // Верхняя часть — голова-туман + c.fillStyle = '#1a0028'; + c.beginPath(); c.ellipse(cx, 40, 22, 25, 0, 0, Math.PI * 2); c.fill(); + // Корона из тьмы + c.fillStyle = '#3a0055'; + for (let i = 0; i < 5; i++) { + const px2 = cx - 20 + i * 10, spiky = i % 2 === 0 ? 14 : 8; + c.beginPath(); c.moveTo(px2 - 5, 26); c.lineTo(px2, 26 - spiky); c.lineTo(px2 + 5, 26); c.fill(); + } + c.fillRect(cx - 22, 23, 44, 6); + // Множество глаз + const eyes = [[cx - 9, 38], [cx + 9, 38], [cx - 3, 45], [cx + 3, 45], [cx, 32]]; + eyes.forEach(([ex, ey]) => { + c.fillStyle = '#cc00ff'; c.shadowColor = '#ff00ff'; c.shadowBlur = 6; + c.beginPath(); c.arc(ex, ey, 2.5, 0, Math.PI * 2); c.fill(); + }); + c.shadowBlur = 0; + // Щупальца внизу + c.strokeStyle = '#1a0030'; c.lineWidth = 4; + for (let i = -2; i <= 2; i++) { + c.beginPath(); + c.moveTo(cx + i * 8, 82); + c.quadraticCurveTo(cx + i * 12, 92, cx + i * 6 + Math.sin(i) * 4, 100); + c.stroke(); + } + break; + } + } + }, +}; diff --git a/rpg.js b/rpg.js new file mode 100644 index 0000000..82cdab6 --- /dev/null +++ b/rpg.js @@ -0,0 +1,1026 @@ +// ============================================================ +// RPG.JS — Данные и механики игры +// ============================================================ + +const RPG = { + + // ══════════════════════════════════════════ + // КЛАССЫ ПЕРСОНАЖЕЙ [данные в data/classes.json] + // ══════════════════════════════════════════ + CLASSES: {}, // заполняется DataLoader из data/classes.json + + // ══════════════════════════════════════════ + // ЗАКЛИНАНИЯ [данные в data/classes.json] + // ══════════════════════════════════════════ + SPELLS: {}, // заполняется DataLoader из data/classes.json + + // ══════════════════════════════════════════ + // НАВЫКИ [данные в data/classes.json] + // ══════════════════════════════════════════ + SKILLS: {}, // заполняется DataLoader из data/classes.json + + // Пул навыков при повышении уровня + getLevelUpSkills(classId, learnedSpells) { + const pool = [ + 'tough_skin','sharp_mind','quick_feet','iron_will','arcane_mastery','fortify', + 'learn_power_strike','learn_berserk','learn_greater_heal', + 'learn_fireball2','learn_blizzard','learn_chain_lightning', + 'learn_stone_skin','learn_earthquake','learn_arrow_rain', + ]; + // Фильтр: не предлагать уже изученные заклинания + const available = pool.filter(sk => { + const s = this.SKILLS[sk]; + if (s.effect === 'spell' && learnedSpells.includes(s.val)) return false; + return true; + }); + // Выбираем 3 случайных + const shuffled = available.sort(() => Math.random() - 0.5); + return shuffled.slice(0, 3); + }, + + // ══════════════════════════════════════════ + // БАЗЫ ДАННЫХ ВРАГОВ [данные в data/enemies.json] + // ══════════════════════════════════════════ + ENEMY_DB: {}, // заполняется DataLoader из data/enemies.json + + createEnemy(type, level, x, y) { + const db = this.ENEMY_DB[type] || this.ENEMY_DB.goblin; + const m = 1 + (level - 1) * 0.22; + return { + id: 'e_'+Date.now()+'_'+Math.random().toString(36).substr(2,6), + type, name: db.name, level, + hp: Math.floor(db.hp * m), + maxHp: Math.floor(db.hp * m), + mp: db.hasMp ? Math.floor(50 * m) : 0, + maxMp: db.hasMp ? Math.floor(50 * m) : 0, + dmg: Math.floor(db.dmg * m), + def: Math.floor(db.def * m), + exp: Math.floor(db.exp * m), + gold: Math.floor(db.gold* m), + loot: db.loot, + isBoss: db.isBoss || false, + ai: db.ai || null, + weakness: db.weakness || null, + resist: db.resist || null, + isMini: db.isMini || false, + uniqueLoot: db.uniqueLoot || null, + x, y, + isAtk: false, + status: null, statusTurns: 0, // яд/горение/заморозка + debuff: 1, // множитель урона (проклятие) + }; + }, + + // ══════════════════════════════════════════ + // СОЗДАНИЕ ПЕРСОНАЖА + // ══════════════════════════════════════════ + createCharacter(classId) { + const cls = this.CLASSES[classId] || this.CLASSES.warrior; + return { + class: classId, + level: 1, + exp: 0, expNext: 100, + hp: cls.hp, maxHp: cls.hp, + mp: cls.mp, maxMp: cls.mp, + str: cls.str, def: cls.def, mag: cls.mag, spd: cls.spd, + baseStr: cls.str, baseDef: cls.def, baseMag: cls.mag, baseSpd: cls.spd, + gold: 50, + x: 6, y: 6, tx: 6, ty: 6, isMoving: false, mp_move: 0, facing: 'down', + inventory: [], + equipment: { head:null, chest:null, legs:null, feet:null, weapon:null, shield:null, acc:null }, + learnedSpells: [...cls.startSpells], + quests: [], + completedQuests: [], + stats: { kills:0, dmgDealt:0, dmgTaken:0 }, + hairColor: '#3a2510', + buffs: [], // активные баффы: [{stat, val, expires}] + status: null, // яд/горение/заморозка + statusTurns: 0, + perks: [], + perkPoints: 0, + deathSaveUsed: false, + bestiary: {}, + foundNotes: [], + }; + }, + + // ══════════════════════════════════════════ + // ПРЕДМЕТЫ + // ══════════════════════════════════════════ + RARITY_COLORS: { common:'#888', uncommon:'#27ae60', rare:'#2980b9', epic:'#8e44ad', legendary:'#e67e22' }, + + createItem(id, type, name, opts) { + opts = opts || {}; + return { + id: id || ('item_'+Date.now()+'_'+Math.random().toString(36).substr(2,5)), + type, name, + desc: opts.desc || '', + rarity: opts.rarity || 'common', + value: opts.value || 0, + damage: opts.damage || 0, + defense: opts.defense || 0, + healAmount: opts.healAmount || 0, + restoreMp: opts.restoreMp || 0, + bonusStr: opts.bonusStr || 0, + bonusDef: opts.bonusDef || 0, + bonusMag: opts.bonusMag || 0, + bonusHp: opts.bonusHp || 0, + bonusMp: opts.bonusMp || 0, + spell: opts.spell || null, + stackable: opts.stackable || false, + qty: opts.qty || 1, + slot: opts.slot || null, // head/chest/legs/feet/weapon/shield/acc + icon: opts.icon || this._itemIcon(type), + setId: opts.setId || null, + enchant: opts.enchant || null, + buffStat: opts.buffStat || null, // 'str'|'def'|'regen' + buffVal: opts.buffVal || 1, + buffDur: opts.buffDur || 0, + combatEffect: opts.combatEffect || null, // 'poison'|'fire' + combatDmg: opts.combatDmg || 0, + cureStatus: opts.cureStatus || false, + }; + }, + + _itemIcon(type) { + return { weapon:'⚔️', armor:'🛡️', potion:'🧪', scroll:'📜', material:'📦', gold:'💰', food:'🍖', gem:'💎' }[type] || '❓'; + }, + + // База стартовых предметов по классу + getStarterItems(classId) { + const kits = { + warrior: [ + { id:'sw1',type:'weapon',name:'Меч', opts:{ damage:5, value:50, slot:'weapon', icon:'⚔️' }}, + { id:'sh1',type:'armor', name:'Деревянный щит', opts:{ defense:3, value:40, slot:'shield', icon:'🛡️' }}, + ], + mage: [ + { id:'st1',type:'weapon',name:'Посох мага', opts:{ damage:3, bonusMag:4, value:60, slot:'weapon', icon:'✨' }}, + ], + archer: [ + { id:'bw1',type:'weapon',name:'Лук', opts:{ damage:7, value:70, slot:'weapon', icon:'🏹' }}, + ], + paladin: [ + { id:'hm1',type:'weapon',name:'Молот', opts:{ damage:5, value:55, slot:'weapon', icon:'🔨' }}, + { id:'sh1',type:'armor', name:'Щит паладина',opts:{ defense:4, value:45, slot:'shield', icon:'🛡️' }}, + ], + necromancer:[ + { id:'sk1',type:'weapon',name:'Посох тьмы', opts:{ damage:4, bonusMag:3, value:70, slot:'weapon', icon:'💀' }}, + ], + berserker: [ + { id:'ax1',type:'weapon',name:'Топор', opts:{ damage:9, value:75, slot:'weapon', icon:'🪓' }}, + ], + druid: [ + { id:'st2',type:'weapon',name:'Посох друида',opts:{ damage:3, bonusMag:5, value:65, slot:'weapon', icon:'🌿' }}, + ], + }; + const common = [ + { id:'hp1',type:'potion',name:'Зелье HP', opts:{ healAmount:30, value:20, stackable:true, qty:3, icon:'🧪' }}, + { id:'mp1',type:'potion',name:'Зелье MP', opts:{ restoreMp:20, value:25, stackable:true, qty:2, icon:'💧' }}, + ]; + const kit = kits[classId] || kits.warrior; + return [...kit, ...common].map(d => this.createItem(d.id, d.type, d.name, d.opts)); + }, + + // База магазина [данные в data/shop.json] + SHOP_ITEMS: [], // заполняется DataLoader из data/shop.json + + // Лут с врага [данные в data/loot.json] + LOOT_DB: {}, // заполняется DataLoader из data/loot.json + + generateLoot(enemy) { + const items = []; + // Всегда — золото + items.push(this.createItem('gold','gold','Золото',{ value:enemy.gold, stackable:true, qty:enemy.gold, icon:'💰' })); + // Лут-таблица (20% каждый) + if (enemy.loot) { + enemy.loot.forEach(lid => { + if (Math.random() < 0.22) { + const ld = this.LOOT_DB[lid]; + if (!ld) return; + items.push(this.createItem(lid+'_'+Date.now(), ld.t, ld.n, { + value:ld.v, damage:ld.dmg, defense:ld.def, + healAmount:ld.heal, spell:ld.spell, + slot:ld.slot, icon:ld.icon, + rarity:ld.rarity||'common', + bonusMag: ld.bonusMag || 0, + setId: ld.setId || null, + })); + } + }); + } + // Шанс 25% — случайное зелье + if (Math.random() < 0.25) { + items.push(this.createItem('rnd_hp','potion','Зелье HP',{ healAmount:30+Math.floor(Math.random()*30), value:20, stackable:true, qty:1, icon:'🧪' })); + } + return items; + }, + + // ══════════════════════════════════════════ + // КВЕСТЫ [данные в data/quests.json] + // ══════════════════════════════════════════ + QUEST_DB: [], // заполняется DataLoader из data/quests.json + + // ══════════════════════════════════════════ + // ИНВЕНТАРЬ И ЭКИПИРОВКА + // ══════════════════════════════════════════ + addToInventory(char, item) { + if (item.stackable) { + const ex = char.inventory.find(i => i.id === item.id && i.stackable); + if (ex) { ex.qty += item.qty; return true; } + } + if (char.inventory.length >= 28) return false; + char.inventory.push(item); + return true; + }, + + removeFromInventory(char, itemId, qty) { + qty = qty || 1; + const idx = char.inventory.findIndex(i => i.id === itemId); + if (idx < 0) return false; + const item = char.inventory[idx]; + if (item.stackable) { + item.qty -= qty; + if (item.qty <= 0) char.inventory.splice(idx, 1); + } else { + char.inventory.splice(idx, 1); + } + return true; + }, + + equip(char, item) { + const slot = item.slot; + if (!slot) return { ok:false, msg:'Нельзя экипировать!' }; + // Снять старый предмет + if (char.equipment[slot]) { + this.addToInventory(char, char.equipment[slot]); + } + char.equipment[slot] = item; + this.removeFromInventory(char, item.id); + return { ok:true, msg:'Экипировано: '+item.name }; + }, + + unequip(char, slot) { + const item = char.equipment[slot]; + if (!item) return false; + this.addToInventory(char, item); + char.equipment[slot] = null; + return true; + }, + + getEqBonus(char) { + const b = { damage:0, defense:0, hp:0, mp:0, str:0, def:0, mag:0 }; + Object.values(char.equipment).forEach(it => { + if (!it) return; + b.damage += it.damage || 0; + b.defense += it.defense || 0; + b.hp += it.bonusHp || 0; + b.mp += it.bonusMp || 0; + b.str += it.bonusStr|| 0; + b.def += it.bonusDef|| 0; + b.mag += it.bonusMag|| 0; + // зачарование + if (it.enchant && this.ENCHANTS[it.enchant]) { + const en = this.ENCHANTS[it.enchant].bonus; + b.damage += en.damage || 0; + b.defense += en.defense || 0; + b.hp += en.hp || 0; + b.mp += en.mp || 0; + b.str += en.str || 0; + b.mag += en.mag || 0; + } + }); + return b; + }, + + getTotalStats(char) { + const eq = this.getEqBonus(char); + const sb = this.getSetBonus(char); + // Активные баффы + let buffStr = 0, buffDef = 0; + const now = Date.now(); + if (char.buffs) { + char.buffs = char.buffs.filter(b => b.expires > now); + char.buffs.forEach(b => { + if (b.stat === 'str') buffStr = Math.max(buffStr, char.str * (b.val - 1)); + if (b.stat === 'def') buffDef = Math.max(buffDef, char.def * (b.val - 1)); + }); + } + return { + damage: (char.str + eq.str + buffStr + (sb.str||0)) + eq.damage, + defense: (char.def + eq.def + buffDef + (sb.def||0)) + eq.defense, + magic: (char.mag + eq.mag + (sb.mag||0)), + maxHp: char.maxHp + eq.hp + (sb.hp||0), + maxMp: char.maxMp + eq.mp + (sb.mp||0), + }; + }, + + // ── Сеты экипировки [данные в data/sets.json] ─────────── + EQUIPMENT_SETS: {}, // заполняется DataLoader из data/sets.json + + getSetBonus(char) { + const totals = { str:0, def:0, mag:0, hp:0, mp:0 }; + const active = []; + Object.entries(this.EQUIPMENT_SETS).forEach(([sid, set]) => { + let count = 0; + Object.values(char.equipment).forEach(it => { + if (!it) return; + // Совпадение по setId или по началу ID + if (it.setId === sid || set.pieces.some(pid => it.id && it.id.startsWith(pid))) count++; + }); + if (count === 0) return; + // найти наибольший порог + const thresholds = Object.keys(set.bonuses).map(Number).sort((a,b)=>b-a); + const thr = thresholds.find(t => count >= t); + if (thr == null) return; + const bon = set.bonuses[thr]; + totals.str += bon.str || 0; + totals.def += bon.def || 0; + totals.mag += bon.mag || 0; + totals.hp += bon.hp || 0; + totals.mp += bon.mp || 0; + active.push({ name:set.name, icon:set.icon, count, thr, desc:bon.desc }); + }); + totals._active = active; + return totals; + }, + + // ── Зачарование [данные в data/enchants.json] ─────────── + ENCHANTS: {}, // заполняется DataLoader из data/enchants.json + + _enchantTargetAllowed(slot, target) { + if (target === 'any') return true; + const weaponSlots = ['weapon','shield']; + const armorSlots = ['head','chest','legs','feet','acc']; + if (target === 'weapon') return weaponSlots.includes(slot); + if (target === 'armor') return armorSlots.includes(slot); + return false; + }, + + getAvailableEnchants(player, item) { + if (!item || !item.slot) return []; + return Object.entries(this.ENCHANTS) + .filter(([, en]) => this._enchantTargetAllowed(item.slot, en.target)) + .map(([eid, en]) => { + const matCount = player.inventory.filter(i => i.id && i.id.startsWith(en.mat)).reduce((s,i)=>s+(i.qty||1),0); + const hasGold = player.gold >= en.cost; + const hasMat = matCount >= en.matQty; + const matItem = this.LOOT_DB[en.mat]; + return { id:eid, ...en, matCount, hasGold, hasMat, + matName: matItem ? matItem.n : en.mat, + canDo: hasGold && hasMat && item.enchant !== eid }; + }); + }, + + enchantItem(player, item, enchantId) { + const en = this.ENCHANTS[enchantId]; + if (!en) return { ok:false, msg:'Неизвестное зачарование' }; + if (!item || !item.slot) return { ok:false, msg:'Нельзя зачаровать этот предмет' }; + if (!this._enchantTargetAllowed(item.slot, en.target)) return { ok:false, msg:'Не подходит для этого предмета' }; + if (player.gold < en.cost) return { ok:false, msg:'Недостаточно золота' }; + // подсчёт материала + let matLeft = en.matQty; + for (const invIt of player.inventory) { + if (invIt.id && invIt.id.startsWith(en.mat) && matLeft > 0) { + const take = Math.min(matLeft, invIt.qty || 1); + invIt.qty -= take; + matLeft -= take; + } + } + player.inventory = player.inventory.filter(i => (i.qty||1) > 0); + if (matLeft > 0) return { ok:false, msg:'Недостаточно материалов' }; + player.gold -= en.cost; + // старое зачарование удаляется, добавляется новое + const oldEnchant = item.enchant; + item.enchant = enchantId; + // обновить имя предмета (убрать старый суффикс, добавить новый) + const baseName = item._baseName || item.name; + item._baseName = baseName; + item.name = `${baseName} ${en.icon}`; + return { ok:true, msg:`Зачаровано: ${item.name}`, replaced: oldEnchant }; + }, + + useItem(char, item, combatEnemy) { + if (item.type === 'potion' || item.type === 'food') { + // Боевые зелья (бросок — работают только в бою) + if (item.combatEffect && combatEnemy) { + if (item.combatEffect === 'poison') { + combatEnemy.status = 'poison'; + combatEnemy.statusTurns = 3; + combatEnemy.dotDmg = 8; + this.removeFromInventory(char, item.id, 1); + return { ok:true, msg:`Яд нанесён на ${combatEnemy.name}! ☠️`, combatUsed:true }; + } + if (item.combatEffect === 'fire') { + const dmg = item.combatDmg || 30; + combatEnemy.hp = Math.max(0, combatEnemy.hp - dmg); + this.removeFromInventory(char, item.id, 1); + return { ok:true, msg:`Огненная колба: ${dmg} урона! 🔥`, combatUsed:true, dmg }; + } + } + if (item.combatEffect && !combatEnemy) { + return { ok:false, msg:'Это зелье можно использовать только в бою!' }; + } + if (item.healAmount) { + char.hp = Math.min(char.hp + item.healAmount, char.maxHp); + } + if (item.restoreMp) { + char.mp = Math.min(char.mp + item.restoreMp, char.maxMp); + } + // Снятие статусов + if (item.cureStatus) { + char.status = null; char.statusTurns = 0; + } + // Бафф к стату + if (item.buffStat && item.buffStat !== 'regen') { + char.buffs = char.buffs || []; + char.buffs.push({ stat: item.buffStat, val: item.buffVal, expires: Date.now() + item.buffDur }); + } + this.removeFromInventory(char, item.id, 1); + return { ok:true, msg:`Использовано: ${item.name}` }; + } + if (item.type === 'scroll' && item.spell) { + if (!char.learnedSpells.includes(item.spell)) { + char.learnedSpells.push(item.spell); + this.removeFromInventory(char, item.id); + const sp = this.SPELLS[item.spell]; + return { ok:true, msg:`Изучено: ${sp ? sp.name : item.spell}` }; + } + return { ok:false, msg:'Вы уже знаете это заклинание!' }; + } + return { ok:false, msg:'Нельзя использовать здесь!' }; + }, + + // ══════════════════════════════════════════ + // ЭЛЕМЕНТАЛЬНЫЕ СЛАБОСТИ / СОПРОТИВЛЕНИЯ + // ══════════════════════════════════════════ + ELEMENT_NAMES: { fire:'Огонь', ice:'Лёд', holy:'Святость', magic:'Магия', physical:'Физика', poison:'Яд', lightning:'Молния', dark:'Тьма' }, + + // Возвращает множитель урона: >1 = слабость, <1 = сопротивление, 1 = нейтрально + getElementMultiplier(enemy, damageType) { + if (!enemy || !damageType) return { mult: 1, type: 'neutral' }; + // Нормализация: "magic" покрывает lightning и dark + const weakAgainst = (w, dt) => { + if (w === dt) return true; + if (w === 'magic' && (dt === 'lightning' || dt === 'dark')) return true; + return false; + }; + if (enemy.weakness && weakAgainst(enemy.weakness, damageType)) { + return { mult: 1.5, type: 'weak' }; + } + if (enemy.resist && weakAgainst(enemy.resist, damageType)) { + return { mult: 0.5, type: 'resist' }; + } + return { mult: 1, type: 'neutral' }; + }, + + // ══════════════════════════════════════════ + // БОЕВАЯ СИСТЕМА + // ══════════════════════════════════════════ + // Суммирует все перк-бонусы конкретного типа эффекта + _sumPerkVal(char, effectType) { + if (!char.perks || !char.perks.length || !char.class) return 0; + const tree = this.PERK_TREE[char.class]; + if (!tree) return 0; + let total = 0; + for (const branch of tree.branches) { + for (const perk of branch.perks) { + if (perk.effect === effectType && char.perks.includes(perk.id)) { + total += perk.val; + } + } + } + return total; + }, + + calcDamage(attacker, defender) { + const as = this.getTotalStats(attacker); + const ds = (defender.isPlayer) ? this.getTotalStats(defender) : { defense: defender.def, damage: defender.dmg }; + let dmg = Math.max(1, as.damage - ds.defense); + + // Перк: уклонение защитника + if (defender.perks) { + const totalDodge = this._sumPerkVal(defender, 'dodge'); + if (totalDodge > 0 && Math.random() < totalDodge) { + return { dmg: 0, crit: false, dodged: true }; + } + } + + // Перк: усиление крита атакующего + let critMult = 1.5 + Math.random() * 0.5; + if (attacker.perks) critMult += this._sumPerkVal(attacker, 'critDmg'); + + const crit = Math.random() < 0.1 + (attacker.spd||0)*0.008; + if (crit) dmg = Math.floor(dmg * critMult); + dmg = Math.floor(dmg * (0.85 + Math.random()*0.3)); + + // Перк: ярость (< 30% HP) + if (attacker.perks && attacker.maxHp > 0 && attacker.hp / attacker.maxHp < 0.3) { + const enrage = this._sumPerkVal(attacker, 'enrage'); + if (enrage > 0) dmg = Math.floor(dmg * (1 + enrage)); + } + + return { dmg, crit }; + }, + + attackEnemy(player, enemy) { + const r = this.calcDamage(player, enemy); + if (r.dodged) return { dmg: 0, crit: false, dodged: true, killed: false }; + + // Элементальный множитель для физических атак + // Проверяем combatEffect оружия (poison/fire) или считаем physical + const weapon = player.equipment && player.equipment.weapon; + const atkElement = (weapon && weapon.combatEffect) || 'physical'; + const elemResult = this.getElementMultiplier(enemy, atkElement); + + let actual = Math.floor(r.dmg * (enemy.debuff || 1) * elemResult.mult); + enemy.hp = Math.max(0, enemy.hp - actual); + player.stats.dmgDealt += actual; + + // Перк: вампиризм + const lifesteal = this._sumPerkVal(player, 'lifesteal'); + if (lifesteal > 0 && actual > 0) { + const healed = Math.max(1, Math.floor(actual * lifesteal)); + player.hp = Math.min(player.hp + healed, player.maxHp); + } + + // Перк: двойной удар + const doubleChance = this._sumPerkVal(player, 'doubleAtk'); + if (doubleChance > 0 && Math.random() < doubleChance && enemy.hp > 0) { + const r2 = this.calcDamage(player, enemy); + if (!r2.dodged) { + const dmg2 = Math.floor(r2.dmg * (enemy.debuff || 1)); + enemy.hp = Math.max(0, enemy.hp - dmg2); + actual += dmg2; + player.stats.dmgDealt += dmg2; + } + } + + return { dmg: actual, crit: r.crit, killed: enemy.hp <= 0, elemType: elemResult.type }; + }, + + enemyAttackPlayer(enemy, player) { + // Перк: уклонение игрока + const dodge = this._sumPerkVal(player, 'dodge'); + if (dodge > 0 && Math.random() < dodge) { + return { dmg: 0, crit: false, killed: false, dodged: true }; + } + + const ds = this.getTotalStats(player); + let dmg = Math.max(1, enemy.dmg - ds.defense); + const crit = Math.random() < 0.08; + if (crit) dmg = Math.floor(dmg * 1.5); + dmg = Math.floor(dmg * (0.88 + Math.random()*0.24)); + + // Перк: смертный рывок (выжить с 1 HP один раз) + const hasDeathSave = this._sumPerkVal(player, 'deathSave') > 0; + if (hasDeathSave && !player.deathSaveUsed && player.hp - dmg <= 0 && player.hp > 1) { + player.hp = 1; + player.deathSaveUsed = true; + player.stats.dmgTaken += dmg; + return { dmg, crit, killed: false, deathSave: true }; + } + + player.hp = Math.max(0, player.hp - dmg); + player.stats.dmgTaken += dmg; + + // Перк: шипы — отражение урона + const thorns = this._sumPerkVal(player, 'thorns'); + if (thorns > 0 && dmg > 0) { + enemy.hp = Math.max(0, enemy.hp - Math.floor(dmg * thorns)); + } + + return { dmg, crit, killed: player.hp <= 0 }; + }, + + castSpell(player, spellId, enemy) { + const sp = this.SPELLS[spellId]; + if (!sp) return { ok:false, msg:'Неизвестное заклинание!' }; + if (!player.learnedSpells.includes(spellId)) return { ok:false, msg:'Вы не знаете этого заклинания!' }; + if (player.mp < sp.mp) return { ok:false, msg:'Недостаточно маны!' }; + player.mp -= sp.mp; + + const result = { ok:true, spellName: sp.name, particleType:'magic', dmg:0, heal:0 }; + + if (sp.dmg) { + const ds = this.getTotalStats(player); + let dmg = sp.dmg + Math.floor(ds.magic * 0.8) + Math.floor(Math.random()*8); + // Перк: усиление заклинаний + const spellBoost = this._sumPerkVal(player, 'spelldmg'); + if (spellBoost > 0) dmg = Math.floor(dmg * (1 + spellBoost)); + // dmgMult — для Мощного удара + if (sp.dmgMult) dmg = Math.floor(ds.damage * sp.dmgMult); + // Элементальный множитель (слабость / сопротивление) + if (enemy) { + const spellElement = sp.type || 'magic'; + const elemResult = this.getElementMultiplier(enemy, spellElement); + dmg = Math.floor(dmg * elemResult.mult); + result.elemType = elemResult.type; + result.elemName = spellElement; + enemy.hp = Math.max(0, enemy.hp - dmg); + result.dmg = dmg; + result.killed = enemy.hp <= 0; + } + result.particleType = sp.type === 'fire' ? 'fire' : sp.type === 'ice' ? 'ice' : sp.type === 'holy' ? 'holy' : 'magic'; + } + + if (sp.heal) { + const healed = Math.min(sp.heal, player.maxHp - player.hp); + player.hp += healed; + result.heal = healed; + result.particleType = 'heal'; + } + + if (sp.buff) { + player.buffs = player.buffs || []; + player.buffs.push({ stat: sp.buff, val: sp.val, expires: Date.now() + sp.dur }); + result.msg = `${sp.name}: бафф активен!`; + } + + if (sp.debuff && enemy) { + enemy.debuff = sp.val; + result.msg = `${sp.name}: враг ослаблен!`; + } + + if (sp.dot && enemy) { + enemy.status = sp.dot; + enemy.statusTurns = sp.dotTurns; + enemy.dotDmg = sp.dotDmg || 5; + result.msg = `${sp.name}: ${sp.dot}!`; + } + + if (sp.slow && enemy) { + enemy.status = 'slow'; + enemy.statusTurns = 2; + } + + return result; + }, + + // Урон от статусов в начале хода + tickStatus(entity) { + if (!entity.status || entity.statusTurns <= 0) return 0; + let dmg = 0; + if (entity.status === 'poison' || entity.status === 'burn') { + dmg = entity.dotDmg || 6; + entity.hp = Math.max(0, entity.hp - dmg); + } + entity.statusTurns--; + if (entity.statusTurns <= 0) entity.status = null; + return dmg; + }, + + // ══════════════════════════════════════════ + // СИСТЕМА УРОВНЕЙ + // ══════════════════════════════════════════ + checkLevelUp(char) { + if (char.exp < char.expNext) return false; + char.exp -= char.expNext; + char.level++; + char.expNext = Math.floor(100 * Math.pow(1.5, char.level - 1)); + const cls = this.CLASSES[char.class] || this.CLASSES.warrior; + const b = cls.lvlBonuses; + char.maxHp += b.hp; char.hp = char.maxHp; + char.maxMp += b.mp; char.mp = char.maxMp; + char.baseStr+= b.str; char.str = char.baseStr; + char.baseDef+= b.def; char.def = char.baseDef; + char.baseMag+= b.mag; char.mag = char.baseMag; + char.baseSpd+= b.spd; char.spd = char.baseSpd; + return true; + }, + + applySkill(char, skillId) { + const sk = this.SKILLS[skillId]; + if (!sk) return; + if (sk.effect === 'hp') { char.maxHp += sk.val; char.hp = Math.min(char.hp + sk.val, char.maxHp); } + if (sk.effect === 'mp') { char.maxMp += sk.val; char.mp = Math.min(char.mp + sk.val, char.maxMp); } + if (sk.effect === 'str') { char.baseStr += sk.val; char.str = char.baseStr; } + if (sk.effect === 'def') { char.baseDef += sk.val; char.def = char.baseDef; } + if (sk.effect === 'mag') { char.baseMag += sk.val; char.mag = char.baseMag; } + if (sk.effect === 'spd') { char.baseSpd += sk.val; char.spd = char.baseSpd; } + if (sk.effect === 'spell') { + if (!char.learnedSpells.includes(sk.val)) char.learnedSpells.push(sk.val); + } + }, + + // ══════════════════════════════════════════ + // ПРОХОДИМОСТЬ + // ══════════════════════════════════════════ + isPassable(map, x, y) { + if (x < 0 || y < 0 || x >= map[0].length || y >= map.length) return false; + const t = map[y][x]; + return t !== 1 && t !== 4 && t !== 6; // вода, стена, лава + }, + + // ══════════════════════════════════════════ + // СОХРАНЕНИЕ / ЗАГРУЗКА + // ══════════════════════════════════════════ + // СОХРАНЕНИЕ — 3 СЛОТА + // ══════════════════════════════════════════ + SAVE_PREFIX: 'eidon_s', + _LOC: { village:'Деревня', tavern:'Таверна', forest:'Лес', dungeon:'Подземелье', swamp:'Болото', mountain:'Горы', cave:'Пещера', ruins:'Руины', abyss:'Бездна' }, + + save(gameData, slot = 0) { + try { + const now = new Date(); + gameData._meta = { + date: now.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'}), + time: now.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}), + }; + // Set → Array при сериализации (Set не переживает JSON.stringify) + const json = JSON.stringify(gameData, (key, value) => { + if (value instanceof Set) return [...value]; + return value; + }); + localStorage.setItem(this.SAVE_PREFIX + slot, json); + // Асинхронно пишем JSON-файл если выбрана папка + if (typeof SaveFS !== 'undefined' && SaveFS.hasDir()) { + SaveFS.writeSlot(slot, gameData).catch(e => console.warn('SaveFS write:', e)); + } + return true; + } catch(e) { return false; } + }, + + load(slot = 0) { + try { + const s = localStorage.getItem(this.SAVE_PREFIX + slot); + return s ? JSON.parse(s) : null; + } catch(e) { return null; } + }, + + hasSave(slot = 0) { + return !!localStorage.getItem(this.SAVE_PREFIX + slot); + }, + + deleteSave(slot = 0) { + localStorage.removeItem(this.SAVE_PREFIX + slot); + if (typeof SaveFS !== 'undefined' && SaveFS.hasDir()) { + SaveFS.deleteSlot(slot).catch(e => console.warn('SaveFS delete:', e)); + } + }, + + getSaveMeta(slot = 0) { + try { + const s = localStorage.getItem(this.SAVE_PREFIX + slot); + if (!s) return null; + const d = JSON.parse(s); + const p = d.player; + const cls = this.CLASSES[p.class] || { name:'?', icon:'?' }; + const pt = p._playTime || 0; + const h = Math.floor(pt / 3600), m = Math.floor((pt % 3600) / 60); + return { + icon: cls.icon, + className: cls.name, + level: p.level, + mapName: this._LOC[d.mapId] || d.mapId || '?', + playTime: h > 0 ? `${h}ч ${m}м` : m > 0 ? `${m}м` : '<1м', + date: d._meta?.date || '?', + saveTime: d._meta?.time || '', + kills: p.stats?.kills || 0, + days: d.dayCount || 1, + }; + } catch(e) { return null; } + }, + + // ══════════════════════════════════════════ + // ДЕРЕВО НАВЫКОВ [данные в data/classes.json] + // ══════════════════════════════════════════ + PERK_TREE: {}, // заполняется DataLoader из data/classes.json + getPerkPrereq(classId, perkId) { + const tree = this.PERK_TREE[classId]; + if (!tree) return null; + for (const branch of tree.branches) { + const idx = branch.perks.findIndex(p => p.id === perkId); + if (idx > 0) return branch.perks[idx - 1].id; + } + return null; + }, + + canLearnPerk(char, perkId) { + if (char.perks.includes(perkId)) return { ok: false, msg: 'Уже изучено' }; + if ((char.perkPoints || 0) <= 0) return { ok: false, msg: 'Нет очков перков' }; + const req = this.getPerkPrereq(char.class, perkId); + if (req && !char.perks.includes(req)) return { ok: false, msg: 'Нужен предыдущий перк ветки' }; + return { ok: true }; + }, + + applyPerk(char, perkId) { + const check = this.canLearnPerk(char, perkId); + if (!check.ok) return check; + + const tree = this.PERK_TREE[char.class]; + let perk = null; + for (const branch of tree.branches) { + perk = branch.perks.find(p => p.id === perkId); + if (perk) break; + } + if (!perk) return { ok: false, msg: 'Перк не найден' }; + + char.perks.push(perkId); + char.perkPoints--; + + if (perk.effect === 'stat') { + if (perk.stat === 'str') { char.baseStr += perk.val; char.str = char.baseStr; } + if (perk.stat === 'def') { char.baseDef += perk.val; char.def = char.baseDef; } + if (perk.stat === 'mag') { char.baseMag += perk.val; char.mag = char.baseMag; } + if (perk.stat === 'spd') { char.baseSpd += perk.val; char.spd = char.baseSpd; } + if (perk.stat === 'maxHp') { char.maxHp += perk.val; char.hp = Math.min(char.hp + perk.val, char.maxHp); } + if (perk.stat === 'maxMp') { char.maxMp += perk.val; } + } + return { ok: true, msg: 'Перк изучен: ' + perk.name }; + }, + + // ══════════════════════════════════════════ + // ЖУРНАЛ ЛОРА [данные в data/lore.json] + // ══════════════════════════════════════════ + LORE_NOTES: [], // заполняется DataLoader из data/lore.json + + // ══════════════════════════════════════════ + // КРАФТИНГ [данные в data/recipes.json] + // ══════════════════════════════════════════ + CRAFT_RECIPES: [], // заполняется DataLoader из data/recipes.json + + canCraft(char, recipeId) { + const recipe = this.CRAFT_RECIPES.find(r => r.id === recipeId); + if (!recipe) return false; + return recipe.ingredients.every(ing => { + const total = char.inventory + .filter(i => i.id === ing.id || i.id.startsWith(ing.id + '_')) + .reduce((sum, i) => sum + (i.qty || 1), 0); + return total >= ing.qty; + }); + }, + + craft(char, recipeId) { + const recipe = this.CRAFT_RECIPES.find(r => r.id === recipeId); + if (!recipe) return { ok: false, msg: 'Рецепт не найден' }; + if (!this.canCraft(char, recipeId)) return { ok: false, msg: 'Не хватает материалов' }; + + recipe.ingredients.forEach(ing => { + let needed = ing.qty; + const toRemove = []; + for (const item of char.inventory) { + if (needed <= 0) break; + if (item.id === ing.id || item.id.startsWith(ing.id + '_')) { + const take = Math.min(needed, item.qty || 1); + needed -= take; + toRemove.push({ id: item.id, qty: take }); + } + } + toRemove.forEach(r => this.removeFromInventory(char, r.id, r.qty)); + }); + + const r = recipe.result; + const produced = this.createItem( + recipeId + '_crafted_' + Date.now(), r.type, r.name, r.opts + ); + this.addToInventory(char, produced); + return { ok: true, msg: '⚒️ Создано: ' + recipe.name, item: produced }; + }, + + // ══════════════════════════════════════════ + // СЮЖЕТНЫЕ КВЕСТЫ + // ══════════════════════════════════════════ + STORY_QUESTS: [ + { + id: 'sq_village_threat', name: 'Угроза деревне', giverNpc: 'Стражник', icon: '⚔️', + stages: [ + { stageIdx:0, title:'Первая разведка', desc:'Убей 3 гоблинов в окрестностях.', + type:'kill', target:'goblin', need:3, + dialogBefore:'Путник! Гоблины снова появились у деревни. Убей хотя бы троих — дай нам время подготовиться!', + dialogAfter:'Хорошая работа! Но их вожак ещё жив и собирает новый отряд.', + reward:{ exp:80, gold:40 }}, + { stageIdx:1, title:'Логово вожака', desc:'Разведай подземелье — там прячется вожак.', + type:'visit', target:'dungeon', need:1, + dialogBefore:'Вожак укрылся в подземелье. Разведай его — мне нужно знать, есть ли там ловушки.', + dialogAfter:'Значит, там засела крупная банда. Нужно действовать решительно.', + reward:{ exp:50, gold:20 }}, + { stageIdx:2, title:'Убить тролля', desc:'Уничтожь тролля — командира гоблинов.', + type:'kill', target:'troll', need:1, + dialogBefore:'Тролль ведёт гоблинов. Убей его — и угроза деревне исчезнет!', + dialogAfter:'Невероятно! Ты спас деревню. Держи заслуженную награду, герой.', + reward:{ exp:200, gold:120 }}, + ] + }, + { + id: 'sq_cursed_swamp', name: 'Проклятое болото', giverNpc: 'Шаман', icon: '🌿', + stages: [ + { stageIdx:0, title:'Яд пауков', desc:'Принеси материалы: убей 3 пауков.', + type:'kill', target:'spider', need:3, + dialogBefore:'Болото отравлено тёмной магией. Мне нужен паучий яд для обряда очищения. Убей трёх пауков!', + dialogAfter:'Хорошо. Теперь мне нужно место силы — старая пещера. Проверь, безопасна ли она.', + reward:{ exp:70, gold:35 }}, + { stageIdx:1, title:'Место силы', desc:'Найди пещеру для ритуала.', + type:'visit', target:'cave', need:1, + dialogBefore:'Место силы находится в пещере на северо-западе. Проверь, что там безопасно.', + dialogAfter:'Там ещё водятся зомби! Очисти пещеру от нежити, тогда я смогу провести ритуал.', + reward:{ exp:60, gold:25 }}, + { stageIdx:2, title:'Очистить пещеру', desc:'Убей 3 зомби в пещере.', + type:'kill', target:'zombie', need:3, + dialogBefore:'Зомби мешают ритуалу очищения. Уничтожь трёх из них — тогда болото будет спасено.', + dialogAfter:'Болото очищено! Духи природы благодарны тебе. Возьми эту награду.', + reward:{ exp:160, gold:100 }}, + ] + }, + { + id: 'sq_dragon_hunt', name: 'Охота на дракона', giverNpc: 'Эльф', icon: '🐉', + stages: [ + { stageIdx:0, title:'Следы дракона', desc:'Разведай пещеру — там видели дракона.', + type:'visit', target:'cave', need:1, + dialogBefore:'Дракон сжигает наш лес! Разведай его логово в пещере — нам нужно знать его силу.', + dialogAfter:'Ты его видел? Значит, он настоящий. Сначала ослабь его — убей слуг.', + reward:{ exp:60, gold:30 }}, + { stageIdx:1, title:'Слуги дракона', desc:'Убей 3 орков — прислужников дракона.', + type:'kill', target:'orc', need:3, + dialogBefore:'Орки служат дракону и охраняют подступы к его логову. Убей трёх — ослабь его!', + dialogAfter:'Отлично! Дракон ослаблен. Теперь иди в пещеру и уничтожь его!', + reward:{ exp:100, gold:50 }}, + { stageIdx:2, title:'Убить дракона', desc:'Уничтожь дракона в пещере.', + type:'kill', target:'dragon', need:1, + dialogBefore:'Дракон ждёт тебя в глубине пещеры. Это опасно — но ты должен его остановить!', + dialogAfter:'Легенда! Ты убил дракона. Лес снова в безопасности. Возьми реликвию эльфов.', + reward:{ exp:500, gold:300 }}, + ] + }, + { + id: 'sq_lich_rise', name: 'Восстание Лича', giverNpc: 'Призрак', icon: '💀', + stages: [ + { stageIdx:0, title:'Армия нежити', desc:'Уничтожь 4 скелета — авангард Лича.', + type:'kill', target:'skeleton', need:4, + dialogBefore:'Я чувствую это... Лич собирает армию нежити. Уничтожь его авангард — четырёх скелетов!', + dialogAfter:'Хорошо. Но Лич сам укрылся в болоте. Разведай его логово.', + reward:{ exp:90, gold:45 }}, + { stageIdx:1, title:'Логово Лича', desc:'Исследуй болото — там прячется Лич.', + type:'visit', target:'swamp', need:1, + dialogBefore:'Лич обосновался на болоте. Иди туда — подтверди мои слова, пока он не набрал полную мощь.', + dialogAfter:'Ты его нашёл. Теперь возвращайся и уничтожь это чудовище!', + reward:{ exp:80, gold:40 }}, + { stageIdx:2, title:'Убить Лича', desc:'Уничтожь Лича на болоте.', + type:'kill', target:'lich', need:1, + dialogBefore:'Лич должен быть уничтожен. Только смерть Лича положит конец нашествию нежити.', + dialogAfter:'Покой наконец пришёл в этот мир. Я могу идти дальше. Спасибо, герой.', + reward:{ exp:450, gold:250 }}, + ] + }, + { + id: 'sq_mountain_golem', name: 'Голем гор', giverNpc: 'Старик', icon: '🏔️', + stages: [ + { stageIdx:0, title:'Легенда гор', desc:'Разведай горы — там спит древний голем.', + type:'visit', target:'mountain', need:1, + dialogBefore:'Давным-давно в горах был создан великий голем-страж. Иди туда — проверь, цел ли он.', + dialogAfter:'Голем проснулся! Его разбудили йети. Убей двух — тогда можно сразиться с ним.', + reward:{ exp:50, gold:20 }}, + { stageIdx:1, title:'Стражи гор', desc:'Убей 2 йети у логова голема.', + type:'kill', target:'yeti', need:2, + dialogBefore:'Йети охраняют голема и не дадут тебе подойти к нему. Убей двух — тогда путь будет открыт.', + dialogAfter:'Хорошо, путь свободен. Теперь сразись с самим големом!', + reward:{ exp:120, gold:60 }}, + { stageIdx:2, title:'Победить голема', desc:'Победи голема в горах.', + type:'kill', target:'golem', need:1, + dialogBefore:'Голем — последний страж. Одолей его, и тайна гор будет раскрыта.', + dialogAfter:'Великолепно! Ты достоин звания героя Эйдона. Возьми это золото и мою благодарность.', + reward:{ exp:300, gold:180 }}, + ] + }, + ], + + getStoryQuest(id) { + return this.STORY_QUESTS.find(q => q.id === id) || null; + }, + + getPlayerStoryQuest(char, id) { + return char.quests.find(q => q.id === id && q.isStory) || null; + }, + + giveStoryQuest(char, id) { + if (this.getPlayerStoryQuest(char, id)) return false; + const sq = this.getStoryQuest(id); + if (!sq) return false; + char.quests.push({ id, isStory: true, stageIdx: 0, progress: 0, done: false, completedStages: [] }); + return true; + }, + + updateStoryQuestProgress(char, type, target) { + const results = []; + char.quests.forEach(pq => { + if (!pq.isStory || pq.done) return; + const sq = this.getStoryQuest(pq.id); + if (!sq) return; + const stage = sq.stages[pq.stageIdx]; + if (!stage) return; + if (stage.type === type && (stage.target === target || stage.target === 'any')) { + pq.progress++; + if (pq.progress >= stage.need) { + pq.completedStages.push(pq.stageIdx); + results.push({ questId: pq.id, stageCompleted: pq.stageIdx, stage, sq }); + if (pq.stageIdx + 1 >= sq.stages.length) { + pq.done = true; + } else { + pq.stageIdx++; + pq.progress = 0; + } + } + } + }); + return results; + }, +}; diff --git a/saves.js b/saves.js new file mode 100644 index 0000000..ef6faed --- /dev/null +++ b/saves.js @@ -0,0 +1,97 @@ +// ============================================================ +// SAVES.JS — JSON-сохранения в отдельной папке (File System API) +// ============================================================ +// Приоритет: если выбрана папка — пишем JSON-файлы. +// Всегда также пишем в localStorage как кэш/резерв. +// ============================================================ + +const SaveFS = { + _dir: null, // FileSystemDirectoryHandle + + isSupported() { + return 'showDirectoryPicker' in window; + }, + + hasDir() { + return !!this._dir; + }, + + getDirName() { + return this._dir ? this._dir.name : null; + }, + + // Открыть диалог выбора папки, затем синхронизировать JSON → localStorage + async selectDir() { + try { + this._dir = await window.showDirectoryPicker({ id: 'eidon-saves', mode: 'readwrite' }); + await this._syncFromDir(); + return true; + } catch (e) { + if (e.name !== 'AbortError') console.warn('SaveFS.selectDir:', e); + return false; + } + }, + + // Записать слот в JSON-файл + async writeSlot(slot, data) { + if (!this._dir) return false; + try { + const fh = await this._dir.getFileHandle('save_' + slot + '.json', { create: true }); + const w = await fh.createWritable(); + await w.write(JSON.stringify(data, null, 2)); + await w.close(); + return true; + } catch (e) { + console.warn('SaveFS.writeSlot:', e); + return false; + } + }, + + // Прочитать слот из JSON-файла + async readSlot(slot) { + if (!this._dir) return null; + try { + const fh = await this._dir.getFileHandle('save_' + slot + '.json'); + const file = await fh.getFile(); + return JSON.parse(await file.text()); + } catch (e) { + return null; + } + }, + + // Удалить JSON-файл слота + async deleteSlot(slot) { + if (!this._dir) return false; + try { + await this._dir.removeEntry('save_' + slot + '.json'); + return true; + } catch (e) { + return false; + } + }, + + // Прочитать все 3 JSON-файла из папки → обновить localStorage + // Если файла нет — слот в localStorage тоже очищается (папка — источник истины) + async _syncFromDir() { + for (let sl = 0; sl < 3; sl++) { + const data = await this.readSlot(sl); + if (data) { + localStorage.setItem(RPG.SAVE_PREFIX + sl, JSON.stringify(data)); + } else { + localStorage.removeItem(RPG.SAVE_PREFIX + sl); + } + } + }, + + // Экспортировать все сохранения из localStorage → JSON-файлы + async exportAll() { + for (let sl = 0; sl < 3; sl++) { + const raw = localStorage.getItem(RPG.SAVE_PREFIX + sl); + if (raw) { + try { + await this.writeSlot(sl, JSON.parse(raw)); + } catch (e) { /* skip */ } + } + } + }, +}; diff --git a/style.css b/style.css new file mode 100644 index 0000000..51d23d8 --- /dev/null +++ b/style.css @@ -0,0 +1,791 @@ +/* ============================================================ + STYLE.CSS — Хроники Эйдона RPG + ============================================================ */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: #07070f; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + font-family: 'Segoe UI', Tahoma, sans-serif; + color: #e0e0e0; + overflow: hidden; +} + +#game-container { + position: relative; + width: 900px; + height: 600px; + border: 2px solid #2a2a4a; + border-radius: 6px; + box-shadow: 0 0 50px rgba(80,80,200,0.2); + overflow: hidden; +} + +canvas { display: block; } + +/* ───── HUD ───── */ +#hud { + position: absolute; + top: 0; left: 0; right: 0; + height: 46px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + background: linear-gradient(to bottom, rgba(5,5,15,0.92), transparent); + pointer-events: none; + z-index: 10; +} +.hpill { + background: rgba(15,15,30,0.85); + border: 1px solid #2a2a4a; + border-radius: 12px; + padding: 3px 8px; + font-size: 11px; + white-space: nowrap; +} +.hpill b { color: #ffd700; } +.hbar { width: 90px; height: 10px; background: #0d0d1a; border-radius: 5px; border: 1px solid #2a2a4a; overflow: hidden; } +.hbar-hp { height: 100%; background: linear-gradient(to right, #c0392b, #e74c3c); border-radius: 5px; transition: width .3s; } +.hbar-mp { height: 100%; background: linear-gradient(to right, #1a6aa0, #3498db); border-radius: 5px; transition: width .3s; } +.hbar-exp { height: 100%; background: linear-gradient(to right, #6c3483, #9b59b6); border-radius: 5px; transition: width .3s; } +#hud-right { margin-left: auto; display: flex; gap: 8px; align-items: center; } + +/* ───── Панели (общее) ───── */ +.panel { + position: absolute; + background: rgba(8,8,18,0.97); + border: 2px solid #28284a; + border-radius: 8px; + color: #e0e0e0; + z-index: 50; + box-shadow: 0 4px 40px rgba(0,0,0,0.9); + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease, visibility 0s linear 0.18s; +} +.panel.open { + visibility: visible; + opacity: 1; + pointer-events: auto; + transition: opacity 0.18s ease; + animation: panelIn 0.18s ease forwards; +} +@keyframes panelIn { + from { opacity: 0; } + to { opacity: 1; } +} +.ph { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 14px; + border-bottom: 1px solid #1e1e38; + background: rgba(20,20,45,0.6); + border-radius: 8px 8px 0 0; +} +.ph-title { font-size: 16px; font-weight: bold; color: #ffd700; } +.ph-close { + background: none; border: 1px solid #444; color: #888; + width: 22px; height: 22px; border-radius: 4px; cursor: pointer; + font-size: 15px; line-height: 20px; text-align: center; +} +.ph-close:hover { background: #3a1a1a; color: #ff6666; border-color: #f66; } + +/* ───── ИНВЕНТАРЬ ───── */ +#inventory-panel { top: 25px; left: 50%; transform: translateX(-50%); width: 800px; max-height: 565px; overflow-y: auto; } +.inv-body { padding: 10px 12px; } +.inv-stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } +.inv-stat { background: rgba(20,20,40,0.7); border: 1px solid #1e1e38; border-radius: 5px; padding: 3px 8px; font-size: 11px; } +.sec-title { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin: 7px 0 5px; } + +.eq-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin-bottom: 10px; } +.eq-slot { + background: rgba(15,15,35,0.9); border: 2px solid #1e1e38; border-radius: 5px; + padding: 6px; cursor: pointer; min-height: 52px; transition: border-color .15s; +} +.eq-slot:hover { border-color: #ffd700; } +.eq-slot.filled { border-color: #3a3a7a; } +.eq-label { font-size: 9px; color: #555; text-transform: uppercase; } +.eq-name { font-size: 11px; color: #ddd; margin-top: 3px; } +.eq-val { font-size: 10px; color: #888; } + +.inv-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; } +.inv-slot { + background: rgba(15,15,35,0.9); border: 2px solid #1e1e38; border-radius: 5px; + padding: 4px; cursor: pointer; min-height: 52px; position: relative; + transition: all .15s; text-align: center; +} +.inv-slot:hover { border-color: #ffd700; background: rgba(35,35,65,0.95); transform: scale(1.04); } +.is-icon { font-size: 17px; margin-bottom: 1px; } +.is-name { font-size: 8px; color: #ccc; line-height: 1.2; } +.is-val { font-size: 8px; color: #888; } +.is-qty { position: absolute; top: 2px; right: 3px; font-size: 8px; color: #ffd700; font-weight: bold; } +/* Редкость */ +.r-common { border-color: #3a3a3a !important; } +.r-uncommon { border-color: #2a7a2a !important; } +.r-rare { border-color: #2a2a9a !important; } +.r-epic { border-color: #7a2a9a !important; } +.r-legendary { border-color: #9a6a00 !important; } + +/* ───── МАГАЗИН ───── */ +#shop-panel { top: 35px; left: 50%; transform: translateX(-50%); width: 660px; } +.shop-gold { padding: 6px 14px; text-align: center; color: #ffd700; font-size: 13px; border-bottom: 1px solid #1e1e38; } +.shop-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; padding: 10px; } +.shop-item { + background: rgba(10,20,10,0.85); border: 2px solid #1e381e; border-radius: 6px; + padding: 7px; cursor: pointer; transition: all .18s; +} +.shop-item:hover { background: rgba(25,50,25,0.9); border-color: #4f4; } +.si-name { font-size: 12px; font-weight: bold; } +.si-price { color: #ffd700; font-size: 11px; margin-top: 2px; } +.si-stat { color: #888; font-size: 10px; } + +/* ───── КВЕСТЫ ───── */ +#quest-panel { top: 35px; left: 50%; transform: translateX(-50%); width: 560px; max-height: 530px; overflow-y: auto; } +.quest-list { padding: 8px 10px; } +.q-sec { color: #666; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin: 7px 0 4px; } +.q-card { + background: rgba(15,15,32,0.8); border: 1px solid #1e1e38; border-radius: 5px; + padding: 7px 9px; margin-bottom: 5px; +} +.q-card.active { border-left: 3px solid #ffd700; } +.q-card.completed { border-left: 3px solid #2ecc71; opacity: 0.65; } +.q-name { font-size: 12px; font-weight: bold; } +.q-desc { font-size: 10px; color: #888; margin-top: 1px; } +.q-pbar { height: 4px; background: #0d0d1a; border-radius: 2px; margin-top: 5px; overflow: hidden; } +.q-pfill { height: 100%; background: linear-gradient(to right, #6c3483, #ffd700); border-radius: 2px; transition: width .3s; } +.q-reward { font-size: 9px; color: #ffd700; margin-top: 3px; } + +/* ───── ДИАЛОГ ───── */ +#dialog-panel { bottom: 18px; left: 50%; transform: translateX(-50%); width: 600px; border-color: #3a3a6a; } +.dlg-name { padding: 7px 14px; color: #ffd700; font-size: 15px; font-weight: bold; border-bottom: 1px solid #1e1e38; } +.dlg-text { padding: 9px 14px; font-size: 13px; line-height: 1.55; color: #ddd; } +.dlg-options { display: flex; flex-wrap: wrap; gap: 5px; padding: 7px 14px; border-top: 1px solid #1e1e38; } +.dlg-opt { + background: rgba(20,20,50,0.85); border: 1px solid #3a3a6a; border-radius: 4px; + color: #ddd; padding: 4px 10px; cursor: pointer; font-size: 11px; transition: all .15s; +} +.dlg-opt:hover { background: rgba(50,50,90,0.9); border-color: #ffd700; color: #ffd700; } + +/* ───── БОЕВАЯ ПАНЕЛЬ ───── */ +#combat-panel { + bottom: 0; left: 0; right: 0; + border-radius: 0; border-bottom: none; border-left: none; border-right: none; + border-top-color: #5a1a1a; + background: rgba(8,3,8,0.97); +} +.cbt-head { + display: flex; justify-content: space-between; align-items: center; + padding: 5px 14px; background: rgba(35,5,5,0.7); border-bottom: 1px solid #3a1010; +} +.cbt-title { color: #e74c3c; font-size: 13px; font-weight: bold; } +#cbt-status { color: #aaa; font-size: 11px; } +.cbt-body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px 14px; } +.cbt-fighter { background: rgba(15,10,15,0.8); border-radius: 5px; padding: 7px; } +.cf-name { font-size: 12px; font-weight: bold; } +.cf-hpbar { height: 9px; background: #0a0a0f; border-radius: 4px; margin: 4px 0; overflow: hidden; } +.cf-hpfill { height: 100%; border-radius: 4px; transition: width .3s; } +.cf-enemy { background: linear-gradient(to right, #6a0000, #c0392b); } +.cf-player { background: linear-gradient(to right, #0a3a1a, #27ae60); } +.cf-hptext { font-size: 9px; color: #888; } +.cf-status { font-size: 9px; color: #e67e22; margin-top: 2px; } +.cbt-actions { display: flex; flex-wrap: wrap; gap: 5px; padding: 5px 14px 8px; } +.cbt-btn { + background: rgba(20,12,20,0.9); border: 1px solid #3a2a3a; border-radius: 4px; + color: #ddd; padding: 5px 10px; cursor: pointer; font-size: 11px; transition: all .15s; +} +.cbt-btn:hover:not(.disabled) { transform: scale(1.06); } +.cbt-btn.b-atk { border-color: #8a2020; } +.cbt-btn.b-atk:hover { background: rgba(60,10,10,0.9); } +.cbt-btn.b-spl { border-color: #1a4a8a; } +.cbt-btn.b-spl:hover { background: rgba(10,25,60,0.9); } +.cbt-btn.b-itm { border-color: #1a6a2a; } +.cbt-btn.b-itm:hover { background: rgba(10,45,15,0.9); } +.cbt-btn.b-fle { border-color: #444; } +.cbt-btn.b-fle:hover { background: rgba(30,30,30,0.9); } +.cbt-btn.disabled { opacity: 0.35; cursor: default; pointer-events: none; } +.cbt-btn:active:not(.disabled) { transform: scale(0.93) !important; filter: brightness(1.4); } +.cbt-log { padding: 4px 14px 8px; font-size: 11px; min-height: 80px; border-top: 1px solid #1a0f1a; overflow-y: auto; max-height: 90px; display: flex; flex-direction: column-reverse; } +.cbt-log div { margin-bottom: 1px; line-height: 1.35; } + +/* ───── ПОРТРЕТ В БОЮ ───── */ +.portrait-canvas { display:block; margin:0 auto 4px; border:1px solid #2a2a4a; border-radius:4px; background:#08080f; } + +/* ───── СКИЛЛ-ПАНЕЛЬ (левел-ап) ───── */ +#skill-panel { top: 50%; left: 50%; transform: translate(-50%,-50%); width: 480px; z-index: 100; } +.sk-intro { padding: 8px 14px; color: #999; font-size: 11px; border-bottom: 1px solid #1e1e38; } +.sk-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px; } +.sk-card { + background: rgba(15,15,35,0.9); border: 2px solid #2a2a5a; border-radius: 7px; + padding: 9px; cursor: pointer; text-align: center; transition: all .18s; +} +.sk-card:hover { border-color: #ffd700; transform: scale(1.04); box-shadow: 0 0 12px rgba(255,215,0,.2); } +.sk-icon { font-size: 22px; margin-bottom: 4px; } +.sk-name { font-size: 12px; font-weight: bold; color: #ffd700; } +.sk-desc { font-size: 9px; color: #888; margin-top: 3px; line-height: 1.4; } + +/* ───── ДЕРЕВО ПЕРКОВ ───── */ +#perk-panel { top: 22px; left: 50%; transform: translateX(-50%); width: 840px; max-height: 580px; } +#perk-branches { display: grid; grid-template-columns: repeat(4,1fr); gap: 0; padding: 10px 8px; overflow-y: auto; max-height: 490px; } +.perk-branch { border-right: 1px solid #1a1a30; padding: 0 8px; } +.perk-branch:last-child { border-right: none; } +.perk-branch-title { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; text-align: center; padding: 4px 0 8px; border-bottom: 1px solid #1a1a30; margin-bottom: 8px; } +.perk-tier-line { width: 2px; height: 12px; background: #1e1e38; margin: 0 auto; } +.perk-card { background: rgba(12,12,30,0.9); border: 2px solid #1e1e38; border-radius: 6px; padding: 7px 6px; margin-bottom: 4px; cursor: pointer; text-align: center; transition: all .15s; position: relative; } +.perk-card:hover:not(.locked) { border-color: #ffd700; transform: scale(1.04); } +.perk-card.learned { border-color: #27ae60; background: rgba(8,35,18,0.9); } +.perk-card.available { border-color: #3a3a7a; } +.perk-card.locked { opacity: 0.38; cursor: not-allowed; } +.perk-icon { font-size: 17px; margin-bottom: 2px; } +.perk-name { font-size: 9px; font-weight: bold; color: #ffd700; line-height: 1.3; } +.perk-desc { font-size: 8px; color: #666; margin-top: 2px; line-height: 1.3; } +.perk-tier-badge { position: absolute; top: 2px; right: 3px; font-size: 7px; color: #444; } + +/* ───── КРАФТИНГ ───── */ +#craft-panel { top: 30px; left: 50%; transform: translateX(-50%); width: 660px; } +.craft-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 7px 10px; border-bottom: 1px solid #1e1e38; } +.craft-tab { background: rgba(12,12,28,0.8); border: 1px solid #2a2a4a; border-radius: 4px; color: #777; padding: 4px 10px; font-size: 10px; cursor: pointer; transition: all .15s; } +.craft-tab.active { background: rgba(38,38,78,0.9); border-color: #ffd700; color: #ffd700; } +.craft-tab:hover:not(.active) { border-color: #4a4a7a; color: #aaa; } +.craft-recipe-btn { display: block; width: 100%; text-align: left; background: rgba(10,10,25,0.8); border: 1px solid #1a1a35; border-radius: 4px; color: #bbb; padding: 5px 9px; font-size: 11px; cursor: pointer; margin-bottom: 3px; transition: all .15s; } +.craft-recipe-btn:hover { border-color: #3a3a7a; color: #eee; } +.craft-recipe-btn.can-craft { border-color: #27ae60; color: #6f6; } +.craft-recipe-btn.selected { border-color: #ffd700; background: rgba(28,28,58,0.9); } +.craft-ing { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; border-bottom: 1px solid #0a0a18; font-size: 10px; } +.craft-ing.have { color: #4f4; } +.craft-ing.missing { color: #f44; } + +/* ───── БЕСТИАРИЙ ───── */ +#bestiary-panel { top: 22px; left: 50%; transform: translateX(-50%); width: 820px; max-height: 575px; } +#bestiary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 10px; overflow-y: auto; max-height: 495px; } +.beast-card { background: rgba(12,12,28,0.9); border: 2px solid #1e1e38; border-radius: 7px; padding: 8px; text-align: center; transition: border-color .15s; } +.beast-card.seen { border-color: #2a3a6a; cursor: default; } +.beast-card.seen:hover { border-color: #ffd700; } +.beast-card.unseen { opacity: 0.35; filter: grayscale(1); } +.beast-canvas { display:block; margin:0 auto 4px; border:1px solid #1a1a35; border-radius:3px; background:#06060e; } +.beast-name { font-size: 11px; font-weight: bold; color: #ffd700; } +.beast-lore { font-size: 8px; color: #666; margin: 3px 0; line-height: 1.4; text-align: left; } +.beast-kills { font-size: 9px; color: #4f4; margin-top: 2px; } +.beast-weak { font-size: 8px; color: #e67e22; margin-top: 1px; } +.beast-resist{ font-size: 8px; color: #3498db; } + +/* ───── КНОПКА MUTE ───── */ +#btn-mute { + background: rgba(15,15,30,0.85); border: 1px solid #2a2a4a; + border-radius: 8px; padding: 2px 6px; font-size: 13px; + cursor: pointer; pointer-events: all; user-select: none; +} +#btn-mute:hover { border-color: #ffd700; } + +/* ───── СООБЩЕНИЯ ───── */ +#msg-overlay { position: absolute; top: 54px; left: 50%; transform: translateX(-50%); pointer-events: none; z-index: 30; min-width: 200px; text-align: center; } +.msg-pop { + background: rgba(8,8,18,0.88); border: 1px solid #2a2a4a; + border-radius: 16px; padding: 4px 14px; font-size: 12px; color: #fff; + margin-bottom: 3px; animation: msgFade 2.8s forwards; display: inline-block; +} +@keyframes msgFade { + 0% { opacity: 0; transform: translateY(8px); } + 12% { opacity: 1; transform: translateY(0); } + 72% { opacity: 1; } + 100% { opacity: 0; transform: translateY(-12px); } +} + +/* ───── ТОСТ ДОСТИЖЕНИЯ ───── */ +#ach-toast { + position: absolute; top: 58px; right: 8px; + background: rgba(255,215,0,.12); border: 2px solid #ffd700; + border-radius: 7px; padding: 7px 11px; font-size: 11px; color: #ffd700; + z-index: 60; display: none; max-width: 190px; + animation: none; +} +#ach-toast.show { + display: block; + animation: achToastIn .3s ease; +} +@keyframes achToastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ───── СПЛЭШ ЭКРАН ───── */ +#splash-screen { + position: absolute; inset: 0; z-index: 300; overflow: hidden; + background: #02020a; + transition: opacity 0.75s ease; +} +#splash-canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.splash-content { + position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; + pointer-events: none; +} +.splash-eyeline { + font-size: 11px; color: #6644aa; letter-spacing: 8px; + text-transform: uppercase; margin-bottom: 16px; + opacity: 0; animation: splashFadeUp 1.2s 0.6s ease forwards; +} +.splash-title { + font-size: 68px; font-weight: 900; line-height: 1.05; + font-family: Georgia, 'Times New Roman', serif; + color: #ffd700; text-align: center; letter-spacing: 3px; + text-shadow: 0 0 60px rgba(255,215,0,0.85), 0 0 130px rgba(255,140,0,0.45), 0 3px 6px rgba(0,0,0,0.95); + opacity: 0; animation: splashTitleIn 1.6s 0.9s ease forwards; +} +.splash-subtitle { + font-size: 11px; color: #4433aa; letter-spacing: 5px; + text-transform: uppercase; margin-top: 12px; margin-bottom: 58px; + opacity: 0; animation: splashFadeUp 1.2s 1.4s ease forwards; +} +.splash-btn { + pointer-events: auto; + background: linear-gradient(160deg, #100828 0%, #1c0948 50%, #100828 100%); + border: 2px solid #6644aa; border-radius: 8px; + color: #c8aaee; padding: 15px 48px; + font-size: 14px; letter-spacing: 3px; text-transform: uppercase; + cursor: pointer; + opacity: 0; animation: splashFadeUp 1.2s 1.9s ease forwards, splashPulse 2.8s 3.5s ease-in-out infinite; + transition: background 0.25s, border-color 0.25s, color 0.25s, box-shadow 0.25s; +} +.splash-btn:hover { + background: linear-gradient(160deg, #1c0948 0%, #2e1268 50%, #1c0948 100%); + border-color: #ffd700; color: #ffd700; + box-shadow: 0 0 50px rgba(255,215,0,0.22), inset 0 0 20px rgba(255,215,0,0.06); +} +.splash-hint { + font-size: 10px; color: #2a1840; letter-spacing: 3px; margin-top: 22px; + opacity: 0; animation: splashHintBlink 2.4s 3.2s infinite; +} +@keyframes splashFadeUp { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes splashTitleIn { + from { opacity: 0; transform: scale(0.9) translateY(10px); filter: blur(8px); } + to { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); } +} +@keyframes splashPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(100,60,180,0); } + 50% { box-shadow: 0 0 24px 8px rgba(100,60,180,0.18); } +} +@keyframes splashHintBlink { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 0.12; } +} + +/* ───── СТАРТОВЫЙ ЭКРАН ───── */ +#start-screen { + position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; + z-index: 200; overflow: hidden; +} +#menu-canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.menu-view { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; width: 100%; } + +.s-title { + font-size: 36px; font-weight: bold; color: #ffd700; + text-shadow: 0 0 40px rgba(255,215,0,.6), 0 0 80px rgba(255,215,0,.2); + margin-bottom: 4px; letter-spacing: 2px; + animation: titlePulse 3s ease-in-out infinite; +} +@keyframes titlePulse { + 0%,100% { text-shadow: 0 0 30px rgba(255,215,0,.5), 0 0 60px rgba(255,215,0,.15); } + 50% { text-shadow: 0 0 50px rgba(255,215,0,.9), 0 0 100px rgba(255,215,0,.3); } +} +.s-subtitle { font-size: 12px; color: #556; letter-spacing: 3px; text-transform: uppercase; margin-bottom: 26px; } + +/* Слоты */ +.s-slots { display: flex; gap: 14px; margin-bottom: 18px; } +.slot-card { + width: 220px; min-height: 130px; + background: rgba(8,8,20,0.85); border: 2px solid #1e1e3a; border-radius: 10px; + padding: 14px 12px; cursor: pointer; transition: all .22s; text-align: center; + display: flex; flex-direction: column; align-items: center; justify-content: center; +} +.slot-card:hover { border-color: #ffd700; transform: translateY(-4px); box-shadow: 0 8px 28px rgba(255,215,0,.18); } +.slot-card.empty { border-style: dashed; opacity: 0.55; } +.slot-card.empty:hover { opacity: 0.9; } +.sc-num { font-size: 9px; color: #444; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; } +.sc-icon { font-size: 30px; margin-bottom: 6px; } +.sc-class { font-size: 14px; font-weight: bold; color: #ffd700; } +.sc-info { font-size: 10px; color: #778; margin-top: 4px; line-height: 1.7; } +.sc-date { font-size: 9px; color: #445; margin-top: 6px; } +.sc-del { font-size: 9px; color: #c44; cursor: pointer; margin-top: 8px; padding: 2px 8px; border: 1px solid #622; border-radius: 3px; transition: all .15s; } +.sc-del:hover { background: rgba(200,50,50,.18); color: #f66; border-color: #f44; } +.sc-play { font-size: 11px; color: #4f4; border: 1px solid #282; border-radius: 4px; padding: 4px 14px; margin-top: 8px; transition: all .15s; } +.sc-play:hover { background: rgba(50,200,50,.15); } +.s-new-btn { + background: rgba(18,18,42,0.88); border: 2px solid #3a3a6a; border-radius: 6px; + color: #aab; padding: 9px 32px; cursor: pointer; font-size: 13px; transition: all .22s; letter-spacing: 1px; +} +.s-new-btn:hover { border-color: #ffd700; color: #ffd700; transform: scale(1.05); } + +/* Выбор класса */ +.cls-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 14px; max-width: 840px; } +.cls-btn { + background: linear-gradient(135deg, #0f0f22, #080813); + border: 2px solid #2a2a4a; border-radius: 8px; + color: #e0e0e0; padding: 10px 8px; cursor: pointer; transition: all .22s; text-align: center; +} +.cls-btn:hover { transform: translateY(-3px); box-shadow: 0 4px 18px rgba(255,215,0,.22); border-color: #ffd700; } +.cb-icon { font-size: 26px; margin-bottom: 5px; } +.cb-name { font-size: 14px; font-weight: bold; color: #ffd700; } +.cb-desc { font-size: 9px; color: #666; margin-top: 3px; line-height: 1.35; } +.cb-stats { font-size: 9px; color: #888; margin-top: 4px; } +.s-back-btn { background: rgba(12,12,28,0.85); border: 1px solid #333; border-radius: 5px; color: #778; padding: 5px 14px; cursor: pointer; font-size: 11px; transition: all .15s; } +.s-back-btn:hover { border-color: #888; color: #ddd; } +.s-folder-btn { background: rgba(12,12,28,0.85); border: 1px solid #2a3a5a; border-radius: 5px; color: #6688aa; padding: 5px 14px; cursor: pointer; font-size: 11px; transition: all .15s; } +.s-folder-btn:hover { border-color: #4488cc; color: #88bbee; } +.cls-slot-hint { font-size: 11px; color: #556; margin: 0 0 10px; letter-spacing: 1px; } + +/* ───── ИНДИКАТОР СОХРАНЕНИЯ ───── */ +#save-ind { + position: absolute; bottom: 52px; right: 10px; + background: rgba(10,40,15,0.92); border: 1px solid #27ae60; + border-radius: 6px; padding: 5px 10px; font-size: 10px; color: #4f4; + z-index: 25; opacity: 0; transition: opacity .35s; pointer-events: none; +} + +/* ───── ХИНТ ВЗАИМОДЕЙСТВИЯ ───── */ +#interact-hint { + position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); + background: rgba(8, 8, 20, 0.88); border: 1px solid #3a3a7a; + border-radius: 8px; padding: 7px 18px; + font-size: 12px; color: #ccc; pointer-events: none; + opacity: 0; transition: opacity 0.22s ease, transform 0.22s ease; + white-space: nowrap; z-index: 50; + transform: translateX(-50%) translateY(6px); + box-shadow: 0 2px 12px rgba(60,0,120,0.25); +} +#interact-hint.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.hint-key { + display: inline-block; background: #1e1e4a; border: 1px solid #6a6aaa; + border-radius: 4px; padding: 1px 8px; font-size: 11px; font-weight: bold; + color: #ffd700; margin-right: 7px; font-family: monospace; + box-shadow: 0 1px 3px rgba(100,0,200,0.3); +} + +/* ───── СКРОЛЛБАР ───── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: #0a0a14; } +::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 2px; } + +/* ───── СЕТЫ ЭКИПИРОВКИ ───── */ +.set-bonus-bar { + display: flex; flex-wrap: wrap; gap: 5px; + margin: 6px 0 2px; padding: 5px 6px; + background: rgba(255,215,0,0.05); border: 1px solid rgba(255,215,0,0.18); + border-radius: 5px; +} +.set-bonus-card { + display: flex; align-items: center; gap: 4px; + background: rgba(20,20,40,0.8); border: 1px solid rgba(255,215,0,0.3); + border-radius: 4px; padding: 3px 7px; font-size: 10px; +} +.sb-icon { font-size: 13px; } +.sb-name { color: #ffd700; font-weight: bold; margin-right: 3px; } +.sb-desc { color: #a8c; font-size: 9px; } +.enc-tag { font-size: 10px; margin-left: 3px; } + +/* ───── ПАНЕЛЬ ЗАЧАРОВАНИЯ ───── */ +#enchant-panel { top: 30px; left: 50%; transform: translateX(-50%); width: 620px; } +.enchant-body { + display: flex; height: 430px; +} +.enchant-left { + width: 200px; border-right: 1px solid #1e1e38; + display: flex; flex-direction: column; +} +.enchant-right { + flex: 1; overflow-y: auto; padding: 8px 10px; +} +.enchant-col-title { + font-size: 9px; color: #555; letter-spacing: 1px; text-transform: uppercase; + padding: 6px 10px 4px; border-bottom: 1px solid #1a1a30; +} +#enchant-item-list { + overflow-y: auto; flex: 1; padding: 6px; +} +.enchant-item-btn { + display: flex; align-items: center; gap: 5px; + padding: 6px 8px; border-radius: 5px; cursor: pointer; + font-size: 10px; color: #99a; margin-bottom: 3px; + border: 1px solid transparent; transition: all .15s; +} +.enchant-item-btn:hover { background: rgba(80,80,180,0.15); border-color: #3a3a6a; color: #ccd; } +.enchant-item-btn.active { background: rgba(100,80,200,0.22); border-color: #5a5aaa; color: #ddf; } +.eib-icon { font-size: 14px; } +.eib-name { flex: 1; } +.eib-ench { font-size: 12px; } +.eib-src { font-size: 9px; color: #445; } + +.ench-item-header { + display: flex; align-items: center; gap: 6px; + padding: 7px 4px 8px; border-bottom: 1px solid #1e1e38; margin-bottom: 6px; +} +.eih-icon { font-size: 20px; } +.eih-name { font-size: 13px; color: #dde; font-weight: bold; flex: 1; } +.eih-cur { font-size: 10px; color: #a8c; background: rgba(150,80,200,0.15); padding: 2px 7px; border-radius: 10px; } + +.enchant-card { + background: linear-gradient(135deg, #0f0f22, #080813); + border: 1px solid #2a2a4a; border-radius: 6px; + padding: 8px 10px; margin-bottom: 6px; cursor: pointer; transition: all .18s; +} +.enchant-card:hover:not(.disabled) { border-color: #8844cc; background: rgba(80,40,140,0.25); transform: translateX(2px); } +.enchant-card.disabled { opacity: 0.5; cursor: default; } +.enchant-card.current { border-color: #5580cc; background: rgba(40,60,140,0.25); } +.ec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.ec-icon { font-size: 16px; } +.ec-name { font-size: 11px; color: #ccaaff; font-weight: bold; flex: 1; } +.ec-desc { font-size: 10px; color: #88aadd; } +.ec-cost { font-size: 9px; color: #667; } + +.ench-empty { color: #445; font-size: 11px; padding: 20px; text-align: center; } + +/* ───── ЖУРНАЛ ЛОРА ───── */ +#lore-panel { top: 30px; left: 50%; transform: translateX(-50%); width: 560px; } +#lore-list { max-height: 480px; overflow-y: auto; padding: 10px 12px; } +.lore-group { margin-bottom: 14px; } +.lore-group-title { + font-size: 10px; text-transform: uppercase; letter-spacing: 1px; + color: #ffd700; border-bottom: 1px solid #2a2a4a; + padding-bottom: 4px; margin-bottom: 7px; +} +.lore-card { + background: rgba(10,10,22,0.9); border: 1px solid #2a2a4a; + border-radius: 6px; padding: 8px 10px; margin-bottom: 6px; + transition: border-color .15s; +} +.lore-card:hover { border-color: #5566aa; } +.lc-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } +.lc-icon { font-size: 16px; } +.lc-title { font-size: 12px; color: #ccccff; font-weight: bold; } +.lc-text { font-size: 11px; color: #889; line-height: 1.5; font-style: italic; } +.lc-hint { font-size: 11px; color: #ffdd44; margin-top: 5px; padding: 4px 6px; + background: rgba(255,221,68,0.08); border-left: 2px solid #ffdd44; border-radius: 3px; } +.lore-map-status { font-size: 11px; margin-top: 6px; padding: 3px 0; text-align: right; } +.lore-empty { color: #445; font-size: 12px; padding: 30px; text-align: center; line-height: 1.7; } + +/* ── Boss HP Bar ─────────────────────────────────── */ +#boss-bar { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + width: 580px; + z-index: 60; + text-align: center; + pointer-events: none; + animation: bossBarIn .4s ease; +} +@keyframes bossBarIn { + from { opacity: 0; transform: translateX(-50%) translateY(12px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +#boss-bar-name { + color: #ff6644; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 3px; + text-shadow: 0 0 10px #ff440088; + margin-bottom: 4px; + font-family: monospace; +} +#boss-bar-track { + height: 14px; + background: #110505; + border: 1px solid #660000; + border-radius: 7px; + overflow: hidden; + box-shadow: 0 0 14px #cc110044, inset 0 0 6px #00000088; +} +#boss-bar-fill { + height: 100%; + width: 100%; + background: linear-gradient(90deg, #6b0000 0%, #cc2200 60%, #ff4400 100%); + border-radius: 7px; + transition: width .5s ease; + box-shadow: inset 0 1px 0 #ff660044; +} +#boss-bar-text { + color: #aa6655; + font-size: 10px; + margin-top: 3px; + letter-spacing: 1px; +} + +/* ───── АНИМАЦИЯ ПОРТРЕТОВ (дыхание + моргание + вспышка) ───── */ +#portrait-player { animation: portrait-breathe 3.2s ease-in-out infinite; } +#portrait-enemy { animation: portrait-breathe 2.4s ease-in-out infinite; } +@keyframes portrait-flash { + 0% { filter: brightness(1) saturate(1); } + 25% { filter: brightness(3) saturate(0); } + 100% { filter: brightness(1) saturate(1); } +} +.portrait-hit { animation: portrait-flash 0.28s ease forwards !important; } +@keyframes portrait-breathe { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } +} +.portrait-wrap { position: relative; display: inline-block; } +.portrait-blink { + position: absolute; bottom: 28px; left: 50%; + transform: translateX(-50%); + width: 32px; height: 4px; + background: transparent; pointer-events: none; transition: background .05s; +} +.portrait-blink.blinking { background: #08080f; } + +/* ───── DRAG & DROP ИНВЕНТАРЬ ───── */ +.inv-slot[draggable="true"] { cursor: grab; } +.inv-slot.dragging { opacity: 0.35; cursor: grabbing; } +.eq-slot.drag-over, .inv-slot.drag-over { + border-color: #4488ff !important; + box-shadow: 0 0 8px #4488ff55; +} + +/* ───── ДОСТИЖЕНИЯ ───── */ +#achiev-panel { top: 30px; left: 50%; transform: translateX(-50%); width: 500px; } +#achiev-grid { + display: grid; grid-template-columns: repeat(4, 1fr); + gap: 6px; padding: 10px; max-height: 400px; overflow-y: auto; +} +.ach-card { + background: #0d0d1a; border: 1px solid #1a1a2a; + border-radius: 6px; padding: 8px; text-align: center; +} +.ach-card.unlocked { + border-color: #ffd700; background: #1a1400; + box-shadow: 0 0 6px #ffd70033; +} +.ach-card.locked { opacity: 0.35; filter: grayscale(0.7); } +.ach-icon { font-size: 20px; display: block; margin-bottom: 3px; } +.ach-name { font-size: 10px; color: #ccccff; font-weight: bold; } +.ach-desc { font-size: 9px; color: #556; margin-top: 2px; line-height: 1.3; } + +/* ───── ПРОГРЕСС ДОСТИЖЕНИЙ ───── */ +.ach-progress { height: 3px; background: #1a1a2a; border-radius: 2px; margin: 5px 2px 1px; overflow: hidden; } +.ach-prog-fill { height: 100%; background: linear-gradient(to right, #3a3a8a, #6666cc); border-radius: 2px; transition: width .3s; } +.ach-prog-text { font-size: 8px; color: #445; text-align: center; margin-top: 1px; } + +/* ───── ЭКИПИРОВАННЫЕ ПРЕДМЕТЫ ───── */ +.inv-slot.equipped { border-color: #ffd700 !important; box-shadow: 0 0 8px #ffd70044; } +.inv-slot.equipped::after { + content: '✓'; + position: absolute; top: 2px; right: 4px; + color: #ffd700; font-size: 12px; font-weight: bold; + text-shadow: 0 0 4px #ffd700; + pointer-events: none; +} + +/* ───── МАГАЗИН — недоступные предметы ───── */ +.shop-item { position: relative; } +.shop-item.cant-afford { opacity: 0.40; cursor: not-allowed; pointer-events: none; } +.shop-item.cant-afford .si-price { color: #e74c3c; } + +/* ───── КАРТА МИРА ───── */ +#worldmap-panel { top: 50%; left: 50%; transform: translate(-50%, -50%); width: 520px; } + +/* ───── SVG-ЛИНИИ ПЕРКОВ ───── */ +.perk-tier-line { display: none; } +.perk-svg-line { display: block; margin: 2px auto; } + +/* ════════════════════════════════════════════ + МЕНЮ ПАУЗЫ + ════════════════════════════════════════════ */ +.pause-overlay { + position: fixed !important; top: 0 !important; left: 0 !important; + width: 100vw !important; height: 100vh !important; transform: none !important; + background: rgba(0,0,0,0.75); + display: flex !important; align-items: center; justify-content: center; z-index: 9999; +} +.pause-box { + background: #0d0d1e; border: 1px solid #2a2a6a; border-radius: 10px; + padding: 32px 44px; display: flex; flex-direction: column; align-items: center; gap: 14px; + min-width: 270px; box-shadow: 0 0 50px rgba(80,0,180,0.45); +} +.pause-title { + font-size: 22px; color: #ffd700; letter-spacing: 5px; font-weight: bold; + margin-bottom: 8px; text-shadow: 0 0 12px #ffd70066; +} +.pause-btn { + width: 100%; padding: 9px 0; background: #14143a; border: 1px solid #3a3a7a; + border-radius: 6px; color: #ccc; font-size: 13px; cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.pause-btn:hover { background: #1e1e5a; color: #fff; border-color: #6644cc; } +.pause-exit { border-color: #6a1a1a !important; color: #e88; margin-top: 4px; } +.pause-exit:hover { background: #2a0a0a !important; color: #faa; border-color: #cc4444 !important; } +.pause-vol { width: 100%; display: flex; flex-direction: column; gap: 6px; align-items: center; } +.pause-vol label { font-size: 11px; color: #778; } +.pause-vol input[type=range] { width: 90%; accent-color: #6644cc; cursor: pointer; } + +/* ════════════════════════════════════════════ + ВКЛАДКИ ИНВЕНТАРЯ + ════════════════════════════════════════════ */ +.inv-tabs { display:flex; border-bottom:1px solid #1e1e38; flex-shrink:0; } +.inv-tab { + flex:1; padding:7px 4px; background:none; border:none; + border-bottom:2px solid transparent; color:#556; font-size:11px; cursor:pointer; + transition: color 0.15s, border-color 0.15s; +} +.inv-tab:hover { color:#aaa; } +.inv-tab.active { color:#ffd700; border-bottom-color:#ffd700; } +.inv-tab-pane { display:none; } +.inv-tab-pane.active { display:block; } + +/* ════════════════════════════════════════════ + БУМАЖНАЯ КУКЛА (PAPER DOLL) + ════════════════════════════════════════════ */ +.paperdoll { + display:flex; align-items:center; justify-content:center; + gap:8px; padding:10px 8px 4px; +} +.pd-col { display:flex; flex-direction:column; gap:6px; } +.pd-portrait { border-radius:6px; border:1px solid #2a2a5a; background:#06060e; display:block; } +.pd-slot { + width:64px; min-height:64px; background:#0e0e22; border:1px solid #2a2a4a; + border-radius:6px; display:flex; flex-direction:column; + align-items:center; justify-content:center; font-size:10px; color:#445; cursor:pointer; + text-align:center; padding:4px; transition: border-color 0.15s, background 0.15s; + user-select:none; +} +.pd-slot:hover { border-color:#4a4a8a; background:#141430; } +.pd-slot.filled { border-color:#3a5a8a; background:#0e1a2a; color:#ccc; } +.pd-slot.filled:hover { border-color:#6699ff; background:#101e32; } +.pd-slot.drag-over { border-color:#ffd700; background:#1a1808; } +.pd-slot small { font-size:8px; color:#334; display:block; margin-top:1px; } +.pd-item-icon { font-size:18px; line-height:1.1; } +.pd-item-name { font-size:9px; color:#99aacc; margin-top:2px; max-width:60px; + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.pd-item-stat { font-size:8px; color:#667; margin-top:1px; } +.pd-acc-row { display:flex; justify-content:center; padding:0 8px 8px; } +.pd-slot-wide { width:180px; min-height:40px; flex-direction:row; gap:8px; min-width:0; } + +/* ════════════════════════════════════════════ + ДЕТАЛЬНЫЕ СТАТЫ + ════════════════════════════════════════════ */ +#inv-stats-detail { padding:10px 12px; font-size:11px; overflow-y:auto; max-height:360px; } +.stat-group { margin-bottom:12px; } +.stat-group-title { + color:#ffd700; font-size:10px; letter-spacing:1px; + text-transform:uppercase; border-bottom:1px solid #1e1e38; + padding-bottom:3px; margin-bottom:6px; +} +.stat-row { display:flex; align-items:baseline; gap:6px; margin-bottom:4px; flex-wrap:wrap; } +.stat-label { color:#778; width:115px; flex-shrink:0; } +.stat-value { color:#ddd; font-weight:bold; min-width:32px; } +.stat-breakdown { color:#445; font-size:9px; flex:1; min-width:0; } +.stat-breakdown b { color:#667; } +.stat-bar { height:3px; background:#1a1a2a; border-radius:2px; margin-top:2px; margin-bottom:5px; } +.stat-bar-fill { height:100%; background:linear-gradient(to right,#3a3a8a,#6644cc); border-radius:2px; transition:width .3s; }