132 lines
8.7 KiB
JavaScript
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;
|
|
})();
|