1027 lines
52 KiB
JavaScript
1027 lines
52 KiB
JavaScript
// ============================================================
|
||
// RPG.JS — Данные и механики игры
|
||
// ============================================================
|
||
|
||
const RPG = {
|
||
|
||
// ══════════════════════════════════════════
|
||
// КЛАССЫ ПЕРСОНАЖЕЙ [данные в data/classes.json]
|
||
// ══════════════════════════════════════════
|
||
CLASSES: {}, // заполняется DataLoader из data/classes.json
|
||
|
||
// ══════════════════════════════════════════
|
||
// ЗАКЛИНАНИЯ [данные в data/classes.json]
|
||
// ══════════════════════════════════════════
|
||
SPELLS: {}, // заполняется DataLoader из data/classes.json
|
||
|
||
// ══════════════════════════════════════════
|
||
// НАВЫКИ [данные в data/classes.json]
|
||
// ══════════════════════════════════════════
|
||
SKILLS: {}, // заполняется DataLoader из data/classes.json
|
||
|
||
// Пул навыков при повышении уровня
|
||
getLevelUpSkills(classId, learnedSpells) {
|
||
const pool = [
|
||
'tough_skin','sharp_mind','quick_feet','iron_will','arcane_mastery','fortify',
|
||
'learn_power_strike','learn_berserk','learn_greater_heal',
|
||
'learn_fireball2','learn_blizzard','learn_chain_lightning',
|
||
'learn_stone_skin','learn_earthquake','learn_arrow_rain',
|
||
];
|
||
// Фильтр: не предлагать уже изученные заклинания
|
||
const available = pool.filter(sk => {
|
||
const s = this.SKILLS[sk];
|
||
if (s.effect === 'spell' && learnedSpells.includes(s.val)) return false;
|
||
return true;
|
||
});
|
||
// Выбираем 3 случайных
|
||
const shuffled = available.sort(() => Math.random() - 0.5);
|
||
return shuffled.slice(0, 3);
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// БАЗЫ ДАННЫХ ВРАГОВ [данные в data/enemies.json]
|
||
// ══════════════════════════════════════════
|
||
ENEMY_DB: {}, // заполняется DataLoader из data/enemies.json
|
||
|
||
createEnemy(type, level, x, y) {
|
||
const db = this.ENEMY_DB[type] || this.ENEMY_DB.goblin;
|
||
const m = 1 + (level - 1) * 0.22;
|
||
return {
|
||
id: 'e_'+Date.now()+'_'+Math.random().toString(36).substr(2,6),
|
||
type, name: db.name, level,
|
||
hp: Math.floor(db.hp * m),
|
||
maxHp: Math.floor(db.hp * m),
|
||
mp: db.hasMp ? Math.floor(50 * m) : 0,
|
||
maxMp: db.hasMp ? Math.floor(50 * m) : 0,
|
||
dmg: Math.floor(db.dmg * m),
|
||
def: Math.floor(db.def * m),
|
||
exp: Math.floor(db.exp * m),
|
||
gold: Math.floor(db.gold* m),
|
||
loot: db.loot,
|
||
isBoss: db.isBoss || false,
|
||
ai: db.ai || null,
|
||
weakness: db.weakness || null,
|
||
resist: db.resist || null,
|
||
isMini: db.isMini || false,
|
||
uniqueLoot: db.uniqueLoot || null,
|
||
x, y,
|
||
isAtk: false,
|
||
status: null, statusTurns: 0, // яд/горение/заморозка
|
||
debuff: 1, // множитель урона (проклятие)
|
||
};
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// СОЗДАНИЕ ПЕРСОНАЖА
|
||
// ══════════════════════════════════════════
|
||
createCharacter(classId) {
|
||
const cls = this.CLASSES[classId] || this.CLASSES.warrior;
|
||
return {
|
||
class: classId,
|
||
level: 1,
|
||
exp: 0, expNext: 100,
|
||
hp: cls.hp, maxHp: cls.hp,
|
||
mp: cls.mp, maxMp: cls.mp,
|
||
str: cls.str, def: cls.def, mag: cls.mag, spd: cls.spd,
|
||
baseStr: cls.str, baseDef: cls.def, baseMag: cls.mag, baseSpd: cls.spd,
|
||
gold: 50,
|
||
x: 6, y: 6, tx: 6, ty: 6, isMoving: false, mp_move: 0, facing: 'down',
|
||
inventory: [],
|
||
equipment: { head:null, chest:null, legs:null, feet:null, weapon:null, shield:null, acc:null },
|
||
learnedSpells: [...cls.startSpells],
|
||
quests: [],
|
||
completedQuests: [],
|
||
stats: { kills:0, dmgDealt:0, dmgTaken:0 },
|
||
hairColor: '#3a2510',
|
||
buffs: [], // активные баффы: [{stat, val, expires}]
|
||
status: null, // яд/горение/заморозка
|
||
statusTurns: 0,
|
||
perks: [],
|
||
perkPoints: 0,
|
||
deathSaveUsed: false,
|
||
bestiary: {},
|
||
foundNotes: [],
|
||
};
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ПРЕДМЕТЫ
|
||
// ══════════════════════════════════════════
|
||
RARITY_COLORS: { common:'#888', uncommon:'#27ae60', rare:'#2980b9', epic:'#8e44ad', legendary:'#e67e22' },
|
||
|
||
createItem(id, type, name, opts) {
|
||
opts = opts || {};
|
||
return {
|
||
id: id || ('item_'+Date.now()+'_'+Math.random().toString(36).substr(2,5)),
|
||
type, name,
|
||
desc: opts.desc || '',
|
||
rarity: opts.rarity || 'common',
|
||
value: opts.value || 0,
|
||
damage: opts.damage || 0,
|
||
defense: opts.defense || 0,
|
||
healAmount: opts.healAmount || 0,
|
||
restoreMp: opts.restoreMp || 0,
|
||
bonusStr: opts.bonusStr || 0,
|
||
bonusDef: opts.bonusDef || 0,
|
||
bonusMag: opts.bonusMag || 0,
|
||
bonusHp: opts.bonusHp || 0,
|
||
bonusMp: opts.bonusMp || 0,
|
||
spell: opts.spell || null,
|
||
stackable: opts.stackable || false,
|
||
qty: opts.qty || 1,
|
||
slot: opts.slot || null, // head/chest/legs/feet/weapon/shield/acc
|
||
icon: opts.icon || this._itemIcon(type),
|
||
setId: opts.setId || null,
|
||
enchant: opts.enchant || null,
|
||
buffStat: opts.buffStat || null, // 'str'|'def'|'regen'
|
||
buffVal: opts.buffVal || 1,
|
||
buffDur: opts.buffDur || 0,
|
||
combatEffect: opts.combatEffect || null, // 'poison'|'fire'
|
||
combatDmg: opts.combatDmg || 0,
|
||
cureStatus: opts.cureStatus || false,
|
||
};
|
||
},
|
||
|
||
_itemIcon(type) {
|
||
return { weapon:'⚔️', armor:'🛡️', potion:'🧪', scroll:'📜', material:'📦', gold:'💰', food:'🍖', gem:'💎' }[type] || '❓';
|
||
},
|
||
|
||
// База стартовых предметов по классу
|
||
getStarterItems(classId) {
|
||
const kits = {
|
||
warrior: [
|
||
{ id:'sw1',type:'weapon',name:'Меч', opts:{ damage:5, value:50, slot:'weapon', icon:'⚔️' }},
|
||
{ id:'sh1',type:'armor', name:'Деревянный щит', opts:{ defense:3, value:40, slot:'shield', icon:'🛡️' }},
|
||
],
|
||
mage: [
|
||
{ id:'st1',type:'weapon',name:'Посох мага', opts:{ damage:3, bonusMag:4, value:60, slot:'weapon', icon:'✨' }},
|
||
],
|
||
archer: [
|
||
{ id:'bw1',type:'weapon',name:'Лук', opts:{ damage:7, value:70, slot:'weapon', icon:'🏹' }},
|
||
],
|
||
paladin: [
|
||
{ id:'hm1',type:'weapon',name:'Молот', opts:{ damage:5, value:55, slot:'weapon', icon:'🔨' }},
|
||
{ id:'sh1',type:'armor', name:'Щит паладина',opts:{ defense:4, value:45, slot:'shield', icon:'🛡️' }},
|
||
],
|
||
necromancer:[
|
||
{ id:'sk1',type:'weapon',name:'Посох тьмы', opts:{ damage:4, bonusMag:3, value:70, slot:'weapon', icon:'💀' }},
|
||
],
|
||
berserker: [
|
||
{ id:'ax1',type:'weapon',name:'Топор', opts:{ damage:9, value:75, slot:'weapon', icon:'🪓' }},
|
||
],
|
||
druid: [
|
||
{ id:'st2',type:'weapon',name:'Посох друида',opts:{ damage:3, bonusMag:5, value:65, slot:'weapon', icon:'🌿' }},
|
||
],
|
||
};
|
||
const common = [
|
||
{ id:'hp1',type:'potion',name:'Зелье HP', opts:{ healAmount:30, value:20, stackable:true, qty:3, icon:'🧪' }},
|
||
{ id:'mp1',type:'potion',name:'Зелье MP', opts:{ restoreMp:20, value:25, stackable:true, qty:2, icon:'💧' }},
|
||
];
|
||
const kit = kits[classId] || kits.warrior;
|
||
return [...kit, ...common].map(d => this.createItem(d.id, d.type, d.name, d.opts));
|
||
},
|
||
|
||
// База магазина [данные в data/shop.json]
|
||
SHOP_ITEMS: [], // заполняется DataLoader из data/shop.json
|
||
|
||
// Лут с врага [данные в data/loot.json]
|
||
LOOT_DB: {}, // заполняется DataLoader из data/loot.json
|
||
|
||
generateLoot(enemy) {
|
||
const items = [];
|
||
// Всегда — золото
|
||
items.push(this.createItem('gold','gold','Золото',{ value:enemy.gold, stackable:true, qty:enemy.gold, icon:'💰' }));
|
||
// Лут-таблица (20% каждый)
|
||
if (enemy.loot) {
|
||
enemy.loot.forEach(lid => {
|
||
if (Math.random() < 0.22) {
|
||
const ld = this.LOOT_DB[lid];
|
||
if (!ld) return;
|
||
items.push(this.createItem(lid+'_'+Date.now(), ld.t, ld.n, {
|
||
value:ld.v, damage:ld.dmg, defense:ld.def,
|
||
healAmount:ld.heal, spell:ld.spell,
|
||
slot:ld.slot, icon:ld.icon,
|
||
rarity:ld.rarity||'common',
|
||
bonusMag: ld.bonusMag || 0,
|
||
setId: ld.setId || null,
|
||
}));
|
||
}
|
||
});
|
||
}
|
||
// Шанс 25% — случайное зелье
|
||
if (Math.random() < 0.25) {
|
||
items.push(this.createItem('rnd_hp','potion','Зелье HP',{ healAmount:30+Math.floor(Math.random()*30), value:20, stackable:true, qty:1, icon:'🧪' }));
|
||
}
|
||
return items;
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// КВЕСТЫ [данные в data/quests.json]
|
||
// ══════════════════════════════════════════
|
||
QUEST_DB: [], // заполняется DataLoader из data/quests.json
|
||
|
||
// ══════════════════════════════════════════
|
||
// ИНВЕНТАРЬ И ЭКИПИРОВКА
|
||
// ══════════════════════════════════════════
|
||
addToInventory(char, item) {
|
||
if (item.stackable) {
|
||
const ex = char.inventory.find(i => i.id === item.id && i.stackable);
|
||
if (ex) { ex.qty += item.qty; return true; }
|
||
}
|
||
if (char.inventory.length >= 28) return false;
|
||
char.inventory.push(item);
|
||
return true;
|
||
},
|
||
|
||
removeFromInventory(char, itemId, qty) {
|
||
qty = qty || 1;
|
||
const idx = char.inventory.findIndex(i => i.id === itemId);
|
||
if (idx < 0) return false;
|
||
const item = char.inventory[idx];
|
||
if (item.stackable) {
|
||
item.qty -= qty;
|
||
if (item.qty <= 0) char.inventory.splice(idx, 1);
|
||
} else {
|
||
char.inventory.splice(idx, 1);
|
||
}
|
||
return true;
|
||
},
|
||
|
||
equip(char, item) {
|
||
const slot = item.slot;
|
||
if (!slot) return { ok:false, msg:'Нельзя экипировать!' };
|
||
// Снять старый предмет
|
||
if (char.equipment[slot]) {
|
||
this.addToInventory(char, char.equipment[slot]);
|
||
}
|
||
char.equipment[slot] = item;
|
||
this.removeFromInventory(char, item.id);
|
||
return { ok:true, msg:'Экипировано: '+item.name };
|
||
},
|
||
|
||
unequip(char, slot) {
|
||
const item = char.equipment[slot];
|
||
if (!item) return false;
|
||
this.addToInventory(char, item);
|
||
char.equipment[slot] = null;
|
||
return true;
|
||
},
|
||
|
||
getEqBonus(char) {
|
||
const b = { damage:0, defense:0, hp:0, mp:0, str:0, def:0, mag:0 };
|
||
Object.values(char.equipment).forEach(it => {
|
||
if (!it) return;
|
||
b.damage += it.damage || 0;
|
||
b.defense += it.defense || 0;
|
||
b.hp += it.bonusHp || 0;
|
||
b.mp += it.bonusMp || 0;
|
||
b.str += it.bonusStr|| 0;
|
||
b.def += it.bonusDef|| 0;
|
||
b.mag += it.bonusMag|| 0;
|
||
// зачарование
|
||
if (it.enchant && this.ENCHANTS[it.enchant]) {
|
||
const en = this.ENCHANTS[it.enchant].bonus;
|
||
b.damage += en.damage || 0;
|
||
b.defense += en.defense || 0;
|
||
b.hp += en.hp || 0;
|
||
b.mp += en.mp || 0;
|
||
b.str += en.str || 0;
|
||
b.mag += en.mag || 0;
|
||
}
|
||
});
|
||
return b;
|
||
},
|
||
|
||
getTotalStats(char) {
|
||
const eq = this.getEqBonus(char);
|
||
const sb = this.getSetBonus(char);
|
||
// Активные баффы
|
||
let buffStr = 0, buffDef = 0;
|
||
const now = Date.now();
|
||
if (char.buffs) {
|
||
char.buffs = char.buffs.filter(b => b.expires > now);
|
||
char.buffs.forEach(b => {
|
||
if (b.stat === 'str') buffStr = Math.max(buffStr, char.str * (b.val - 1));
|
||
if (b.stat === 'def') buffDef = Math.max(buffDef, char.def * (b.val - 1));
|
||
});
|
||
}
|
||
return {
|
||
damage: (char.str + eq.str + buffStr + (sb.str||0)) + eq.damage,
|
||
defense: (char.def + eq.def + buffDef + (sb.def||0)) + eq.defense,
|
||
magic: (char.mag + eq.mag + (sb.mag||0)),
|
||
maxHp: char.maxHp + eq.hp + (sb.hp||0),
|
||
maxMp: char.maxMp + eq.mp + (sb.mp||0),
|
||
};
|
||
},
|
||
|
||
// ── Сеты экипировки [данные в data/sets.json] ───────────
|
||
EQUIPMENT_SETS: {}, // заполняется DataLoader из data/sets.json
|
||
|
||
getSetBonus(char) {
|
||
const totals = { str:0, def:0, mag:0, hp:0, mp:0 };
|
||
const active = [];
|
||
Object.entries(this.EQUIPMENT_SETS).forEach(([sid, set]) => {
|
||
let count = 0;
|
||
Object.values(char.equipment).forEach(it => {
|
||
if (!it) return;
|
||
// Совпадение по setId или по началу ID
|
||
if (it.setId === sid || set.pieces.some(pid => it.id && it.id.startsWith(pid))) count++;
|
||
});
|
||
if (count === 0) return;
|
||
// найти наибольший порог
|
||
const thresholds = Object.keys(set.bonuses).map(Number).sort((a,b)=>b-a);
|
||
const thr = thresholds.find(t => count >= t);
|
||
if (thr == null) return;
|
||
const bon = set.bonuses[thr];
|
||
totals.str += bon.str || 0;
|
||
totals.def += bon.def || 0;
|
||
totals.mag += bon.mag || 0;
|
||
totals.hp += bon.hp || 0;
|
||
totals.mp += bon.mp || 0;
|
||
active.push({ name:set.name, icon:set.icon, count, thr, desc:bon.desc });
|
||
});
|
||
totals._active = active;
|
||
return totals;
|
||
},
|
||
|
||
// ── Зачарование [данные в data/enchants.json] ───────────
|
||
ENCHANTS: {}, // заполняется DataLoader из data/enchants.json
|
||
|
||
_enchantTargetAllowed(slot, target) {
|
||
if (target === 'any') return true;
|
||
const weaponSlots = ['weapon','shield'];
|
||
const armorSlots = ['head','chest','legs','feet','acc'];
|
||
if (target === 'weapon') return weaponSlots.includes(slot);
|
||
if (target === 'armor') return armorSlots.includes(slot);
|
||
return false;
|
||
},
|
||
|
||
getAvailableEnchants(player, item) {
|
||
if (!item || !item.slot) return [];
|
||
return Object.entries(this.ENCHANTS)
|
||
.filter(([, en]) => this._enchantTargetAllowed(item.slot, en.target))
|
||
.map(([eid, en]) => {
|
||
const matCount = player.inventory.filter(i => i.id && i.id.startsWith(en.mat)).reduce((s,i)=>s+(i.qty||1),0);
|
||
const hasGold = player.gold >= en.cost;
|
||
const hasMat = matCount >= en.matQty;
|
||
const matItem = this.LOOT_DB[en.mat];
|
||
return { id:eid, ...en, matCount, hasGold, hasMat,
|
||
matName: matItem ? matItem.n : en.mat,
|
||
canDo: hasGold && hasMat && item.enchant !== eid };
|
||
});
|
||
},
|
||
|
||
enchantItem(player, item, enchantId) {
|
||
const en = this.ENCHANTS[enchantId];
|
||
if (!en) return { ok:false, msg:'Неизвестное зачарование' };
|
||
if (!item || !item.slot) return { ok:false, msg:'Нельзя зачаровать этот предмет' };
|
||
if (!this._enchantTargetAllowed(item.slot, en.target)) return { ok:false, msg:'Не подходит для этого предмета' };
|
||
if (player.gold < en.cost) return { ok:false, msg:'Недостаточно золота' };
|
||
// подсчёт материала
|
||
let matLeft = en.matQty;
|
||
for (const invIt of player.inventory) {
|
||
if (invIt.id && invIt.id.startsWith(en.mat) && matLeft > 0) {
|
||
const take = Math.min(matLeft, invIt.qty || 1);
|
||
invIt.qty -= take;
|
||
matLeft -= take;
|
||
}
|
||
}
|
||
player.inventory = player.inventory.filter(i => (i.qty||1) > 0);
|
||
if (matLeft > 0) return { ok:false, msg:'Недостаточно материалов' };
|
||
player.gold -= en.cost;
|
||
// старое зачарование удаляется, добавляется новое
|
||
const oldEnchant = item.enchant;
|
||
item.enchant = enchantId;
|
||
// обновить имя предмета (убрать старый суффикс, добавить новый)
|
||
const baseName = item._baseName || item.name;
|
||
item._baseName = baseName;
|
||
item.name = `${baseName} ${en.icon}`;
|
||
return { ok:true, msg:`Зачаровано: ${item.name}`, replaced: oldEnchant };
|
||
},
|
||
|
||
useItem(char, item, combatEnemy) {
|
||
if (item.type === 'potion' || item.type === 'food') {
|
||
// Боевые зелья (бросок — работают только в бою)
|
||
if (item.combatEffect && combatEnemy) {
|
||
if (item.combatEffect === 'poison') {
|
||
combatEnemy.status = 'poison';
|
||
combatEnemy.statusTurns = 3;
|
||
combatEnemy.dotDmg = 8;
|
||
this.removeFromInventory(char, item.id, 1);
|
||
return { ok:true, msg:`Яд нанесён на ${combatEnemy.name}! ☠️`, combatUsed:true };
|
||
}
|
||
if (item.combatEffect === 'fire') {
|
||
const dmg = item.combatDmg || 30;
|
||
combatEnemy.hp = Math.max(0, combatEnemy.hp - dmg);
|
||
this.removeFromInventory(char, item.id, 1);
|
||
return { ok:true, msg:`Огненная колба: ${dmg} урона! 🔥`, combatUsed:true, dmg };
|
||
}
|
||
}
|
||
if (item.combatEffect && !combatEnemy) {
|
||
return { ok:false, msg:'Это зелье можно использовать только в бою!' };
|
||
}
|
||
if (item.healAmount) {
|
||
char.hp = Math.min(char.hp + item.healAmount, char.maxHp);
|
||
}
|
||
if (item.restoreMp) {
|
||
char.mp = Math.min(char.mp + item.restoreMp, char.maxMp);
|
||
}
|
||
// Снятие статусов
|
||
if (item.cureStatus) {
|
||
char.status = null; char.statusTurns = 0;
|
||
}
|
||
// Бафф к стату
|
||
if (item.buffStat && item.buffStat !== 'regen') {
|
||
char.buffs = char.buffs || [];
|
||
char.buffs.push({ stat: item.buffStat, val: item.buffVal, expires: Date.now() + item.buffDur });
|
||
}
|
||
this.removeFromInventory(char, item.id, 1);
|
||
return { ok:true, msg:`Использовано: ${item.name}` };
|
||
}
|
||
if (item.type === 'scroll' && item.spell) {
|
||
if (!char.learnedSpells.includes(item.spell)) {
|
||
char.learnedSpells.push(item.spell);
|
||
this.removeFromInventory(char, item.id);
|
||
const sp = this.SPELLS[item.spell];
|
||
return { ok:true, msg:`Изучено: ${sp ? sp.name : item.spell}` };
|
||
}
|
||
return { ok:false, msg:'Вы уже знаете это заклинание!' };
|
||
}
|
||
return { ok:false, msg:'Нельзя использовать здесь!' };
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ЭЛЕМЕНТАЛЬНЫЕ СЛАБОСТИ / СОПРОТИВЛЕНИЯ
|
||
// ══════════════════════════════════════════
|
||
ELEMENT_NAMES: { fire:'Огонь', ice:'Лёд', holy:'Святость', magic:'Магия', physical:'Физика', poison:'Яд', lightning:'Молния', dark:'Тьма' },
|
||
|
||
// Возвращает множитель урона: >1 = слабость, <1 = сопротивление, 1 = нейтрально
|
||
getElementMultiplier(enemy, damageType) {
|
||
if (!enemy || !damageType) return { mult: 1, type: 'neutral' };
|
||
// Нормализация: "magic" покрывает lightning и dark
|
||
const weakAgainst = (w, dt) => {
|
||
if (w === dt) return true;
|
||
if (w === 'magic' && (dt === 'lightning' || dt === 'dark')) return true;
|
||
return false;
|
||
};
|
||
if (enemy.weakness && weakAgainst(enemy.weakness, damageType)) {
|
||
return { mult: 1.5, type: 'weak' };
|
||
}
|
||
if (enemy.resist && weakAgainst(enemy.resist, damageType)) {
|
||
return { mult: 0.5, type: 'resist' };
|
||
}
|
||
return { mult: 1, type: 'neutral' };
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// БОЕВАЯ СИСТЕМА
|
||
// ══════════════════════════════════════════
|
||
// Суммирует все перк-бонусы конкретного типа эффекта
|
||
_sumPerkVal(char, effectType) {
|
||
if (!char.perks || !char.perks.length || !char.class) return 0;
|
||
const tree = this.PERK_TREE[char.class];
|
||
if (!tree) return 0;
|
||
let total = 0;
|
||
for (const branch of tree.branches) {
|
||
for (const perk of branch.perks) {
|
||
if (perk.effect === effectType && char.perks.includes(perk.id)) {
|
||
total += perk.val;
|
||
}
|
||
}
|
||
}
|
||
return total;
|
||
},
|
||
|
||
calcDamage(attacker, defender) {
|
||
const as = this.getTotalStats(attacker);
|
||
const ds = (defender.isPlayer) ? this.getTotalStats(defender) : { defense: defender.def, damage: defender.dmg };
|
||
let dmg = Math.max(1, as.damage - ds.defense);
|
||
|
||
// Перк: уклонение защитника
|
||
if (defender.perks) {
|
||
const totalDodge = this._sumPerkVal(defender, 'dodge');
|
||
if (totalDodge > 0 && Math.random() < totalDodge) {
|
||
return { dmg: 0, crit: false, dodged: true };
|
||
}
|
||
}
|
||
|
||
// Перк: усиление крита атакующего
|
||
let critMult = 1.5 + Math.random() * 0.5;
|
||
if (attacker.perks) critMult += this._sumPerkVal(attacker, 'critDmg');
|
||
|
||
const crit = Math.random() < 0.1 + (attacker.spd||0)*0.008;
|
||
if (crit) dmg = Math.floor(dmg * critMult);
|
||
dmg = Math.floor(dmg * (0.85 + Math.random()*0.3));
|
||
|
||
// Перк: ярость (< 30% HP)
|
||
if (attacker.perks && attacker.maxHp > 0 && attacker.hp / attacker.maxHp < 0.3) {
|
||
const enrage = this._sumPerkVal(attacker, 'enrage');
|
||
if (enrage > 0) dmg = Math.floor(dmg * (1 + enrage));
|
||
}
|
||
|
||
return { dmg, crit };
|
||
},
|
||
|
||
attackEnemy(player, enemy) {
|
||
const r = this.calcDamage(player, enemy);
|
||
if (r.dodged) return { dmg: 0, crit: false, dodged: true, killed: false };
|
||
|
||
// Элементальный множитель для физических атак
|
||
// Проверяем combatEffect оружия (poison/fire) или считаем physical
|
||
const weapon = player.equipment && player.equipment.weapon;
|
||
const atkElement = (weapon && weapon.combatEffect) || 'physical';
|
||
const elemResult = this.getElementMultiplier(enemy, atkElement);
|
||
|
||
let actual = Math.floor(r.dmg * (enemy.debuff || 1) * elemResult.mult);
|
||
enemy.hp = Math.max(0, enemy.hp - actual);
|
||
player.stats.dmgDealt += actual;
|
||
|
||
// Перк: вампиризм
|
||
const lifesteal = this._sumPerkVal(player, 'lifesteal');
|
||
if (lifesteal > 0 && actual > 0) {
|
||
const healed = Math.max(1, Math.floor(actual * lifesteal));
|
||
player.hp = Math.min(player.hp + healed, player.maxHp);
|
||
}
|
||
|
||
// Перк: двойной удар
|
||
const doubleChance = this._sumPerkVal(player, 'doubleAtk');
|
||
if (doubleChance > 0 && Math.random() < doubleChance && enemy.hp > 0) {
|
||
const r2 = this.calcDamage(player, enemy);
|
||
if (!r2.dodged) {
|
||
const dmg2 = Math.floor(r2.dmg * (enemy.debuff || 1));
|
||
enemy.hp = Math.max(0, enemy.hp - dmg2);
|
||
actual += dmg2;
|
||
player.stats.dmgDealt += dmg2;
|
||
}
|
||
}
|
||
|
||
return { dmg: actual, crit: r.crit, killed: enemy.hp <= 0, elemType: elemResult.type };
|
||
},
|
||
|
||
enemyAttackPlayer(enemy, player) {
|
||
// Перк: уклонение игрока
|
||
const dodge = this._sumPerkVal(player, 'dodge');
|
||
if (dodge > 0 && Math.random() < dodge) {
|
||
return { dmg: 0, crit: false, killed: false, dodged: true };
|
||
}
|
||
|
||
const ds = this.getTotalStats(player);
|
||
let dmg = Math.max(1, enemy.dmg - ds.defense);
|
||
const crit = Math.random() < 0.08;
|
||
if (crit) dmg = Math.floor(dmg * 1.5);
|
||
dmg = Math.floor(dmg * (0.88 + Math.random()*0.24));
|
||
|
||
// Перк: смертный рывок (выжить с 1 HP один раз)
|
||
const hasDeathSave = this._sumPerkVal(player, 'deathSave') > 0;
|
||
if (hasDeathSave && !player.deathSaveUsed && player.hp - dmg <= 0 && player.hp > 1) {
|
||
player.hp = 1;
|
||
player.deathSaveUsed = true;
|
||
player.stats.dmgTaken += dmg;
|
||
return { dmg, crit, killed: false, deathSave: true };
|
||
}
|
||
|
||
player.hp = Math.max(0, player.hp - dmg);
|
||
player.stats.dmgTaken += dmg;
|
||
|
||
// Перк: шипы — отражение урона
|
||
const thorns = this._sumPerkVal(player, 'thorns');
|
||
if (thorns > 0 && dmg > 0) {
|
||
enemy.hp = Math.max(0, enemy.hp - Math.floor(dmg * thorns));
|
||
}
|
||
|
||
return { dmg, crit, killed: player.hp <= 0 };
|
||
},
|
||
|
||
castSpell(player, spellId, enemy) {
|
||
const sp = this.SPELLS[spellId];
|
||
if (!sp) return { ok:false, msg:'Неизвестное заклинание!' };
|
||
if (!player.learnedSpells.includes(spellId)) return { ok:false, msg:'Вы не знаете этого заклинания!' };
|
||
if (player.mp < sp.mp) return { ok:false, msg:'Недостаточно маны!' };
|
||
player.mp -= sp.mp;
|
||
|
||
const result = { ok:true, spellName: sp.name, particleType:'magic', dmg:0, heal:0 };
|
||
|
||
if (sp.dmg) {
|
||
const ds = this.getTotalStats(player);
|
||
let dmg = sp.dmg + Math.floor(ds.magic * 0.8) + Math.floor(Math.random()*8);
|
||
// Перк: усиление заклинаний
|
||
const spellBoost = this._sumPerkVal(player, 'spelldmg');
|
||
if (spellBoost > 0) dmg = Math.floor(dmg * (1 + spellBoost));
|
||
// dmgMult — для Мощного удара
|
||
if (sp.dmgMult) dmg = Math.floor(ds.damage * sp.dmgMult);
|
||
// Элементальный множитель (слабость / сопротивление)
|
||
if (enemy) {
|
||
const spellElement = sp.type || 'magic';
|
||
const elemResult = this.getElementMultiplier(enemy, spellElement);
|
||
dmg = Math.floor(dmg * elemResult.mult);
|
||
result.elemType = elemResult.type;
|
||
result.elemName = spellElement;
|
||
enemy.hp = Math.max(0, enemy.hp - dmg);
|
||
result.dmg = dmg;
|
||
result.killed = enemy.hp <= 0;
|
||
}
|
||
result.particleType = sp.type === 'fire' ? 'fire' : sp.type === 'ice' ? 'ice' : sp.type === 'holy' ? 'holy' : 'magic';
|
||
}
|
||
|
||
if (sp.heal) {
|
||
const healed = Math.min(sp.heal, player.maxHp - player.hp);
|
||
player.hp += healed;
|
||
result.heal = healed;
|
||
result.particleType = 'heal';
|
||
}
|
||
|
||
if (sp.buff) {
|
||
player.buffs = player.buffs || [];
|
||
player.buffs.push({ stat: sp.buff, val: sp.val, expires: Date.now() + sp.dur });
|
||
result.msg = `${sp.name}: бафф активен!`;
|
||
}
|
||
|
||
if (sp.debuff && enemy) {
|
||
enemy.debuff = sp.val;
|
||
result.msg = `${sp.name}: враг ослаблен!`;
|
||
}
|
||
|
||
if (sp.dot && enemy) {
|
||
enemy.status = sp.dot;
|
||
enemy.statusTurns = sp.dotTurns;
|
||
enemy.dotDmg = sp.dotDmg || 5;
|
||
result.msg = `${sp.name}: ${sp.dot}!`;
|
||
}
|
||
|
||
if (sp.slow && enemy) {
|
||
enemy.status = 'slow';
|
||
enemy.statusTurns = 2;
|
||
}
|
||
|
||
return result;
|
||
},
|
||
|
||
// Урон от статусов в начале хода
|
||
tickStatus(entity) {
|
||
if (!entity.status || entity.statusTurns <= 0) return 0;
|
||
let dmg = 0;
|
||
if (entity.status === 'poison' || entity.status === 'burn') {
|
||
dmg = entity.dotDmg || 6;
|
||
entity.hp = Math.max(0, entity.hp - dmg);
|
||
}
|
||
entity.statusTurns--;
|
||
if (entity.statusTurns <= 0) entity.status = null;
|
||
return dmg;
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// СИСТЕМА УРОВНЕЙ
|
||
// ══════════════════════════════════════════
|
||
checkLevelUp(char) {
|
||
if (char.exp < char.expNext) return false;
|
||
char.exp -= char.expNext;
|
||
char.level++;
|
||
char.expNext = Math.floor(100 * Math.pow(1.5, char.level - 1));
|
||
const cls = this.CLASSES[char.class] || this.CLASSES.warrior;
|
||
const b = cls.lvlBonuses;
|
||
char.maxHp += b.hp; char.hp = char.maxHp;
|
||
char.maxMp += b.mp; char.mp = char.maxMp;
|
||
char.baseStr+= b.str; char.str = char.baseStr;
|
||
char.baseDef+= b.def; char.def = char.baseDef;
|
||
char.baseMag+= b.mag; char.mag = char.baseMag;
|
||
char.baseSpd+= b.spd; char.spd = char.baseSpd;
|
||
return true;
|
||
},
|
||
|
||
applySkill(char, skillId) {
|
||
const sk = this.SKILLS[skillId];
|
||
if (!sk) return;
|
||
if (sk.effect === 'hp') { char.maxHp += sk.val; char.hp = Math.min(char.hp + sk.val, char.maxHp); }
|
||
if (sk.effect === 'mp') { char.maxMp += sk.val; char.mp = Math.min(char.mp + sk.val, char.maxMp); }
|
||
if (sk.effect === 'str') { char.baseStr += sk.val; char.str = char.baseStr; }
|
||
if (sk.effect === 'def') { char.baseDef += sk.val; char.def = char.baseDef; }
|
||
if (sk.effect === 'mag') { char.baseMag += sk.val; char.mag = char.baseMag; }
|
||
if (sk.effect === 'spd') { char.baseSpd += sk.val; char.spd = char.baseSpd; }
|
||
if (sk.effect === 'spell') {
|
||
if (!char.learnedSpells.includes(sk.val)) char.learnedSpells.push(sk.val);
|
||
}
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ПРОХОДИМОСТЬ
|
||
// ══════════════════════════════════════════
|
||
isPassable(map, x, y) {
|
||
if (x < 0 || y < 0 || x >= map[0].length || y >= map.length) return false;
|
||
const t = map[y][x];
|
||
return t !== 1 && t !== 4 && t !== 6; // вода, стена, лава
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// СОХРАНЕНИЕ / ЗАГРУЗКА
|
||
// ══════════════════════════════════════════
|
||
// СОХРАНЕНИЕ — 3 СЛОТА
|
||
// ══════════════════════════════════════════
|
||
SAVE_PREFIX: 'eidon_s',
|
||
_LOC: { village:'Деревня', tavern:'Таверна', forest:'Лес', dungeon:'Подземелье', swamp:'Болото', mountain:'Горы', cave:'Пещера', ruins:'Руины', abyss:'Бездна' },
|
||
|
||
save(gameData, slot = 0) {
|
||
try {
|
||
const now = new Date();
|
||
gameData._meta = {
|
||
date: now.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'}),
|
||
time: now.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}),
|
||
};
|
||
// Set → Array при сериализации (Set не переживает JSON.stringify)
|
||
const json = JSON.stringify(gameData, (key, value) => {
|
||
if (value instanceof Set) return [...value];
|
||
return value;
|
||
});
|
||
localStorage.setItem(this.SAVE_PREFIX + slot, json);
|
||
// Асинхронно пишем JSON-файл если выбрана папка
|
||
if (typeof SaveFS !== 'undefined' && SaveFS.hasDir()) {
|
||
SaveFS.writeSlot(slot, gameData).catch(e => console.warn('SaveFS write:', e));
|
||
}
|
||
return true;
|
||
} catch(e) { return false; }
|
||
},
|
||
|
||
load(slot = 0) {
|
||
try {
|
||
const s = localStorage.getItem(this.SAVE_PREFIX + slot);
|
||
return s ? JSON.parse(s) : null;
|
||
} catch(e) { return null; }
|
||
},
|
||
|
||
hasSave(slot = 0) {
|
||
return !!localStorage.getItem(this.SAVE_PREFIX + slot);
|
||
},
|
||
|
||
deleteSave(slot = 0) {
|
||
localStorage.removeItem(this.SAVE_PREFIX + slot);
|
||
if (typeof SaveFS !== 'undefined' && SaveFS.hasDir()) {
|
||
SaveFS.deleteSlot(slot).catch(e => console.warn('SaveFS delete:', e));
|
||
}
|
||
},
|
||
|
||
getSaveMeta(slot = 0) {
|
||
try {
|
||
const s = localStorage.getItem(this.SAVE_PREFIX + slot);
|
||
if (!s) return null;
|
||
const d = JSON.parse(s);
|
||
const p = d.player;
|
||
const cls = this.CLASSES[p.class] || { name:'?', icon:'?' };
|
||
const pt = p._playTime || 0;
|
||
const h = Math.floor(pt / 3600), m = Math.floor((pt % 3600) / 60);
|
||
return {
|
||
icon: cls.icon,
|
||
className: cls.name,
|
||
level: p.level,
|
||
mapName: this._LOC[d.mapId] || d.mapId || '?',
|
||
playTime: h > 0 ? `${h}ч ${m}м` : m > 0 ? `${m}м` : '<1м',
|
||
date: d._meta?.date || '?',
|
||
saveTime: d._meta?.time || '',
|
||
kills: p.stats?.kills || 0,
|
||
days: d.dayCount || 1,
|
||
};
|
||
} catch(e) { return null; }
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ДЕРЕВО НАВЫКОВ [данные в data/classes.json]
|
||
// ══════════════════════════════════════════
|
||
PERK_TREE: {}, // заполняется DataLoader из data/classes.json
|
||
getPerkPrereq(classId, perkId) {
|
||
const tree = this.PERK_TREE[classId];
|
||
if (!tree) return null;
|
||
for (const branch of tree.branches) {
|
||
const idx = branch.perks.findIndex(p => p.id === perkId);
|
||
if (idx > 0) return branch.perks[idx - 1].id;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
canLearnPerk(char, perkId) {
|
||
if (char.perks.includes(perkId)) return { ok: false, msg: 'Уже изучено' };
|
||
if ((char.perkPoints || 0) <= 0) return { ok: false, msg: 'Нет очков перков' };
|
||
const req = this.getPerkPrereq(char.class, perkId);
|
||
if (req && !char.perks.includes(req)) return { ok: false, msg: 'Нужен предыдущий перк ветки' };
|
||
return { ok: true };
|
||
},
|
||
|
||
applyPerk(char, perkId) {
|
||
const check = this.canLearnPerk(char, perkId);
|
||
if (!check.ok) return check;
|
||
|
||
const tree = this.PERK_TREE[char.class];
|
||
let perk = null;
|
||
for (const branch of tree.branches) {
|
||
perk = branch.perks.find(p => p.id === perkId);
|
||
if (perk) break;
|
||
}
|
||
if (!perk) return { ok: false, msg: 'Перк не найден' };
|
||
|
||
char.perks.push(perkId);
|
||
char.perkPoints--;
|
||
|
||
if (perk.effect === 'stat') {
|
||
if (perk.stat === 'str') { char.baseStr += perk.val; char.str = char.baseStr; }
|
||
if (perk.stat === 'def') { char.baseDef += perk.val; char.def = char.baseDef; }
|
||
if (perk.stat === 'mag') { char.baseMag += perk.val; char.mag = char.baseMag; }
|
||
if (perk.stat === 'spd') { char.baseSpd += perk.val; char.spd = char.baseSpd; }
|
||
if (perk.stat === 'maxHp') { char.maxHp += perk.val; char.hp = Math.min(char.hp + perk.val, char.maxHp); }
|
||
if (perk.stat === 'maxMp') { char.maxMp += perk.val; }
|
||
}
|
||
return { ok: true, msg: 'Перк изучен: ' + perk.name };
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// ЖУРНАЛ ЛОРА [данные в data/lore.json]
|
||
// ══════════════════════════════════════════
|
||
LORE_NOTES: [], // заполняется DataLoader из data/lore.json
|
||
|
||
// ══════════════════════════════════════════
|
||
// КРАФТИНГ [данные в data/recipes.json]
|
||
// ══════════════════════════════════════════
|
||
CRAFT_RECIPES: [], // заполняется DataLoader из data/recipes.json
|
||
|
||
canCraft(char, recipeId) {
|
||
const recipe = this.CRAFT_RECIPES.find(r => r.id === recipeId);
|
||
if (!recipe) return false;
|
||
return recipe.ingredients.every(ing => {
|
||
const total = char.inventory
|
||
.filter(i => i.id === ing.id || i.id.startsWith(ing.id + '_'))
|
||
.reduce((sum, i) => sum + (i.qty || 1), 0);
|
||
return total >= ing.qty;
|
||
});
|
||
},
|
||
|
||
craft(char, recipeId) {
|
||
const recipe = this.CRAFT_RECIPES.find(r => r.id === recipeId);
|
||
if (!recipe) return { ok: false, msg: 'Рецепт не найден' };
|
||
if (!this.canCraft(char, recipeId)) return { ok: false, msg: 'Не хватает материалов' };
|
||
|
||
recipe.ingredients.forEach(ing => {
|
||
let needed = ing.qty;
|
||
const toRemove = [];
|
||
for (const item of char.inventory) {
|
||
if (needed <= 0) break;
|
||
if (item.id === ing.id || item.id.startsWith(ing.id + '_')) {
|
||
const take = Math.min(needed, item.qty || 1);
|
||
needed -= take;
|
||
toRemove.push({ id: item.id, qty: take });
|
||
}
|
||
}
|
||
toRemove.forEach(r => this.removeFromInventory(char, r.id, r.qty));
|
||
});
|
||
|
||
const r = recipe.result;
|
||
const produced = this.createItem(
|
||
recipeId + '_crafted_' + Date.now(), r.type, r.name, r.opts
|
||
);
|
||
this.addToInventory(char, produced);
|
||
return { ok: true, msg: '⚒️ Создано: ' + recipe.name, item: produced };
|
||
},
|
||
|
||
// ══════════════════════════════════════════
|
||
// СЮЖЕТНЫЕ КВЕСТЫ
|
||
// ══════════════════════════════════════════
|
||
STORY_QUESTS: [
|
||
{
|
||
id: 'sq_village_threat', name: 'Угроза деревне', giverNpc: 'Стражник', icon: '⚔️',
|
||
stages: [
|
||
{ stageIdx:0, title:'Первая разведка', desc:'Убей 3 гоблинов в окрестностях.',
|
||
type:'kill', target:'goblin', need:3,
|
||
dialogBefore:'Путник! Гоблины снова появились у деревни. Убей хотя бы троих — дай нам время подготовиться!',
|
||
dialogAfter:'Хорошая работа! Но их вожак ещё жив и собирает новый отряд.',
|
||
reward:{ exp:80, gold:40 }},
|
||
{ stageIdx:1, title:'Логово вожака', desc:'Разведай подземелье — там прячется вожак.',
|
||
type:'visit', target:'dungeon', need:1,
|
||
dialogBefore:'Вожак укрылся в подземелье. Разведай его — мне нужно знать, есть ли там ловушки.',
|
||
dialogAfter:'Значит, там засела крупная банда. Нужно действовать решительно.',
|
||
reward:{ exp:50, gold:20 }},
|
||
{ stageIdx:2, title:'Убить тролля', desc:'Уничтожь тролля — командира гоблинов.',
|
||
type:'kill', target:'troll', need:1,
|
||
dialogBefore:'Тролль ведёт гоблинов. Убей его — и угроза деревне исчезнет!',
|
||
dialogAfter:'Невероятно! Ты спас деревню. Держи заслуженную награду, герой.',
|
||
reward:{ exp:200, gold:120 }},
|
||
]
|
||
},
|
||
{
|
||
id: 'sq_cursed_swamp', name: 'Проклятое болото', giverNpc: 'Шаман', icon: '🌿',
|
||
stages: [
|
||
{ stageIdx:0, title:'Яд пауков', desc:'Принеси материалы: убей 3 пауков.',
|
||
type:'kill', target:'spider', need:3,
|
||
dialogBefore:'Болото отравлено тёмной магией. Мне нужен паучий яд для обряда очищения. Убей трёх пауков!',
|
||
dialogAfter:'Хорошо. Теперь мне нужно место силы — старая пещера. Проверь, безопасна ли она.',
|
||
reward:{ exp:70, gold:35 }},
|
||
{ stageIdx:1, title:'Место силы', desc:'Найди пещеру для ритуала.',
|
||
type:'visit', target:'cave', need:1,
|
||
dialogBefore:'Место силы находится в пещере на северо-западе. Проверь, что там безопасно.',
|
||
dialogAfter:'Там ещё водятся зомби! Очисти пещеру от нежити, тогда я смогу провести ритуал.',
|
||
reward:{ exp:60, gold:25 }},
|
||
{ stageIdx:2, title:'Очистить пещеру', desc:'Убей 3 зомби в пещере.',
|
||
type:'kill', target:'zombie', need:3,
|
||
dialogBefore:'Зомби мешают ритуалу очищения. Уничтожь трёх из них — тогда болото будет спасено.',
|
||
dialogAfter:'Болото очищено! Духи природы благодарны тебе. Возьми эту награду.',
|
||
reward:{ exp:160, gold:100 }},
|
||
]
|
||
},
|
||
{
|
||
id: 'sq_dragon_hunt', name: 'Охота на дракона', giverNpc: 'Эльф', icon: '🐉',
|
||
stages: [
|
||
{ stageIdx:0, title:'Следы дракона', desc:'Разведай пещеру — там видели дракона.',
|
||
type:'visit', target:'cave', need:1,
|
||
dialogBefore:'Дракон сжигает наш лес! Разведай его логово в пещере — нам нужно знать его силу.',
|
||
dialogAfter:'Ты его видел? Значит, он настоящий. Сначала ослабь его — убей слуг.',
|
||
reward:{ exp:60, gold:30 }},
|
||
{ stageIdx:1, title:'Слуги дракона', desc:'Убей 3 орков — прислужников дракона.',
|
||
type:'kill', target:'orc', need:3,
|
||
dialogBefore:'Орки служат дракону и охраняют подступы к его логову. Убей трёх — ослабь его!',
|
||
dialogAfter:'Отлично! Дракон ослаблен. Теперь иди в пещеру и уничтожь его!',
|
||
reward:{ exp:100, gold:50 }},
|
||
{ stageIdx:2, title:'Убить дракона', desc:'Уничтожь дракона в пещере.',
|
||
type:'kill', target:'dragon', need:1,
|
||
dialogBefore:'Дракон ждёт тебя в глубине пещеры. Это опасно — но ты должен его остановить!',
|
||
dialogAfter:'Легенда! Ты убил дракона. Лес снова в безопасности. Возьми реликвию эльфов.',
|
||
reward:{ exp:500, gold:300 }},
|
||
]
|
||
},
|
||
{
|
||
id: 'sq_lich_rise', name: 'Восстание Лича', giverNpc: 'Призрак', icon: '💀',
|
||
stages: [
|
||
{ stageIdx:0, title:'Армия нежити', desc:'Уничтожь 4 скелета — авангард Лича.',
|
||
type:'kill', target:'skeleton', need:4,
|
||
dialogBefore:'Я чувствую это... Лич собирает армию нежити. Уничтожь его авангард — четырёх скелетов!',
|
||
dialogAfter:'Хорошо. Но Лич сам укрылся в болоте. Разведай его логово.',
|
||
reward:{ exp:90, gold:45 }},
|
||
{ stageIdx:1, title:'Логово Лича', desc:'Исследуй болото — там прячется Лич.',
|
||
type:'visit', target:'swamp', need:1,
|
||
dialogBefore:'Лич обосновался на болоте. Иди туда — подтверди мои слова, пока он не набрал полную мощь.',
|
||
dialogAfter:'Ты его нашёл. Теперь возвращайся и уничтожь это чудовище!',
|
||
reward:{ exp:80, gold:40 }},
|
||
{ stageIdx:2, title:'Убить Лича', desc:'Уничтожь Лича на болоте.',
|
||
type:'kill', target:'lich', need:1,
|
||
dialogBefore:'Лич должен быть уничтожен. Только смерть Лича положит конец нашествию нежити.',
|
||
dialogAfter:'Покой наконец пришёл в этот мир. Я могу идти дальше. Спасибо, герой.',
|
||
reward:{ exp:450, gold:250 }},
|
||
]
|
||
},
|
||
{
|
||
id: 'sq_mountain_golem', name: 'Голем гор', giverNpc: 'Старик', icon: '🏔️',
|
||
stages: [
|
||
{ stageIdx:0, title:'Легенда гор', desc:'Разведай горы — там спит древний голем.',
|
||
type:'visit', target:'mountain', need:1,
|
||
dialogBefore:'Давным-давно в горах был создан великий голем-страж. Иди туда — проверь, цел ли он.',
|
||
dialogAfter:'Голем проснулся! Его разбудили йети. Убей двух — тогда можно сразиться с ним.',
|
||
reward:{ exp:50, gold:20 }},
|
||
{ stageIdx:1, title:'Стражи гор', desc:'Убей 2 йети у логова голема.',
|
||
type:'kill', target:'yeti', need:2,
|
||
dialogBefore:'Йети охраняют голема и не дадут тебе подойти к нему. Убей двух — тогда путь будет открыт.',
|
||
dialogAfter:'Хорошо, путь свободен. Теперь сразись с самим големом!',
|
||
reward:{ exp:120, gold:60 }},
|
||
{ stageIdx:2, title:'Победить голема', desc:'Победи голема в горах.',
|
||
type:'kill', target:'golem', need:1,
|
||
dialogBefore:'Голем — последний страж. Одолей его, и тайна гор будет раскрыта.',
|
||
dialogAfter:'Великолепно! Ты достоин звания героя Эйдона. Возьми это золото и мою благодарность.',
|
||
reward:{ exp:300, gold:180 }},
|
||
]
|
||
},
|
||
],
|
||
|
||
getStoryQuest(id) {
|
||
return this.STORY_QUESTS.find(q => q.id === id) || null;
|
||
},
|
||
|
||
getPlayerStoryQuest(char, id) {
|
||
return char.quests.find(q => q.id === id && q.isStory) || null;
|
||
},
|
||
|
||
giveStoryQuest(char, id) {
|
||
if (this.getPlayerStoryQuest(char, id)) return false;
|
||
const sq = this.getStoryQuest(id);
|
||
if (!sq) return false;
|
||
char.quests.push({ id, isStory: true, stageIdx: 0, progress: 0, done: false, completedStages: [] });
|
||
return true;
|
||
},
|
||
|
||
updateStoryQuestProgress(char, type, target) {
|
||
const results = [];
|
||
char.quests.forEach(pq => {
|
||
if (!pq.isStory || pq.done) return;
|
||
const sq = this.getStoryQuest(pq.id);
|
||
if (!sq) return;
|
||
const stage = sq.stages[pq.stageIdx];
|
||
if (!stage) return;
|
||
if (stage.type === type && (stage.target === target || stage.target === 'any')) {
|
||
pq.progress++;
|
||
if (pq.progress >= stage.need) {
|
||
pq.completedStages.push(pq.stageIdx);
|
||
results.push({ questId: pq.id, stageCompleted: pq.stageIdx, stage, sq });
|
||
if (pq.stageIdx + 1 >= sq.stages.length) {
|
||
pq.done = true;
|
||
} else {
|
||
pq.stageIdx++;
|
||
pq.progress = 0;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return results;
|
||
},
|
||
};
|