176 lines
13 KiB
JavaScript
176 lines
13 KiB
JavaScript
// Bot pemain sederhana + pemutar ronde untuk simulator keseimbangan (RENCANA 5.1) dan uji pasangan (RENCANA 5.4).
|
|
// installBalance() dijalankan DI DALAM halaman (page.evaluate(installBalance)) dan memasang window.BalSim = { bot, play, round, think, reset, TICK, ... }:
|
|
// round({a, b, level, rseed, cap}) memainkan satu ronde (a = bot, b = CPU) dan mengembalikan hasilnya, play({a, b, n, level, seed0, cap}) memainkan n ronde.
|
|
// Tidak mengubah game: hanya memakai API uji window.__game (startMatch, press, release, advance, snapshot, hero, dummy, projectiles, config, balance).
|
|
// Sisi pemain (hero) dikendalikan bot ini, sisi lawan (dummy) dikendalikan CPU bawaan game pada tingkat yang diminta.
|
|
// Bot sengaja "manusia biasa": bereaksi setelah ~0,17 dtk, tidak selalu memblok, tidak memakai lempar/jurus perintah/jurus toko, dan buta terhadap genangan api/asam.
|
|
export function installBalance() {
|
|
const g = window.__game, h = g.hero, d = g.dummy, FI = window.FIGHTERS || {}, BW = window.Browo;
|
|
const TICK = 1 / 30, REACH = g.config.reachScale || 1;
|
|
const sign = x => (x > 0) - (x < 0);
|
|
let seed = 1;
|
|
const rnd = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
|
// Penyetelan bot. react = jeda membaca gerakan lawan; pBlock/pParry = peluang bertahan terhadap ancaman yang terbaca; pJump = peluang melompati proyektil rendah;
|
|
// ultFallback/modeWait = ultimate dipakai sendiri bila lawan belum memakai ultimate-nya sampai detik itu (di mode pasca-ultimate: sekian detik sesudah masuk mode).
|
|
const C = { react: .17, jitter: .08, pBlock: .5, pParry: .15, pJump: .3, pSkill: .9, ultFallback: 8, modeWait: 2, pHop: .002, runFrom: 380 };
|
|
const bot = { C, t: 0, id: null, foe: null, run: { dir: 0, stage: 0 }, seen: new WeakMap(), blockFrom: 0, blockUntil: 0, jumpAt: -1, stats: {} };
|
|
|
|
// ---- data jurus: jangkauan efektif tiap skill (dibaca dari data karakter, jadi ikut berubah saat angka disetel) ----
|
|
const comboOf = id => (FI[id] ? FI[id].combo : BW.combo);
|
|
const reachOf = id => (FI[id] ? Math.round((FI[id].combo[0].reach || 105) * REACH) : BW.combo[0].reach) + 27; // jangkauan pukulan dasar yang sungguh mengenai
|
|
function skillSpec(id, name, rage) {
|
|
if (!FI[id]) { // Bro Wo: jurus buatan tangan (browo.js)
|
|
if (rage) return name === 'skill1' ? { min: 160, max: 900, tau: .3 } : { min: 90, max: BW.fire.first + BW.fire.gap * (BW.fire.palms - 1), tau: .35 };
|
|
return name === 'skill1' ? { min: BW.tree.ahead - BW.tree.radius + 14, max: BW.tree.ahead + BW.tree.radius - 14, tau: .34 } : { min: 0, max: BW.dance.radius - 30, tau: .1 };
|
|
}
|
|
const F = FI[id], k = (rage && F.mode?.skills?.[name]) || F.skills[name], dur = k.duration || .6, hit = k.hitAt ?? .5;
|
|
if (['projectile', 'wave', 'homing', 'lob'].includes(k.kind)) return { min: 170, max: (k.range || 560) - 40, tau: dur * hit, speed: k.speed || 700 };
|
|
if (k.kind === 'dash') return { min: 130, max: (k.reach || 120) + 27 + k.dash * dur * Math.max(0, hit - .2) - 15, tau: dur * hit };
|
|
const a = k.ahead || 0, r = k.radius || 100; // area / pillar
|
|
return { min: Math.max(0, a - r + 14), max: a + r - 14, tau: dur * hit };
|
|
}
|
|
|
|
// ---- membaca keadaan ----
|
|
// Apakah pukulan yang tiba tau detik lagi akan mengenai lawan (bukan jatuh/bangun, bukan masa kebal, masih dalam stun)?
|
|
function landsAt(tau) {
|
|
if (d.state === 'down' || d.state === 'recover') return false;
|
|
if (d.state === 'hurt') return d.invuln < tau - .03 && tau < .5 - d.stateTime - .02;
|
|
return d.invuln < tau - .03;
|
|
}
|
|
const inDanger = (a, dist) => {
|
|
if (a.tree) return dist < BW.tree.ahead + BW.tree.radius + 20;
|
|
if (a.dance || a.fire || a.truck || a.projectile) return false; // tidak bisa ditahan, atau proyektilnya dibaca nanti
|
|
if (a.area) return dist < (a.ahead || 0) + (a.radius || 100) + 25;
|
|
return dist < (a.reach || 140) + 27 + (a.dash ? a.dash * a.duration * Math.max(0, (a.hitAt ?? .5) - .2) : 0) + 20;
|
|
};
|
|
function defend(t, tHit, parry) { // jendela blok: parry = tekan L tepat sebelum kena (jendela parry 0,16 dtk), selain itu tekan segera
|
|
const from = parry ? t + Math.max(0, tHit - (.03 + rnd() * .08)) : t;
|
|
if (t >= bot.blockFrom && t < bot.blockUntil) { bot.blockUntil = Math.max(bot.blockUntil, t + tHit + .12); return; }
|
|
bot.blockFrom = from; bot.blockUntil = t + tHit + .12;
|
|
}
|
|
function sense(t, dist) {
|
|
const a = d.action;
|
|
if (a && !a.fired && a.type !== 'ultimate' && inDanger(a, dist)) {
|
|
let rec = bot.seen.get(a);
|
|
if (!rec) bot.seen.set(a, rec = { start: t - a.t, react: C.react + rnd() * C.jitter, done: false });
|
|
if (!rec.done && t >= rec.start + rec.react) {
|
|
rec.done = true; const tHit = a.duration * (a.hitAt ?? .5) - a.t, r = rnd();
|
|
if (tHit > .03) { if (r < C.pParry) defend(t, tHit, true); else if (r < C.pParry + C.pBlock) defend(t, tHit, false); }
|
|
}
|
|
}
|
|
for (const p of g.projectiles) {
|
|
if (p.owner !== 'enemy' || p.spent || p.wait > 0) continue;
|
|
let rec = bot.seen.get(p);
|
|
if (!rec) bot.seen.set(p, rec = { start: t, react: C.react + rnd() * C.jitter, done: false });
|
|
if (rec.done || t < rec.start + rec.react) continue;
|
|
rec.done = true;
|
|
const vx = p.vx || 0, toward = vx && sign(vx) === sign(h.x - p.x), tti = vx ? (Math.abs(h.x - p.x) - 28) / Math.abs(vx) : 0, r = rnd();
|
|
if (!vx) { if (dist < 240 && r < C.pBlock) defend(t, .5, false); continue; } // semut berputar/mengejar: tahan sebentar bila dekat
|
|
if (!toward || tti <= .03) continue;
|
|
if ((p.tall || 75) <= 95 && tti > .18 && tti < .44 && h.grounded && r < C.pJump) bot.jumpAt = t + Math.max(0, tti - .27);
|
|
else if (r < C.pJump + C.pParry) defend(t, tti, true);
|
|
else if (r < C.pJump + C.pParry + C.pBlock) defend(t, tti, false);
|
|
}
|
|
}
|
|
|
|
// ---- masukan tombol ----
|
|
function sync(dir, block) { for (const [k, on] of [['a', dir < 0], ['d', dir > 0], ['l', block]]) { if (on) g.press(k); else g.release(k); } }
|
|
function tap(k) { g.press(k); g.release(k); }
|
|
|
|
// Skill yang siap dipakai (skill 2 lebih dulu, lalu skill 1) bila jaraknya cocok dan akan mengenai; mengembalikan tombolnya atau null. Ultimate diatur terpisah di think().
|
|
function pickSkill(dist, side) {
|
|
if (side !== h.facing) return null;
|
|
const rage = !!h.bwRage;
|
|
for (const [name, key] of [['skill2', 'o'], ['skill1', 'i']]) {
|
|
if (h.cooldowns[name] > 0) continue;
|
|
const s = skillSpec(bot.id, name, rage); if (!s) continue;
|
|
const tau = s.tau + (s.speed ? Math.max(0, dist - 100) / s.speed : 0);
|
|
if (dist >= s.min && dist <= s.max && landsAt(tau) && rnd() < C.pSkill) return key;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function think() {
|
|
const t = (bot.t += TICK);
|
|
if (h.hp <= 0 || d.ko) { sync(0, false); return; }
|
|
if (h.hurtTime > 0) { sync(0, false); bot.run.stage = 0; return; }
|
|
const dx = d.x - h.x, dist = Math.abs(dx), side = sign(dx) || h.facing, reach = bot.reach, act = h.action;
|
|
sense(t, dist);
|
|
const defending = t >= bot.blockFrom && t < bot.blockUntil;
|
|
// Ultimate menentukan hampir semua ronde (lawan terkunci beberapa detik), jadi bot menjawab ultimate lawan dengan ultimate sendiri dalam waktu reaksi
|
|
// (seperti pemain yang menahan ultimate sebagai balasan). Bila lawan tak kunjung memakainya, bot memakainya sendiri setelah ultFallback detik.
|
|
if (h.bwRage && !bot.rage) bot.rageAt = t; bot.rage = !!h.bwRage;
|
|
let ultGo = false;
|
|
if (d.action?.type === 'ultimate') { let rec = bot.seen.get(d.action); if (!rec) bot.seen.set(d.action, rec = { start: t - d.action.t, react: C.react + rnd() * C.jitter }); ultGo = t >= rec.start + rec.react; }
|
|
else ultGo = bot.rage ? t - bot.rageAt > C.modeWait : t > C.ultFallback;
|
|
if (ultGo && h.cooldowns.ultimate <= 0 && d.state !== 'down' && d.state !== 'recover' && (!act || act.type === 'attack')) { tap('p'); if (h.action?.type === 'ultimate') { bot.stats.ult = (bot.stats.ult || 0) + 1; sync(0, false); return; } }
|
|
if (act) { // sedang menyerang: sambung kombo bila pukulan berikutnya akan mengenai, atau akhiri dengan skill
|
|
if (act.type === 'attack' && act.fired && !act.special && d.state === 'hurt') {
|
|
const key = pickSkill(dist, side);
|
|
if (key) { tap(key); if (h.action?.type === 'skill1' || h.action?.type === 'skill2') bot.stats[h.action.type] = (bot.stats[h.action.type] || 0) + 1; }
|
|
else if (act.index < 3 && !act.queued && !defending) { const n = comboOf(bot.id)[act.index], tau = act.duration - act.t + n.duration * (n.hitAt || .5); if (dist < reach - 8 && landsAt(tau)) tap(' '); }
|
|
}
|
|
sync(0, false); return;
|
|
}
|
|
// lompat terjadwal (menghindari proyektil rendah) dan lompat kadang-kadang
|
|
if (bot.jumpAt >= 0 && t >= bot.jumpAt) { bot.jumpAt = -1; if (h.grounded) { tap('w'); bot.stats.jump = (bot.stats.jump || 0) + 1; } }
|
|
else if (h.grounded && dist > 260 && !defending && rnd() < C.pHop) tap('w');
|
|
if (defending) { sync(0, h.grounded); bot.run.stage = 0; return; }
|
|
const key = pickSkill(dist, side);
|
|
if (key) { tap(key); if (h.action?.type === 'skill1' || h.action?.type === 'skill2') { bot.stats[h.action.type] = (bot.stats[h.action.type] || 0) + 1; sync(0, false); return; } }
|
|
const flat = Math.abs(d.y - h.y) < 100;
|
|
if (dist < reach - 10 && side === h.facing && flat && landsAt(comboOf(bot.id)[0].duration * (comboOf(bot.id)[0].hitAt || .5))) { tap(' '); bot.stats.basic = (bot.stats.basic || 0) + 1; sync(0, false); return; }
|
|
// gerak: lawan sedang kebal -> jaga jarak di luar jangkauannya sampai kebalnya hampir habis; selain itu dekati
|
|
let want = 0;
|
|
const foeReach = reachOf(bot.foe), shielded = (d.invuln > .12 && d.state !== 'hurt') || d.state === 'down' || d.state === 'recover';
|
|
const closeIn = Math.max(0, dist - (reach - 14)) / 320 + .06;
|
|
if (shielded && Math.max(d.invuln, d.state === 'recover' ? .4 : 0) > closeIn) { const keep = foeReach + 36; want = dist < keep - 8 && h.x > 130 && h.x < 1150 ? -side : dist > keep + 24 ? side : 0; }
|
|
else if (dist > reach - 22) want = side;
|
|
else if (side !== h.facing) want = side; // putar badan menghadap lawan
|
|
// lari (ketuk dua kali) bila jauh: lepas, tekan, lepas, tekan (dalam jendela 0,26 dtk), lalu tahan
|
|
const R = bot.run;
|
|
if (want !== 0 && want === side && dist > C.runFrom) {
|
|
if (R.dir !== want) { R.dir = want; R.stage = 0; }
|
|
if (R.stage === 4 && h.runDirection !== want) R.stage = 0;
|
|
if (R.stage === 0 || R.stage === 2) { sync(0, false); R.stage++; return; }
|
|
sync(want, false); if (R.stage < 4) R.stage++; return;
|
|
}
|
|
R.dir = 0; R.stage = 0;
|
|
sync(want, false);
|
|
}
|
|
|
|
function reset(id, foe, s) {
|
|
bot.id = id; bot.foe = foe; bot.t = 0; bot.reach = reachOf(id); bot.blockFrom = bot.blockUntil = 0; bot.jumpAt = -1; bot.run = { dir: 0, stage: 0 }; bot.seen = new WeakMap(); bot.stats = {}; bot.rage = false; bot.rageAt = 0;
|
|
seed = (s >>> 0) || 1; sync(0, false);
|
|
}
|
|
|
|
// Satu ronde: startMatch -> lewati intro -> bot bermain sampai fase K.O. Hasil dari sisi pemain (hero = bot) dan sisi CPU.
|
|
function round({ a, b, level, rseed, cap }) {
|
|
if (!g.startMatch({ player: a, enemy: b, stage: 'bellora', level, mode: 'versus', styles: { player: 'seimbang', enemy: 'seimbang' } })) return { error: `startMatch gagal: ${a} vs ${b}` };
|
|
reset(a, b, rseed);
|
|
let m = null, guard = 0;
|
|
while (guard++ < 400) { g.advance(.05, true); m = g.snapshot().match; if (m.phase !== 'intro') break; }
|
|
let ticks = 0, phase = m.phase, uh = -1, uc = -1, hs = 0, cs = 0; // hs/cs: bit 1 = skill 1, 2 = skill 2, 4 = ultimate pernah dipakai (sisi bot / sisi CPU)
|
|
while (phase === 'fight' && ticks < cap) {
|
|
think(); g.advance(TICK, true); ticks++;
|
|
hs |= (h.cooldowns.skill1 > 0) | ((h.cooldowns.skill2 > 0) << 1) | ((h.cooldowns.ultimate > 0) << 2); cs |= (d.cooldowns.skill1 > 0) | ((d.cooldowns.skill2 > 0) << 1) | ((d.cooldowns.ultimate > 0) << 2);
|
|
if (uh < 0 && h.cooldowns.ultimate > 0) uh = ticks * TICK; // detik (waktu bot) saat ultimate pertama tiap sisi dipakai
|
|
if (uc < 0 && d.cooldowns.ultimate > 0) uc = ticks * TICK;
|
|
if (h.hp <= 0 || d.ko || ticks % 10 === 0) phase = g.snapshot().match.phase;
|
|
}
|
|
sync(0, false);
|
|
m = g.snapshot().match;
|
|
const eHp = Math.max(0, g.balance.dummyMax - d.damage), hHp = Math.max(0, h.hp), end = m.phase === 'ko';
|
|
return { w: !end ? 'S' : m.lastWinner === 'player' ? 'H' : m.lastWinner === 'enemy' ? 'C' : 'D', hh: Math.round(hHp), ch: Math.round(eHp), t: +(90 - m.seconds).toFixed(2), to: end && hHp > 0 && eHp > 0, uh: +uh.toFixed(1), uc: +uc.toFixed(1), hs, cs, nan: ![h.x, d.x, h.hp, d.damage].every(Number.isFinite), st: { ...bot.stats } };
|
|
}
|
|
// Banyak ronde untuk satu pasangan arah (a = bot/pemain, b = CPU). seed0 tetap -> hasil sama bila urutan sama.
|
|
function play({ a, b, n, level, seed0, cap }) {
|
|
const out = [];
|
|
for (let i = 0; i < n; i++) out.push(round({ a, b, level, rseed: (seed0 + i * 7919 + a.length * 131 + b.length * 17) >>> 0, cap: cap || 6000 }));
|
|
return out;
|
|
}
|
|
g.manual = true; // hentikan loop waktu nyata supaya simulasi hanya maju lewat advance()
|
|
window.BalSim = { bot, play, round, think, reset, TICK, skillSpec, reachOf };
|
|
return true;
|
|
}
|