Files
RPG_FromClaude/game.js
Maxim Dolgolyov ac1f348311 Initial commit: RPG game project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:01:02 +03:00

2786 lines
135 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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, // 024
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<S;y++) for (let x=0;x<S;x++) {
if (x===0||x===S-1||y===0||y===S-1) m[y][x] = 4;
}
// Заполнение по типу
const R = (n) => Math.random() < n;
for (let y=1;y<S-1;y++) for (let x=1;x<S-1;x++) {
switch(id) {
case 'village':
m[y][x] = R(0.65)?0: R(0.6)?9:8; break;
case 'forest':
m[y][x] = R(0.5)?0: R(0.5)?2: R(0.4)?1:8; break;
case 'dungeon':
m[y][x] = R(0.15)?4: R(0.6)?9:8; break;
case 'cave':
m[y][x] = R(0.2)?4: R(0.4)?2: R(0.2)?6:8; break;
case 'mountain':
m[y][x] = R(0.25)?4: R(0.5)?2:7; break;
case 'swamp':
m[y][x] = R(0.4)?10: R(0.3)?1:8; break;
case 'ruins':
m[y][x] = R(0.12)?4: R(0.25)?2: R(0.1)?3:9; break;
case 'abyss':
m[y][x] = R(0.08)?2: R(0.04)?6:12; break;
case 'tavern':
m[y][x] = 5; break;
}
}
// Центральная поляна
for (let y=5;y<9;y++) for (let x=5;x<9;x++) {
m[y][x] = id==='dungeon'?9: id==='cave'?8: id==='swamp'?10: id==='mountain'?2: id==='ruins'?9: id==='tavern'?5: 0;
}
// Дорожка к центру
if (id==='village') {
for (let x=5;x<9;x++) m[13][x]=9;
}
return m;
},
// ══════════════════════════════════════════
// ЗАГРУЗКА ЛОКАЦИИ
// ══════════════════════════════════════════
loadMap(id) {
this.mapId = id;
this.map = this.maps[id] || this.maps.village;
// Переставить игрока
if (this.player) {
this.player.x = 7; this.player.y = 7;
this.player.tx= 7; this.player.ty= 7;
this.player.isMoving = false;
}
this.spawnEnemies();
this.spawnNPCs();
this.spawnDecos();
this.spawnGroundItems();
this.spawnLoreNotes();
this.setWeather();
},
setWeather() {
const opts = (this._WORLD.weather || {})[this.mapId] || ['none'];
this.weather = opts[Math.floor(Math.random() * opts.length)];
this.weatherParts = [];
},
// ══════════════════════════════════════════
// СПАВН ОБЪЕКТОВ
// ══════════════════════════════════════════
spawnEnemies() {
this.enemies = [];
const p = this.player;
const lvl = p ? p.level : 1;
const list = ((this._WORLD.spawns || {})[this.mapId]) || [];
list.forEach(s => 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 = `<span class="hint-key">F</span>${icon} ${verb}: <b style="color:#ffd700">${nearby.label}</b>`;
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 = `<div class="pd-item-icon">${item.icon||'📦'}</div>
<div class="pd-item-name">${item.name}${enchTag}</div>
<div class="pd-item-stat">${stat}</div>`;
el.onclick = () => {
RPG.unequip(p, slot);
this.updateHUD(); this._renderPaperDoll();
this.showMsg('Снято: ' + item.name);
};
} else {
el.classList.remove('filled');
el.innerHTML = `<small>${label}</small>`;
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 = `
<div class="is-icon">${item.icon||'📦'}</div>
<div class="is-name">${item.name.substring(0,14)}</div>
<div class="is-val">${statStr}</div>
${item.stackable && item.qty > 1 ? `<div class="is-qty">×${item.qty}</div>` : ''}`;
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 `<div class="stat-row">
<span class="stat-label">${icon} ${label}</span>
<span class="stat-value">${val}</span>
<span class="stat-breakdown">${breakdown}</span>
</div>${barMax ? `<div class="stat-bar"><div class="stat-bar-fill" style="width:${pct}%"></div></div>` : ''}`;
};
const fmtBreak = (base, growth, equip, set) => {
let parts = [`база <b>${base}</b>`];
if (growth > 0) parts.push(`рост <b>+${growth}</b>`);
if (equip > 0) parts.push(`экип <b>+${equip}</b>`);
if (set > 0) parts.push(`набор <b>+${set}</b>`);
return parts.join(' ');
};
el.innerHTML = `
<div class="stat-group">
<div class="stat-group-title">Основные</div>
${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)}
</div>
<div class="stat-group">
<div class="stat-group-title">Живучесть</div>
${row('❤️','HP макс', p.maxHp, eqHp||sb.hp ? `экип <b>+${eqHp+(sb.hp||0)}</b>` : '—', 0)}
${row('💧','MP макс', p.maxMp, eqMp||sb.mp ? `экип <b>+${eqMp+(sb.mp||0)}</b>` : '—', 0)}
${row('⭐','Уровень', p.level, `опыт: ${p.exp}/${p.expNext}`, 0)}
</div>
<div class="stat-group">
<div class="stat-group-title">Боевые</div>
${row('💥','Крит', critPct+'%', `база 10% + спд×0.8% (спд: <b>${p.spd||0}</b>)`, 50)}
${row('⚡','Скорость', p.spd||0, `база <b>${p.baseSpd||p.spd||0}</b> + рост <b>+${Math.max(0,(p.spd||0)-(p.baseSpd||p.spd||0))}</b>`, 20)}
${dodge >0 ? row('👤','Уклонение', dodge+'%', `из перков`, 50) : ''}
${dblAtk >0 ? row('🗡️','Двойной удар', dblAtk+'%', `из перков`, 50) : ''}
${lifesteal>0? row('🩸','Вампиризм', lifesteal+'%', `из перков`, 30) : ''}
</div>`;
},
// ──── Магазин ────
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 = `
<div class="si-name">${item.icon||''} ${item.name}</div>
<div class="si-price">💰 ${item.value}</div>
<div class="si-stat">${statStr}</div>`;
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 ? ` <span style="color:#666">(${pq.progress}/${st.need})</span>` : '';
return `<div style="font-size:9px;color:${clr};padding:1px 0;padding-left:${idx*6}px">${pfx} ${st.title}${prog}</div>`;
}).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 = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
<span style="font-size:13px">${sq.icon}</span>
<div class="q-name">${sq.name}</div>
<span style="font-size:9px;color:#555;margin-left:auto">от: ${sq.giverNpc}</span>
</div>
<div class="q-desc">${stage.desc}</div>
<div style="margin:4px 0">${stageChecks}</div>
${stage.need > 1 ? `<div class="q-pbar"><div class="q-pfill" style="width:${pct}%"></div></div>` : ''}
<div class="q-reward" style="color:#ffaa44">Этап: +${stage.reward.exp} XP · +${stage.reward.gold} золота</div>`;
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 = `
<div class="q-name">${qdb?qdb.name:q.id}</div>
<div class="q-desc">${qdb?qdb.desc:''}</div>
<div class="q-pbar"><div class="q-pfill" style="width:${pct}%"></div></div>
<div class="q-reward">+${q.reward.exp} XP · +${q.reward.gold} золота</div>`;
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 = `<div class="q-name">✓ ${name}</div>`;
ql.appendChild(div);
});
}
if (!this.player.quests.length) {
ql.innerHTML = '<div style="padding:12px;color:#555;font-size:12px">Нет квестов. Поговори с NPC!</div>';
}
},
// ──── Диалог ────
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) => `<div style="color:${e.color};opacity:${1 - i*0.16}">${e.msg}</div>`)
.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 = `<div class="perk-branch-title">${branch.icon} ${branch.name}</div>`;
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 = `
<div class="perk-tier-badge">T${perk.tier}</div>
<div class="perk-icon">${perk.icon}</div>
<div class="perk-name">${perk.name}</div>
<div class="perk-desc">${perk.desc}</div>`;
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 `<div class="craft-ing ${have ? 'have' : 'missing'}">
<span>${name}</span><span>${playerQty}/${ing.qty} ${have ? '✓' : '✗'}</span>
</div>`;
}).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 ? `<span style="color:${rarityColor}">${r.opts.rarity}</span> · ` : '';
detail.innerHTML = `
<div style="font-size:28px;text-align:center;margin-bottom:5px">${recipe.icon}</div>
<div style="font-size:14px;font-weight:bold;color:#ffd700;text-align:center;margin-bottom:10px">${recipe.name}</div>
<div style="font-size:9px;color:#555;text-transform:uppercase;margin-bottom:5px">Ингредиенты:</div>
${ingRows}
<div style="margin-top:12px;font-size:9px;color:#555;text-transform:uppercase;margin-bottom:5px">Результат:</div>
<div style="background:rgba(12,12,30,0.8);border:1px solid #2a2a4a;border-radius:4px;padding:8px;font-size:10px;color:#bbb">
${r.opts.icon || ''} <b style="color:#eee">${r.name}</b><br>
${rarityStr}${statStr}
</div>
<button onclick="Game._doCraft('${recipe.id}')"
style="width:100%;margin-top:12px;padding:9px;
background:${canCraft ? 'rgba(15,50,15,0.9)' : 'rgba(12,12,12,0.7)'};
border:2px solid ${canCraft ? '#27ae60' : '#2a2a2a'};
border-radius:5px;color:${canCraft ? '#4f4' : '#444'};
font-size:13px;cursor:${canCraft ? 'pointer' : 'not-allowed'}">
${canCraft ? '⚒️ Создать' : '⚒️ Не хватает материалов'}
</button>`;
},
_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 = '<div class="lore-empty">Вы ещё не нашли ни одной записи.<br>Исследуйте локации!</div>';
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
? `<div class="lc-hint">💡 ${note.reveals.hint}</div>` : '';
card.innerHTML = `
<div class="lc-head"><span class="lc-icon">${note.icon}</span><span class="lc-title">${note.title}</span></div>
<div class="lc-text">${note.text}</div>${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 = `<span class="eib-icon">${item.icon||'📦'}</span><span class="eib-name">${item.name}</span>${enchIcon ? `<span class="eib-ench">${enchIcon}</span>` : ''}<span class="eib-src">${source==='eq'?'👕экип.':'🎒инв.'}</span>`;
btn.onclick = () => this.renderEnchantPanel(item);
leftEl.appendChild(btn);
});
// Правая колонка — список зачарований
rightEl.innerHTML = '';
if (!selectedItem) {
rightEl.innerHTML = '<div class="ench-empty">Нет предметов для зачарования</div>';
return;
}
const header = document.createElement('div');
header.className = 'ench-item-header';
header.innerHTML = `<span class="eih-icon">${selectedItem.icon||'📦'}</span><span class="eih-name">${selectedItem.name}</span>`;
if (selectedItem.enchant && RPG.ENCHANTS[selectedItem.enchant]) {
const cur = RPG.ENCHANTS[selectedItem.enchant];
header.innerHTML += `<span class="eih-cur">Текущее: ${cur.icon} ${cur.name}</span>`;
}
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 = `
<div class="ec-head"><span class="ec-icon">${en.icon}</span><span class="ec-name">${en.name}</span><span class="ec-desc">${en.desc}</span></div>
<div class="ec-cost">${goldOk} 💰 ${en.cost} &nbsp; ${matOk} ${en.matName} ×${en.matQty} (есть: ${en.matCount})</div>`;
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 = `<span class="ach-icon">${a.icon}</span>
<div class="ach-name">${a.name}</div>
<div class="ach-desc">${a.desc}</div>`;
if (a.maxProgress && !isUnlocked) {
const prog = Math.min(a.maxProgress, a.getProgress(p));
const pct = Math.round(prog / a.maxProgress * 100);
div.innerHTML += `<div class="ach-progress"><div class="ach-prog-fill" style="width:${pct}%"></div></div>
<div class="ach-prog-text">${prog}/${a.maxProgress}</div>`;
}
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<cvs.width;i+=30) { ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,cvs.height); ctx.stroke(); }
for (let i=0;i<cvs.height;i+=30) { ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(cvs.width,i); ctx.stroke(); }
// Соединения (рисуем только уникальные пары)
const drawn = new Set();
Object.entries(this.MAP_GRAPH).forEach(([from, tos]) => {
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');
}
}
});
};
},
};