fighting-konoha/ai.js

132 lines
8.7 KiB
JavaScript

/* Rival adaptif (RENCANA 6.6, ai.js): CPU mengingat kebiasaan pemain dalam SATU pertandingan (sejak startMatch; Arcade dan Survival memulai ingatan baru tiap lawan)
lewat jendela bergulir, lalu menggeser sedikit taktiknya. Mesin tidak diubah: game.js memanggil dua kait kecil dan tidak ada yang lain.
AIAdapt.reset(on, level) di startMatch (on = pertandingan versus).
AIAdapt.apply(P, dt, aktif, hero, dummy) di awal updateFenrAI: mencatat kebiasaan lalu mengembalikan profil CPU (P) yang sudah dikali pengali kecil.
Yang dicatat hanya fakta yang sudah terjadi di arena: pemain mulai melompat, mulai memblok, mulai pukulan dasar (rantai dihitung sekali), skill dekat, skill proyektil
atau jarak jauh. Tombol yang sedang ditekan tidak dibaca, dan sebuah kejadian baru "terlihat" oleh CPU LAG detik sesudahnya, jadi tidak ada yang dibaca sebelum terjadi.
Pengaruhnya kecil dan dibatasi: pengali antiAir, evade, punish, speed, penambah aggression, dan peluang skillBias (jurus didahulukan dari pukulan dasar).
Besarnya dikali kekuatan tingkat (easy hampir nol, excellent penuh) dan keyakinan (butuh sedikitnya MIN_N kejadian), dua arah: pemain yang jarang melompat
justru menurunkan sedikit peluang anti-udara. Damage, HP, jeda reaksi, dan batas kombo CPU tidak disentuh, dan tanpa pola yang jelas semua pengali = 1,
jadi perilaku lama (termasuk urutan random() mesin) tetap sama. Tidak memakai Math.random atau Date.now: hasilnya hanya bergantung pada keadaan permainan.
Layar hasil: AIAdapt.summary() = {kind, text} satu kalimat bila ada kebiasaan dominan; ResultView.mount dibungkus supaya kalimat itu muncul tanpa mengubah vs.js.
Matikan lewat konsol: AIAdapt.enabled = false. Intip keadaan: AIAdapt.state(). */
(() => {
'use strict';
const WIN = 45; // detik permainan yang diingat (jendela bergulir)
const MAXEV = 36; // paling banyak kejadian yang diingat
const LAG = .3; // sebuah kejadian baru terbaca CPU sekian detik sesudah terjadi
const MIN_N = 8, FULL_N = 20; // kejadian minimum sebelum ada pengaruh, dan jumlah untuk keyakinan penuh
const STRENGTH = { easy: .1, medium: .5, hard: .85, excellent: 1 };
// Titik nol penyetelan, bukan data nyata: porsi kejadian pemain "biasa" dan lebar rentangnya (porsi = acuan + lebar -> kecenderungan +1, porsi 0 -> -1 bila acuan = lebar).
const REF = { jump: [.16, .16], block: [.12, .12], zone: [.12, .12], melee: [.45, .3] };
const KINDS = ['jump', 'block', 'melee', 'skill', 'zone'];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const empty = () => ({ jump: 0, block: 0, melee: 0, skill: 0, zone: 0 });
const fresh = (on, level) => ({ on: !!on, level: level || 'medium', t: 0, ev: [], tot: empty(), n: 0, grounded: true, def: false, act: null, calcAt: 0, mul: null, seen: false });
let S = fresh(false);
// ---- mencatat kebiasaan ----
function note(kind) {
S.seen = true; S.n++; S.tot[kind]++;
S.ev.push({ t: S.t, k: kind });
while (S.ev.length > MAXEV || (S.ev.length && S.ev[0].t < S.t - WIN)) S.ev.shift();
}
function classify(a, dist) {
if (a.type === 'attack') { if (a.special || a.index === 1) note('melee'); } // gerakan perintah, lempar, dan jurus toko juga hitung; sambungan kombo tidak
else if (a.type === 'skill1' || a.type === 'skill2') note(a.projectile || dist >= 330 ? 'zone' : 'skill'); // proyektil (tembakan, gelombang, kejar) atau dilepas dari jauh
}
function observe(dt, hero, dummy) {
S.t += dt;
const grounded = hero.grounded !== false;
if (S.grounded && !grounded) note('jump');
S.grounded = grounded;
const def = !!hero.blocking || hero.parry > 0;
if (def && !S.def) note('block');
S.def = def;
const a = hero.action;
if (a !== S.act) { S.act = a; if (a) classify(a, Math.abs(hero.x - dummy.x)); }
}
// ---- membaca kebiasaan ----
function tally(until) {
const c = empty(); let n = 0;
for (const e of S.ev) { if (e.t > until) break; c[e.k]++; n++; }
return { c, n };
}
// kecenderungan -1..+1 untuk tiap kebiasaan terhadap porsi acuannya
function tend(c, n) {
const t = {}; for (const k of Object.keys(REF)) t[k] = n ? clamp((c[k] / n - REF[k][0]) / REF[k][1], -1, 1) : 0;
return t;
}
function strength() { return STRENGTH[S.level] ?? .5; }
function recalc() {
const { c, n } = tally(S.t - LAG);
if (n < MIN_N) { S.mul = null; return; }
const t = tend(c, n), s = strength() * clamp((n - MIN_N) / (FULL_N - MIN_N), 0, 1), pos = x => Math.max(0, x);
S.mul = {
antiAir: 1 + .35 * s * (t.jump > 0 ? t.jump : t.jump * .5), // sering melompat -> lebih sering menyambut di bawah; jarang melompat -> sedikit berkurang
evade: 1 + .12 * s * pos(t.zone) + .1 * s * pos(t.melee), // sering menembak dari jauh atau memukul dekat -> lebih sering menghindar
punish: 1 + .12 * s * pos(t.melee), // sering memukul dekat -> lebih sering menghukum jeda serangan
speed: 1 + .07 * s * pos(t.zone), // sering menembak dari jauh -> lebih cepat mendekat
aggr: .06 * s * pos(t.block), // sering memblok -> lebih berani menekan
skill: .25 * s * pos(t.block) // sering memblok -> kadang jurus didahulukan dari pukulan dasar (guard lebih cepat pecah)
};
}
// Dipanggil tiap langkah dari updateFenrAI. P = profil CPU baru (salinan), boleh diubah; mengembalikan P.
function apply(P, dt, active, hero, dummy) {
if (!active || !S.on || !api.enabled) return P;
try {
observe(dt, hero, dummy);
if (S.t >= S.calcAt) { S.calcAt = S.t + .25; recalc(); }
const m = S.mul; if (!m) return P;
P.antiAir = Math.min(1, P.antiAir * m.antiAir); P.evade = Math.min(1, P.evade * m.evade); P.punish = Math.min(1, P.punish * m.punish);
P.speed *= m.speed; P.aggression = Math.min(.99, P.aggression + m.aggr); P.skillBias = m.skill;
} catch (e) { console.error(e); api.enabled = false; }
return P;
}
function reset(on, level) { S = fresh(on, level); }
// ---- kalimat untuk layar hasil ----
const LINES = {
jump: ['kamu sering melompat, jadi dia mulai menengadah menunggu pendaratanmu.', 'kamu sering melompat, jadi dia mulai menghitung kapan kakimu mendarat.'],
block: ['kamu sering memblok, jadi dia berpikir perisai pun ada batas tahannya.', 'kamu sering memblok, jadi dia sabar mencari celah di balik blokmu.'],
zone: ['kamu sering menembak dari jauh, jadi dia mulai rajin lari pagi ke arahmu.', 'kamu sering menembak dari jauh, jadi dia belajar melompati tembakanmu sambil berlari.'],
melee: ['kamu sering menyerang dari dekat, jadi dia hafal kapan harus mundur selangkah.', 'kamu sering adu jotos dari dekat, jadi dia mulai pandai menghindar lalu membalas.']
};
// Kebiasaan dominan sepanjang pertandingan (bukan hanya jendela terakhir), atau null bila tidak ada yang menonjol.
function summary() {
if (!S.on || !S.seen || !api.enabled) return null;
const tot = S.tot, n = KINDS.reduce((a, k) => a + tot[k], 0);
if (n < 14) return null;
const t = tend(tot, n); let best = null;
for (const k of Object.keys(LINES)) if (t[k] >= .6 && (!best || t[k] > t[best])) best = k;
if (!best) return null;
const lead = S.level === 'easy' ? 'Rival mulai memperhatikan: ' : 'Rival belajar: ';
return { kind: best, text: lead + LINES[best][n % LINES[best].length] };
}
function state() {
const { c, n } = tally(S.t); const t = tend(c, n);
return { on: S.on, level: S.level, t: +S.t.toFixed(2), n: S.n, total: { ...S.tot }, window: { n, ...c }, tend: t, mul: S.mul ? { ...S.mul } : null, strength: strength() };
}
// ---- layar hasil: satu baris di bawah kutipan pemenang (hanya di jendela yang cukup tinggi, lihat training.css) ----
function decorate(root) {
const s = summary(), rs = root?.querySelector?.('.rs');
if (!s || !rs || rs.querySelector('.rs-learn')) return;
const p = document.createElement('p'); p.className = 'rs-learn'; p.dataset.kind = s.kind; p.textContent = s.text;
(rs.querySelector('.rs-quote') || rs.querySelector('.rs-head'))?.insertAdjacentElement('afterend', p);
}
function hookResult() {
const R = window.ResultView; if (!R || R.mount?.__learn) return;
const base = R.mount;
R.mount = function (root) { const out = base.apply(this, arguments); try { decorate(root); } catch (e) { console.error(e); } return out; };
R.mount.__learn = true;
}
// vs.js dimuat sesudah berkas ini: pasang pembungkus setelah semua skrip sinkron selesai (menu.js memanggil ResultView.mount sesudah itu).
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hookResult); else hookResult();
const api = { enabled: true, reset, apply, summary, state, decorate };
window.AIAdapt = api;
})();