// ============================================================ // 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'); } } }); }; }, };