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

1027 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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;
},
};