Spaces:
Running
Running
| const canvas = document.getElementById("gameCanvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const keys = {}; | |
| let lastTime = 0; | |
| const GAME_STATE = { | |
| MENU: "menu", | |
| PLAYING: "playing", | |
| LEVELUP: "levelup", | |
| DIVINE_REWARD: "divine_reward", | |
| OVER: "over", | |
| }; | |
| // ============ КОНСТАНТЫ ВРЕМЕНИ ============ | |
| const WAVE_DURATION = 60; // 1 минута для обычных волн | |
| const BOSS_DURATION = 180; // 3 минуты для босса | |
| // ============ СИСТЕМА СОХРАНЕНИЯ ============ | |
| const SAVE_KEY = 'waveborne_save'; | |
| let gameLoaded = false; | |
| function saveGame() { | |
| if (!player || !proficiency) return; | |
| const save = { | |
| classKey: classKey, | |
| player: { | |
| x: player.x, | |
| y: player.y, | |
| hp: player.hp, | |
| maxHp: player.maxHp, | |
| mana: player.mana, | |
| maxMana: player.maxMana, | |
| level: player.level, | |
| xp: player.xp, | |
| xpToNext: player.xpToNext | |
| }, | |
| proficiency: { | |
| name: proficiency.name, | |
| currentLevel: proficiency.currentLevel, | |
| exp: proficiency.exp, | |
| expToNextLevel: proficiency.expToNextLevel, | |
| unlockedPerkIds: proficiency.unlockedPerkIds | |
| }, | |
| wave: wave, | |
| waveTimer: waveTimer, | |
| perks: perkLevels, | |
| currentPassives: currentPassives, | |
| currentActives: currentActives, | |
| divinePerksUnlocked: divinePerksUnlocked, | |
| modifiers: modifiers, | |
| timestamp: Date.now(), | |
| enemiesKilled: enemiesKilled, | |
| totalDamageDealt: totalDamageDealt | |
| }; | |
| try { | |
| localStorage.setItem(SAVE_KEY, JSON.stringify(save)); | |
| console.log('Игра сохранена'); | |
| } catch (e) { | |
| console.error('Ошибка сохранения:', e); | |
| } | |
| } | |
| function loadGame() { | |
| try { | |
| const saved = localStorage.getItem(SAVE_KEY); | |
| if (!saved) return false; | |
| const save = JSON.parse(saved); | |
| if (!save || !save.classKey) return false; | |
| // Проверяем, не устарело ли сохранение (больше 1 часа) | |
| const now = Date.now(); | |
| if (now - save.timestamp > 3600000) { | |
| console.log('Сохранение устарело (больше 1 часа)'); | |
| return false; | |
| } | |
| return save; | |
| } catch (e) { | |
| console.error('Ошибка загрузки:', e); | |
| return false; | |
| } | |
| } | |
| function deleteSave() { | |
| localStorage.removeItem(SAVE_KEY); | |
| } | |
| // ============ УНИКАЛЬНАЯ СИСТЕМА КОМБО ДЛЯ ВАРРИОРА ============ | |
| class WarriorComboSystem { | |
| constructor() { | |
| this.combo = 0; | |
| this.multiplier = 1.0; | |
| this.timer = 0; | |
| this.maxCombo = 0; | |
| this.comboHits = 0; // Счетчик ударов для комбо | |
| this.lastHitTime = 0; // Время последнего удара | |
| this.COMBO_WINDOW = 0.5; // Окно для комбо - 0.5 секунды | |
| this.COMBO_RESET_TIME = 1.0; // Сброс комбо через 1 секунду без ударов | |
| } | |
| addHit() { | |
| const now = Date.now() / 1000; | |
| // Проверяем, был ли удар в окне комбо | |
| if (now - this.lastHitTime <= this.COMBO_WINDOW) { | |
| this.comboHits++; | |
| // Каждые 3 удара в окне комбо дают +1 к комбо | |
| if (this.comboHits >= 3) { | |
| this.combo++; | |
| this.comboHits = 0; | |
| // Обновляем множитель | |
| this.multiplier = 1.0 + Math.min(1.5, (this.combo * 0.05)); // до 2.5x множителя | |
| if (this.combo > this.maxCombo) { | |
| this.maxCombo = this.combo; | |
| } | |
| // Сбрасываем таймер | |
| this.timer = this.COMBO_RESET_TIME; | |
| console.log(`Комбо: ${this.combo} (x${this.multiplier.toFixed(2)})`); | |
| } | |
| } else { | |
| // Если удар был вне окна комбо, начинаем заново | |
| this.comboHits = 1; | |
| } | |
| this.lastHitTime = now; | |
| this.timer = this.COMBO_RESET_TIME; | |
| return this.multiplier; | |
| } | |
| reset() { | |
| if (this.combo > 5) { | |
| // Бонус опыта за сброшенное комбо | |
| const bonusXP = Math.floor(this.combo * 0.8); | |
| if (player) player.giveXp(bonusXP); | |
| } | |
| this.combo = 0; | |
| this.comboHits = 0; | |
| this.multiplier = 1.0; | |
| this.timer = 0; | |
| } | |
| update(delta) { | |
| if (this.timer > 0) { | |
| this.timer -= delta; | |
| if (this.timer <= 0) { | |
| this.reset(); | |
| } | |
| } | |
| } | |
| getMultiplier() { | |
| return this.multiplier; | |
| } | |
| getComboHits() { | |
| return this.comboHits; | |
| } | |
| getComboProgress() { | |
| return this.comboHits / 3; // Прогресс до следующего комбо (0-1) | |
| } | |
| } | |
| const comboSystem = new WarriorComboSystem(); | |
| // ============ СИСТЕМА УМНОГО НАВЕДЕНИЯ ДЛЯ МАГА ============ | |
| class SmartTargeting { | |
| constructor() { | |
| this.activeProjectiles = new Set(); | |
| this.targetedEnemies = new Map(); // enemy -> projectile count | |
| } | |
| registerProjectile(projectileId) { | |
| this.activeProjectiles.add(projectileId); | |
| } | |
| unregisterProjectile(projectileId) { | |
| this.activeProjectiles.delete(projectileId); | |
| } | |
| getBestTarget(enemies, playerX, playerY, range, excludeTarget = null) { | |
| // Фильтруем живых врагов в радиусе | |
| const availableEnemies = enemies.filter(enemy => | |
| !enemy.isDead() && | |
| Math.hypot(enemy.x - playerX, enemy.y - playerY) <= range && | |
| enemy !== excludeTarget | |
| ); | |
| if (availableEnemies.length === 0) return null; | |
| // Находим врага с наименьшим количеством снарядов, летящих в него | |
| let bestTarget = null; | |
| let minProjectiles = Infinity; | |
| for (const enemy of availableEnemies) { | |
| const projectileCount = this.targetedEnemies.get(enemy) || 0; | |
| // Предпочитаем цели, в которые летит меньше снарядов | |
| if (projectileCount < minProjectiles) { | |
| minProjectiles = projectileCount; | |
| bestTarget = enemy; | |
| } else if (projectileCount === minProjectiles) { | |
| // При равенстве выбираем ближайшего | |
| const currentDist = bestTarget ? Math.hypot(bestTarget.x - playerX, bestTarget.y - playerY) : Infinity; | |
| const newDist = Math.hypot(enemy.x - playerX, enemy.y - playerY); | |
| if (newDist < currentDist) { | |
| bestTarget = enemy; | |
| } | |
| } | |
| } | |
| return bestTarget; | |
| } | |
| assignTarget(enemy) { | |
| const count = this.targetedEnemies.get(enemy) || 0; | |
| this.targetedEnemies.set(enemy, count + 1); | |
| // Возвращаем ID для отслеживания | |
| const projectileId = Date.now() + Math.random(); | |
| return projectileId; | |
| } | |
| releaseTarget(enemy, projectileId) { | |
| const count = this.targetedEnemies.get(enemy); | |
| if (count !== undefined) { | |
| if (count <= 1) { | |
| this.targetedEnemies.delete(enemy); | |
| } else { | |
| this.targetedEnemies.set(enemy, count - 1); | |
| } | |
| } | |
| this.activeProjectiles.delete(projectileId); | |
| } | |
| clear() { | |
| this.activeProjectiles.clear(); | |
| this.targetedEnemies.clear(); | |
| } | |
| } | |
| const smartTargeting = new SmartTargeting(); | |
| // ============ ВСПЛЫВАЮЩИЙ ТЕКСТ ============ | |
| let damageNumbers = []; | |
| let healNumbers = []; | |
| let comboNumbers = []; | |
| class FloatingText { | |
| constructor(x, y, text, color, isCritical = false, fontSize = 16) { | |
| this.x = x; | |
| this.y = y; | |
| this.text = text; | |
| this.color = color; | |
| this.life = 1.0; | |
| this.velocityY = -40; | |
| this.gravity = 10; | |
| this.fontSize = isCritical ? `bold ${fontSize}px` : `bold ${fontSize}px`; | |
| this.isCritical = isCritical; | |
| } | |
| update(delta) { | |
| this.y += this.velocityY * delta; | |
| this.velocityY += this.gravity * delta; | |
| this.life -= delta; | |
| } | |
| draw(ctx) { | |
| const alpha = Math.min(1, this.life * 2); | |
| ctx.font = this.fontSize + ' Arial'; | |
| ctx.fillStyle = this.color.replace(')', `, ${alpha})`).replace('rgb', 'rgba'); | |
| ctx.textAlign = 'center'; | |
| if (this.isCritical) { | |
| ctx.shadowColor = this.color; | |
| ctx.shadowBlur = 10; | |
| ctx.fillText(this.text, this.x, this.y); | |
| ctx.shadowBlur = 0; | |
| } else { | |
| ctx.fillText(this.text, this.x, this.y); | |
| } | |
| } | |
| isAlive() { | |
| return this.life > 0; | |
| } | |
| } | |
| // ============ ДОСТИЖЕНИЯ ============ | |
| const ACHIEVEMENTS = { | |
| FIRST_BLOOD: { | |
| id: 'first_blood', | |
| name: 'Первая кровь', | |
| desc: 'Убить первого врага', | |
| icon: '🩸', | |
| unlocked: false, | |
| check: () => enemiesKilled >= 1 | |
| }, | |
| COMBO_MASTER: { | |
| id: 'combo_master', | |
| name: 'Мастер комбо', | |
| desc: 'Набрать комбо из 10 ударов', | |
| icon: '⚡', | |
| unlocked: false, | |
| check: () => classKey === 'warrior' && comboSystem.maxCombo >= 10 | |
| }, | |
| WAVE_10: { | |
| id: 'wave_10', | |
| name: 'Выживший', | |
| desc: 'Достичь 10 волны', | |
| icon: '🏆', | |
| unlocked: false, | |
| check: () => wave >= 10 | |
| }, | |
| BOSS_SLAYER: { | |
| id: 'boss_slayer', | |
| name: 'Убийца боссов', | |
| desc: 'Победить первого босса', | |
| icon: '👑', | |
| unlocked: false, | |
| check: () => bossKilled | |
| }, | |
| PERK_COLLECTOR: { | |
| id: 'perk_collector', | |
| name: 'Коллекционер', | |
| desc: 'Собрать 10 различных навыков', | |
| icon: '📚', | |
| unlocked: false, | |
| check: () => Object.keys(perkLevels).length >= 10 | |
| } | |
| }; | |
| let unlockedAchievements = []; | |
| let enemiesKilled = 0; | |
| let bossKilled = false; | |
| let totalDamageDealt = 0; | |
| function checkAchievements() { | |
| Object.values(ACHIEVEMENTS).forEach(achievement => { | |
| if (!achievement.unlocked && achievement.check()) { | |
| achievement.unlocked = true; | |
| unlockedAchievements.push(achievement); | |
| showAchievementPopup(achievement); | |
| } | |
| }); | |
| } | |
| function showAchievementPopup(achievement) { | |
| const popup = document.createElement('div'); | |
| popup.style.cssText = ` | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| padding: 15px; | |
| border-radius: 10px; | |
| border-left: 5px solid gold; | |
| z-index: 1000; | |
| animation: slideIn 0.5s ease-out; | |
| max-width: 300px; | |
| `; | |
| popup.innerHTML = ` | |
| <div style="font-size: 24px; margin-bottom: 5px;">${achievement.icon} ${achievement.name}</div> | |
| <div style="color: #ccc; font-size: 14px;">${achievement.desc}</div> | |
| `; | |
| document.body.appendChild(popup); | |
| setTimeout(() => { | |
| popup.style.animation = 'slideOut 0.5s ease-in'; | |
| setTimeout(() => { | |
| if (popup.parentNode) { | |
| popup.parentNode.removeChild(popup); | |
| } | |
| }, 500); | |
| }, 3000); | |
| } | |
| // Добавляем стили для анимации | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| @keyframes slideOut { | |
| from { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| to { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // ============ БАЗОВЫЕ КОНСТАНТЫ ============ | |
| // Базовые навыки для каждого класса (открываются на уровне мастерства 1) | |
| const BASE_PERKS = { | |
| warrior: [ | |
| { id: "base_toughness", rarity: "common", max: 3, name: "Выносливость", desc: "+10 HP", effect: (m) => { m.hpBonus += 10; } }, | |
| { id: "base_strength", rarity: "common", max: 3, name: "Сила", desc: "+5% урон", effect: (m) => { m.damageMult *= 1.05; } }, | |
| { id: "base_speed", rarity: "common", max: 3, name: "Проворство", desc: "+4% скорость", effect: (m) => { m.speedMult *= 1.04; } }, | |
| { id: "base_defense", rarity: "common", max: 3, name: "Защита", desc: "-5% получаемого урона", effect: (m) => { m.damageTakenMult *= 0.95; } }, | |
| { id: "base_haste", rarity: "common", max: 3, name: "Спешка", desc: "-4% кд", effect: (m) => { m.cooldownMult *= 0.96; } }, | |
| ], | |
| archer: [ | |
| { id: "base_agility", rarity: "common", max: 3, name: "Ловкость", desc: "+6% скорость", effect: (m) => { m.speedMult *= 1.06; } }, | |
| { id: "base_aim", rarity: "common", max: 3, name: "Прицел", desc: "+4% урон", effect: (m) => { m.damageMult *= 1.04; } }, | |
| { id: "base_quickness", rarity: "common", max: 3, name: "Быстрота", desc: "-5% кд", effect: (m) => { m.cooldownMult *= 0.95; } }, | |
| { id: "base_range", rarity: "common", max: 3, name: "Дальность", desc: "+15 радиус", effect: (m) => { m.rangeBonus += 15; } }, | |
| { id: "base_evasion", rarity: "common", max: 3, name: "Уклонение", desc: "-4% получаемого урона", effect: (m) => { m.damageTakenMult *= 0.96; } }, | |
| ], | |
| mage: [ | |
| { id: "base_wisdom", rarity: "common", max: 3, name: "Мудрость", desc: "+15 макс маны", effect: (m) => { m.manaBonus += 15; } }, | |
| { id: "base_power", rarity: "common", max: 3, name: "Мощь", desc: "+5% урон", effect: (m) => { m.damageMult *= 1.05; } }, | |
| { id: "base_flow", rarity: "common", max: 3, name: "Поток", desc: "+1 реген маны/с", effect: (m) => { m.manaRegen += 1; } }, | |
| { id: "base_efficiency", rarity: "common", max: 3, name: "Эффективность", desc: "-5% манакост", effect: (m) => { m.manaCostMult *= 0.95; } }, | |
| { id: "base_focus", rarity: "common", max: 3, name: "Фокус", desc: "-4% кд", effect: (m) => { m.cooldownMult *= 0.96; } }, | |
| ], | |
| acolyte: [ | |
| { id: "base_vitality", rarity: "common", max: 3, name: "Жизненность", desc: "+15 HP", effect: (m) => { m.hpBonus += 15; } }, | |
| { id: "base_healing", rarity: "common", max: 3, name: "Исцеление", desc: "+1 исцеление/пульс", effect: (m) => { m.auraHeal += 1; } }, | |
| { id: "base_protection", rarity: "common", max: 3, name: "Защита", desc: "-5% получаемого урона", effect: (m) => { m.damageTakenMult *= 0.95; } }, | |
| { id: "base_radiance", rarity: "common", max: 3, name: "Сияние", desc: "+4% урон", effect: (m) => { m.damageMult *= 1.04; } }, | |
| { id: "base_presence", rarity: "common", max: 3, name: "Присутствие", desc: "+8 радиус", effect: (m) => { m.rangeBonus += 8; } }, | |
| ], | |
| }; | |
| // Per-class perk kits (10 passives, 4 actives) with levels | |
| const CLASS_KITS = { | |
| warrior: { | |
| passives: [ | |
| { id: "iron_skin", rarity: "common", max: 3, name: "Железная кожа", desc: "+20 HP, +10% защита", effect: (m) => { m.hpBonus += 20; m.damageTakenMult *= 0.9; } }, | |
| { id: "battle_fervor", rarity: "uncommon", max: 3, name: "Боевой пыл", desc: "+12% урон", effect: (m) => { m.damageMult *= 1.12; } }, | |
| { id: "blade_mastery", rarity: "rare", max: 3, name: "Мастер клинка", desc: "-10% кд удара", effect: (m) => { m.cooldownMult *= 0.9; } }, | |
| { id: "riposte", rarity: "rare", max: 3, name: "Рипост", desc: "5% шанс доп. удара", effect: (m) => { m.doubleHit += 0.05; } }, | |
| { id: "heavy_guard", rarity: "uncommon", max: 3, name: "Тяжёлый гард", desc: "+15% защита, -5% скорость", effect: (m) => { m.damageTakenMult *= 0.85; m.speedMult *= 0.95; } }, | |
| { id: "bloodlust", rarity: "uncommon", max: 3, name: "Кровожадность", desc: "Лечение 2 HP за убийство", effect: (m) => { m.killHeal += 2; } }, | |
| { id: "sweeping_edge", rarity: "rare", max: 3, name: "Режущий вихрь", desc: "+12 радиус удара", effect: (m) => { m.rangeBonus += 12; } }, | |
| { id: "adamant", rarity: "epic", max: 1, name: "Непреклонный", desc: "Иммунитет к отбрасыванию (флаг)", effect: () => {} }, | |
| { id: "momentum", rarity: "uncommon", max: 3, name: "Импульс", desc: "+8% скорость", effect: (m) => { m.speedMult *= 1.08; } }, | |
| { id: "war_banner", rarity: "rare", max: 3, name: "Знамя войны", desc: "Доп. 6% урона и 6% кд", effect: (m) => { m.damageMult *= 1.06; m.cooldownMult *= 0.94; } }, | |
| ], | |
| actives: [ | |
| { id: "blade_dash", rarity: "uncommon", max: 3, name: "Рывок клинка", desc: "Скорость +15%", effect: (m) => { m.speedMult *= 1.15; } }, | |
| { id: "shockwave", rarity: "rare", max: 3, name: "Ударная волна", desc: "Урон +15% в ближнем бою", effect: (m) => { m.damageMult *= 1.15; } }, | |
| { id: "guard_stance", rarity: "uncommon", max: 3, name: "Стойка защиты", desc: "-12% получаемого урона", effect: (m) => { m.damageTakenMult *= 0.88; } }, | |
| { id: "whirlwind", rarity: "epic", max: 3, name: "Вихрь", desc: "Шанс доп. удара +8%", effect: (m) => { m.doubleHit += 0.08; } }, | |
| ], | |
| }, | |
| archer: { | |
| passives: [ | |
| { id: "quickdraw", rarity: "uncommon", max: 3, name: "Быстрый выстрел", desc: "-12% кд", effect: (m) => { m.cooldownMult *= 0.88; } }, | |
| { id: "light_steps", rarity: "uncommon", max: 3, name: "Лёгкие шаги", desc: "+12% скорость", effect: (m) => { m.speedMult *= 1.12; } }, | |
| { id: "piercing_arrows", rarity: "rare", max: 3, name: "Пробитие", desc: "Стрелы проходят врагов (флаг)", effect: (m) => { m.pierce += 1; } }, | |
| { id: "hunter_focus", rarity: "rare", max: 3, name: "Фокус охотника", desc: "+14% урон", effect: (m) => { m.damageMult *= 1.14; } }, | |
| { id: "ricochet", rarity: "epic", max: 3, name: "Рикошет", desc: "5% шанс рикошета", effect: (m) => { m.ricochet += 0.05; } }, | |
| { id: "wind_runner", rarity: "uncommon", max: 3, name: "Бег по ветру", desc: "Скорость +8% и +5% урон", effect: (m) => { m.speedMult *= 1.08; m.damageMult *= 1.05; } }, | |
| { id: "bleeding_edge", rarity: "rare", max: 3, name: "Кровоточащий", desc: "Накладывает 4 доп. урона", effect: (m) => { m.flatDamage += 4; } }, | |
| { id: "camo", rarity: "uncommon", max: 3, name: "Маскировка", desc: "-8% получаемого урона", effect: (m) => { m.damageTakenMult *= 0.92; } }, | |
| { id: "falcon_eye", rarity: "rare", max: 3, name: "Глаз ястреба", desc: "+30 радиус", effect: (m) => { m.rangeBonus += 30; } }, | |
| { id: "arrow_surge", rarity: "rare", max: 3, name: "Сурж", desc: "Кд -6%, урон +6%", effect: (m) => { m.cooldownMult *= 0.94; m.damageMult *= 1.06; } }, | |
| ], | |
| actives: [ | |
| { id: "power_shot", rarity: "rare", max: 3, name: "Силовой выстрел", desc: "+18% урон", effect: (m) => { m.damageMult *= 1.18; } }, | |
| { id: "volley", rarity: "epic", max: 3, name: "Залп", desc: "Доп. снаряд (пирсинг)", effect: (m) => { m.extraProjectiles += 1; } }, | |
| { id: "evasive_roll", rarity: "uncommon", max: 3, name: "Кувырок", desc: "+10% скорость", effect: (m) => { m.speedMult *= 1.1; } }, | |
| { id: "marked_target", rarity: "rare", max: 3, name: "Метка", desc: "+12% урон по ближ. цели", effect: (m) => { m.damageMult *= 1.12; } }, | |
| ], | |
| }, | |
| mage: { | |
| passives: [ | |
| { id: "ember_focus", rarity: "rare", max: 3, name: "Жар", desc: "+16% урон огнём", effect: (m) => { m.damageMult *= 1.16; m.burn += 4; } }, | |
| { id: "mana_font", rarity: "uncommon", max: 3, name: "Источник маны", desc: "+30 макс маны, +1.5/с реген", effect: (m) => { m.manaBonus += 30; m.manaRegen += 1.5; } }, | |
| { id: "arcane_precision", rarity: "uncommon", max: 3, name: "Точность", desc: "-10% кд", effect: (m) => { m.cooldownMult *= 0.9; } }, | |
| { id: "frost_weave", rarity: "rare", max: 3, name: "Мороз", desc: "Замедляет врагов при попадании", effect: (m) => { m.slowOnHit = true; } }, | |
| { id: "overload", rarity: "epic", max: 3, name: "Перегруз", desc: "+20% урон, +10% манакост", effect: (m) => { m.damageMult *= 1.2; m.manaCostMult *= 1.1; } }, | |
| { id: "runic_shield", rarity: "uncommon", max: 3, name: "Рун. щит", desc: "-10% вход. урон", effect: (m) => { m.damageTakenMult *= 0.9; } }, | |
| { id: "elemental_sync", rarity: "rare", max: 3, name: "Синхрония", desc: "-12% манакост", effect: (m) => { m.manaCostMult *= 0.88; } }, | |
| { id: "astral_echo", rarity: "epic", max: 3, name: "Астральный эхо", desc: "5% шанс двойного шара", effect: (m) => { m.doubleHit += 0.05; } }, | |
| { id: "channeling", rarity: "uncommon", max: 3, name: "Канал", desc: "+2.5 реген маны", effect: (m) => { m.manaRegen += 2.5; } }, | |
| { id: "mystic_amp", rarity: "epic", max: 3, name: "Усиление", desc: "+18% урон", effect: (m) => { m.damageMult *= 1.18; } }, | |
| ], | |
| actives: [ | |
| { id: "arcane_comet", rarity: "rare", max: 3, name: "Комета", desc: "Больший AoE", effect: (m) => { m.splashBonus += 18; } }, | |
| { id: "flame_burst", rarity: "uncommon", max: 3, name: "Вспышка", desc: "Манакост -10%", effect: (m) => { m.manaCostMult *= 0.9; } }, | |
| { id: "frost_nova", rarity: "rare", max: 3, name: "Фрост нова", desc: "Замедление сильнее", effect: (m) => { m.slowStrength += 0.2; } }, | |
| { id: "mana_barrier", rarity: "rare", max: 3, name: "Барьер", desc: "+12% защита", effect: (m) => { m.damageTakenMult *= 0.88; } }, | |
| ], | |
| }, | |
| acolyte: { | |
| passives: [ | |
| { id: "steadfast", rarity: "common", max: 3, name: "Стойкость", desc: "+25 HP", effect: (m) => { m.hpBonus += 25; } }, | |
| { id: "radiant_armor", rarity: "uncommon", max: 3, name: "Сияющая броня", desc: "-12% вход. урон", effect: (m) => { m.damageTakenMult *= 0.88; } }, | |
| { id: "soothing_light", rarity: "uncommon", max: 3, name: "Утешение", desc: "+2 исцеление/пульс", effect: (m) => { m.auraHeal += 2; } }, | |
| { id: "devotion", rarity: "rare", max: 3, name: "Преданность", desc: "+10% урон ауры", effect: (m) => { m.damageMult *= 1.1; } }, | |
| { id: "sanctuary", rarity: "rare", max: 3, name: "Святыня", desc: "+12 радиус ауры", effect: (m) => { m.rangeBonus += 12; } }, | |
| { id: "spirit_chain", rarity: "uncommon", max: 3, name: "Цепь духа", desc: "Лечение за убийство 3 HP", effect: (m) => { m.killHeal += 3; } }, | |
| { id: "warding", rarity: "uncommon", max: 3, name: "Оберег", desc: "-8% кд", effect: (m) => { m.cooldownMult *= 0.92; } }, | |
| { id: "resolve", rarity: "uncommon", max: 3, name: "Решимость", desc: "+8% скорость", effect: (m) => { m.speedMult *= 1.08; } }, | |
| { id: "holy_edge", rarity: "rare", max: 3, name: "Святая грань", desc: "+10% урон", effect: (m) => { m.damageMult *= 1.1; } }, | |
| { id: "blessing", rarity: "epic", max: 3, name: "Благословение", desc: "+3 исцеление при ударе", effect: (m) => { m.onHitHeal += 3; } }, | |
| ], | |
| actives: [ | |
| { id: "healing_wave", rarity: "rare", max: 3, name: "Волна исцел.", desc: "+3 исцеление/пульс", effect: (m) => { m.auraHeal += 3; } }, | |
| { id: "smite", rarity: "rare", max: 3, name: "Кара", desc: "+15% урон", effect: (m) => { m.damageMult *= 1.15; } }, | |
| { id: "consecrate", rarity: "rare", max: 3, name: "Освящение", desc: "+14 радиус", effect: (m) => { m.rangeBonus += 14; } }, | |
| { id: "guardian_aura", rarity: "epic", max: 3, name: "Аура стража", desc: "-10% вход. урон", effect: (m) => { m.damageTakenMult *= 0.9; } }, | |
| ], | |
| }, | |
| }; | |
| function getPerkDef(id) { | |
| if (!classKey) return null; | |
| const kit = CLASS_KITS[classKey]; | |
| const basePerks = BASE_PERKS[classKey] || []; | |
| if (!kit) return null; | |
| return [...basePerks, ...kit.passives, ...kit.actives].find((p) => p.id === id) || null; | |
| } | |
| function rarityLabel(r) { | |
| const map = { | |
| common: "Обычный", | |
| uncommon: "Необычный", | |
| rare: "Редкий", | |
| unique: "Уникальный", | |
| epic: "Мифический", | |
| legendary: "Легендарный", | |
| divine: "Божественный", | |
| }; | |
| return map[r] || "Обычный"; | |
| } | |
| const CLASS_DEFS = { | |
| warrior: { | |
| name: "Warrior", | |
| color: "#6cf1ff", | |
| hp: 140, | |
| mana: 0, | |
| manaRegen: 0, | |
| speed: 210, | |
| weapon: "sword", | |
| proficiency: "Владение Мечом", | |
| }, | |
| archer: { | |
| name: "Archer", | |
| color: "#f4d35e", | |
| hp: 110, | |
| mana: 20, | |
| manaRegen: 1, | |
| speed: 240, | |
| weapon: "bow", | |
| proficiency: "Владение Луком", | |
| }, | |
| mage: { | |
| name: "Mage", | |
| color: "#c9b4ff", | |
| hp: 100, | |
| mana: 120, | |
| manaRegen: 6, | |
| speed: 225, | |
| weapon: "orb", | |
| proficiency: "Стихийная Магия", | |
| }, | |
| acolyte: { | |
| name: "Acolyte", | |
| color: "#9be7ff", | |
| hp: 150, | |
| mana: 60, | |
| manaRegen: 3, | |
| speed: 205, | |
| weapon: "aura", | |
| proficiency: "Сакральная Магия", | |
| }, | |
| }; | |
| function baseModifiers() { | |
| return { | |
| hpBonus: 0, | |
| damageMult: 1, | |
| cooldownMult: 1, | |
| rangeBonus: 0, | |
| speedMult: 1, | |
| manaBonus: 0, | |
| manaRegen: 0, | |
| manaCostMult: 1, | |
| damageTakenMult: 1, | |
| doubleHit: 0, | |
| killHeal: 0, | |
| flatDamage: 0, | |
| pierce: 0, | |
| ricochet: 0, | |
| extraProjectiles: 0, | |
| splashBonus: 0, | |
| burn: 0, | |
| slowOnHit: false, | |
| slowStrength: 0.25, | |
| auraHeal: 0, | |
| onHitHeal: 0, | |
| // Божественные навыки | |
| divineStrike: false, | |
| divineStrikeCooldown: 0, | |
| divineStrikeReady: false, | |
| celestialShield: false, | |
| celestialShieldCooldown: 0, | |
| celestialShieldActive: false, | |
| timeDilation: false, | |
| timeDilationCooldown: 0, | |
| timeDilationActive: false, | |
| phoenixRebirth: false, | |
| phoenixRebirthCooldown: 0, | |
| voidStep: false, | |
| voidStepCooldown: 0, | |
| divineFury: false, | |
| divineFuryCooldown: 0, | |
| divineFuryActive: false, | |
| eternalFlame: false, | |
| eternalFlameCooldown: 0, | |
| starfall: false, | |
| starfallCooldown: 0, | |
| divineGrace: false, | |
| divineGraceCooldown: 0, | |
| cosmicAwareness: false, | |
| cosmicAwarenessCooldown: 0, | |
| cosmicAwarenessActive: false, | |
| cosmicAwarenessTimer: 0, | |
| timeDilationTimer: 0, | |
| }; | |
| } | |
| class Player { | |
| constructor(x, y, color, hp, speed, mana, manaRegen) { | |
| this.x = x; | |
| this.y = y; | |
| this.size = 32; | |
| this.color = color; | |
| this.maxHp = hp; | |
| this.hp = hp; | |
| this.baseSpeed = speed; | |
| this.mana = mana; | |
| this.maxMana = mana; | |
| this.manaRegen = manaRegen; | |
| this.level = 1; | |
| this.xp = 0; | |
| this.xpToNext = 40; | |
| this.hurtTimer = 0; | |
| } | |
| update(delta, mods) { | |
| let dx = 0; | |
| let dy = 0; | |
| if (keys["ArrowUp"] || keys["KeyW"]) dy -= 1; | |
| if (keys["ArrowDown"] || keys["KeyS"]) dy += 1; | |
| if (keys["ArrowLeft"] || keys["KeyA"]) dx -= 1; | |
| if (keys["ArrowRight"] || keys["KeyD"]) dx += 1; | |
| if (dx !== 0 || dy !== 0) { | |
| const len = Math.hypot(dx, dy); | |
| dx /= len; | |
| dy /= len; | |
| this.x += dx * this.baseSpeed * mods.speedMult * delta; | |
| this.y += dy * this.baseSpeed * mods.speedMult * delta; | |
| } | |
| this.x = Math.max(this.size / 2, Math.min(canvas.width - this.size / 2, this.x)); | |
| this.y = Math.max(this.size / 2, Math.min(canvas.height - this.size / 2, this.y)); | |
| if (this.hurtTimer > 0) this.hurtTimer -= delta; | |
| // mana regen | |
| this.mana = Math.min(this.maxMana + mods.manaBonus, this.mana + (this.manaRegen + mods.manaRegen) * delta); | |
| // Божественные навыки - обновление кд | |
| if (mods.divineStrike) { | |
| mods.divineStrikeCooldown = Math.max(0, mods.divineStrikeCooldown - delta); | |
| mods.divineStrikeReady = mods.divineStrikeCooldown <= 0; | |
| } | |
| if (mods.celestialShield) { | |
| mods.celestialShieldCooldown = Math.max(0, mods.celestialShieldCooldown - delta); | |
| } | |
| if (mods.timeDilation) { | |
| mods.timeDilationCooldown = Math.max(0, mods.timeDilationCooldown - delta); | |
| if (mods.timeDilationActive) { | |
| mods.timeDilationActive = false; | |
| mods.cooldownMult /= 0.5; // Возврат к нормальному кд | |
| } | |
| } | |
| if (mods.phoenixRebirth) { | |
| mods.phoenixRebirthCooldown = Math.max(0, mods.phoenixRebirthCooldown - delta); | |
| } | |
| if (mods.voidStep) { | |
| mods.voidStepCooldown = Math.max(0, mods.voidStepCooldown - delta); | |
| } | |
| if (mods.divineFury) { | |
| mods.divineFuryCooldown = Math.max(0, mods.divineFuryCooldown - delta); | |
| if (mods.divineFuryActive && this.hp >= this.maxHp * 0.3) { | |
| mods.divineFuryActive = false; | |
| mods.damageMult /= 1.4; | |
| mods.speedMult /= 1.25; | |
| } | |
| } | |
| if (mods.eternalFlame) { | |
| mods.eternalFlameCooldown = Math.max(0, mods.eternalFlameCooldown - delta); | |
| } | |
| if (mods.starfall) { | |
| mods.starfallCooldown = Math.max(0, mods.starfallCooldown - delta); | |
| } | |
| if (mods.divineGrace) { | |
| mods.divineGraceCooldown = Math.max(0, mods.divineGraceCooldown - delta); | |
| } | |
| if (mods.cosmicAwareness) { | |
| mods.cosmicAwarenessCooldown = Math.max(0, mods.cosmicAwarenessCooldown - delta); | |
| if (mods.cosmicAwarenessActive) { | |
| mods.cosmicAwarenessActive = false; | |
| mods.damageMult /= 1.2; | |
| } | |
| } | |
| } | |
| giveXp(amount) { | |
| this.xp += amount; | |
| if (this.xp >= this.xpToNext) { | |
| this.xp -= this.xpToNext; | |
| this.level += 1; | |
| this.xpToNext = Math.round(this.xpToNext * 1.25 + 15); | |
| openLevelUp(); | |
| } | |
| } | |
| takeDamage(amount, mods) { | |
| // Небесный щит | |
| if (mods.celestialShield && mods.celestialShieldCooldown <= 0 && !mods.celestialShieldActive) { | |
| mods.celestialShieldActive = true; | |
| mods.celestialShieldCooldown = mods.celestialShieldCooldown || 12; | |
| return; // Блокирует урон | |
| } | |
| if (mods.celestialShieldActive) { | |
| mods.celestialShieldActive = false; | |
| return; | |
| } | |
| // Возрождение феникса | |
| if (this.hp - amount * mods.damageTakenMult <= 0 && mods.phoenixRebirth && mods.phoenixRebirthCooldown <= 0) { | |
| this.hp = Math.max(1, this.maxHp * 0.5); | |
| mods.phoenixRebirthCooldown = mods.phoenixRebirthCooldown || 60; | |
| return; | |
| } | |
| this.hp -= amount * mods.damageTakenMult; | |
| this.hurtTimer = 0.25; | |
| // Божественная ярость | |
| if (this.hp < this.maxHp * 0.3 && mods.divineFury && mods.divineFuryCooldown <= 0 && !mods.divineFuryActive) { | |
| mods.divineFuryActive = true; | |
| mods.divineFuryCooldown = mods.divineFuryCooldown || 20; | |
| mods.damageMult *= 1.4; | |
| mods.speedMult *= 1.25; | |
| } | |
| } | |
| draw(context) { | |
| context.fillStyle = this.hurtTimer > 0 ? "#ff7b7b" : this.color; | |
| context.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size); | |
| } | |
| } | |
| class Proficiency { | |
| constructor(name) { | |
| this.name = name; | |
| this.currentLevel = 0; | |
| this.exp = 0; | |
| this.expToNextLevel = 25; | |
| this.unlockedPerkIds = []; // ID навыков, открытых через мастерство | |
| } | |
| getModifiers() { | |
| // Базовые модификаторы от уровня мастерства (минимальные) | |
| const dmg = 1 + this.currentLevel * 0.03; | |
| const cd = Math.max(0.85, 1 - this.currentLevel * 0.01); | |
| const range = this.currentLevel * 1; | |
| return { damageMult: dmg, cooldownMult: cd, rangeBonus: range }; | |
| } | |
| addExp(amount) { | |
| this.exp += amount; | |
| while (this.exp >= this.expToNextLevel) { | |
| this.exp -= this.expToNextLevel; | |
| this.currentLevel += 1; | |
| console.log(`[${this.name}] уровень повышен до ${this.currentLevel}!`); | |
| this.expToNextLevel = Math.round(this.expToNextLevel * 1.35 + 8); | |
| this.unlockRandomPerk(); | |
| refreshSkillIcons(); | |
| } | |
| } | |
| unlockRandomPerk() { | |
| if (!classKey) return; | |
| // На 1 уровне мастерства открываем все базовые навыки | |
| if (this.currentLevel === 1) { | |
| const basePerks = BASE_PERKS[classKey] || []; | |
| basePerks.forEach(perk => { | |
| if (!this.unlockedPerkIds.includes(perk.id)) { | |
| this.unlockedPerkIds.push(perk.id); | |
| console.log(`[${this.name}] открыт базовый навык: ${perk.name}`); | |
| } | |
| }); | |
| } else { | |
| // На остальных уровнях - случайный навык из основного пула класса | |
| const kit = CLASS_KITS[classKey]; | |
| const allPerks = [...kit.passives, ...kit.actives]; | |
| const available = allPerks.filter(p => !this.unlockedPerkIds.includes(p.id)); | |
| if (available.length > 0) { | |
| const randomPerk = available[Math.floor(Math.random() * available.length)]; | |
| this.unlockedPerkIds.push(randomPerk.id); | |
| console.log(`[${this.name}] уровень ${this.currentLevel}: открыт навык ${randomPerk.name} (${randomPerk.rarity || 'common'})`); | |
| } else { | |
| console.log(`[${this.name}] все навыки уже открыты!`); | |
| } | |
| } | |
| } | |
| } | |
| class Weapon { | |
| constructor(player, proficiency, mods) { | |
| this.player = player; | |
| this.proficiency = proficiency; | |
| this.mods = mods; | |
| } | |
| update() {} | |
| draw() {} | |
| syncMods(mods) { | |
| this.mods = mods; | |
| } | |
| } | |
| class SwordWeapon extends Weapon { | |
| constructor(player, proficiency, mods) { | |
| super(player, proficiency, mods); | |
| this.baseRange = 52; | |
| this.baseDamage = 16; | |
| this.baseCooldown = 0.6; | |
| this.timer = 0; | |
| this.swingMarker = 0; | |
| this.comboVisualTimer = 0; | |
| } | |
| update(enemies, delta) { | |
| this.timer += delta; | |
| const prof = this.proficiency.getModifiers(); | |
| const cooldown = this.baseCooldown * prof.cooldownMult * this.mods.cooldownMult; | |
| const range = this.baseRange + prof.rangeBonus + this.mods.rangeBonus; | |
| // Визуальный таймер комбо | |
| if (this.comboVisualTimer > 0) { | |
| this.comboVisualTimer -= delta; | |
| } | |
| if (this.timer < cooldown) return; | |
| const target = findClosestEnemy(this.player, enemies, range); | |
| if (!target) return; | |
| let damage = (this.baseDamage + this.mods.flatDamage) * prof.damageMult * this.mods.damageMult; | |
| // Применяем множитель комбо (только для воина) | |
| if (classKey === 'warrior') { | |
| damage *= comboSystem.getMultiplier(); | |
| const multiplier = comboSystem.addHit(); | |
| // Визуальный эффект при увеличении комбо | |
| if (comboSystem.getComboHits() === 3) { | |
| this.comboVisualTimer = 0.3; | |
| comboNumbers.push(new FloatingText( | |
| this.player.x, | |
| this.player.y - 40, | |
| `COMBO x${comboSystem.combo}!`, | |
| '#ffd166', | |
| true, | |
| 20 | |
| )); | |
| } | |
| } | |
| // Божественный удар | |
| if (this.mods.divineStrike && this.mods.divineStrikeReady) { | |
| damage *= 3; | |
| this.mods.divineStrikeCooldown = this.mods.divineStrikeCooldown || 8; | |
| this.mods.divineStrikeReady = false; | |
| } | |
| target.takeDamage(damage); | |
| // Добавляем всплывающий текст урона | |
| const isCrit = classKey === 'warrior' && comboSystem.getMultiplier() > 1.2; | |
| damageNumbers.push(new FloatingText( | |
| target.x, | |
| target.y, | |
| Math.round(damage), | |
| '#ff7b7b', | |
| isCrit | |
| )); | |
| if (Math.random() < this.mods.doubleHit) { | |
| const extraDamage = damage * 0.6; | |
| target.takeDamage(extraDamage); | |
| damageNumbers.push(new FloatingText( | |
| target.x, | |
| target.y - 15, | |
| Math.round(extraDamage), | |
| '#ffaa44', | |
| false | |
| )); | |
| } | |
| this.timer = 0; | |
| this.swingMarker = 0.2; | |
| this.proficiency.addExp(6); | |
| if (this.mods.onHitHeal) { | |
| this.player.hp = Math.min(this.player.maxHp + this.mods.hpBonus, this.player.hp + this.mods.onHitHeal); | |
| healNumbers.push(new FloatingText( | |
| this.player.x, | |
| this.player.y - 20, | |
| '+' + this.mods.onHitHeal, | |
| '#54e894', | |
| false | |
| )); | |
| } | |
| } | |
| draw(context) { | |
| if (this.swingMarker > 0) { | |
| const prof = this.proficiency.getModifiers(); | |
| const range = this.baseRange + prof.rangeBonus + this.mods.rangeBonus; | |
| context.strokeStyle = "rgba(255,255,255,0.35)"; | |
| context.beginPath(); | |
| context.arc(this.player.x, this.player.y, range, 0, Math.PI * 2); | |
| context.stroke(); | |
| this.swingMarker -= 1 / 60; | |
| } | |
| // Визуализация комбо для воина | |
| if (classKey === 'warrior' && this.comboVisualTimer > 0) { | |
| const alpha = this.comboVisualTimer / 0.3; | |
| const radius = 60 + comboSystem.combo * 5; | |
| context.strokeStyle = `rgba(255, 209, 102, ${alpha})`; | |
| context.lineWidth = 3; | |
| context.beginPath(); | |
| context.arc(this.player.x, this.player.y, radius, 0, Math.PI * 2); | |
| context.stroke(); | |
| // Прогресс комбо | |
| const progress = comboSystem.getComboProgress(); | |
| if (progress > 0) { | |
| context.fillStyle = `rgba(255, 209, 102, ${alpha * 0.3})`; | |
| context.beginPath(); | |
| context.moveTo(this.player.x, this.player.y); | |
| context.arc(this.player.x, this.player.y, 40, -Math.PI/2, -Math.PI/2 + progress * Math.PI * 2); | |
| context.closePath(); | |
| context.fill(); | |
| } | |
| } | |
| } | |
| } | |
| class BowWeapon extends Weapon { | |
| constructor(player, proficiency, mods) { | |
| super(player, proficiency, mods); | |
| this.baseRange = 420; | |
| this.baseDamage = 10; | |
| this.baseCooldown = 0.55; | |
| this.projectiles = []; | |
| this.timer = 0; | |
| } | |
| update(enemies, delta) { | |
| this.timer += delta; | |
| const prof = this.proficiency.getModifiers(); | |
| const cooldown = this.baseCooldown * prof.cooldownMult * this.mods.cooldownMult; | |
| if (this.timer >= cooldown) { | |
| const target = findClosestEnemy(this.player, enemies, this.baseRange + this.mods.rangeBonus); | |
| if (target) { | |
| const angle = Math.atan2(target.y - this.player.y, target.x - this.player.x); | |
| const speed = 360; | |
| const count = 1 + this.mods.extraProjectiles; | |
| for (let i = 0; i < count; i++) { | |
| const spread = (i - (count - 1) / 2) * 0.12; | |
| let damage = (this.baseDamage + this.mods.flatDamage) * prof.damageMult * this.mods.damageMult; | |
| if (this.mods.divineStrike && this.mods.divineStrikeReady) { | |
| damage *= 3; | |
| this.mods.divineStrikeCooldown = this.mods.divineStrikeCooldown || 8; | |
| this.mods.divineStrikeReady = false; | |
| } | |
| this.projectiles.push({ | |
| x: this.player.x, | |
| y: this.player.y, | |
| vx: Math.cos(angle + spread) * speed, | |
| vy: Math.sin(angle + spread) * speed, | |
| life: 1.2, | |
| radius: 5, | |
| damage: damage, | |
| pierce: this.mods.pierce, | |
| }); | |
| } | |
| this.timer = 0; | |
| this.proficiency.addExp(4); | |
| } | |
| } | |
| for (let i = this.projectiles.length - 1; i >= 0; i--) { | |
| const p = this.projectiles[i]; | |
| p.x += p.vx * delta; | |
| p.y += p.vy * delta; | |
| p.life -= delta; | |
| for (const enemy of enemies) { | |
| if (Math.hypot(enemy.x - p.x, enemy.y - p.y) < enemy.radius + p.radius) { | |
| enemy.takeDamage(p.damage); | |
| damageNumbers.push(new FloatingText( | |
| enemy.x, | |
| enemy.y - 10, | |
| Math.round(p.damage), | |
| '#f4d35e', | |
| false | |
| )); | |
| if (this.mods.ricochet > 0 && Math.random() < this.mods.ricochet) { | |
| const target = findClosestEnemy(enemy, enemies, 200); | |
| if (target) { | |
| p.vx = (target.x - enemy.x) * 3; | |
| p.vy = (target.y - enemy.y) * 3; | |
| p.life = 0.4; | |
| continue; | |
| } | |
| } | |
| if (p.pierce > 0) { | |
| p.pierce -= 1; | |
| } else { | |
| this.projectiles.splice(i, 1); | |
| } | |
| break; | |
| } | |
| } | |
| if (p.life <= 0) this.projectiles.splice(i, 1); | |
| } | |
| } | |
| draw(context) { | |
| context.fillStyle = "#f4d35e"; | |
| this.projectiles.forEach((p) => { | |
| context.beginPath(); | |
| context.arc(p.x, p.y, p.radius, 0, Math.PI * 2); | |
| context.fill(); | |
| }); | |
| } | |
| } | |
| class OrbWeapon extends Weapon { | |
| constructor(player, proficiency, mods) { | |
| super(player, proficiency, mods); | |
| this.baseCooldown = 0.8; | |
| this.baseDamage = 18; | |
| this.timer = 0; | |
| this.projectiles = []; | |
| this.manaCost = 10; | |
| this.activeProjectiles = new Map(); // projectileId -> target | |
| } | |
| update(enemies, delta) { | |
| this.timer += delta; | |
| const prof = this.proficiency.getModifiers(); | |
| const cooldown = this.baseCooldown * prof.cooldownMult * this.mods.cooldownMult; | |
| const cost = this.manaCost * this.mods.manaCostMult; | |
| if (this.timer >= cooldown) { | |
| // Используем умное наведение для выбора цели | |
| let target = smartTargeting.getBestTarget(enemies, this.player.x, this.player.y, 520 + this.mods.rangeBonus); | |
| if (target && this.player.mana >= cost) { | |
| this.player.mana -= cost; | |
| const angle = Math.atan2(target.y - this.player.y, target.x - this.player.x); | |
| const speed = 260; | |
| let damage = this.baseDamage * prof.damageMult * this.mods.damageMult; | |
| if (this.mods.divineStrike && this.mods.divineStrikeReady) { | |
| damage *= 3; | |
| this.mods.divineStrikeCooldown = this.mods.divineStrikeCooldown || 8; | |
| this.mods.divineStrikeReady = false; | |
| } | |
| const projectileId = smartTargeting.assignTarget(target); | |
| this.projectiles.push({ | |
| id: projectileId, | |
| x: this.player.x, | |
| y: this.player.y, | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| life: 1.6, | |
| radius: 10, | |
| damage: damage, | |
| splash: 32 + prof.rangeBonus + this.mods.rangeBonus + this.mods.splashBonus, | |
| target: target, | |
| originalTarget: target | |
| }); | |
| this.timer = 0; | |
| this.proficiency.addExp(5); | |
| } | |
| } | |
| for (let i = this.projectiles.length - 1; i >= 0; i--) { | |
| const p = this.projectiles[i]; | |
| p.x += p.vx * delta; | |
| p.y += p.vy * delta; | |
| p.life -= delta; | |
| // Проверяем, жив ли исходный целевой враг | |
| if (p.target && p.target.isDead()) { | |
| // Ищем новую цель, исключая текущую | |
| p.target = smartTargeting.getBestTarget(enemies, p.x, p.y, 200, p.originalTarget); | |
| if (p.target) { | |
| // Пересчитываем направление к новой цели | |
| const angle = Math.atan2(p.target.y - p.y, p.target.x - p.x); | |
| const speed = Math.hypot(p.vx, p.vy); | |
| p.vx = Math.cos(angle) * speed; | |
| p.vy = Math.sin(angle) * speed; | |
| } | |
| } | |
| let hit = false; | |
| for (const enemy of enemies) { | |
| if (Math.hypot(enemy.x - p.x, enemy.y - p.y) < enemy.radius + p.radius) { | |
| hit = true; | |
| for (const aoe of enemies) { | |
| if (Math.hypot(aoe.x - p.x, aoe.y - p.y) <= p.splash) { | |
| let dmg = p.damage; | |
| if (this.mods.burn) dmg += this.mods.burn; | |
| aoe.takeDamage(dmg); | |
| damageNumbers.push(new FloatingText( | |
| aoe.x, | |
| aoe.y, | |
| Math.round(dmg), | |
| '#c9b4ff', | |
| this.mods.burn > 0 | |
| )); | |
| if (this.mods.slowOnHit) aoe.slow = this.mods.slowStrength || 0.25; | |
| } | |
| } | |
| // Освобождаем цель | |
| if (p.originalTarget) { | |
| smartTargeting.releaseTarget(p.originalTarget, p.id); | |
| } | |
| if (this.mods.doubleHit && Math.random() < this.mods.doubleHit) { | |
| p.life = 0.3; // second detonation soon | |
| } else { | |
| break; | |
| } | |
| } | |
| } | |
| if (hit || p.life <= 0) { | |
| if (p.originalTarget) { | |
| smartTargeting.releaseTarget(p.originalTarget, p.id); | |
| } | |
| this.projectiles.splice(i, 1); | |
| } | |
| } | |
| } | |
| draw(context) { | |
| context.fillStyle = "#c9b4ff"; | |
| this.projectiles.forEach((p) => { | |
| context.beginPath(); | |
| context.arc(p.x, p.y, p.radius, 0, Math.PI * 2); | |
| context.fill(); | |
| context.strokeStyle = "rgba(201,180,255,0.25)"; | |
| context.beginPath(); | |
| context.arc(p.x, p.y, p.splash, 0, Math.PI * 2); | |
| context.stroke(); | |
| // Линия к цели (если есть) | |
| if (p.target && !p.target.isDead()) { | |
| context.strokeStyle = "rgba(255,255,255,0.15)"; | |
| context.setLineDash([5, 5]); | |
| context.beginPath(); | |
| context.moveTo(p.x, p.y); | |
| context.lineTo(p.target.x, p.target.y); | |
| context.stroke(); | |
| context.setLineDash([]); | |
| } | |
| }); | |
| } | |
| } | |
| class AuraWeapon extends Weapon { | |
| constructor(player, proficiency, mods) { | |
| super(player, proficiency, mods); | |
| this.baseCooldown = 1.1; | |
| this.baseDamage = 10; | |
| this.radius = 90; | |
| this.timer = 0; | |
| } | |
| update(enemies, delta) { | |
| this.timer += delta; | |
| const prof = this.proficiency.getModifiers(); | |
| const cooldown = this.baseCooldown * prof.cooldownMult * this.mods.cooldownMult; | |
| if (this.timer >= cooldown) { | |
| let damage = this.baseDamage * prof.damageMult * this.mods.damageMult; | |
| if (this.mods.divineStrike && this.mods.divineStrikeReady) { | |
| damage *= 3; | |
| this.mods.divineStrikeCooldown = this.mods.divineStrikeCooldown || 8; | |
| this.mods.divineStrikeReady = false; | |
| } | |
| for (const enemy of enemies) { | |
| if (Math.hypot(enemy.x - this.player.x, enemy.y - this.player.y) < this.radius + this.mods.rangeBonus + enemy.radius) { | |
| enemy.takeDamage(damage); | |
| damageNumbers.push(new FloatingText( | |
| enemy.x, | |
| enemy.y, | |
| Math.round(damage), | |
| '#9be7ff', | |
| false | |
| )); | |
| } | |
| } | |
| // heal feedback | |
| const heal = 4 + this.mods.auraHeal; | |
| this.player.hp = Math.min(this.player.maxHp + this.mods.hpBonus, this.player.hp + heal); | |
| if (heal > 0) { | |
| healNumbers.push(new FloatingText( | |
| this.player.x, | |
| this.player.y - 20, | |
| '+' + heal, | |
| '#54e894', | |
| false | |
| )); | |
| } | |
| this.timer = 0; | |
| this.proficiency.addExp(4); | |
| } | |
| } | |
| draw(context) { | |
| context.strokeStyle = "rgba(155, 231, 255, 0.35)"; | |
| context.beginPath(); | |
| context.arc(this.player.x, this.player.y, this.radius + this.mods.rangeBonus, 0, Math.PI * 2); | |
| context.stroke(); | |
| } | |
| } | |
| class Enemy { | |
| constructor(x, y, waveScale = 1) { | |
| this.x = x; | |
| this.y = y; | |
| this.radius = 16; | |
| this.speed = 82; | |
| this.baseHp = 28; | |
| this.hp = Math.round(this.baseHp * (1 + waveScale * 0.15)); | |
| this.color = "#f45b69"; | |
| this.attackCooldown = 0.8; | |
| this.timer = Math.random() * 0.5; | |
| this.baseTouchDamage = 8; | |
| this.touchDamage = Math.round(this.baseTouchDamage * (1 + waveScale * 0.12)); | |
| this.slow = 0; | |
| this.waveScale = waveScale; | |
| } | |
| update(player, delta, mods) { | |
| const dx = player.x - this.x; | |
| const dy = player.y - this.y; | |
| const len = Math.hypot(dx, dy) || 1; | |
| const slowMult = this.slow ? 1 - this.slow : 1; | |
| this.x += (dx / len) * this.speed * slowMult * delta; | |
| this.y += (dy / len) * this.speed * slowMult * delta; | |
| if (this.slow) this.slow = Math.max(0, this.slow - delta * 0.6); | |
| this.timer += delta; | |
| const dist = Math.hypot(player.x - this.x, player.y - this.y); | |
| if (dist < this.radius + player.size / 2 && this.timer >= this.attackCooldown) { | |
| player.takeDamage(this.touchDamage, mods); | |
| this.timer = 0; | |
| } | |
| } | |
| takeDamage(amount) { | |
| this.hp -= amount; | |
| totalDamageDealt += amount; | |
| } | |
| isDead() { | |
| return this.hp <= 0; | |
| } | |
| draw(context) { | |
| context.fillStyle = this.color; | |
| context.beginPath(); | |
| context.arc(this.x, this.y, this.radius, 0, Math.PI * 2); | |
| context.fill(); | |
| } | |
| } | |
| class Boss { | |
| constructor(type, wave) { | |
| this.x = canvas.width / 2; | |
| this.y = canvas.height / 2; | |
| this.radius = 40; | |
| this.type = type; | |
| this.wave = wave; | |
| this.maxHp = 800 + wave * 200; | |
| this.hp = this.maxHp; | |
| this.attackTimer = 0; | |
| this.phase = 0; | |
| this.projectiles = []; | |
| this.color = "#ff4444"; | |
| this.name = ""; | |
| this.bossTimeLimit = BOSS_DURATION; // 3 минуты | |
| this.bossTimer = 0; | |
| if (type === 1) { | |
| this.name = "Архимаг Хаоса"; | |
| this.color = "#ff6b9d"; | |
| } else if (type === 2) { | |
| this.name = "Теневой Повелитель"; | |
| this.color = "#8b4fff"; | |
| } else { | |
| this.name = "Древний Титан"; | |
| this.color = "#ffaa44"; | |
| } | |
| } | |
| update(player, delta) { | |
| this.bossTimer += delta; | |
| if (this.bossTimer >= this.bossTimeLimit) { | |
| // Время вышло - игрок проиграл | |
| player.hp = 0; | |
| return; | |
| } | |
| this.attackTimer += delta; | |
| const phase = Math.floor(this.hp / this.maxHp * 3); | |
| this.phase = phase; | |
| if (this.type === 1) { | |
| this.updateArchmage(player, delta); | |
| } else if (this.type === 2) { | |
| this.updateShadowLord(player, delta); | |
| } else { | |
| this.updateTitan(player, delta); | |
| } | |
| // Обновление снарядов босса | |
| for (let i = this.projectiles.length - 1; i >= 0; i--) { | |
| const proj = this.projectiles[i]; | |
| proj.x += proj.vx * delta; | |
| proj.y += proj.vy * delta; | |
| proj.life -= delta; | |
| const dist = Math.hypot(player.x - proj.x, player.y - proj.y); | |
| if (dist < player.size / 2 + proj.radius) { | |
| player.takeDamage(proj.damage, modifiers); | |
| this.projectiles.splice(i, 1); | |
| } else if (proj.life <= 0) { | |
| this.projectiles.splice(i, 1); | |
| } | |
| } | |
| } | |
| updateArchmage(player, delta) { | |
| // Фаза 1: Кольцо огня каждые 2.5 сек | |
| if (this.phase >= 2 && this.attackTimer >= 2.5) { | |
| this.attackTimer = 0; | |
| for (let i = 0; i < 8; i++) { | |
| const angle = (Math.PI * 2 / 8) * i; | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle) * 120, | |
| vy: Math.sin(angle) * 120, | |
| radius: 8, | |
| damage: 12, | |
| life: 3, | |
| color: "#ff6b9d" | |
| }); | |
| } | |
| } | |
| // Фаза 2: Направленные снаряды каждые 1.8 сек | |
| if (this.phase >= 1 && this.attackTimer >= 1.8) { | |
| this.attackTimer = 0; | |
| const angle = Math.atan2(player.y - this.y, player.x - this.x); | |
| for (let i = -1; i <= 1; i++) { | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle + i * 0.3) * 180, | |
| vy: Math.sin(angle + i * 0.3) * 180, | |
| radius: 10, | |
| damage: 15, | |
| life: 4, | |
| color: "#ff6b9d" | |
| }); | |
| } | |
| } | |
| // Фаза 3: Спираль каждые 3 сек | |
| if (this.phase === 0 && this.attackTimer >= 3) { | |
| this.attackTimer = 0; | |
| const baseAngle = Math.atan2(player.y - this.y, player.x - this.x); | |
| for (let i = 0; i < 12; i++) { | |
| const angle = baseAngle + (Math.PI * 2 / 12) * i; | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle) * 100, | |
| vy: Math.sin(angle) * 100, | |
| radius: 9, | |
| damage: 18, | |
| life: 5, | |
| color: "#ff6b9d" | |
| }); | |
| } | |
| } | |
| } | |
| updateShadowLord(player, delta) { | |
| // Фаза 1: Теневые клинки каждые 2 сек | |
| if (this.phase >= 2 && this.attackTimer >= 2) { | |
| this.attackTimer = 0; | |
| for (let i = 0; i < 6; i++) { | |
| const angle = Math.atan2(player.y - this.y, player.x - this.x) + (i - 2.5) * 0.4; | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle) * 200, | |
| vy: Math.sin(angle) * 200, | |
| radius: 7, | |
| damage: 14, | |
| life: 3, | |
| color: "#8b4fff" | |
| }); | |
| } | |
| } | |
| // Фаза 2: Взрывающиеся сферы каждые 2.5 сек | |
| if (this.phase >= 1 && this.attackTimer >= 2.5) { | |
| this.attackTimer = 0; | |
| const targets = [ | |
| { x: player.x, y: player.y }, | |
| { x: player.x + 100, y: player.y }, | |
| { x: player.x - 100, y: player.y } | |
| ]; | |
| targets.forEach(target => { | |
| const angle = Math.atan2(target.y - this.y, target.x - this.x); | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle) * 150, | |
| vy: Math.sin(angle) * 150, | |
| radius: 12, | |
| damage: 20, | |
| life: 3, | |
| color: "#8b4fff", | |
| explode: true, | |
| explodeRadius: 80 | |
| }); | |
| }); | |
| } | |
| // Фаза 3: Крест теней каждые 3.5 сек | |
| if (this.phase === 0 && this.attackTimer >= 3.5) { | |
| this.attackTimer = 0; | |
| const dirs = [ | |
| { x: 1, y: 0 }, { x: -1, y: 0 }, | |
| { x: 0, y: 1 }, { x: 0, y: -1 } | |
| ]; | |
| dirs.forEach(dir => { | |
| for (let i = 0; i < 5; i++) { | |
| this.projectiles.push({ | |
| x: this.x + dir.x * i * 30, | |
| y: this.y + dir.y * i * 30, | |
| vx: dir.x * 140, | |
| vy: dir.y * 140, | |
| radius: 8, | |
| damage: 16, | |
| life: 4, | |
| color: "#8b4fff" | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| updateTitan(player, delta) { | |
| // Фаза 1: Ударные волны каждые 2.2 сек | |
| if (this.phase >= 2 && this.attackTimer >= 2.2) { | |
| this.attackTimer = 0; | |
| const angle = Math.atan2(player.y - this.y, player.x - this.x); | |
| for (let i = -2; i <= 2; i++) { | |
| this.projectiles.push({ | |
| x: this.x, | |
| y: this.y, | |
| vx: Math.cos(angle + i * 0.25) * 160, | |
| vy: Math.sin(angle + i * 0.25) * 160, | |
| radius: 15, | |
| damage: 22, | |
| life: 4, | |
| color: "#ffaa44" | |
| }); | |
| } | |
| } | |
| // Фаза 2: Круговые молнии каждые 2.8 сек | |
| if (this.phase >= 1 && this.attackTimer >= 2.8) { | |
| this.attackTimer = 0; | |
| for (let i = 0; i < 10; i++) { | |
| const angle = (Math.PI * 2 / 10) * i; | |
| this.projectiles.push({ | |
| x: this.x + Math.cos(angle) * 60, | |
| y: this.y + Math.sin(angle) * 60, | |
| vx: Math.cos(angle) * 130, | |
| vy: Math.sin(angle) * 130, | |
| radius: 11, | |
| damage: 19, | |
| life: 3.5, | |
| color: "#ffaa44" | |
| }); | |
| } | |
| } | |
| // Фаза 3: Метеоритный дождь каждые 4 сек | |
| if (this.phase === 0 && this.attackTimer >= 4) { | |
| this.attackTimer = 0; | |
| for (let i = 0; i < 8; i++) { | |
| const x = Math.random() * canvas.width; | |
| this.projectiles.push({ | |
| x: x, | |
| y: -30, | |
| vx: (Math.random() - 0.5) * 50, | |
| vy: 200, | |
| radius: 14, | |
| damage: 25, | |
| life: 4, | |
| color: "#ffaa44" | |
| }); | |
| } | |
| } | |
| } | |
| takeDamage(amount) { | |
| this.hp -= amount; | |
| totalDamageDealt += amount; | |
| } | |
| isDead() { | |
| return this.hp <= 0; | |
| } | |
| draw(context) { | |
| // Тело босса | |
| context.fillStyle = this.color; | |
| context.beginPath(); | |
| context.arc(this.x, this.y, this.radius, 0, Math.PI * 2); | |
| context.fill(); | |
| // Обводка | |
| context.strokeStyle = "#ffffff"; | |
| context.lineWidth = 3; | |
| context.stroke(); | |
| // Имя | |
| context.fillStyle = "#ffffff"; | |
| context.font = "bold 16px Arial"; | |
| context.textAlign = "center"; | |
| context.fillText(this.name, this.x, this.y - this.radius - 10); | |
| // HP бар | |
| const barWidth = 120; | |
| const barHeight = 8; | |
| context.fillStyle = "#333333"; | |
| context.fillRect(this.x - barWidth / 2, this.y + this.radius + 15, barWidth, barHeight); | |
| context.fillStyle = "#ff4444"; | |
| context.fillRect(this.x - barWidth / 2, this.y + this.radius + 15, barWidth * (this.hp / this.maxHp), barHeight); | |
| // Снаряды | |
| this.projectiles.forEach(proj => { | |
| context.fillStyle = proj.color; | |
| context.beginPath(); | |
| context.arc(proj.x, proj.y, proj.radius, 0, Math.PI * 2); | |
| context.fill(); | |
| }); | |
| } | |
| } | |
| let player = null; | |
| let proficiency = null; | |
| let weapon = null; | |
| let enemies = []; | |
| let spawnTimer = 0; | |
| let wave = 1; | |
| let waveTimer = 0; | |
| let currentBoss = null; | |
| let gameState = GAME_STATE.MENU; | |
| let classKey = null; | |
| let perkLevels = {}; | |
| let currentPassives = []; | |
| let currentActives = []; | |
| let modifiers = baseModifiers(); | |
| let divinePerksUnlocked = []; | |
| let pressureTimer = 0; | |
| let pressureDuration = 30; // 30 секунд до поражения при перегрузке | |
| let pressureActive = false; | |
| function findClosestEnemy(origin, list, maxRange = Infinity) { | |
| let closest = null; | |
| let closestDist = Number.MAX_VALUE; | |
| for (const enemy of list) { | |
| const dist = Math.hypot(enemy.x - origin.x, enemy.y - origin.y); | |
| if (dist < closestDist && dist <= maxRange) { | |
| closest = enemy; | |
| closestDist = dist; | |
| } | |
| } | |
| return closest; | |
| } | |
| function spawnEnemy() { | |
| const edge = Math.floor(Math.random() * 4); | |
| let x, y; | |
| switch (edge) { | |
| case 0: | |
| x = Math.random() * canvas.width; | |
| y = -20; | |
| break; | |
| case 1: | |
| x = canvas.width + 20; | |
| y = Math.random() * canvas.height; | |
| break; | |
| case 2: | |
| x = Math.random() * canvas.width; | |
| y = canvas.height + 20; | |
| break; | |
| default: | |
| x = -20; | |
| y = Math.random() * canvas.height; | |
| } | |
| const waveScale = Math.max(0, wave - 1); | |
| enemies.push(new Enemy(x, y, waveScale)); | |
| } | |
| function update(delta) { | |
| if (gameState !== GAME_STATE.PLAYING) return; | |
| // Обновляем систему комбо (только для воина) | |
| if (classKey === 'warrior') { | |
| comboSystem.update(delta); | |
| } | |
| // Очищаем систему умного наведения для мага | |
| if (classKey === 'mage') { | |
| // Периодическая очистка мертвых целей | |
| if (Math.floor(waveTimer) % 5 === 0) { | |
| // Нет автоматической очистки - она происходит при попадании/смерти снаряда | |
| } | |
| } | |
| // Обновляем всплывающий текст | |
| for (let i = damageNumbers.length - 1; i >= 0; i--) { | |
| damageNumbers[i].update(delta); | |
| if (!damageNumbers[i].isAlive()) { | |
| damageNumbers.splice(i, 1); | |
| } | |
| } | |
| for (let i = healNumbers.length - 1; i >= 0; i--) { | |
| healNumbers[i].update(delta); | |
| if (!healNumbers[i].isAlive()) { | |
| healNumbers.splice(i, 1); | |
| } | |
| } | |
| for (let i = comboNumbers.length - 1; i >= 0; i--) { | |
| comboNumbers[i].update(delta); | |
| if (!comboNumbers[i].isAlive()) { | |
| comboNumbers.splice(i, 1); | |
| } | |
| } | |
| // Проверка на босса (каждые 10 волн: 10, 20, 30...) | |
| if (wave % 10 === 0 && !currentBoss && enemies.length === 0) { | |
| const bossType = Math.floor((wave / 10 - 1) % 3) + 1; | |
| currentBoss = new Boss(bossType, wave); | |
| waveTimer = 0; // Сброс таймера для босса | |
| smartTargeting.clear(); // Очищаем систему наведения при появлении босса | |
| } | |
| // Обработка божественных навыков | |
| if (modifiers.eternalFlame && modifiers.eternalFlameCooldown <= 0) { | |
| modifiers.eternalFlameCooldown = modifiers.eternalFlameCooldown || 6; | |
| const targets = currentBoss ? [currentBoss] : enemies; | |
| targets.forEach(target => { | |
| const dist = Math.hypot(target.x - player.x, target.y - player.y); | |
| if (dist <= 150) { | |
| target.takeDamage(15); | |
| damageNumbers.push(new FloatingText( | |
| target.x, | |
| target.y, | |
| '15', | |
| '#ff4444', | |
| true | |
| )); | |
| } | |
| }); | |
| } | |
| if (modifiers.starfall && modifiers.starfallCooldown <= 0) { | |
| modifiers.starfallCooldown = modifiers.starfallCooldown || 18; | |
| const targets = currentBoss ? [currentBoss] : enemies.filter(e => !e.isDead()); | |
| for (let i = 0; i < Math.min(5, targets.length); i++) { | |
| const target = targets[Math.floor(Math.random() * targets.length)]; | |
| if (target) { | |
| target.takeDamage(30); | |
| damageNumbers.push(new FloatingText( | |
| target.x, | |
| target.y, | |
| '30', | |
| '#ffaa44', | |
| true | |
| )); | |
| } | |
| } | |
| } | |
| if (modifiers.divineGrace && modifiers.divineGraceCooldown <= 0) { | |
| modifiers.divineGraceCooldown = modifiers.divineGraceCooldown || 25; | |
| player.hp = Math.min(player.maxHp + modifiers.hpBonus, player.hp + 40); | |
| player.mana = Math.min(player.maxMana + modifiers.manaBonus, player.mana + 30); | |
| healNumbers.push(new FloatingText( | |
| player.x, | |
| player.y - 30, | |
| '+40 HP', | |
| '#54e894', | |
| true | |
| )); | |
| } | |
| if (modifiers.cosmicAwareness && modifiers.cosmicAwarenessCooldown <= 0) { | |
| modifiers.cosmicAwarenessCooldown = modifiers.cosmicAwarenessCooldown || 14; | |
| modifiers.cosmicAwarenessActive = true; | |
| modifiers.cosmicAwarenessTimer = 4; | |
| modifiers.damageMult *= 1.2; | |
| } | |
| if (modifiers.cosmicAwarenessActive) { | |
| modifiers.cosmicAwarenessTimer -= delta; | |
| if (modifiers.cosmicAwarenessTimer <= 0) { | |
| modifiers.cosmicAwarenessActive = false; | |
| modifiers.damageMult /= 1.2; | |
| } | |
| } | |
| if (modifiers.timeDilation && modifiers.timeDilationCooldown <= 0) { | |
| modifiers.timeDilationCooldown = modifiers.timeDilationCooldown || 15; | |
| modifiers.timeDilationActive = true; | |
| modifiers.timeDilationTimer = 3; | |
| modifiers.cooldownMult *= 0.5; | |
| } | |
| if (modifiers.timeDilationActive) { | |
| modifiers.timeDilationTimer -= delta; | |
| if (modifiers.timeDilationTimer <= 0) { | |
| modifiers.timeDilationActive = false; | |
| modifiers.cooldownMult /= 0.5; | |
| } | |
| } | |
| if (currentBoss) { | |
| // Бой с боссом | |
| currentBoss.update(player, delta); | |
| weapon.update([currentBoss], delta); | |
| // Проверка смерти босса | |
| if (currentBoss.isDead()) { | |
| player.giveXp(500 + wave * 50); | |
| bossKilled = true; | |
| openDivineReward(); | |
| currentBoss = null; | |
| wave++; | |
| waveTimer = 0; | |
| } else if (player.hp <= 0) { | |
| gameState = GAME_STATE.OVER; | |
| showGameOver(); | |
| return; | |
| } | |
| } else { | |
| // Обычная волна (1 минута) | |
| waveTimer += delta; | |
| // Система напряжения: если врагов >= 10, запускается обратный отсчёт | |
| const enemyCount = enemies.filter(e => !e.isDead()).length; | |
| if (enemyCount >= 10) { | |
| if (!pressureActive) { | |
| pressureActive = true; | |
| pressureTimer = pressureDuration; | |
| } | |
| pressureTimer -= delta; | |
| if (pressureTimer <= 0) { | |
| // Поражение от перегрузки | |
| player.hp = 0; | |
| } | |
| } else { | |
| // Если врагов меньше 10, сбрасываем таймер напряжения | |
| if (pressureActive) { | |
| pressureActive = false; | |
| pressureTimer = 0; | |
| } | |
| } | |
| // Завершение волны через 1 минуту | |
| if (waveTimer >= WAVE_DURATION) { | |
| wave++; | |
| waveTimer = 0; | |
| enemies = []; // Очистка врагов | |
| spawnTimer = 0; | |
| pressureActive = false; | |
| pressureTimer = 0; | |
| smartTargeting.clear(); // Очищаем систему наведения между волнами | |
| } | |
| spawnTimer += delta; | |
| const spawnRate = Math.max(0.25, 1.0 - wave * 0.03); | |
| if (spawnTimer > spawnRate) { | |
| spawnEnemy(); | |
| spawnTimer = 0; | |
| } | |
| player.update(delta, modifiers); | |
| enemies.forEach((enemy) => enemy.update(player, delta, modifiers)); | |
| weapon.update(enemies, delta); | |
| for (let i = enemies.length - 1; i >= 0; i--) { | |
| if (enemies[i].isDead()) { | |
| enemiesKilled++; | |
| if (modifiers.killHeal) { | |
| player.hp = Math.min(player.maxHp + modifiers.hpBonus, player.hp + modifiers.killHeal); | |
| healNumbers.push(new FloatingText( | |
| player.x, | |
| player.y - 30, | |
| '+' + modifiers.killHeal, | |
| '#54e894', | |
| false | |
| )); | |
| } | |
| const xpGain = Math.round(12 * (1 + Math.max(0, wave - 1) * 0.1)); | |
| player.giveXp(xpGain); | |
| enemies.splice(i, 1); | |
| } | |
| } | |
| } | |
| if (player.hp <= 0) { | |
| gameState = GAME_STATE.OVER; | |
| showGameOver(); | |
| } | |
| // Автосохранение каждые 30 секунд | |
| if (Math.floor(waveTimer) % 30 === 0 && Math.floor(waveTimer) > 0) { | |
| saveGame(); | |
| } | |
| // Проверка достижений | |
| checkAchievements(); | |
| updateUI(); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // backdrop grid | |
| ctx.strokeStyle = "rgba(255,255,255,0.04)"; | |
| for (let x = 0; x < canvas.width; x += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, canvas.height); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < canvas.height; y += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(canvas.width, y); | |
| ctx.stroke(); | |
| } | |
| // Отображаем всплывающий текст | |
| damageNumbers.forEach(text => text.draw(ctx)); | |
| healNumbers.forEach(text => text.draw(ctx)); | |
| comboNumbers.forEach(text => text.draw(ctx)); | |
| if (weapon) weapon.draw(ctx); | |
| if (player) player.draw(ctx); | |
| if (currentBoss) { | |
| currentBoss.draw(ctx); | |
| } else { | |
| enemies.forEach((enemy) => enemy.draw(ctx)); | |
| } | |
| // Отображаем комбо для воина | |
| if (classKey === 'warrior' && comboSystem.combo > 0) { | |
| drawWarriorCombo(ctx); | |
| } | |
| } | |
| function drawWarriorCombo(ctx) { | |
| const combo = comboSystem.combo; | |
| const multiplier = comboSystem.getMultiplier().toFixed(2); | |
| const alpha = Math.min(1, comboSystem.timer / comboSystem.COMBO_RESET_TIME); | |
| // Фон | |
| ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.5})`; | |
| ctx.fillRect(canvas.width - 120, 10, 110, 50); | |
| // Текст комбо | |
| ctx.font = 'bold 24px Arial'; | |
| ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(`КОМБО: ${combo}`, canvas.width - 15, 35); | |
| // Множитель | |
| ctx.font = '16px Arial'; | |
| ctx.fillStyle = `rgba(255, 215, 102, ${alpha})`; | |
| ctx.fillText(`${multiplier}x`, canvas.width - 15, 55); | |
| // Прогресс до следующего комбо | |
| const progress = comboSystem.getComboProgress(); | |
| const progressWidth = 100 * progress; | |
| ctx.fillStyle = `rgba(255, 105, 180, ${alpha})`; | |
| ctx.fillRect(canvas.width - 110, 60, progressWidth, 3); | |
| // Таймер | |
| const timerWidth = 100 * (comboSystem.timer / comboSystem.COMBO_RESET_TIME); | |
| ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.3})`; | |
| ctx.fillRect(canvas.width - 110, 60, timerWidth, 3); | |
| } | |
| function updateUI() { | |
| document.getElementById("hpValue").textContent = player | |
| ? `${Math.max(0, Math.round(player.hp))}/${Math.round(player.maxHp + modifiers.hpBonus)}` | |
| : "-"; | |
| document.getElementById("manaValue").textContent = player | |
| ? `${Math.round(player.mana)}/${Math.round(player.maxMana + modifiers.manaBonus)}` | |
| : "-"; | |
| document.getElementById("profValue").textContent = proficiency | |
| ? `${proficiency.currentLevel} (${proficiency.exp}/${proficiency.expToNextLevel})` | |
| : "-"; | |
| document.getElementById("waveValue").textContent = wave; | |
| document.getElementById("classValue").textContent = classKey ? CLASS_DEFS[classKey].name : "-"; | |
| document.getElementById("levelValue").textContent = player | |
| ? `${player.level} (${Math.round(player.xp)}/${player.xpToNext})` | |
| : "-"; | |
| // Комбо только для воина | |
| const comboContainer = document.getElementById("comboContainer"); | |
| const comboElement = document.getElementById("comboValue"); | |
| if (classKey === 'warrior') { | |
| comboContainer.style.display = "flex"; | |
| if (comboSystem.combo > 0) { | |
| comboElement.textContent = `${comboSystem.combo} (${comboSystem.getMultiplier().toFixed(2)}x)`; | |
| comboElement.style.color = '#ffd166'; | |
| } else { | |
| comboElement.textContent = '0'; | |
| comboElement.style.color = '#a7b7d5'; | |
| } | |
| } else { | |
| comboContainer.style.display = "none"; | |
| } | |
| // Таймер волны | |
| if (currentBoss) { | |
| const remaining = Math.max(0, BOSS_DURATION - waveTimer); | |
| const mins = Math.floor(remaining / 60); | |
| const secs = Math.floor(remaining % 60); | |
| document.getElementById("waveTime").textContent = `Босс: ${mins}:${secs.toString().padStart(2, '0')}`; | |
| } else { | |
| const remaining = Math.max(0, WAVE_DURATION - waveTimer); | |
| const mins = Math.floor(remaining / 60); | |
| const secs = Math.floor(remaining % 60); | |
| document.getElementById("waveTime").textContent = `${mins}:${secs.toString().padStart(2, '0')}`; | |
| } | |
| // Таймер напряжения | |
| const pressureEl = document.getElementById("pressureTimer"); | |
| const pressureTimeEl = document.getElementById("pressureTime"); | |
| if (pressureActive && !currentBoss) { | |
| pressureEl.style.display = "flex"; | |
| const remaining = Math.max(0, pressureTimer); | |
| const secs = Math.floor(remaining); | |
| const ms = Math.floor((remaining - secs) * 10); | |
| pressureTimeEl.textContent = `${secs}.${ms}`; | |
| pressureTimeEl.style.color = remaining < 10 ? "#ff4444" : "#ffaa44"; | |
| } else { | |
| pressureEl.style.display = "none"; | |
| } | |
| } | |
| function loop(timestamp) { | |
| if (!lastTime) lastTime = timestamp; | |
| const delta = Math.min(0.05, (timestamp - lastTime) / 1000); | |
| lastTime = timestamp; | |
| update(delta); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| function setupClassSelect() { | |
| const buttons = document.querySelectorAll(".class-card"); | |
| buttons.forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| startGame(btn.dataset.class); | |
| }); | |
| }); | |
| // Проверяем есть ли сохранение | |
| const saved = loadGame(); | |
| if (saved) { | |
| const continueBtn = document.createElement('button'); | |
| continueBtn.textContent = 'Продолжить'; | |
| continueBtn.style.cssText = ` | |
| margin-top: 10px; | |
| padding: 10px 20px; | |
| background: linear-gradient(135deg, #7cfbce, #4be0ff); | |
| color: #041022; | |
| border: none; | |
| border-radius: 8px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| width: 100%; | |
| `; | |
| continueBtn.addEventListener('click', () => { | |
| continueGame(saved); | |
| }); | |
| const panel = document.querySelector('#classSelect .panel'); | |
| panel.appendChild(continueBtn); | |
| } | |
| document.getElementById("restartBtn").addEventListener("click", () => { | |
| deleteSave(); // Удаляем сохранение при перезапуске | |
| resetGame(); | |
| }); | |
| // Горячие клавиши | |
| window.addEventListener('keydown', (e) => { | |
| keys[e.code] = true; | |
| // ESC - пауза/меню | |
| if (e.code === 'Escape') { | |
| if (gameState === GAME_STATE.PLAYING) { | |
| saveGame(); | |
| showQuickMessage('Игра сохранена!'); | |
| } | |
| } | |
| // F5 - быстрое сохранение | |
| if (e.code === 'F5') { | |
| e.preventDefault(); | |
| saveGame(); | |
| showQuickMessage('Игра сохранена!'); | |
| } | |
| // F9 - быстрая загрузка | |
| if (e.code === 'F9') { | |
| e.preventDefault(); | |
| const saved = loadGame(); | |
| if (saved) { | |
| continueGame(saved); | |
| showQuickMessage('Игра загружена!'); | |
| } | |
| } | |
| }); | |
| window.addEventListener("keyup", (e) => { | |
| keys[e.code] = false; | |
| }); | |
| } | |
| function showQuickMessage(text) { | |
| const msg = document.createElement('div'); | |
| msg.textContent = text; | |
| msg.style.cssText = ` | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| padding: 20px 40px; | |
| border-radius: 10px; | |
| z-index: 1001; | |
| animation: fadeInOut 2s; | |
| `; | |
| document.body.appendChild(msg); | |
| setTimeout(() => { | |
| if (msg.parentNode) { | |
| msg.parentNode.removeChild(msg); | |
| } | |
| }, 2000); | |
| } | |
| // Добавляем анимацию для быстрого сообщения | |
| const quickMsgStyle = document.createElement('style'); | |
| quickMsgStyle.textContent = ` | |
| @keyframes fadeInOut { | |
| 0%, 100% { opacity: 0; } | |
| 20%, 80% { opacity: 1; } | |
| } | |
| `; | |
| document.head.appendChild(quickMsgStyle); | |
| function continueGame(save) { | |
| classKey = save.classKey; | |
| const def = CLASS_DEFS[classKey]; | |
| // Восстанавливаем игрока | |
| player = new Player( | |
| save.player.x || canvas.width / 2, | |
| save.player.y || canvas.height / 2, | |
| def.color, | |
| save.player.maxHp || def.hp, | |
| def.speed, | |
| save.player.maxMana || def.mana, | |
| def.manaRegen | |
| ); | |
| // Восстанавливаем состояние | |
| player.hp = save.player.hp; | |
| player.mana = save.player.mana; | |
| player.level = save.player.level; | |
| player.xp = save.player.xp; | |
| player.xpToNext = save.player.xpToNext; | |
| // Восстанавливаем мастерство | |
| proficiency = new Proficiency(def.proficiency); | |
| proficiency.currentLevel = save.proficiency.currentLevel; | |
| proficiency.exp = save.proficiency.exp; | |
| proficiency.expToNextLevel = save.proficiency.expToNextLevel; | |
| proficiency.unlockedPerkIds = save.proficiency.unlockedPerkIds; | |
| // Восстанавливаем остальное | |
| wave = save.wave || 1; | |
| waveTimer = save.waveTimer || 0; | |
| perkLevels = save.perks || {}; | |
| currentPassives = save.currentPassives || []; | |
| currentActives = save.currentActives || []; | |
| divinePerksUnlocked = save.divinePerksUnlocked || []; | |
| modifiers = save.modifiers || baseModifiers(); | |
| enemiesKilled = save.enemiesKilled || 0; | |
| totalDamageDealt = save.totalDamageDealt || 0; | |
| bossKilled = save.bossKilled || false; | |
| // Создаем оружие | |
| switch (def.weapon) { | |
| case "bow": | |
| weapon = new BowWeapon(player, proficiency, modifiers); | |
| break; | |
| case "orb": | |
| weapon = new OrbWeapon(player, proficiency, modifiers); | |
| break; | |
| case "aura": | |
| weapon = new AuraWeapon(player, proficiency, modifiers); | |
| break; | |
| default: | |
| weapon = new SwordWeapon(player, proficiency, modifiers); | |
| } | |
| // Начинаем игру | |
| enemies = []; | |
| spawnTimer = 0; | |
| currentBoss = null; | |
| gameState = GAME_STATE.PLAYING; | |
| gameLoaded = true; | |
| // Очищаем системы | |
| damageNumbers = []; | |
| healNumbers = []; | |
| comboNumbers = []; | |
| smartTargeting.clear(); | |
| if (classKey === 'warrior') { | |
| comboSystem.reset(); | |
| } | |
| document.getElementById("classSelect").classList.add("hidden"); | |
| document.getElementById("gameOver").classList.add("hidden"); | |
| refreshSkillIcons(); | |
| updateUI(); | |
| console.log('Игра загружена'); | |
| } | |
| function startGame(key) { | |
| classKey = key; | |
| const def = CLASS_DEFS[key]; | |
| modifiers = baseModifiers(); | |
| perkLevels = {}; | |
| player = new Player(canvas.width / 2, canvas.height / 2, def.color, def.hp, def.speed, def.mana, def.manaRegen); | |
| proficiency = new Proficiency(def.proficiency); | |
| // Открываем базовый навык сразу при старте (уровень мастерства 0 -> 1) | |
| proficiency.currentLevel = 1; | |
| proficiency.unlockRandomPerk(); | |
| currentPassives = []; | |
| currentActives = []; | |
| switch (def.weapon) { | |
| case "bow": | |
| weapon = new BowWeapon(player, proficiency, modifiers); | |
| break; | |
| case "orb": | |
| weapon = new OrbWeapon(player, proficiency, modifiers); | |
| break; | |
| case "aura": | |
| weapon = new AuraWeapon(player, proficiency, modifiers); | |
| break; | |
| default: | |
| weapon = new SwordWeapon(player, proficiency, modifiers); | |
| } | |
| enemies = []; | |
| spawnTimer = 0; | |
| wave = 1; | |
| waveTimer = 0; | |
| currentBoss = null; | |
| divinePerksUnlocked = []; | |
| pressureActive = false; | |
| pressureTimer = 0; | |
| enemiesKilled = 0; | |
| totalDamageDealt = 0; | |
| bossKilled = false; | |
| damageNumbers = []; | |
| healNumbers = []; | |
| comboNumbers = []; | |
| smartTargeting.clear(); | |
| comboSystem.reset(); | |
| gameState = GAME_STATE.PLAYING; | |
| document.getElementById("classSelect").classList.add("hidden"); | |
| document.getElementById("gameOver").classList.add("hidden"); | |
| document.getElementById("levelUp").classList.add("hidden"); | |
| document.getElementById("divineReward").classList.add("hidden"); | |
| renderSkillIcons(currentActives, currentPassives); | |
| renderMetaIcons(); | |
| updateUI(); | |
| } | |
| function resetGame() { | |
| gameState = GAME_STATE.MENU; | |
| document.getElementById("classSelect").classList.remove("hidden"); | |
| document.getElementById("gameOver").classList.add("hidden"); | |
| enemies = []; | |
| player = null; | |
| weapon = null; | |
| proficiency = null; | |
| classKey = null; | |
| currentActives = []; | |
| currentPassives = []; | |
| perkLevels = {}; | |
| currentBoss = null; | |
| waveTimer = 0; | |
| divinePerksUnlocked = []; | |
| pressureActive = false; | |
| pressureTimer = 0; | |
| enemiesKilled = 0; | |
| totalDamageDealt = 0; | |
| bossKilled = false; | |
| comboSystem.reset(); | |
| damageNumbers = []; | |
| healNumbers = []; | |
| comboNumbers = []; | |
| smartTargeting.clear(); | |
| } | |
| function showGameOver() { | |
| let stats = `Волна: ${wave} · Мастерство: ${proficiency.currentLevel} · Уровень: ${player.level} · Убийств: ${enemiesKilled}`; | |
| if (classKey === 'warrior') { | |
| stats += ` · Макс комбо: ${comboSystem.maxCombo}`; | |
| } | |
| document.getElementById("gameOverStats").textContent = stats; | |
| document.getElementById("gameOver").classList.remove("hidden"); | |
| } | |
| function openLevelUp() { | |
| gameState = GAME_STATE.LEVELUP; | |
| const choices = document.getElementById("levelChoices"); | |
| choices.innerHTML = ""; | |
| const pool = getAvailablePerks(); | |
| shuffle(pool); | |
| const pick = pool.slice(0, 3); | |
| pick.forEach((perk) => { | |
| const card = document.createElement("div"); | |
| const rarity = perk.rarity || "common"; | |
| card.className = `choice-card rarity-${rarity}`; | |
| card.innerHTML = `<div class="title">${perk.name}</div><div class="type">${rarityLabel(rarity)} · ${perk.type === "passive" ? "Пассив" : "Актив"}</div><div class="desc">${perk.desc}</div>`; | |
| card.addEventListener("click", () => { | |
| applyPerk(perk); | |
| document.getElementById("levelUp").classList.add("hidden"); | |
| gameState = GAME_STATE.PLAYING; | |
| }); | |
| choices.appendChild(card); | |
| }); | |
| document.getElementById("levelUp").classList.remove("hidden"); | |
| } | |
| function openDivineReward() { | |
| gameState = GAME_STATE.DIVINE_REWARD; | |
| const choices = document.getElementById("divineChoices"); | |
| choices.innerHTML = ""; | |
| const available = DIVINE_PERKS.filter(p => !divinePerksUnlocked.includes(p.id)); | |
| shuffle(available); | |
| const pick = available.slice(0, 3); | |
| if (pick.length === 0) { | |
| // Все божественные навыки получены - даём обычные награды | |
| openLevelUp(); | |
| return; | |
| } | |
| pick.forEach((perk) => { | |
| const card = document.createElement("div"); | |
| card.className = `choice-card rarity-divine`; | |
| card.innerHTML = `<div class="title">${perk.name}</div><div class="type">Божественный · Пассив</div><div class="desc">${perk.desc}</div>`; | |
| card.addEventListener("click", () => { | |
| applyDivinePerk(perk); | |
| document.getElementById("divineReward").classList.add("hidden"); | |
| gameState = GAME_STATE.PLAYING; | |
| }); | |
| choices.appendChild(card); | |
| }); | |
| document.getElementById("divineReward").classList.remove("hidden"); | |
| } | |
| function applyDivinePerk(perk) { | |
| perk.effect(modifiers); | |
| divinePerksUnlocked.push(perk.id); | |
| weapon.syncMods(modifiers); | |
| refreshSkillIcons(); | |
| } | |
| function getAvailablePerks() { | |
| if (!classKey || !proficiency) return []; | |
| const kit = CLASS_KITS[classKey]; | |
| const basePerks = BASE_PERKS[classKey] || []; | |
| // Объединяем базовые и основные навыки | |
| const allPerks = [ | |
| ...basePerks.map((p) => ({ ...p, type: "passive" })), | |
| ...kit.passives.map((p) => ({ ...p, type: "passive" })), | |
| ...kit.actives.map((p) => ({ ...p, type: "active" })), | |
| ]; | |
| // Фильтруем только открытые через мастерство | |
| const unlocked = allPerks.filter(p => proficiency.unlockedPerkIds.includes(p.id)); | |
| return unlocked; | |
| } | |
| function applyPerk(perk) { | |
| const info = perkLevels[perk.id] || { level: 0, ascended: 0, type: perk.type }; | |
| info.level += 1; | |
| const max = perk.max || 3; | |
| const isAscend = info.level > max; | |
| // apply effect; for ascension, add a light extra scaling | |
| perk.effect(modifiers); | |
| if (isAscend) { | |
| modifiers.damageMult *= 1.04; | |
| modifiers.cooldownMult *= 0.98; | |
| modifiers.hpBonus += 6; | |
| modifiers.rangeBonus += 3; | |
| } | |
| info.ascended = Math.max(0, info.level - max); | |
| perkLevels[perk.id] = info; | |
| if (perk.type === "passive" && !currentPassives.includes(perk.id)) currentPassives.push(perk.id); | |
| if (perk.type === "active" && !currentActives.includes(perk.id)) currentActives.push(perk.id); | |
| weapon.syncMods(modifiers); | |
| refreshSkillIcons(); | |
| } | |
| function refreshSkillIcons() { | |
| renderSkillIcons(currentActives, currentPassives); | |
| renderMetaIcons(); | |
| } | |
| function renderSkillIcons(activeIds, passiveIds) { | |
| const activeContainer = document.getElementById("activeSkills"); | |
| const passiveContainer = document.getElementById("passiveSkills"); | |
| activeContainer.innerHTML = ""; | |
| passiveContainer.innerHTML = ""; | |
| activeIds.forEach((id) => activeContainer.appendChild(makeSkillIcon(id))); | |
| passiveIds.forEach((id) => passiveContainer.appendChild(makeSkillIcon(id))); | |
| } | |
| const META_GROUPS = { | |
| warrior: [ | |
| { id: "w_meta_1", title: "Вихрь + Рипост", needs: ["whirlwind", "riposte"] }, | |
| { id: "w_meta_2", title: "Пыл + Знамя", needs: ["battle_fervor", "war_banner"] }, | |
| ], | |
| archer: [ | |
| { id: "a_meta_1", title: "Залп + Пробитие", needs: ["volley", "piercing_arrows"] }, | |
| { id: "a_meta_2", title: "Рико + Марка", needs: ["ricochet", "marked_target"] }, | |
| ], | |
| mage: [ | |
| { id: "m_meta_1", title: "Комета + Жар", needs: ["arcane_comet", "ember_focus"] }, | |
| { id: "m_meta_2", title: "Синхрония + Барьер", needs: ["elemental_sync", "mana_barrier"] }, | |
| ], | |
| acolyte: [ | |
| { id: "c_meta_1", title: "Освящение + Свет", needs: ["consecrate", "soothing_light"] }, | |
| { id: "c_meta_2", title: "Страж + Броня", needs: ["guardian_aura", "radiant_armor"] }, | |
| ], | |
| }; | |
| function renderMetaIcons() { | |
| const container = document.getElementById("metaSkills"); | |
| if (!container) return; | |
| container.innerHTML = ""; | |
| if (!classKey) return; | |
| const groups = META_GROUPS[classKey] || []; | |
| groups.forEach((g) => { | |
| const div = document.createElement("div"); | |
| div.className = "skill-icon meta"; | |
| const dot = document.createElement("div"); | |
| dot.className = "meta-dot"; | |
| const ready = g.needs.every((id) => perkLevels[id]?.level >= 1); | |
| if (ready) dot.classList.add("ready"); | |
| const text = document.createElement("div"); | |
| text.className = "meta-text"; | |
| text.textContent = g.title; | |
| div.appendChild(dot); | |
| div.appendChild(text); | |
| container.appendChild(div); | |
| }); | |
| } | |
| function makeSkillIcon(id) { | |
| const wrapper = document.createElement("div"); | |
| const rarity = (getPerkDef(id)?.rarity) || "common"; | |
| wrapper.className = `skill-icon rarity-${rarity}`; | |
| const canvasIcon = document.createElement("canvas"); | |
| canvasIcon.width = 32; | |
| canvasIcon.height = 32; | |
| drawIcon(canvasIcon.getContext("2d"), id); | |
| wrapper.appendChild(canvasIcon); | |
| const label = document.createElement("div"); | |
| label.className = "skill-label"; | |
| const kit = CLASS_KITS[classKey]; | |
| const all = kit ? [...kit.passives, ...kit.actives] : []; | |
| const max = all.find((p) => p.id === id)?.max || 3; | |
| const info = perkLevels[id]; | |
| const lv = info ? info.level : 0; | |
| const asc = info ? info.ascended : 0; | |
| label.textContent = `${shortLabel(id)} ${lv}/${max}${asc > 0 ? "+" + asc : ""}`; | |
| wrapper.appendChild(label); | |
| return wrapper; | |
| } | |
| function shortLabel(id) { | |
| const map = { | |
| // warrior | |
| iron_skin: "Skin", | |
| battle_fervor: "Ferv", | |
| blade_mastery: "Blade", | |
| riposte: "Rip", | |
| heavy_guard: "Guard", | |
| bloodlust: "Blood", | |
| sweeping_edge: "Sweep", | |
| adamant: "Adam", | |
| momentum: "Move", | |
| war_banner: "Banner", | |
| blade_dash: "Dash", | |
| shockwave: "Wave", | |
| guard_stance: "Stance", | |
| whirlwind: "Whirl", | |
| // archer | |
| quickdraw: "QDraw", | |
| light_steps: "Steps", | |
| piercing_arrows: "Pierce", | |
| hunter_focus: "Focus", | |
| ricochet: "Rico", | |
| wind_runner: "Wind", | |
| bleeding_edge: "Bleed", | |
| camo: "Camo", | |
| falcon_eye: "Eye", | |
| arrow_surge: "Surge", | |
| power_shot: "Power", | |
| volley: "Volley", | |
| evasive_roll: "Roll", | |
| marked_target: "Mark", | |
| // mage | |
| ember_focus: "Ember", | |
| mana_font: "Font", | |
| arcane_precision: "Prec", | |
| frost_weave: "Frost", | |
| overload: "Over", | |
| runic_shield: "Shield", | |
| elemental_sync: "Sync", | |
| astral_echo: "Echo", | |
| channeling: "Chan", | |
| mystic_amp: "Amp", | |
| arcane_comet: "Comet", | |
| flame_burst: "Burst", | |
| frost_nova: "Nova", | |
| mana_barrier: "Barrier", | |
| // acolyte | |
| steadfast: "Stand", | |
| radiant_armor: "Armor", | |
| soothing_light: "Light", | |
| devotion: "Dev", | |
| sanctuary: "Sanc", | |
| spirit_chain: "Chain", | |
| warding: "Ward", | |
| resolve: "Res", | |
| holy_edge: "Edge", | |
| blessing: "Bless", | |
| healing_wave: "Heal", | |
| smite: "Smite", | |
| consecrate: "Cons", | |
| guardian_aura: "GuardA", | |
| }; | |
| return map[id] || "Perk"; | |
| } | |
| function drawIcon(c, id) { | |
| c.imageSmoothingEnabled = false; | |
| c.clearRect(0, 0, 32, 32); | |
| const px = 4; | |
| const drawPixel = (x, y, color) => { | |
| c.fillStyle = color; | |
| c.fillRect(x * px, y * px, px, px); | |
| }; | |
| // simple shapes per type | |
| const swordColors = ["#9bd5ff", "#d7ecff"]; | |
| const bowColors = ["#c17b2a", "#f4d35e"]; | |
| const orbColors = ["#c9b4ff", "#ff7b7b", "#ffa64d"]; | |
| const shieldColors = ["#9be7ff", "#5bbcf2"]; | |
| if (id.includes("blade") || id.includes("whirl") || id.includes("slash") || id.includes("shock")) { | |
| swordColors.forEach((color, idx) => { | |
| drawPixel(4 + idx, 1 + idx, color); | |
| drawPixel(5 + idx, 2 + idx, color); | |
| drawPixel(6 + idx, 3 + idx, color); | |
| }); | |
| } else if (id.includes("arrow") || id.includes("shot") || id.includes("bow") || id.includes("mark")) { | |
| drawPixel(1, 2, bowColors[0]); | |
| drawPixel(2, 2, bowColors[1]); | |
| drawPixel(3, 2, bowColors[0]); | |
| drawPixel(4, 2, bowColors[1]); | |
| drawPixel(5, 2, bowColors[0]); | |
| drawPixel(4, 1, bowColors[1]); | |
| drawPixel(4, 3, bowColors[1]); | |
| } else if (id.includes("orb") || id.includes("mana") || id.includes("flame") || id.includes("frost") || id.includes("comet")) { | |
| drawPixel(2, 2, orbColors[0]); | |
| drawPixel(2, 3, orbColors[1]); | |
| drawPixel(3, 2, orbColors[2]); | |
| drawPixel(3, 3, orbColors[0]); | |
| } else if (id.includes("guard") || id.includes("armor") || id.includes("shield") || id.includes("aura")) { | |
| shieldColors.forEach((color, idx) => { | |
| drawPixel(2 + idx, 1, color); | |
| drawPixel(2 + idx, 2, color); | |
| drawPixel(2 + idx, 3, color); | |
| drawPixel(2 + idx, 4, color); | |
| }); | |
| } else { | |
| drawPixel(2, 2, "#ffffff"); | |
| } | |
| } | |
| function shuffle(arr) { | |
| for (let i = arr.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [arr[i], arr[j]] = [arr[j], arr[i]]; | |
| } | |
| } | |
| setupClassSelect(); | |
| requestAnimationFrame(loop); |