2786 lines
135 KiB
JavaScript
2786 lines
135 KiB
JavaScript
// ============================================================
|
||
// GAME.JS — Основная логика игры
|
||
// ============================================================
|
||
|
||
const Game = {
|
||
|
||
// ── Состояние ──
|
||
state: 'menu', // menu | playing | combat | levelup
|
||
player: null,
|
||
map: [],
|
||
mapId: 'village',
|
||
maps: {},
|
||
enemies: [],
|
||
npcs: [],
|
||
decorations: [],
|
||
groundItems: [],
|
||
weather: 'none',
|
||
weatherParts: [],
|
||
timeOfDay: 12, // 0–24
|
||
dayCount: 1,
|
||
daySpeed: 0.00014, // скорость смены дня (1/мс)
|
||
combatEnemy: null,
|
||
pendingLevelUp: false,
|
||
mouse: { x:0, y:0, tx:-1, ty:-1 },
|
||
time: 0,
|
||
lastTime: 0,
|
||
openPanels: new Set(),
|
||
_keysDown: new Set(),
|
||
saveSlot: 0,
|
||
_sessionStart: 0,
|
||
loreNotes: [],
|
||
_combatLog: [],
|
||
_blinkInterval: null,
|
||
_abyssParticles: null,
|
||
_paused: false,
|
||
_invTab: 'equip',
|
||
_exitingToMenu: false,
|
||
|
||
LOCATIONS: {}, // заполняется DataLoader из data/world.json
|
||
NPC_DIALOGS: {}, // заполняется DataLoader из data/world.json
|
||
_WORLD: {}, // заполняется DataLoader из data/world.json
|
||
|
||
// ══════════════════════════════════════════
|
||
// ЗАПУСК ИГРЫ
|
||
// ══════════════════════════════════════════
|
||
start(classId, slot = 0) {
|
||
this.saveSlot = slot;
|
||
this._sessionStart = Date.now();
|
||
document.getElementById('start-screen').style.display = 'none';
|
||
Renderer.init('gameCanvas');
|
||
this.buildAllMaps();
|
||
this.player = RPG.createCharacter(classId);
|
||
// Стартовые предметы
|
||
const kit = RPG.getStarterItems(classId);
|
||
kit.forEach(it => RPG.addToInventory(this.player, it));
|
||
// Экипировать оружие и щит если есть
|
||
const weapon = this.player.inventory.find(i => i.slot === 'weapon');
|
||
if (weapon) RPG.equip(this.player, weapon);
|
||
const shield = this.player.inventory.find(i => i.slot === 'shield');
|
||
if (shield) RPG.equip(this.player, shield);
|
||
// Первый квест
|
||
this.giveQuest('q_first');
|
||
this.loadMap('village');
|
||
this.state = 'playing';
|
||
this._initCamera();
|
||
this.setupInput();
|
||
this.updateHUD();
|
||
this.showMsg('Добро пожаловать в Хроники Эйдона!', '#ffd700');
|
||
_stopMenuBgm();
|
||
Audio.init(); Audio.playTheme('village');
|
||
requestAnimationFrame(t => this.loop(t));
|
||
},
|
||
|
||
loadAndStart(slot = 0) {
|
||
const data = RPG.load(slot);
|
||
if (!data) return;
|
||
this.saveSlot = slot;
|
||
this._sessionStart = Date.now();
|
||
document.getElementById('start-screen').style.display = 'none';
|
||
Renderer.init('gameCanvas');
|
||
this.buildAllMaps();
|
||
this.player = data.player;
|
||
this.dayCount = data.dayCount || 1;
|
||
this.timeOfDay= data.timeOfDay|| 12;
|
||
this.loadMap(data.mapId || 'village');
|
||
this.state = 'playing';
|
||
this._initCamera();
|
||
this.setupInput();
|
||
this.updateHUD();
|
||
this.showMsg('Игра загружена! День '+this.dayCount, '#4f4');
|
||
_stopMenuBgm();
|
||
Audio.init(); Audio.playTheme(data.mapId || 'village');
|
||
requestAnimationFrame(t => this.loop(t));
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ГЕНЕРАЦИЯ КАРТ
|
||
// ══════════════════════════════════════════
|
||
buildAllMaps() {
|
||
['village','tavern','forest','dungeon','cave','mountain','swamp','ruins','abyss'].forEach(id => {
|
||
this.maps[id] = this.genMap(id);
|
||
});
|
||
},
|
||
|
||
genMap(id) {
|
||
const S = 15;
|
||
const m = Array.from({length:S}, () => Array(S).fill(0));
|
||
|
||
// Границы — стена
|
||
for (let y=0;y<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} ${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');
|
||
}
|
||
}
|
||
});
|
||
};
|
||
},
|
||
};
|