'use strict'; /* ============================================================ COMBAT — grid tactics engine (10x7, Chebyshev distance) ============================================================ */ const GRID_W = 10, GRID_H = 7; function makeUnit(o) { return Object.assign({ uid: Util.uid(), name: '?', side: 'enemy', x: 0, y: 0, hp: 50, hpMax: 50, mp: 30, mpMax: 30, atk: 10, def: 3, spd: 8, crit: 4, dodge: 5, mv: 4, rng: 1, wt: 'sword', techs: [], internal: null, internalLv: 1, light: null, sts: [], cds: {}, moved: false, acted: false, alive: true, ai: 'brute', glyph: '敌', color: '#a05c46', lvl: 1, coatPoisonTurns: 0, isPlayer: false, npcId: null }, o); } /* Build the player's battle unit from persistent state */ function makePlayerUnit(p) { const d = Stats.recalc(p); const techs = Object.keys(p.techs); const u = makeUnit({ name: p.name, side: 'ally', isPlayer: true, hp: p.hp, hpMax: d.maxHp, mp: p.mp, mpMax: d.maxMp, atk: d.atk, def: d.def, spd: d.spd, crit: d.crit, dodge: d.dodge, mv: d.mv, rng: d.rng, wt: d.wt, techs, internal: p.internal, internalLv: p.internalLv, light: p.light, glyph: '侠', color: '#d8b36a', ai: 'player' }); // companion chemistry passives (active battle party only) if (G.party && G.party.includes('n_tiehu')) { u.hpMax = Math.round(u.hpMax * 1.08); u.hp = Math.min(u.hp + Math.round(d.maxHp * 0.08), u.hpMax); } if (G.party && G.party.includes('n_lin')) u.crit += 4; if (G.party && G.party.includes('n_suqing')) { u.dodge += 6; u.spd += 1; } return u; } /* Build a companion unit from an NPC definition */ function makeCompanionUnit(npcId, plvl) { const n = NPCS[npcId]; const c = n.companion; if (!c) return null; const lvl = Math.max(1, plvl); const ia = DATA ? INTERNALS[c.internal] : null; const hp = Math.round((70 + lvl * 16) * (c.hpM || 1)); const atk = Math.round((9 + lvl * 2.6) * (c.atkM || 1)); const def = Math.round((4 + lvl * 0.9) * (c.defM || 1)); const spd = 9 + Math.floor(lvl * 0.7) + (c.ai === 'support' ? 2 : 0); const mpMax = 40 + lvl * 6; return makeUnit({ name: n.name, side: 'ally', npcId, hp: hp, hpMax: hp, mp: mpMax, mpMax, atk, def, spd, crit: 6 + Math.floor(lvl / 4), dodge: 6 + Math.floor(lvl / 3), mv: 4 + (c.ai === 'support' ? 2 : 1), rng: c.wt === 'hidden' ? 4 : (c.wt === 'spear' ? 2 : 1), wt: c.wt, techs: [c.techs[0], 't_univ_1'].concat(c.techs.slice(1)), internal: c.internal, internalLv: 1 + Math.floor(lvl / 6), glyph: n.char, color: n.hue, ai: c.ai || 'brute' }); } /* Build an enemy unit; scale = level scaling factor */ function makeEnemyUnit(enemyId, scale) { const e = ENEMIES[enemyId]; if (!e) return null; scale = scale || 1; const hp = Math.round(e.hp * scale), mp = 20 + e.tier * 12; return makeUnit({ name: e.name, side: 'enemy', hp, hpMax: hp, mp, mpMax: mp, atk: Math.round(e.atk * scale), def: Math.round(e.def * (0.7 + 0.3 * scale)), spd: e.spd, crit: 4 + e.tier * 2, dodge: 4 + e.tier * 2, mv: e.mv, rng: e.rng || 1, wt: 'sword', moves: e.moves, techs: [], internal: null, silver: e.silver, expVal: e.exp, loot: e.loot, tier: e.tier, glyph: e.glyph || '敌', color: e.color || '#a05c46', ai: e.ai || 'brute', boss: !!e.boss }); } /* ---------------- Battle class ---------------- */ class Battle { constructor(cfg) { this.cfg = cfg; this.allies = cfg.allies.map((u, i) => { u.side = 'ally'; return u; }); this.enemies = cfg.enemies.map(u => { u.side = 'enemy'; return u; }); this.units = this.allies.concat(this.enemies); this.round = 0; this.queue = []; this.turnIdx = -1; this.current = null; this.over = false; this.result = null; this.phase = 'intro'; this.selectedMove = null; // move being cast this.reach = null; // map of reachable tiles for current unit this.floaters = []; this.banner = null; this.logLines = []; this.listeners = {}; this.speed = G.settings.aiSpeed || 380; this.killsByPlayer = {}; this.techUses = {}; // techId -> uses this battle this.place(); this.on('start'); } on(evt, fn) { if (fn) this.listeners[evt] = fn; else if (this.listeners[evt]) this.listeners[evt](); } emit(evt, data) { if (this.listeners[evt]) this.listeners[evt](data); } place() { let ay = 1; for (const u of this.allies) { u.x = u.isPlayer ? 0 : 1; u.y = ay; ay += 2; if (ay >= GRID_H) ay = 1; } let ey = Math.floor(GRID_H / 2); let ex = GRID_W - 1; for (let i = 0; i < this.enemies.length; i++) { const u = this.enemies[i]; if (i === 0 && this.enemies.length > 2) { u.x = ex - 1; u.y = ey; } else { u.x = ex; u.y = ey; ey += 2; if (ey >= GRID_H) { ey = 1; ex -= 1; } } } } unitAt(x, y) { return this.units.find(u => u.alive && u.x === x && u.y === y); } free(x, y) { return x >= 0 && x < GRID_W && y >= 0 && y < GRID_H && !this.unitAt(x, y); } startRound() { this.round++; this.emit('log', `— Round ${this.round} —`); this.queue = this.units.filter(u => u.alive).sort((a, b) => (b.spd + Util.rf() * 2) - (a.spd + Util.rf() * 2)); this.turnIdx = -1; this.nextTurn(); } effSpd(u) { return Math.round(u.spd * (u.sts.some(s => s.k === 'slow') ? 0.99 : 1)); } effAtk(u) { let m = 1; if (u.sts.some(s => s.k === 'atkup')) m += 0.3; if (u.sts.some(s => s.k === 'burn')) m -= 0.1; return u.atk * m; } effDef(u) { let m = 1; if (u.sts.some(s => s.k === 'defup')) m += 0.3; return u.def * m; } hasStatus(u, k) { return u.sts.some(s => s.k === k); } addStatus(u, k, dur, v) { if (u.isPlayer || u.npcId) { /* player-side can still be debuffed */ } const ex = u.sts.find(s => s.k === k); if (ex) { ex.dur = Math.max(ex.dur, dur); if (v) ex.v = Math.max(ex.v || 0, v); } else u.sts.push({ k, dur, v: v || 0 }); this.emit('fx', { type: 'status', unit: u, status: k }); } nextTurn() { if (this.over) return; // tick round end? this.turnIdx++; if (this.turnIdx >= this.queue.length) { this.startRound(); return; } const u = this.queue[this.turnIdx]; if (!u.alive) { this.nextTurn(); return; } // start-of-turn statuses this.tickStatuses(u); if (!u.alive) { this.checkEnd(); if (!this.over) this.nextTurn(); return; } u.moved = false; u.acted = false; // slow halves movement u.moveLeft = this.hasStatus(u, 'slow') ? Math.max(1, Math.ceil(this.effMovePts(u) / 2)) : this.effMovePts(u); this.current = u; this.reach = this.computeReach(u); this.emit('refresh'); this.showBanner(u.name); if (u.ai === 'player') { this.phase = 'input'; this.emit('input', u); } else { this.phase = 'ai'; const delay = this.cfg.instant ? 0 : this.speed; setTimeout(() => { if (!this.over) this.runAI(u); }, delay); } } effMovePts(u) { let mv = u.mv; return mv; } showBanner(text) { this.banner = text; this.emit('banner', text); if (!this.cfg.instant) setTimeout(() => { this.banner = null; this.emit('banner', null); }, 700); else this.banner = null; } tickStatuses(u) { for (const s of u.sts) { if (s.k === 'poison') { const dmg = Math.max(3, Math.round(u.hpMax * 0.06)); this.damage(u, dmg, { silentCrit: true, srcName: 'poison' }); this.emit('log', `${Util.esc(u.name)} suffers ${dmg} poison damage.`); } if (s.k === 'bleed') { const dmg = 8 + Math.round(1.5 * (u.lvl || 3)); this.damage(u, dmg, { silentCrit: true, srcName: 'bleeding' }); this.emit('log', `${Util.esc(u.name)} bleeds for ${dmg}.`); } if (s.k === 'regen') { const h = Math.round(u.hpMax * 0.08); this.heal(u, h); } } u.sts = u.sts.filter(s => --s.dur > 0); } computeReach(u) { // BFS over free tiles within moveLeft const start = { x: u.x, y: u.y }; const dist = {}; dist[start.x + ',' + start.y] = 0; const q = [[start.x, start.y]]; while (q.length) { const [cx, cy] = q.shift(); const d = dist[cx + ',' + cy]; if (d >= u.moveLeft) continue; for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) { if (!dx && !dy) continue; const nx = cx + dx, ny = cy + dy; if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) continue; if (dist[nx + ',' + ny] !== undefined) continue; if (this.unitAt(nx, ny)) continue; dist[nx + ',' + ny] = d + 1; q.push([nx, ny]); } } delete dist[u.x + ',' + u.y]; return dist; } moveUnitTo(u, x, y) { if (this.over) return false; if (this.current !== u) return false; if ((this.reach || {})[x + ',' + y] === undefined) return false; u.x = x; u.y = y; u.moved = true; u.moveLeft = 0; this.reach = {}; Sfx.play('click'); this.emit('log', `${Util.esc(u.name)} moves.`); this.emit('refresh'); if (u.ai !== 'player' && !u.acted) setTimeout(() => { if (!this.over && this.current === u) this.aiActPhase(u); }, this.cfg.instant ? 0 : this.speed * 0.6); return true; } /* -------- actions -------- */ /* resolve a move definition whether it comes from TECHNIQUES or an inline enemy move list (tech === '__ai') */ resolveMove(u, tech, mi) { if (tech === '__ai') return { mvd: (u.moves || [])[mi], cdKey: '__ai:' + mi }; const T = TECHNIQUES[tech]; return T ? { mvd: T.moves[mi], cdKey: tech + ':' + mi } : {}; } canUseMove(u, tech, mi) { const { mvd, cdKey } = this.resolveMove(u, tech, mi); if (!mvd) return { ok: false, why: '?' }; if ((u.cds[cdKey] || 0) > 0) return { ok: false, why: 'cooldown' }; if (u.mp < (mvd.mp || 0)) return { ok: false, why: 'no qi' }; if (mvd.costHpPct && u.hp <= u.hpMax * (mv.costHpPct + 0.05)) return { ok: false, why: 'too hurt' }; return { ok: true }; } beginCast(u, tech, mi) { if (this.phase !== 'input' || this.current !== u) return; const chk = this.canUseMove(u, tech, mi); if (!chk.ok) { Toast.show(chk.why === 'cooldown' ? 'That move is recharging.' : chk.why === 'no qi' ? 'Not enough inner energy.' : 'Cannot use that.'); return; } this.selectedMove = { tech, mi, def: TECHNIQUES[tech].moves[mi] }; this.emit('refresh'); } cancelCast() { this.selectedMove = null; this.emit('refresh'); } targetTilesFor(u, mvd) { const out = []; const r = mvd.r || 0; if (mvd.sh === 'self') return [{ x: u.x, y: u.y }]; if (mvd.sh === 'ally') { for (const t of this.units.filter(a => a.alive && a.side === u.side)) { if (Util.dist(u.x, u.y, t.x, t.y) <= Math.max(r, 1)) out.push({ x: t.x, y: t.y }); } if (r >= 99 || mvd.self_ok) out.push({ x: u.x, y: u.y }); return out; } for (let x = 0; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) { const d = Util.dist(u.x, u.y, x, y); if (d === 0 || d > r) continue; if (mvd.sh === 'foe') { const t = this.unitAt(x, y); if (t && t.side !== u.side) out.push({ x, y }); } else if (mvd.sh === 'line') { if (x === u.x || y === u.y) out.push({ x, y }); } else if (mvd.sh === 'cross') { if (d === 1 && (x === u.x || y === u.y)) out.push({ x, y }); } else if (mvd.sh === 'area') { out.push({ x, y }); } } return out; } tilesHitByShape(u, mvd, tx, ty) { const hits = []; const sameSide = (t) => t.side === u.side; switch (mvd.sh) { case 'self': return [u]; case 'ally': { const t = this.unitAt(tx, ty); return t && sameSide(t) ? [t] : []; } case 'foe': { const t = this.unitAt(tx, ty); return t && !sameSide(t) ? [t] : []; } case 'line': { const dirX = tx === u.x ? 0 : Math.sign(tx - u.x); const dirY = ty === u.y ? 0 : Math.sign(ty - u.y); let cx = u.x, cy = u.y; for (let i = 0; i < (mvd.r || 1); i++) { cx += dirX; cy += dirY; if (cx < 0 || cx >= GRID_W || cy < 0 || cy >= GRID_H) break; const t = this.unitAt(cx, cy); if (t && !sameSide(t)) hits.push(t); } return hits; } case 'cross': { const cells = [[tx, ty], [tx - 1, ty], [tx + 1, ty], [tx, ty - 1], [tx, ty + 1]]; for (const [cx, cy] of cells) { const t = this.unitAt(cx, cy); if (t && !sameSide(t)) hits.push(t); } return hits; } case 'area': { for (const t of this.units) { if (!t.alive || sameSide(t)) continue; if (Util.dist(tx, ty, t.x, t.y) <= 1) hits.push(t); } return hits; } } return hits; } clickTile(x, y) { if (this.over || this.phase !== 'input') return; const u = this.current; if (this.selectedMove) { const valid = this.targetTilesFor(u, this.selectedMove.def).some(t => t.x === x && t.y === y); if (!valid) { Toast.show('Out of range for that move.'); return; } this.castMove(u, this.selectedMove.tech, this.selectedMove.mi, x, y); this.cancelCast(); return; } if (!u.moved && this.reach[x + ',' + y] !== undefined) { this.moveUnitTo(u, x, y); return; } // default: basic attack on adjacent-ish enemy in weapon range const t = this.unitAt(x, y); if (t && t.side !== u.side && Util.dist(u.x, u.y, x, y) <= u.rng && !u.acted) { this.basicAttack(u, t); } else if (t && t.side !== u.side) { Toast.show('Too far away.'); } } endTurn() { if (this.phase !== 'input') return; this.selectedMove = null; this.phase = 'between'; this.emit('refresh'); setTimeout(() => { if (!this.over) this.nextTurn(); }, this.cfg.instant ? 0 : 250); } basicAttack(u, t) { u.acted = true; this.attackRoll(u, t, { n: 'Strike', p: 100 }); this.afterAction(u); } attackRoll(u, t, mvd, opts) { opts = opts || {}; const hitChance = Util.clamp(95 - t.dodge * 0.8, 40, 98); if (Util.chance(hitChance / 100)) { let power = mvd.p || 100; let masteryBonus = 0; if (opts.techId && u.isPlayer && G.player.techs[opts.techId] >= 100) masteryBonus = 0.10; let raw = this.effAtk(u) * (power / 100) * masteryFactor(masteryBonus); raw *= 0.9 + Util.rf() * 0.2; let dmg = Math.max(1, Math.round(raw - this.effDef(t) * 0.55)); let crit = Util.chance(Util.clamp(u.crit, 0, 60) / 100); if (crit) dmg = Math.round(dmg * 1.55); this.damage(t, dmg, { crit }); const cls = crit ? 'crit' : ''; this.emit('log', `${Util.esc(u.name)} \u2014 ${Util.esc(mvd.n)} \u2014 ${Util.esc(t.name)} takes ${dmg}${crit ? ' CRIT!' : ''}`); Sfx.play(crit ? 'crit' : 'hit'); // riders if (mvd.st && Util.chance(mvd.st.ch)) { this.addStatus(t, mvd.st.k, mvd.st.dur); this.emit('log', `${Util.esc(t.name)} is afflicted: ${mvd.st.k}.`); } if (mvd.stAll && Util.chance(mvd.stAll.ch)) { this.addStatus(t, mvd.stAll.k, mvd.stAll.dur); this.emit('log', `${Util.esc(t.name)} is afflicted: ${mvd.stAll.k}.`); } if (u.coatPoisonTurns > 0 && Util.chance(0.6)) { this.addStatus(t, 'poison', 3); } // serpent touch if (u.internal === 'i_serpent' && Util.chance(0.05 * (u.internalLv || 1))) this.addStatus(t, 'poison', 2); if (opts.techId && u.isPlayer) { this.techUses[opts.techId] = (this.techUses[opts.techId] || 0) + 1; } return dmg; } else { this.emit('log', `${Util.esc(u.name)}\u2019s ${Util.esc(mvd.n)} misses ${Util.esc(t.name)}.`); this.emit('fx', { type: 'miss', unit: t }); return 0; } } functionRef_mastery() {} castMove(u, tech, mi, tx, ty) { const { mvd, cdKey } = this.resolveMove(u, tech, mi); if (!mvd) return; const chk = this.canUseMove(u, tech, mi); if (!chk.ok) return; u.mp -= (mvd.mp || 0); if (mvd.cd) u.cds[cdKey] = mvd.cd + 1; // +1 since cooldowns tick at turn end if (mvd.costHpPct) { const cost = Math.round(u.hpMax * mvd.costHpPct); u.hp = Math.max(1, u.hp - cost); this.emit('fx', { type: 'hurt', unit: u }); } u.acted = true; // self effects if (mvd.sh === 'self') { if (mvd.mpGain) { u.mp = Math.min(u.mpMax, u.mp + mvd.mpGain); this.emit('fx', { type: 'mp', unit: u, v: mvd.mpGain }); this.emit('log', `${Util.esc(u.name)} gathers qi (+${mvd.mpGain}).`); } if (mvd.healPct) { const h = Math.round(u.hpMax * mvd.healPct); this.heal(u, h); this.emit('log', `${Util.esc(u.name)} recovers ${h} health.`, ); } if (mvd.buff) this.addStatus(u, mvd.buff.stat + 'up', mvd.buff.dur); if (mvd.shield) this.addShield(u, mvd.shieldFlat ? shieldAmount(u) : mvd.shield); Sfx.play('heal'); } // targets const targets = this.tilesHitByShape(u, mvd, tx, ty); if (targets.length) { const hits = mvd.hits || 1; for (const t of targets) { if (mvd.p > 0) { for (let h = 0; h < hits; h++) this.attackRoll(u, t, mvd, { techId: tech }); } if (mvd.healFlat) { const h = mvd.healFlat; this.heal(t, h); this.emit('log', `${Util.esc(u.name)} heals ${Util.esc(t.name)} for ${h}.`); } if (mvd.stAlly) { this.addStatus(t, mvd.stAlly.k, mvd.stAlly.dur); } if (mvd.shield) this.addShield(t, mvd.shield); } } else if (mvd.p === 0 && mvd.sh === 'ally') { // whiffed heal } if (u.internal === 'i_sun') { /* regen handled per-round */ } this.afterAction(u); } addShield(u, amount) { const ex = u.sts.find(s => s.k === 'shield'); if (ex) ex.v += amount; else u.sts.push({ k: 'shield', dur: 99, v: amount }); this.emit('fx', { type: 'shield', unit: u }); } useInternalActive() { const u = this.current; if (!u || !u.internal) return; const key = 'internal'; if ((u.cds[key] || 0) > 0) { Toast.show('Internal art is recharging.'); return; } const ia = INTERNALS[u.internal]; const cd = ia.cd || 4; u.cds[key] = cd + 1; switch (u.internal) { case 'i_water': u.sts = u.sts.filter(s => ['shield', 'atkup', 'defup', 'spdup', 'regen'].includes(s.k)); this.emit('log', `${Util.esc(u.name)}\u2019s waters run clear \u2014 debuffs cleansed.`); break; case 'i_azure': { const tgt = this.nearestFoe(u, 2); if (tgt) this.castDirect(u, tgt, { n: 'Sword Qi Wave', p: 140 }, 15); else { Toast.show('No target in range.'); u.cds[key] = 0; return; } break; } case 'i_vajra': { const amt = 40 + 15 * (u.internalLv || 1); this.addShield(u, amt); u.mp = Math.max(0, u.mp - 12); this.emit('log', `Golden bell rings around ${Util.esc(u.name)} (shield ${amt}).`); Sfx.play('heal'); break; } case 'i_serpent': { let any = false; for (const t of this.units.filter(x => x.alive && x.side !== u.side && Util.dist(u.x, u.y, x.x, x.y) <= 1)) { this.addStatus(t, 'poison', 3); any = true; this.emit('log', `${Util.esc(t.name)} is wreathed in venom mist.`); } u.mp = Math.max(0, u.mp - 14); if (!any) { this.emit('log', `${Util.esc(u.name)} exhales venom into empty air.`); } break; } case 'i_sun': { const tgt = this.bestLineTarget(u, 2); u.mp = Math.max(0, u.mp - 20); if (tgt) this.lineBlast(u, tgt, { n: 'Solar Flare', p: 165 }); else this.emit('log', `${Util.esc(u.name)}\u2019s flare scorches only dust.`); break; } } u.acted = true; this.afterAction(u); } castDirect(u, t, mvd, mpCost) { u.mp = Math.max(0, u.mp - (mpCost || 0)); this.attackRoll(u, t, mvd); } bestLineTarget(u, range) { for (const t of this.units.filter(x => x.alive && x.side !== u.side)) { if ((t.x === u.x || t.y === u.y) && Util.dist(u.x, u.y, t.x, t.y) <= range) return t; } return null; } lineBlast(u, t, mvd) { const dirX = t.x === u.x ? 0 : Math.sign(t.x - u.x); const dirY = t.y === u.y ? 0 : Math.sign(t.y - u.y); let cx = u.x, cy = u.y; for (let i = 0; i < 2; i++) { cx += dirX; cy += dirY; const hit = this.unitAt(cx, cy); if (hit && hit.side !== u.side) this.attackRoll(u, hit, mvd); if (hit && hit.side === u.side) break; // blocked by ally } } afterAction(u) { // decrement cooldowns for (const k of Object.keys(u.cds)) u.cds[k] = Math.max(0, u.cds[k] - 1); this.checkEnd(); if (this.over) return; this.emit('refresh'); if (u.ai !== 'player') { setTimeout(() => { if (!this.over && this.current === u) this.nextTurn(); }, this.cfg.instant ? 0 : 350); } else { if (u.moved && u.acted) this.endTurn(); else { this.phase = 'input'; this.emit('input', u); } } } heal(u, amount) { const before = u.hp; u.hp = Math.min(u.hpMax, u.hp + amount); const real = u.hp - before; if (real > 0) this.emit('fx', { type: 'heal', unit: u, v: real }); return real; } damage(t, amount, opts) { opts = opts || {}; if (!t.alive) return 0; // shield absorb const sh = t.sts.find(s => s.k === 'shield'); let dmg = amount; if (sh) { const absorbed = Math.min(sh.v, dmg); sh.v -= absorbed; dmg -= absorbed; if (sh.v <= 0) t.sts = t.sts.filter(s => s !== sh); if (absorbed > 0) this.emit('fx', { type: 'block', unit: t, v: absorbed }); } t.hp = Math.max(0, t.hp - dmg); this.emit('fx', { type: opts.crit ? 'crit' : 'dmg', unit: t, v: dmg }); if (t.hp <= 0) { t.alive = false; this.emit('log', `${Util.esc(t.name)} falls!`); if (t.side === 'enemy' && this.current && this.current.side === 'ally') { const killer = this.current; if (killer.isPlayer) this.killsByPlayer[t.tier || 1] = (this.killsByPlayer[t.tier || 1] || 0) + 1; } // quest counters if (typeof Game !== 'undefined' && Game.registerKill) Game.registerKill(t); } return dmg; } checkEnd() { if (this.over) return; if (!this.enemies.some(u => u.alive)) this.finish(true); else if (!this.allies.some(u => u.alive)) this.finish(false); } finish(win) { this.over = true; this.win = win; this.phase = 'done'; // sync player hp/mp back const pu = this.allies.find(u => u.isPlayer); if (pu && G.player) { G.player.hp = Math.max(0, pu.hp); G.player.mp = Math.max(0, pu.mp); } this.emit('end', { win, kills: this.killsByPlayer, techUses: this.techUses }); } /* -------- AI -------- */ nearestFoe(u, maxD) { let best = null, bd = 99; for (const t of this.units) { if (!t.alive || t.side === u.side) continue; const d = Util.dist(u.x, u.y, t.x, t.y); if (d < bd) { bd = d; best = t; } } return (bd <= (maxD || 99)) ? best : null; } runAI(u) { if (this.over || !u.alive) return; // decide approach first const foe = this.pickAITarget(u); if (!foe) { this.endTurnAI(u); return; } // healer/support special if (u.ai === 'support') { const woundedAlly = this.units.filter(a => a.alive && a.side === u.side && a.hp < a.hpMax * 0.65 && a !== u) .sort((a, b) => (a.hp / a.hpMax) - (b.hp / b.hpMax))[0]; if (woundedAlly && Util.dist(u.x, u.y, woundedAlly.x, woundedAlly.y) <= 4 && u.mp >= 12) { // find a healing move const cast = this.aiFindMove(u, (mvd) => !!mvd.healFlat); if (cast) { this.castMove(u, cast.tech, cast.mi, woundedAlly.x, woundedAlly.y); return; } } } // choose a move to use vs foe const dist = Util.dist(u.x, u.y, foe.x, foe.y); const usable = this.aiUsableMoves(u); // try moves whose range reaches the foe for (const c of usable.sort((a, b) => (b.mvd.p || 0) - (a.mvd.p || 0))) { if (c.mvd.sh === 'self') continue; const reachR = c.mvd.sh === 'area' ? c.mvd.r : (c.mvd.r || 1); if (dist <= reachR) { // aim: for line/cross/foe target the foe tile this.castMove(u, c.tech, c.mi, foe.x, foe.y); return; } } // self-buff if nothing to hit and hasn't acted const buff = usable.find(c => c.mvd.sh === 'self'); if (buff && Util.chance(0.5)) { this.castMove(u, buff.tech, buff.mi, u.x, u.y); return; } // else move toward foe then act if possible this.aiApproachAndAct(u, foe); } endTurnAI(u) { this.checkEnd(); if (this.over) return; setTimeout(() => { if (!this.over && this.current === u) this.nextTurn(); }, this.cfg.instant ? 0 : 300); } aiUsableMoves(u) { const out = []; const pool = u.moves ? null : u.techs; const list = u.moves || []; list.forEach((mvd, mi) => { if ((u.cds['ai:' + mi] || 0) > 0) return; if (u.mp < (mvd.mp || 0)) return; out.push({ tech: '__ai', mi, mvd }); }); // cooldown bookkeeping for AI moves keyed differently if (list.length) { for (let mi = 0; mi < list.length; mi++) if ((u.cds['__ai:' + mi] || 0) > 0) {} } for (const tid of u.techs || []) { const T = TECHNIQUES[tid]; if (!T) continue; T.moves.forEach((mvd, mi) => { const profReq = PROF_REQ[mi] || 0; const prof = u.isPlayer ? (G.player.techs[tid] || 0) : 100; if (prof < profReq) return; if ((u.cds[tid + ':' + mi] || 0) > 0) return; if (u.mp < (mvd.mp || 0)) return; out.push({ tech: tid, mi, mvd }); }); } return out; } aiFindMove(u, pred) { return this.aiUsableMoves(u).find(c => pred(c.mvd)); } pickAITarget(u) { const foes = this.units.filter(t => t.alive && t.side !== u.side); if (!foes.length) return null; if (u.ai === 'archer' || u.ai === 'skirmish' || u.ai === 'caster') { // prefer lowest hp foes.sort((a, b) => a.hp - b.hp); return foes[0]; } if (u.ai === 'boss' || u.ai === 'duelist') { // prefer player const pl = foes.find(f => f.isPlayer); return pl || foes[0]; } foes.sort((a, b) => Util.dist(u.x, u.y, a.x, a.y) - Util.dist(u.x, u.y, b.x, b.y)); return foes[0]; } aiApproachAndAct(u, foe) { // BFS step toward the foe: pick reachable tile minimizing distance to foe const reach = this.computeReach(u); let bestTile = null, bd = Util.dist(u.x, u.y, foe.x, foe.y); for (const key of Object.keys(reach)) { const [x, y] = key.split(',').map(Number); const d = Util.dist(x, y, foe.x, foe.y); if (d < bd) { bd = d; bestTile = { x, y }; } } const doActAfterMove = () => { const nd = Util.dist(u.x, u.y, foe.x, foe.y); if (nd <= u.rng && !u.acted) { this.basicAttackAI(u, foe); } else { const usable = this.aiUsableMoves(u).filter(c => c.mvd.sh !== 'self' && nd <= (c.mvd.sh === 'area' ? c.mvd.r : (c.mvd.r || 1))); if (usable.length && !u.acted) { const c = usable.sort((a, b) => (b.mvd.p || 0) - (a.mvd.p || 0))[0]; this.castMove(u, c.tech, c.mi, foe.x, foe.y); } else if (!u.acted && nd <= 1) { this.basicAttackAI(u, foe); } else this.endTurnAI(u); } }; if (bestTile) { u.x = bestTile.x; u.y = bestTile.y; u.moved = true; this.emit('refresh'); if (this.cfg.instant) doActAfterMove(); else setTimeout(doActAfterMove, this.speed * 0.55); } else { doActAfterMove(); } } basicAttackAI(u, t) { u.acted = true; this.attackRoll(u, t, { n: 'Strike', p: 100 }); this.afterAction(u); } } function masteryFactor(b) { return 1 + b; } function shieldAmount(u) { return 40 + 15 * (u.internalLv || 1); }