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