Files
deepseek 8680f79102 Initial commit — Jianghu Chronicles: Road of the Wandering Blade
Original open-world wuxia browser RPG:
- Graphical 48px tile world (12 locations) with walkable character,
  NPC interaction, action spots, roaming enemies, travel portals
- Tactical 10x7 grid battles: shapes, statuses, internals, 30+ techniques
  incl. tier-V ultimates & support arts across 6 weapon types
- Full equipment: weapon/head/body/feet/2 accessories, 5 tiers, smithing
- 6-chapter main story + side jobs board + bounty hunts + tournament
- Crafting (smith/alchemy), fishing minigame, gambling, pickpocketing
- Companions with chemistry passives, affection, romance, sects
- Achievements, monster codex, day/night cycle, endings
- 25-test headless smoke suite; no-cache static server included
2026-08-23 07:01:08 +00:00

681 lines
27 KiB
JavaScript

'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', `<b>— Round ${this.round} —</b>`);
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 <span class="bad">${dmg}</span> 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 <span class="bad">${dmg}</span>.`); }
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', `<span class="${cls}">${Util.esc(u.name)} \u2014 ${Util.esc(mvd.n)} \u2014 ${Util.esc(t.name)} takes <b>${dmg}</b>${crit ? ' CRIT!' : ''}</span>`);
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', `<b>${Util.esc(t.name)} falls!</b>`);
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); }