Rasa kena: tabel penyetelan hit-stop dan getaran, K.O. slow-motion, angka damage pop, peringkat kombo

- feel.js baru: tabel FEEL terpusat per jenis serangan (ringan, sedang, berat, ultimate, guard, parry, counter),
  getaran searah pukulan, riak, kilat putih pada sprite yang kena (pemain dan lawan CPU)
- K.O. memperlambat simulasi visual (0,25x ~0,9 dtk; K.O. akhir 0,2x ~1,5 dtk) dengan zoom ke yang kalah dan vignette;
  jam ronde dan penutup K.O. tetap memakai waktu nyata
- angka damage pop dengan warna per jenis (counter oranye + teks COUNTER, parry biru, guard abu), dibatasi 8 aktif
- peringkat kombo C/B/A/S di tengah layar, memantul tiap pukulan dan beranimasi saat naik peringkat
- tests/t1-feel.mjs: skenario headless (53 pemeriksaan) dengan screenshot
This commit is contained in:
rizkyaz0 2026-10-02 22:49:36 +07:00
parent b1e3b75826
commit 376763dedf
5 changed files with 597 additions and 38 deletions

14
feel.css Normal file
View File

@ -0,0 +1,14 @@
/* Rasa kena (feel.js): peringkat kombo C/B/A/S di tengah layar. Letak: di bawah header, di atas kepala petarung; tidak menyentuh HUD bawah. */
.combo-rank{position:absolute;left:50%;top:35%;z-index:2;display:flex;flex-direction:column;align-items:center;pointer-events:none;opacity:0;transform:translateX(-50%);transition:opacity .22s;--rk:#bfeaff;--rk2:#2b6f86;--t:1}
.combo-rank.visible{opacity:.96}
.combo-rank[data-rank=C]{--rk:#bfeaff;--rk2:#2b6f86}
.combo-rank[data-rank=B]{--rk:#ffe08a;--rk2:#8a5a12}
.combo-rank[data-rank=A]{--rk:#ffa24a;--rk2:#8a2f0e}
.combo-rank[data-rank=S]{--rk:#ff6b5a;--rk2:#7a0f1e}
.combo-rank b{display:block;padding:0 .14em;font:700 italic clamp(52px,18.5vh,150px)/.85 Rajdhani,'Arial Narrow',sans-serif;color:var(--rk);transform-origin:50% 62%;
text-shadow:.045em 0 #142430,-.045em 0 #142430,0 .045em #142430,0 -.045em #142430,.032em .032em #142430,-.032em .032em #142430,.032em -.032em #142430,-.032em -.032em #142430,0 .07em 0 var(--rk2),0 0 .22em var(--rk)}
.combo-rank small{display:block;margin-top:.15em;font:700 clamp(12px,3.3vh,24px)/1 Rajdhani,sans-serif;letter-spacing:.28em;text-indent:.28em;color:#fff3d4;text-shadow:2px 2px 0 #142430,0 0 10px #0009}
.combo-rank i{display:block;width:clamp(70px,18vh,150px);height:4px;margin-top:5px;background:#142430a8;box-shadow:0 0 0 1px #ffffff2e;overflow:hidden}
.combo-rank i:after{content:'';display:block;height:100%;background:var(--rk);transform:scaleX(var(--t));transform-origin:0 50%}
.in-menu .combo-rank{visibility:hidden}
@media(prefers-reduced-motion:reduce){.combo-rank{transition:none}}

198
feel.js Normal file
View File

@ -0,0 +1,198 @@
/* Rasa kena (Tahap 1.7-1.9): tabel penyetelan terpusat (hit-stop, getaran, kilat), getaran searah pukulan, riak,
K.O. slow-motion + zoom + vignette, angka damage "pop" dan peringkat kombo.
Murni penyetelan dan tampilan: game.js memanggil ke sini dari beberapa titik kecil dan tetap memegang status permainan.
Titik sambung di game.js: feelImpact/feelHeroHit/startKoFx/koCamera/feelWatchKo (blok "Rasa kena"), step() (waktu nyata vs dt melambat),
draw() (getaran searah pukulan, kamera K.O., vignette), updateVisuals/drawEffects (angka damage), updateHud (peringkat kombo).
Uji headless: window.__game.hitDummy(...), __game.feel (tabel + fungsi), __game.feelState() (K.O., getaran, angka, hit-stop). */
(() => {
'use strict';
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const smooth = u => u * u * (3 - 2 * u);
const calm = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } };
// ---- 1.7 Tabel penyetelan terpusat. Ubah angka di sini, bukan di game.js. ----
// stop = hit-stop (detik), trauma = getaran acak layar (0..1), kick = sentakan searah pukulan (px dunia),
// flash = kilat putih pada sprite yang kena (detik), size = pengali ukuran angka damage, ripple = riak (0 tanpa, 1 sedang, 2 besar).
const FEEL = {
hit: {
light: { stop: .045, trauma: .16, kick: 3, flash: .05, size: 1, ripple: 0 },
medium: { stop: .065, trauma: .22, kick: 6, flash: .07, size: 1.15, ripple: 0 },
heavy: { stop: .095, trauma: .42, kick: 11, flash: .09, size: 1.5, ripple: 1 },
ultimate: { stop: .14, trauma: .8, kick: 17, flash: .12, size: 1.9, ripple: 2 },
guard: { stop: .03, trauma: .06, kick: 2, flash: 0, size: .95, ripple: 0 },
parry: { stop: .12, trauma: .24, kick: 5, flash: 0, size: 1.3, ripple: 1 },
counter: { stop: .11, trauma: .4, kick: 12, flash: .1, size: 1.55, ripple: 1 }
},
// Ambang damage jenis serangan: di bawah medium = ringan, di atas heavy = berat. Ultimate ditandai lewat shake >= ultimateShake.
medium: 10, heavy: 20, ultimateShake: 2,
// Serangan yang tidak membekukan dunia (peluru berantai) hanya boleh memberi sentakan kecil pada jenis ini.
microStop: ['heavy', 'ultimate', 'counter'], microScale: .5,
kickDecay: 11, kickFreq: 30, kickMax: 26,
maxNumbers: 8,
colors: { counter: '#ff9a3c', parry: '#6fc3ff', guard: '#b9c2c7', ultimate: '#ffe08a', hurt: '#ff9b8a' },
pop: { grow: .07, settle: .16, peak: 1.5, from: .35 },
// ---- 1.8 K.O.: skala waktu hanya untuk simulasi visual; jam ronde (advanceMatch) tetap memakai waktu nyata. ----
ko: {
round: { scale: .25, dur: .9, zoom: 1.3, vignette: .55, flash: .5, trauma: .9, tint: '6,8,16' },
final: { scale: .2, dur: 1.5, zoom: 1.55, vignette: .8, flash: .85, trauma: 1, tint: '70,6,14' },
ramp: .3, zoomIn: .16, zoomOut: .45, flashTime: .16
},
// ---- 1.9 Peringkat kombo ----
ranks: [['S', 12, 'DAHSYAT'], ['A', 8, 'LUAR BIASA'], ['B', 5, 'HEBAT'], ['C', 3, 'BAGUS']]
};
function rankOf(hits) { for (const [r, n] of FEEL.ranks) if (hits >= n) return r; return ''; }
function rankLabel(r) { return (FEEL.ranks.find(x => x[0] === r) || [0, 0, ''])[2]; }
// Jenis serangan dari damage + petunjuk; o = { guard, parry, counter, ultimate, shake }.
function classify(damage, o = {}) {
if (o.guard) return 'guard';
if (o.parry) return 'parry';
if (o.counter) return 'counter';
if (o.ultimate || (o.shake || 1) >= FEEL.ultimateShake) return 'ultimate';
return damage >= FEEL.heavy ? 'heavy' : damage >= FEEL.medium ? 'medium' : 'light';
}
// Nilai efektif untuk satu pukulan: freeze=false (peluru/serangan berantai) hanya memberi sentakan kecil pada serangan berat.
function hit(kind, freeze = true) {
const h = FEEL.hit[kind] || FEEL.hit.light;
return { ...h, kind, stop: freeze ? h.stop : FEEL.microStop.includes(kind) ? h.stop * FEEL.microScale : 0 };
}
// ---- Getaran searah pukulan: layar tersentak ke arah pukulan lalu memantul kembali (teredam). ----
const kick = { x: 0, y: 0, t: 99 };
function kickOffset() {
const e = Math.exp(-kick.t * FEEL.kickDecay) * Math.cos(kick.t * FEEL.kickFreq);
return { x: kick.x * e, y: kick.y * e };
}
function kickAt(dir, amp) {
if (!amp) return;
const cur = kickOffset(), m = FEEL.kickMax;
kick.x = clamp(cur.x + (dir || 1) * amp, -m, m); kick.y = clamp(cur.y + amp * .28, -m, m); kick.t = 0;
}
// ---- Riak: cincin yang melebar dari titik pukulan (dunia). ----
const ripples = [];
function ripple(x, y, level, color = '#ffffff', delay = 0) {
if (!level) return;
if (ripples.length > 8) ripples.shift();
ripples.push({ x, y, t: -delay, dur: level >= 2 ? .5 : .32, r0: 14, r1: level >= 2 ? 210 : 120, w: level >= 2 ? 9 : 5, color });
}
function drawRipples(ctx) {
for (const r of ripples) {
if (r.t < 0) continue;
const u = clamp(r.t / r.dur, 0, 1), e = 1 - Math.pow(1 - u, 3);
ctx.save(); ctx.globalAlpha = (1 - u) * .7; ctx.strokeStyle = r.color; ctx.lineWidth = Math.max(1, r.w * (1 - u));
ctx.beginPath(); ctx.ellipse(r.x, r.y, r.r0 + (r.r1 - r.r0) * e, (r.r0 + (r.r1 - r.r0) * e) * .72, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
}
}
// ---- 1.8 K.O. slow-motion dan zoom kamera ----
const ko = { on: false, t: 0, cfg: FEEL.ko.round, who: 'dummy', final: false, calm: false };
function koStart(who, final) {
ko.cfg = final ? FEEL.ko.final : FEEL.ko.round; ko.who = who; ko.final = !!final; ko.t = 0; ko.on = true; ko.calm = calm(); // prefers-reduced-motion: zoom dan kilat diperkecil
}
function koClear() { ko.on = false; ko.t = 0; }
function koScale() {
if (!ko.on) return 1;
const c = ko.cfg, rampAt = c.dur - FEEL.ko.ramp;
if (ko.t >= c.dur) return 1;
if (ko.t <= rampAt) return c.scale;
const u = (ko.t - rampAt) / FEEL.ko.ramp; return c.scale + (1 - c.scale) * u * u;
}
const koZoomMax = () => ko.calm ? 1 + (ko.cfg.zoom - 1) * .4 : ko.cfg.zoom;
function koZoom() {
if (!ko.on) return 1;
const c = ko.cfg, z = koZoomMax() - 1;
if (ko.t < FEEL.ko.zoomIn) return 1 + z * (1 - Math.pow(1 - ko.t / FEEL.ko.zoomIn, 3));
if (ko.t < c.dur) return koZoomMax();
return 1 + z * (1 - smooth(clamp((ko.t - c.dur) / FEEL.ko.zoomOut, 0, 1)));
}
function koDrawOverlay(ctx, w, h) {
if (!ko.on) return;
const c = ko.cfg, env = clamp((koZoom() - 1) / (koZoomMax() - 1), 0, 1), r = Math.hypot(w, h);
if (env > .002) {
const g = ctx.createRadialGradient(w / 2, h * .56, Math.min(w, h) * .22, w / 2, h * .56, r * .6);
g.addColorStop(0, `rgba(${c.tint},0)`); g.addColorStop(1, `rgba(${c.tint},${(c.vignette * env).toFixed(3)})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
}
const f = 1 - ko.t / FEEL.ko.flashTime;
if (f > 0) { ctx.fillStyle = `rgba(255,255,255,${(c.flash * f * f * (ko.calm ? .3 : 1)).toFixed(3)})`; ctx.fillRect(0, 0, w, h); }
}
// Maju dengan waktu NYATA (bukan waktu simulasi yang melambat).
function tick(dt) {
kick.t += dt;
for (let i = ripples.length - 1; i >= 0; i--) { ripples[i].t += dt; if (ripples[i].t >= ripples[i].dur) ripples.splice(i, 1); }
if (ko.on) { ko.t += dt; if (ko.t >= ko.cfg.dur + FEEL.ko.zoomOut) ko.on = false; }
}
function reset() { kick.x = kick.y = 0; kick.t = 99; ripples.length = 0; koClear(); }
// ---- 1.9 Angka damage "pop": membesar, mengecil dengan easing, naik, memudar. ----
function popScale(age) {
const p = FEEL.pop;
if (age < p.grow) { const u = age / p.grow; return p.from + (p.peak - p.from) * (1 - (1 - u) * (1 - u)); }
if (age < p.grow + p.settle) return p.peak + (1 - p.peak) * smooth((age - p.grow) / p.settle);
return Math.max(.8, 1 - (age - p.grow - p.settle) * .12);
}
function numberKind(d) {
if (d.kind) return d.kind;
if (d.amount === 'GUARD') return 'guard';
return typeof d.amount === 'number' ? classify(d.amount) : 'light';
}
function drawNumbers(ctx, list) {
while (list.length > FEEL.maxNumbers) list.shift();
for (let i = 0; i < list.length; i++) {
const d = list[i], age = Math.max(0, d.maxLife - d.life), kind = numberKind(d), h = FEEL.hit[kind] || FEEL.hit.light;
const fs = Math.round(25 * (d.size || h.size) * popScale(age)), x = Math.round(d.x), y = Math.round(d.y);
const color = FEEL.colors[kind] || d.color;
ctx.save(); ctx.globalAlpha = Math.min(1, d.life / (d.maxLife * .35)); ctx.textAlign = 'center'; ctx.lineJoin = 'round';
ctx.font = `900 ${fs}px Rajdhani`; ctx.lineWidth = Math.max(3, fs * .17); ctx.strokeStyle = kind === 'parry' ? '#12304f' : '#254247';
ctx.strokeText(d.amount, x, y); ctx.fillStyle = color; ctx.fillText(d.amount, x, y);
if (d.label) {
const ls = Math.round(fs * .5), ly = y - Math.round(fs * .92);
ctx.font = `900 ${ls}px Rajdhani`; ctx.lineWidth = Math.max(3, ls * .2); ctx.strokeText(d.label, x, ly); ctx.fillStyle = FEEL.colors[kind] || color; ctx.fillText(d.label, x, ly);
}
ctx.restore();
}
}
// Dipanggil tiap langkah visual: naik dengan easing (cepat lalu melambat) dan batasi jumlah angka aktif.
function ageNumbers(list, dt) {
for (let i = list.length - 1; i >= 0; i--) {
const d = list[i]; d.life -= dt; d.y -= dt * 78 * (.35 + .65 * clamp(d.life / d.maxLife, 0, 1));
if (d.life <= 0) list.splice(i, 1);
}
while (list.length > FEEL.maxNumbers) list.shift();
}
// ---- 1.9 Peringkat kombo (DOM #combo-rank): besar di tengah, memantul tiap pukulan, animasi saat naik peringkat. ----
const hud = { el: null, letter: null, label: null, bar: null, hits: 0, rank: '', shown: false };
function animate(el, frames, opts) { if (el && el.animate && !calm()) el.animate(frames, opts); }
function comboHud(s) {
if (!hud.el) {
hud.el = document.getElementById('combo-rank'); if (!hud.el) return;
hud.letter = hud.el.querySelector('b'); hud.label = hud.el.querySelector('small'); hud.bar = hud.el.querySelector('i');
}
const rank = s.show ? rankOf(s.hits) : '';
if (!rank) {
if (hud.shown) { hud.el.classList.remove('visible'); hud.shown = false; }
hud.hits = s.hits; hud.rank = ''; return;
}
if (!hud.shown) { hud.el.classList.add('visible'); hud.shown = true; }
if (rank !== hud.rank) {
hud.letter.textContent = rank; hud.el.dataset.rank = rank; hud.label.textContent = rankLabel(rank);
animate(hud.letter, [
{ transform: 'scale(2.6) rotate(-9deg)', filter: 'brightness(2.4)', opacity: 0 },
{ transform: 'scale(.88) rotate(2deg)', filter: 'brightness(1.5)', opacity: 1, offset: .45 },
{ transform: 'scale(1.14) rotate(-1deg)', filter: 'brightness(1.2)', offset: .72 },
{ transform: 'scale(1) rotate(0)', filter: 'brightness(1)' }], { duration: 540, easing: 'cubic-bezier(.2,.8,.3,1)' });
animate(hud.label, [{ transform: 'translateY(-8px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 260, delay: 120, fill: 'backwards', easing: 'ease-out' });
} else if (s.hits > hud.hits) {
animate(hud.letter, [{ transform: 'scale(1.3) rotate(-3deg)' }, { transform: 'scale(.96)', offset: .6 }, { transform: 'scale(1)' }], { duration: 190, easing: 'cubic-bezier(.2,1.4,.4,1)' });
}
hud.rank = rank; hud.hits = s.hits;
hud.bar.style.setProperty('--t', clamp(s.timer / (s.timerMax || 1), 0, 1).toFixed(3));
}
window.Feel = { table: FEEL, classify, hit, rankOf, rankLabel, kickAt, kickOffset, ripple, drawRipples, tick, reset, drawNumbers, ageNumbers, comboHud,
ko: { start: koStart, clear: koClear, scale: koScale, zoom: koZoom, drawOverlay: koDrawOverlay, state: ko } };
})();

129
game.js
View File

@ -22,7 +22,7 @@
const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}), ...(BW ? { browo: BW } : {}), ...Object.fromEntries(Object.entries(CF).map(([id, d]) => [id, cfKit(d)])) }; const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}), ...(BW ? { browo: BW } : {}), ...Object.fromEntries(Object.entries(CF).map(([id, d]) => [id, cfKit(d)])) };
let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null; let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null;
let selectedCharacter = 'arco', aiEnabled = !!F, fenrCutin = null, hudIdentity = ''; let selectedCharacter = 'arco', aiEnabled = !!F, fenrCutin = null, hudIdentity = '';
const Rules = window.MatchRules; const Rules = window.MatchRules, Feel = window.Feel;
let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED; let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED;
let match = Rules?.create('training') || null, roundAnnouncer = null; let match = Rules?.create('training') || null, roundAnnouncer = null;
const roundCues = []; const roundCues = [];
@ -174,7 +174,7 @@
for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;} for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;}
} }
function setMenuOpen(value) { function setMenuOpen(value) {
menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;setPaused(false);} menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;Feel.reset();setPaused(false);}
document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen;
window.FrontEnd?.syncBackground(); window.FrontEnd?.syncBackground();
} }
@ -202,6 +202,7 @@
if(event.type==='round-end') { if(event.type==='round-end') {
clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0; clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0;
if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;} if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;}
if(!event.timeout&&event.winner!=='draw'||event.doubleKO)startKoFx(event.doubleKO?'both':event.winner==='player'?'dummy':'hero',!!match.winner);
match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy'); match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy');
const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko'; const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko';
const outcome=event.winner==='draw'?'draw':`${event.winner==='player'?selectedCharacter:opponentCharacter}_wins`; const outcome=event.winner==='draw'?'draw':`${event.winner==='player'?selectedCharacter:opponentCharacter}_wins`;
@ -385,20 +386,69 @@
sound('heavy'); sound('heavy');
} else sound('step'); } else sound('step');
} }
function comboRank(n) { return n >= 12 ? 'S' : n >= 8 ? 'A' : n >= 5 ? 'B' : n >= 3 ? 'C' : ''; } function comboRank(n) { return Feel.rankOf(n); }
// ---- Rasa kena (feel.js): satu pintu untuk hit-stop, getaran, kilat putih, riak, dan K.O. slow-motion ----
// side = yang terkena ('hero' | 'dummy'); dir = arah gaya pukulan (+1 ke kanan); o = { x, y, freeze, shake, lethal }.
const FEEL_RIPPLE = { parry: '#8fd0ff', counter: '#ffb15a', ultimate: '#ffe9a8' };
function feelImpact(side, kind, o = {}) {
const actor = side === 'hero' ? hero : dummy, f = Feel.hit(kind, o.freeze !== false), k = kind === 'ultimate' ? 1 : (o.shake ?? 1);
hitstop = Math.max(hitstop, o.lethal ? Math.max(f.stop, .12) : f.stop);
trauma = Math.min(1, trauma + f.trauma * k);
if (f.flash) actor.flash = actor.flashMax = f.flash;
Feel.kickAt(o.dir, f.kick * Math.min(1, k));
Feel.ripple(o.x ?? actor.x, o.y ?? actor.y - 90, f.ripple, FEEL_RIPPLE[kind]);
}
// Lawan memukul pemain: efek sama dengan sisi pemain (jenis dari damage, COUNTER bila pemain sedang menyiapkan serangan).
function feelHeroHit(damage, options, counter) {
const dir = hero.x >= dummy.x ? 1 : -1, kind = Feel.classify(damage, { counter, shake: options.shake });
feelImpact('hero', kind, { dir, x: hero.x - dir * 15, y: hero.y - 80, freeze: options.freeze, shake: options.shake, lethal: hero.hp <= 0 });
damageNumbers.push({ x: hero.x + ((damageNumbers.length % 3) - 1) * 14, y: hero.y - 150, amount: damage, kind, life: .9, maxLife: .9, color: Feel.table.colors.hurt, label: counter ? 'COUNTER' : undefined });
}
// K.O.: slow motion + zoom ke yang kalah + vignette. who = 'hero' | 'dummy' | 'both'; final = K.O. penentu pertandingan.
function startKoFx(who, final) {
Feel.ko.start(who, final);
const c = Feel.ko.state.cfg, both = who === 'both', a = who === 'hero' ? hero : dummy, o = a === hero ? dummy : hero;
const x = both ? (hero.x + dummy.x) / 2 : a.x, y = both ? CONFIG.groundY : a.y;
trauma = Math.max(trauma, c.trauma); hitstop = 0;
Feel.kickAt(both || a.x >= o.x ? 1 : -1, Feel.table.hit.ultimate.kick * (final ? 1.4 : 1));
spawnParticles(x, y - 85, '#fff3b0', final ? 46 : 28, final ? 520 : 380);
Feel.ripple(x, y - 90, 2, '#ffffff'); if (final) { Feel.ripple(x, y - 90, 2, '#ffd36a', .14); Feel.ripple(x, y - 90, 2, '#ff6a5a', .3); }
}
function koCamera() {
const k = Feel.ko.state; if (!k.on || menuOpen) return null;
const z = Feel.ko.zoom(); if (z <= 1.002) return null;
const a = k.who === 'hero' ? hero : dummy;
return k.who === 'both' ? { z, x: (hero.x + dummy.x) / 2, y: CONFIG.groundY - 90 } : { z, x: a.x, y: a.y - 90 };
}
// Mode non-versus (latihan): tidak ada event ronde, jadi K.O. dideteksi dari peralihan status.
const koSeen = { dummy: false, hero: false };
function feelWatchKo() {
const d = !!dummy.ko, h = hero.hp <= 0;
if (match?.mode !== 'versus') { if (d && !koSeen.dummy) startKoFx('dummy', false); else if (h && !koSeen.hero) startKoFx('hero', false); }
koSeen.dummy = d; koSeen.hero = h;
}
// Kilat putih pada sprite yang kena: sprite digambar ulang putih (hanya piksel sprite) dengan alpha menurun.
const flashCv = document.createElement('canvas'), flashCx = flashCv.getContext('2d');
function drawFlash(actor, img, r, dx, dy) {
flashCv.width = r.w; flashCv.height = r.h; flashCx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
flashCx.globalCompositeOperation = 'source-atop'; flashCx.fillStyle = '#fff'; flashCx.fillRect(0, 0, r.w, r.h); flashCx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = Math.min(1, actor.flash / (actor.flashMax || actor.flash)) * .85; ctx.drawImage(flashCv, dx, dy, r.w, r.h); ctx.globalAlpha = 1;
}
// ---- Guard (hold L), parry (tap L just before a hit), throw (J), P2, CPU guard ---- // ---- Guard (hold L), parry (tap L just before a hit), throw (J), P2, CPU guard ----
function heroGuard(damage, options) { function heroGuard(damage, options) {
if (!(hero.blocking || hero.parry > 0) || hero.action || options?.unblockable) return false; if (!(hero.blocking || hero.parry > 0) || hero.action || options?.unblockable) return false;
if (hero.parry > 0) { if (hero.parry > 0) {
hero.parry = 0; hero.invuln = Math.max(hero.invuln, .35); announce('PARRY!', .6); sound('parry'); window.Meta?.shout?.('Parry!'); hitstop = Math.max(hitstop, .1); trauma = Math.min(1, trauma + .2); hero.parry = 0; hero.invuln = Math.max(hero.invuln, .35); announce('PARRY!', .6); sound('parry'); window.Meta?.shout?.('Parry!');
spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#fff3b0', 22, 320); feelImpact('hero', 'parry', { dir: -hero.facing, x: hero.x + hero.facing * 40, y: hero.y - 100 }); damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'PARRY', kind: 'parry', life: .8, maxLife: .8, color: Feel.table.colors.parry });
spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#cfe9ff', 22, 320);
if (!['down', 'recover'].includes(dummy.state)) { dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = -.3; dummy.vx = -hero.facing * 120; } if (!['down', 'recover'].includes(dummy.state)) { dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = -.3; dummy.vx = -hero.facing * 120; }
window.Meta?.event?.('parry'); return true; window.Meta?.event?.('parry'); return true;
} }
const chip = Math.max(1, Math.round(damage * .2)); hero.hp = Math.max(1, hero.hp - chip); hero.guard += damage * 1.6; hero.vx = -hero.facing * 90; const chip = Math.max(1, Math.round(damage * .2)); hero.hp = Math.max(1, hero.hp - chip); hero.guard += damage * 1.6; hero.vx = -hero.facing * 90;
spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); hitstop = Math.max(hitstop, .03); spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); feelImpact('hero', 'guard', { dir: -hero.facing, x: hero.x + hero.facing * 38, y: hero.y - 90 });
damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', life: .6, maxLife: .6, color: '#ffd36a' }); damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard });
if (hero.guard >= 60) { hero.guard = 0; hero.guardBreak = 1.2; hero.blocking = false; hero.hurtTime = .7; state('hurt'); announce('GUARD BREAK', .7); } if (hero.guard >= 60) { hero.guard = 0; hero.guardBreak = 1.2; hero.blocking = false; hero.hurtTime = .7; state('hurt'); announce('GUARD BREAK', .7); }
return true; return true;
} }
@ -436,7 +486,7 @@
Object.assign(dummy, { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0 }); Object.assign(dummy, { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0 });
if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();} if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();}
fenrCutin=null;hudIdentity=''; fenrCutin=null;hudIdentity='';
particles.length = effects.length = projectiles.length = damageNumbers.length = 0; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25); particles.length = effects.length = projectiles.length = damageNumbers.length = 0; Feel.reset(); hero.flash = dummy.flash = 0; koSeen.dummy = koSeen.hero = false; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25);
bwReset(); squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null; bwReset(); squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null;
for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0; for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0;
} }
@ -450,10 +500,11 @@
const dir = sourceX <= dummy.x ? 1 : -1; const dir = sourceX <= dummy.x ? 1 : -1;
if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1)) { if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1)) {
const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip; const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip;
spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', life: .6, maxLife: .6, color: '#ffd36a' }); sound('step'); hitstop = Math.max(hitstop, .03); return true; spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); sound('step'); feelImpact('dummy', 'guard', { dir, x: dummy.x - dir * 30, y: impactY }); return true;
} }
// Counter hit: striking a rival in their wind-up deals extra; long chains scale down so none loops forever. // Counter hit: striking a rival in their wind-up deals extra; long chains scale down so none loops forever.
if (dummy.action && !dummy.action.fired && dummy.action.type === 'attack') { damage = Math.round(damage * 1.25); if (damage > 1) announce('COUNTER!', .5); } const counter = !!(dummy.action && !dummy.action.fired && dummy.action.type === 'attack');
if (counter) { damage = Math.round(damage * 1.25); if (damage > 1) announce('COUNTER!', .5); }
if (comboHits >= 5) damage = Math.max(1, Math.round(damage * Math.max(.55, 1 - .07 * (comboHits - 4)))); if (comboHits >= 5) damage = Math.max(1, Math.round(damage * Math.max(.55, 1 - .07 * (comboHits - 4))));
dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3; dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3;
// Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's), // Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's),
@ -465,11 +516,11 @@
if (dummy.y >= CONFIG.groundY - 1) dummy.juggle = 0; else if ((dummy.juggle = (dummy.juggle || 0) + 1) <= 4) dummy.vy = Math.min(dummy.vy, -260); if (dummy.y >= CONFIG.groundY - 1) dummy.juggle = 0; else if ((dummy.juggle = (dummy.juggle || 0) + 1) <= 4) dummy.vy = Math.min(dummy.vy, -260);
dummy.action=null; dummy.action=null;
if(F&&dummy.transformPending>0)F.end(dummy); if(F&&dummy.transformPending>0)F.end(dummy);
if (freezeWorld) hitstop = Math.max(hitstop, damage >= 30 ? .085 : damage >= 14 ? .065 : .045); const kind = Feel.classify(damage, { counter, shake }), big = kind === 'heavy' || kind === 'ultimate' || kind === 'counter';
trauma = Math.min(1, trauma + (damage >= 30 ? .45 : .18) * shake); feelImpact('dummy', kind, { dir, x: dummy.x - dir * 15, y: impactY, freeze: freezeWorld, shake, lethal: dummy.damage >= BALANCE.dummyMax });
damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, life: .9, maxLife: .9, color }); damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, kind, life: .9, maxLife: .9, color, label: counter ? 'COUNTER' : undefined });
spawnParticles(dummy.x - dir * 21, impactY, color, damage >= 30 ? 22 : 14, damage >= 30 ? 320 : 220); spawnParticles(dummy.x - dir * 21, impactY, color, big ? 22 : 14, big ? 320 : 220);
effects.push({ type: 'impact', x: dummy.x - dir * 15, y: impactY, life: .2, maxLife: .2, color, heavy: damage >= 30 }); effects.push({ type: 'impact', x: dummy.x - dir * 15, y: impactY, life: .2, maxLife: .2, color, heavy: big });
if (dummy.damage >= BALANCE.dummyMax || knockdown) { dummy.ko=dummy.damage>=BALANCE.dummyMax; dummy.state = 'down'; dummy.vy = -330; dummy.vx = dir * 290; dummy.fallDirection = dir; announce(dummy.ko?'K.O.':'KNOCKDOWN', .9); } if (dummy.damage >= BALANCE.dummyMax || knockdown) { dummy.ko=dummy.damage>=BALANCE.dummyMax; dummy.state = 'down'; dummy.vy = -330; dummy.vx = dir * 290; dummy.fallDirection = dir; announce(dummy.ko?'K.O.':'KNOCKDOWN', .9); }
sound(damage >= 30 ? 'heavy' : 'hit'); return true; sound(damage >= 30 ? 'heavy' : 'hit'); return true;
} }
@ -1069,12 +1120,14 @@
if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false; if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false;
if (target === dummy) { if (target === dummy) {
dummy.damage += amount; totalDamage += amount; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += amount; comboTimer = 2.3; dummy.damage += amount; totalDamage += amount; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += amount; comboTimer = 2.3;
if (numbers) damageNumbers.push({ x: dummy.x + (random() - .5) * 30, y: dummy.y - 120, amount, life: .9, maxLife: .9, color }); if (numbers) damageNumbers.push({ x: dummy.x + (random() - .5) * 30, y: dummy.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color });
if (amount >= Feel.table.medium) dummy.flash = dummy.flashMax = Feel.table.hit.medium.flash;
if (dummy.damage >= BALANCE.dummyMax) { dummy.ko = true; dummy.state = 'down'; dummy.vy = -330; dummy.vx = 0; dummy.fallDirection = hero.x <= dummy.x ? 1 : -1; announce('K.O.', .9); } if (dummy.damage >= BALANCE.dummyMax) { dummy.ko = true; dummy.state = 'down'; dummy.vy = -330; dummy.vx = 0; dummy.fallDirection = hero.x <= dummy.x ? 1 : -1; announce('K.O.', .9); }
return true; return true;
} }
hero.hp = Math.max(0, hero.hp - amount); hero.hp = Math.max(0, hero.hp - amount);
if (numbers) damageNumbers.push({ x: hero.x + (random() - .5) * 30, y: hero.y - 120, amount, life: .9, maxLife: .9, color }); if (numbers) damageNumbers.push({ x: hero.x + (random() - .5) * 30, y: hero.y - 120, amount, kind: Feel.classify(amount), life: .9, maxLife: .9, color });
if (amount >= Feel.table.medium) hero.flash = hero.flashMax = Feel.table.hit.medium.flash;
if (!hero.hp) { hero.action = null; hero.hurtTime = 0; state('down'); hero.koTime = 1.7; clearInput(); if (match?.mode !== 'versus') announce('CORE RESTART', 1.7); } if (!hero.hp) { hero.action = null; hero.hurtTime = 0; state('down'); hero.koTime = 1.7; clearInput(); if (match?.mode !== 'versus') announce('CORE RESTART', 1.7); }
return true; return true;
} }
@ -1301,20 +1354,23 @@
const c = Math.cos(d.angle), s = Math.sin(d.angle), x = facing * muzzle.x, y = muzzle.y; const c = Math.cos(d.angle), s = Math.sin(d.angle), x = facing * muzzle.x, y = muzzle.y;
return { x: d.x + x * c - y * s, y: d.y + x * s + y * c }; return { x: d.x + x * c - y * s, y: d.y + x * s + y * c };
} }
function step(dt) { function step(realDt) {
systemAnnouncer?.tick(dt); systemAnnouncer?.tick(realDt);
if (menuOpen || paused || settingsOpen) return; if (menuOpen || paused || settingsOpen) return;
// K.O. slow motion (feel.js) hanya memperlambat simulasi visual (dt); jam ronde/penutup K.O. di advanceMatch tetap waktu nyata.
Feel.tick(realDt); const dt = realDt * Feel.ko.scale();
hero.flash = Math.max(0, (hero.flash || 0) - realDt); dummy.flash = Math.max(0, (dummy.flash || 0) - realDt);
if(match?.mode==='versus'&&match.phase!=='fight') { if(match?.mode==='versus'&&match.phase!=='fight') {
// Shake is driven by realTime, so trauma must keep decaying here or the K.O./intro screen vibrates. // Shake is driven by realTime, so trauma must keep decaying here or the K.O./intro screen vibrates.
realTime+=dt;trauma=Math.max(0,trauma-dt*1.35);cinematic=Math.max(0,cinematic-dt);hero.animTime+=dt;dummy.stateTime+=dt;updateVisuals(dt); realTime+=realDt;trauma=Math.max(0,trauma-realDt*1.35);cinematic=Math.max(0,cinematic-realDt);hero.animTime+=dt;dummy.stateTime+=dt;updateVisuals(dt);
if(hero.y<CONFIG.groundY){hero.vy+=CONFIG.gravity*dt;hero.y=Math.min(CONFIG.groundY,hero.y+hero.vy*dt);} if(hero.y<CONFIG.groundY){hero.vy+=CONFIG.gravity*dt;hero.y=Math.min(CONFIG.groundY,hero.y+hero.vy*dt);}
if(dummy.state==='down'&&dummy.y<CONFIG.groundY){dummy.vy+=CONFIG.gravity*dt;dummy.y=Math.min(CONFIG.groundY,dummy.y+dummy.vy*dt);} if(dummy.state==='down'&&dummy.y<CONFIG.groundY){dummy.vy+=CONFIG.gravity*dt;dummy.y=Math.min(CONFIG.groundY,dummy.y+dummy.vy*dt);}
advanceMatch(dt);return; advanceMatch(realDt);return;
} }
realTime += dt; stepCount++; announceTimer = Math.max(0, announceTimer - dt); trauma = Math.max(0, trauma - dt * 1.35); cinematic = Math.max(0, cinematic - dt); realTime += realDt; stepCount++; announceTimer = Math.max(0, announceTimer - realDt); trauma = Math.max(0, trauma - realDt * 1.35); cinematic = Math.max(0, cinematic - realDt);
updateVisuals(dt); updateVisuals(dt);
for(const name of Object.keys(rechargePulse)) rechargePulse[name]=Math.max(0,rechargePulse[name]-dt); for(const name of Object.keys(rechargePulse)) rechargePulse[name]=Math.max(0,rechargePulse[name]-dt);
if (hitstop > 0) { hitstop -= dt; return; } if (hitstop > 0) { hitstop -= realDt; return; }
time += dt; hero.animTime += dt; hero.invuln = Math.max(0, hero.invuln - dt); hero.doublePose = Math.max(0, (hero.doublePose || 0) - dt); time += dt; hero.animTime += dt; hero.invuln = Math.max(0, hero.invuln - dt); hero.doublePose = Math.max(0, (hero.doublePose || 0) - dt);
if(fenrCutin) {fenrCutin.t+=dt;if(fenrCutin.t>.78)fenrCutin=null;} if(fenrCutin) {fenrCutin.t+=dt;if(fenrCutin.t>.78)fenrCutin=null;}
if(F&&selectedCharacter==='fenr'){const changes=hero.formChanges;F.advance(hero,dt,hero.hp>0);if(changes!==hero.formChanges){syncPlayerForm();hero.doublePose=0;state(hero.grounded?'idle':'jump');}} if(F&&selectedCharacter==='fenr'){const changes=hero.formChanges;F.advance(hero,dt,hero.hp>0);if(changes!==hero.formChanges){syncPlayerForm();hero.doublePose=0;state(hero.grounded?'idle':'jump');}}
@ -1358,7 +1414,7 @@
updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake);
if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt;
if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice();
advanceMatch(dt); feelWatchKo(); advanceMatch(realDt);
} }
// MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them. // MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them.
function bodyGap() { return CONFIG.bodyGap || 80; } function bodyGap() { return CONFIG.bodyGap || 80; }
@ -1366,7 +1422,7 @@
function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};} function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};}
function updateDummy(dt) { function updateDummy(dt) {
if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt);if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);} if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt);if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);}
dummy.stateTime += dt; dummy.flash = Math.max(0, dummy.flash - dt); dummy.invuln = Math.max(0, dummy.invuln - dt); dummy.stateTime += dt; dummy.invuln = Math.max(0, dummy.invuln - dt);
dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || (!aiEnabled && !p2Mode) || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950); dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || (!aiEnabled && !p2Mode) || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950);
if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.x)<bodyGap())dummy.x=clamp(hero.x+(dummy.x>=hero.x?bodyGap():-bodyGap()),110,W-100); if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.x)<bodyGap())dummy.x=clamp(hero.x+(dummy.x>=hero.x?bodyGap():-bodyGap()),110,W-100);
// Versus CPU follows the player's post-hurt immunity rule by difficulty, so a mashed chain cannot loop it forever. // Versus CPU follows the player's post-hurt immunity rule by difficulty, so a mashed chain cannot loop it forever.
@ -1980,7 +2036,7 @@
function updateVisuals(dt) { function updateVisuals(dt) {
for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680) * dt; p.vx *= Math.exp(-dt * 3); if (p.life <= 0) particles.splice(i, 1); } for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680) * dt; p.vx *= Math.exp(-dt * 3); if (p.life <= 0) particles.splice(i, 1); }
for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); } for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); }
for (let i = damageNumbers.length - 1; i >= 0; i--) { damageNumbers[i].life -= dt; damageNumbers[i].y -= dt * 55; if (damageNumbers[i].life <= 0) damageNumbers.splice(i, 1); } Feel.ageNumbers(damageNumbers, dt);
} }
function drawBackground(shakeX, shakeY) { function drawBackground(shakeX, shakeY) {
@ -2042,7 +2098,7 @@
if (hero.shield > 0) { ctx.save(); ctx.strokeStyle = '#9fe8ff'; ctx.globalAlpha = Math.min(1, hero.shield) * .8; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(hero.x, hero.y - 90, 70, 105, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } if (hero.shield > 0) { ctx.save(); ctx.strokeStyle = '#9fe8ff'; ctx.globalAlpha = Math.min(1, hero.shield) * .8; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(hero.x, hero.y - 90, 70, 105, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); }
ctx.save(); if (skin) ctx.filter = skin; ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false; ctx.save(); if (skin) ctx.filter = skin; ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false;
if (pose.pivot) { ctx.translate(pose.worldPivot.x, pose.worldPivot.y); ctx.rotate(pose.rotation); ctx.translate(-pose.pivot.x, -pose.pivot.y); } if (pose.pivot) { ctx.translate(pose.worldPivot.x, pose.worldPivot.y); ctx.rotate(pose.rotation); ctx.translate(-pose.pivot.x, -pose.pivot.y); }
ctx.drawImage(images.hero, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); ctx.restore(); ctx.drawImage(images.hero, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); if (hero.flash > 0) drawFlash(hero, images.hero, r, -anchorX, -anchorY); ctx.restore();
} }
function drawDummy() { function drawDummy() {
const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):(cfFormOn(opponentCharacter,'enemy')?opponentCharacter+'-form':opponentCharacter)]; const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):(cfFormOn(opponentCharacter,'enemy')?opponentCharacter+'-form':opponentCharacter)];
@ -2050,7 +2106,7 @@
if(!m||!img)return; if(!m||!img)return;
const r=m.frame_layout.rows[pose.state][pose.frame]; const r=m.frame_layout.rows[pose.state][pose.frame];
ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));{const om=CF[opponentCharacter]?(cfFormOn(opponentCharacter,'enemy')?CF[opponentCharacter].mode?.form?.metrics:CF[opponentCharacter].metrics):null,k=stateK(om,pose.state);ctx.scale((dummy.facing||-1)*k,k);}ctx.imageSmoothingEnabled=false; ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));{const om=CF[opponentCharacter]?(cfFormOn(opponentCharacter,'enemy')?CF[opponentCharacter].mode?.form?.metrics:CF[opponentCharacter].metrics):null,k=stateK(om,pose.state);ctx.scale((dummy.facing||-1)*k,k);}ctx.imageSmoothingEnabled=false;
ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);ctx.restore(); ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);if(dummy.flash>0)drawFlash(dummy,img,r,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y));ctx.restore();
if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') { if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') {
ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7; ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7;
ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore(); ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore();
@ -2155,7 +2211,7 @@
else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size); else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size);
ctx.restore(); ctx.restore();
} }
for (const n of damageNumbers) { ctx.save(); ctx.globalAlpha = Math.min(1, n.life * 2.5); ctx.font = `900 ${n.amount >= 30 ? 31 : 25}px Rajdhani`; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = '#254247'; ctx.strokeText(n.amount, Math.round(n.x), Math.round(n.y)); ctx.fillStyle = n.color; ctx.fillText(n.amount, Math.round(n.x), Math.round(n.y)); ctx.restore(); } Feel.drawRipples(ctx); Feel.drawNumbers(ctx, damageNumbers);
} }
function drawSquadron(group = squad) { function drawSquadron(group = squad) {
if (!group || !images.drone) return; if (!group || !images.drone) return;
@ -2228,12 +2284,13 @@
} }
function draw() { function draw() {
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH);
const shakeX = Math.sin(realTime * 93) * trauma * trauma * 13, shakeY = Math.cos(realTime * 111) * trauma * trauma * 9; const kick = Feel.kickOffset(), shakeX = Math.sin(realTime * 93) * trauma * trauma * 13 + kick.x, shakeY = Math.cos(realTime * 111) * trauma * trauma * 9 + kick.y;
// BRO WO's ultimate dives the camera onto him: the whole picture (stage and fighters) is zoomed around a point that eases // BRO WO's ultimate dives the camera onto him: the whole picture (stage and fighters) is zoomed around a point that eases
// toward the screen centre, clamped so the zoomed stage never leaves an empty edge; the stage is then darkened. // toward the screen centre, clamped so the zoomed stage never leaves an empty edge; the stage is then darkened.
const camera = bwx.zoom > 1.002 || bwx.dim > .004; // K.O. (feel.js) memakai kamera yang sama: zoom ke yang kalah, dipusatkan penuh.
const koCam = koCamera(), camZoom = koCam ? koCam.z : bwx.zoom, camera = camZoom > 1.002 || bwx.dim > .004;
if (camera) { if (camera) {
const sx = offsetX + bwx.cx * scale, sy = offsetY + bwx.cy * scale, z = bwx.zoom, w = clamp((z - 1) / .75, 0, 1); const sx = offsetX + (koCam ? koCam.x : bwx.cx) * scale, sy = offsetY + (koCam ? koCam.y : bwx.cy) * scale, z = camZoom, w = koCam ? 1 : clamp((z - 1) / .75, 0, 1);
const ax = clamp(sx + (viewW / 2 - sx) * w, viewW - z * (viewW - sx), z * sx), ay = clamp(sy + (viewH * .55 - sy) * w, viewH - z * (viewH - sy), z * sy); const ax = clamp(sx + (viewW / 2 - sx) * w, viewW - z * (viewW - sx), z * sx), ay = clamp(sy + (viewH * .55 - sy) * w, viewH - z * (viewH - sy), z * sy);
ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy); ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy);
} }
@ -2245,7 +2302,7 @@
drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack(); drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack();
if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); }
drawBrowoGiant(true); drawCfFront(); drawBrowoGiant(true); drawCfFront();
drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); updateHud(); drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); Feel.ko.drawOverlay(ctx, viewW, viewH); updateHud();
} }
function fighterInfo(id,form) { function fighterInfo(id,form) {
if(id==='fenr'){const wolf=form==='wolf';return {name:'FENR',cls:wolf?'WEREWOLF':'DEMI-HUMAN',deck:wolf?'FENR · FERAL':'FENR',title:'WOLF RANGER',portrait:'assets/fenr/ui/portrait-'+(wolf?'wolf':'human')+'.webp',names:F.kits[wolf?'wolf':'human'].names,icons:['basic','skill1','skill2','ultimate'].map((s,i)=>'assets/fenr/ui/icon-'+(i===3?'ultimate':(wolf?'wolf':'human')+'-'+s)+'.webp')};} if(id==='fenr'){const wolf=form==='wolf';return {name:'FENR',cls:wolf?'WEREWOLF':'DEMI-HUMAN',deck:wolf?'FENR · FERAL':'FENR',title:'WOLF RANGER',portrait:'assets/fenr/ui/portrait-'+(wolf?'wolf':'human')+'.webp',names:F.kits[wolf?'wolf':'human'].names,icons:['basic','skill1','skill2','ultimate'].map((s,i)=>'assets/fenr/ui/icon-'+(i===3?'ultimate':(wolf?'wolf':'human')+'-'+s)+'.webp')};}
@ -2315,7 +2372,7 @@
$('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`; $('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`;
$('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`; $('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`;
$('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING'); $('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING');
$('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE${comboRank(comboHits) ? ' · RANK ' + comboRank(comboHits) : ''}`; window.Meta?.hud?.({ move: SHOP_MOVES[window.Meta?.equippedMove?.()], cd: hero.shopCd || 0, sp: hero.spCd || 0 }); $('#total-damage').innerHTML = `${totalDamage} <small>DMG</small>`; $('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE`; Feel.comboHud({ hits: comboHits, timer: comboTimer, timerMax: 2.3, show: comboHits > 0 && (match?.mode !== 'versus' || match.phase === 'fight') }); window.Meta?.hud?.({ move: SHOP_MOVES[window.Meta?.equippedMove?.()], cd: hero.shopCd || 0, sp: hero.spCd || 0 }); $('#total-damage').innerHTML = `${totalDamage} <small>DMG</small>`;
$('#best-combo').innerHTML = `${bestCombo} <small>HITS</small>`; $('#best-combo').innerHTML = `${bestCombo} <small>HITS</small>`;
$('#announcement').classList.toggle('visible', announceTimer > 0); $('#announcement').classList.toggle('visible', announceTimer > 0);
for (const card of document.querySelectorAll('.skill-card')) { for (const card of document.querySelectorAll('.skill-card')) {
@ -2381,8 +2438,10 @@
stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;}, stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;},
selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose, selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose,
setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; }, setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; },
receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; } receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; const counter = hero.action?.type === 'attack' && !hero.action.fired; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); feelHeroHit(damage, options, counter); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; }
}; };
// Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka.
Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) });
if(F)reset(); if(F)reset();
if(window.FRONTEND_ENABLED)setMenuOpen(true); if(window.FRONTEND_ENABLED)setMenuOpen(true);
resize(); requestAnimationFrame(tick); resize(); requestAnimationFrame(tick);

View File

@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html lang="id"> <html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css"></head> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="stylesheet" href="feel.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span></div></div><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi"> <body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span></div></div><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div> <canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header"> <header class="combat-header">
@ -11,7 +12,7 @@
<section class="fighter fighter-two" aria-label="Status lawan FENR"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">DEMI-HUMAN</span><h2 id="enemy-name">FENR</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">WOLF RANGER</span></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Portrait FENR"><b class="player-tag">CPU</b></div></section> <section class="fighter fighter-two" aria-label="Status lawan FENR"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">DEMI-HUMAN</span><h2 id="enemy-name">FENR</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">WOLF RANGER</span></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Portrait FENR"><b class="player-tag">CPU</b></div></section>
</header><div class="form-timers"><div id="player-form-timer" class="form-timer" hidden></div><div id="enemy-form-timer" class="form-timer" hidden></div></div> </header><div class="form-timers"><div id="player-form-timer" class="form-timer" hidden></div><div id="enemy-form-timer" class="form-timer" hidden></div></div>
<nav class="match-actions" aria-label="Menu arena"><button id="settings-btn" aria-label="Buka pengaturan" title="Pengaturan"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></svg></button><button id="pause-btn" aria-label="Jeda game" title="Jeda (Esc)">Ⅱ</button></nav> <nav class="match-actions" aria-label="Menu arena"><button id="settings-btn" aria-label="Buka pengaturan" title="Pengaturan"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></svg></button><button id="pause-btn" aria-label="Jeda game" title="Jeda (Esc)">Ⅱ</button></nav>
<section id="combo" class="combo-counter" aria-live="polite"><span id="combo-count">0</span><div><strong>HITS</strong><small id="combo-damage">0 DAMAGE</small></div></section><div id="announcement" class="announcement" role="status"></div> <section id="combo" class="combo-counter" aria-live="polite"><span id="combo-count">0</span><div><strong>HITS</strong><small id="combo-damage">0 DAMAGE</small></div></section><div id="announcement" class="announcement" role="status"></div><div id="combo-rank" class="combo-rank" aria-hidden="true"><b></b><small></small><i></i></div>
<section id="round-call" hidden aria-live="polite"><small id="round-call-detail">FIRST TO TWO WINS</small><strong id="round-call-title"></strong></section><aside id="ultimate-cutin" class="ultimate-cutin" aria-hidden="true" aria-label="ARCO ultimate: Helios Squadron"><div class="cutin-circuit"></div><img class="cutin-art" src="assets/ui/ultimate-cutin.webp" alt=""><div class="cutin-caption"><span class="cutin-kicker">ARCO / AETHER COMMAND</span><strong>HELIOS<br><em>SQUADRON</em></strong><div class="cutin-detail"><span class="cutin-units"><i></i><i></i><i></i><i></i></span>ORBITAL LASER STRIKE</div></div><div class="cutin-edge"></div></aside> <section id="round-call" hidden aria-live="polite"><small id="round-call-detail">FIRST TO TWO WINS</small><strong id="round-call-title"></strong></section><aside id="ultimate-cutin" class="ultimate-cutin" aria-hidden="true" aria-label="ARCO ultimate: Helios Squadron"><div class="cutin-circuit"></div><img class="cutin-art" src="assets/ui/ultimate-cutin.webp" alt=""><div class="cutin-caption"><span class="cutin-kicker">ARCO / AETHER COMMAND</span><strong>HELIOS<br><em>SQUADRON</em></strong><div class="cutin-detail"><span class="cutin-units"><i></i><i></i><i></i><i></i></span>ORBITAL LASER STRIKE</div></div><div class="cutin-edge"></div></aside>
<footer class="bottom-hud"><section class="skill-deck" aria-label="Serangan dan skill"><div class="deck-heading"><span id="deck-character">ARCO</span><i></i><b>MOVE SET</b></div><div class="skill-bar"> <footer class="bottom-hud"><section class="skill-deck" aria-label="Serangan dan skill"><div class="deck-heading"><span id="deck-character">ARCO</span><i></i><b>MOVE SET</b></div><div class="skill-bar">
<button class="skill-card basic" data-action="attack" aria-label="Basic attack, Spasi, 3 kombo"><span class="skill-art"><img src="assets/ui/attack.webp" alt=""><kbd>SPACE</kbd><span class="combo-dots"><i></i><i></i><i></i></span></span><span class="skill-name">IRON CHAIN</span></button> <button class="skill-card basic" data-action="attack" aria-label="Basic attack, Spasi, 3 kombo"><span class="skill-art"><img src="assets/ui/attack.webp" alt=""><kbd>SPACE</kbd><span class="combo-dots"><i></i><i></i><i></i></span></span><span class="skill-name">IRON CHAIN</span></button>
@ -23,6 +24,6 @@
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><span id="load-message">Menyiapkan arena…</span></div> <div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Video loop pada main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog> <dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Video loop pada main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
<dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></dialog> <dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></dialog>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html> </main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

287
tests/t1-feel.mjs Normal file
View File

@ -0,0 +1,287 @@
// Tahap 1.7-1.9: rasa kena (hit-stop, getaran, kilat, K.O. slow-motion, angka damage pop, peringkat kombo).
// node tests/harness.mjs tests/t1-feel.mjs screenshot ke FEEL_SHOTS (default <tmp>/t1-feel-shots)
// Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const options = { viewport: { width: 1280, height: 520 } };
const OUT = (process.env.FEEL_SHOTS || path.join(os.tmpdir(), 't1-feel-shots')).replace(/\/?$/, '/');
export default async ({ page, game }) => {
fs.mkdirSync(OUT, { recursive: true });
const checks = [], ok = (name, pass, info) => { checks.push({ name, pass: !!pass, info }); };
const shot = async name => { await game(() => window.__game.draw()); await page.screenshot({ path: OUT + name + '.png' }); };
const wait = ms => page.waitForTimeout(ms);
const T = await game(() => window.__game.feel.table);
// ---------- 1. jenis pukulan pada lawan (sisi pemain memukul) ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); });
const hitKinds = {};
for (const [kind, dmg, shake] of [['light', 6, 1], ['medium', 14, 1], ['heavy', 24, 1], ['ultimate', 28, 2]]) {
hitKinds[kind] = await game(({ dmg, shake }) => {
const g = window.__game; g.advance(3); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle';
const okHit = g.hitDummy(dmg, 160, '#c6fff0', g.hero.x, g.hero.y - 90, false, true, shake), s = g.feelState();
return { okHit, hitstop: s.hitstop, trauma: s.trauma, flash: g.dummy.flash, numbers: s.numbers.map(n => n.kind), kickX: s.kick.x };
}, { dmg, shake });
const h = hitKinds[kind], t = T.hit[kind];
ok(`pukulan ${kind}: hit-stop sesuai tabel`, h.okHit && Math.abs(h.hitstop - t.stop) < 1e-6, h);
ok(`pukulan ${kind}: angka damage berjenis ${kind}`, h.numbers.at(-1) === kind, h.numbers);
ok(`pukulan ${kind}: kilat putih pada lawan`, Math.abs(h.flash - t.flash) < 1e-6, h.flash);
ok(`pukulan ${kind}: getaran condong ke arah pukulan (kanan)`, h.kickX > 0 && Math.abs(h.kickX - t.kick) < 1.5, h.kickX);
await shot('hit-' + kind + '-flash'); await game(() => window.__game.advance(.1)); await shot('hit-' + kind);
}
ok('hit-stop naik menurut jenis (ringan < sedang < berat < ultimate)', hitKinds.light.hitstop < hitKinds.medium.hitstop && hitKinds.medium.hitstop < hitKinds.heavy.hitstop && hitKinds.heavy.hitstop < hitKinds.ultimate.hitstop);
ok('getaran naik menurut jenis', hitKinds.light.trauma < hitKinds.medium.trauma && hitKinds.medium.trauma < hitKinds.heavy.trauma && hitKinds.heavy.trauma < hitKinds.ultimate.trauma, ['light', 'medium', 'heavy', 'ultimate'].map(k => hitKinds[k].trauma));
// peluru berantai (freeze=false): tidak membekukan dunia kecuali jenis berat, itupun setengah
const noFreeze = await game(() => {
const g = window.__game; g.advance(3); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle';
g.hitDummy(6, 60, '#fff', g.hero.x, g.hero.y - 90, false, false, .6); const a = g.feelState().hitstop;
g.advance(3); g.dummy.state = 'idle'; g.hitDummy(24, 60, '#fff', g.hero.x, g.hero.y - 90, false, false); const b = g.feelState().hitstop; return { light: a, heavy: b };
});
ok('serangan tanpa freeze: ringan 0, berat setengah', noFreeze.light === 0 && Math.abs(noFreeze.heavy - T.hit.heavy.stop * T.microScale) < 1e-6, noFreeze);
// kilat putih: sprite lawan putih saat flash > 0
await game(() => { const g = window.__game; g.advance(3); g.dummy.state = 'idle'; g.hitDummy(24, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.hero.flash = 0; });
await shot('flash-dummy');
// ---------- 2. counter, parry, guard ----------
const counter = await game(() => {
const g = window.__game; g.advance(3); g.dummy.state = 'idle'; g.dummy.action = { type: 'attack', fired: false, t: 0, duration: .5, index: 1 };
const before = g.dummy.damage; g.hitDummy(10, 120, '#fff0b0', g.hero.x, g.hero.y - 90); const s = g.feelState(); g.dummy.action = null;
return { dealt: g.dummy.damage - before, hitstop: s.hitstop, last: s.numbers.at(-1), kickX: s.kick.x };
});
ok('counter: damage x1,25, angka oranye + label COUNTER, hit-stop counter', counter.dealt === 13 && counter.last.kind === 'counter' && counter.last.label === 'COUNTER' && counter.hitstop === T.hit.counter.stop, counter);
await game(() => window.__game.advance(.1)); await shot('counter-dummy');
const parry = await game(() => {
const g = window.__game; g.advance(3); g.hero.parry = .25; g.hero.action = null; g.hero.blocking = false;
const r = g.receiveHit(12), s = g.feelState(); return { r, hitstop: s.hitstop, last: s.numbers.at(-1), kickX: s.kick.x, hp: g.hero.hp };
});
ok('parry: tertangkis, angka biru PARRY, hit-stop parry, getaran balik ke arah pukulan', parry.r && parry.last.kind === 'parry' && parry.last.amount === 'PARRY' && parry.hitstop === T.hit.parry.stop && parry.kickX < 0, parry);
await game(() => window.__game.advance(.1)); await shot('parry-hero');
const guard = await game(() => {
const g = window.__game; g.advance(3); g.hero.parry = 0; g.hero.blocking = true; g.hero.action = null;
const r = g.receiveHit(12), s = g.feelState(); g.hero.blocking = false; return { r, hitstop: s.hitstop, last: s.numbers.at(-1) };
});
ok('guard: angka abu GUARD, hit-stop guard', guard.r && guard.last.kind === 'guard' && guard.hitstop === T.hit.guard.stop, guard);
await game(() => window.__game.advance(.1)); await shot('guard-hero');
// sisi lawan: CPU memukul pemain (jenis dari damage, angka muncul di pemain, kilat pada sprite pemain, getaran ke kiri)
const cpuHits = {};
for (const [kind, dmg, opt] of [['light', 6, {}], ['medium', 14, {}], ['heavy', 24, {}], ['ultimate', 28, { shake: 2 }]]) {
cpuHits[kind] = await game(({ dmg, opt }) => {
const g = window.__game; g.advance(3); g.hero.action = null; g.hero.hp = 200; g.hero.invuln = 0; g.hero.parry = 0; g.hero.blocking = false;
const r = g.receiveHit(dmg, opt), s = g.feelState(); return { r, hitstop: s.hitstop, flash: g.hero.flash, last: s.numbers.at(-1), kickX: s.kick.x };
}, { dmg, opt });
const h = cpuHits[kind], t = T.hit[kind];
ok(`CPU memukul pemain (${kind}): efek sama dengan sisi pemain`, h.r && h.last.kind === kind && Math.abs(h.hitstop - t.stop) < 1e-6 && Math.abs(h.flash - t.flash) < 1e-6 && h.kickX < 0, h);
}
await game(() => window.__game.advance(.1)); await shot('cpu-hits-hero');
const heroCounter = await game(() => {
const g = window.__game; g.advance(3); g.hero.invuln = 0; g.hero.action = { type: 'attack', fired: false, t: 0, duration: .4, index: 1, name: 'attack1' };
const hp0 = g.hero.hp; g.receiveHit(10); const s = g.feelState(); return { lost: hp0 - g.hero.hp, last: s.numbers.at(-1) };
});
ok('pemain terkena saat menyiapkan serangan: label COUNTER, damage tidak berubah', heroCounter.last.label === 'COUNTER' && heroCounter.lost === 10, heroCounter);
// CPU menangkis (guard) saat dipukul: acak berbenih, cari sampai muncul
const cpuGuard = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'excellent', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(true); g.dummy.action = null; let found = null;
for (let i = 0; i < 80 && !found; i++) { g.advance(.2); g.dummy.state = 'idle'; g.dummy.action = null; g.dummy.invuln = 0; g.dummy.x = g.hero.x + 120; g.dummy.y = g.config.groundY; g.dummy.damage = 0; g.hitDummy(8, 100, '#fff', g.hero.x, g.hero.y - 90); const l = g.feelState().numbers.at(-1); if (l && l.kind === 'guard') found = { tries: i + 1, hitstop: g.feelState().hitstop }; }
return found || { tries: 80, none: true };
});
ok('CPU menangkis: angka abu GUARD + hit-stop guard', !cpuGuard.none && cpuGuard.hitstop === T.hit.guard.stop, cpuGuard);
// ---------- 3. batas jumlah angka aktif ----------
const cap = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1);
let peak = 0; for (let i = 0; i < 40; i++) { g.dummy.state = 'idle'; g.hitDummy(6, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.01); peak = Math.max(peak, g.feelState().numbers.length); }
return { peak, limit: g.feel.table.maxNumbers };
});
ok('angka damage aktif dibatasi', cap.peak <= cap.limit, cap);
// ---------- 4. peringkat kombo + tidak menimpa HUD (jendela 520 px) ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1); });
const rankSeen = {};
const hitN = async n => game(n => { const g = window.__game; for (let i = 0; i < n; i++) { g.dummy.state = 'idle'; g.dummy.invuln = 0; g.hitDummy(6, 0, '#c6fff0', g.hero.x, g.hero.y - 90, false, true); g.advance(.02); } g.draw(); return g.snapshot().comboHits; }, n);
const rankInfo = () => page.evaluate(() => { const el = document.getElementById('combo-rank'), r = el.getBoundingClientRect(), cs = getComputedStyle(el); return { rank: el.dataset.rank, letter: el.querySelector('b').textContent, label: el.querySelector('small').textContent, visible: el.classList.contains('visible'), opacity: cs.opacity, rect: { x: r.x, y: r.y, w: r.width, h: r.height } }; });
let hits = 0;
for (const [target, rank] of [[3, 'C'], [5, 'B'], [8, 'A'], [12, 'S']]) {
hits = await hitN(target - hits); // tambah sampai target
await wait(80); await shot('rank-' + rank + '-enter');
await wait(600);
const info = await rankInfo(); rankSeen[rank] = info;
ok(`peringkat ${rank} muncul pada ${target} pukulan`, info.letter === rank && info.visible, info);
await shot('rank-' + rank);
}
// pantulan tiap pukulan: transform pada huruf berubah sesaat setelah pukulan
const bounce = await page.evaluate(async () => { const g = window.__game, b = document.querySelector('#combo-rank b'); const anims0 = b.getAnimations().length; g.dummy.state = 'idle'; g.dummy.invuln = 0; g.hitDummy(6, 0, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.02); g.draw(); return { before: anims0, after: b.getAnimations().length }; });
ok('huruf peringkat memantul tiap pukulan (animasi baru)', bounce.after > bounce.before, bounce);
// tidak menimpa HUD: bandingkan kotak peringkat dengan semua blok HUD
const overlap = await page.evaluate(() => {
const r = document.getElementById('combo-rank').getBoundingClientRect(), hits = [];
for (const sel of ['.combat-header .fighter-one', '.combat-header .fighter-two', '.match-center', '.match-actions', '.skill-deck', '.training-data', '.stage-sign', '.bottom-hud .skill-card']) {
for (const e of document.querySelectorAll(sel)) { const b = e.getBoundingClientRect(); if (b.width && b.height && r.left < b.right && r.right > b.left && r.top < b.bottom && r.bottom > b.top) hits.push(sel); }
}
return { hits, rect: { top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), right: Math.round(r.right) }, vh: innerHeight };
});
ok('peringkat kombo tidak menimpa HUD atas/bawah pada tinggi 520 px', overlap.hits.length === 0 && overlap.vh === 520, overlap);
// peringkat hilang saat kombo habis
await game(() => { window.__game.advance(3); window.__game.draw(); });
const gone = await rankInfo(); ok('peringkat hilang setelah kombo berakhir', !gone.visible, gone);
// ---------- 5. K.O. ronde di versus ----------
const koRound = await game(() => {
const g = window.__game; g.startMatch({ player: 'sonofsolo', enemy: 'megachan', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.5);
g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
const hit = g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true, 1);
const preHitstop = g.feelState().hitstop; let steps = 0;
while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
const s0 = g.snapshot(); return { hit, preHitstop, hitstopSteps: steps, phase: s0.match.phase, koDuration: s0.match.koDuration, fx: g.feelState().ko, enemyWins: s0.match.enemyWins, playerWins: s0.match.playerWins };
});
ok('K.O. ronde: fase ko, pemain menang ronde, hit-stop pembunuh sebelum slow-motion', koRound.hit && koRound.phase === 'ko' && koRound.playerWins === 1 && koRound.preHitstop >= .12, koRound);
ok('K.O. ronde: slow-motion ~0,25 dan zoom ke yang kalah (lawan)', koRound.fx.on && koRound.fx.who === 'dummy' && !koRound.fx.final && Math.abs(koRound.fx.scale - T.ko.round.scale) < 1e-6, koRound.fx);
// slow-motion: waktu simulasi visual melambat, jam K.O. tetap nyata
const slow = await game(() => {
const g = window.__game, st0 = g.dummy.stateTime, ph0 = g.snapshot().match.phaseTime; g.advance(.3);
const s1 = g.snapshot(); return { dState: s1.dummy.stateTime - st0, dPhase: s1.match.phaseTime - ph0, scale: g.feelState().ko.scale, zoom: g.feelState().ko.zoom };
});
ok('K.O. ronde: stateTime simulasi ~0,25x, jam fase memakai waktu nyata', slow.dState < .3 * 0.4 && Math.abs(slow.dPhase - .3) < .02, slow);
await shot('ko-round-slowmo');
await game(() => window.__game.advance(.3)); await shot('ko-round-slowmo-2');
// penutup K.O. selesai tepat pada koDuration (waktu nyata) lalu ronde 2 dimulai
const koTiming = await game(() => {
const g = window.__game, s = g.snapshot(); let steps = 0; const dur = s.match.koDuration, t0 = s.match.phaseTime; let ph = s.match.phase;
while (ph === 'ko' && steps++ < 2000) { g.advance(1 / 120); ph = g.snapshot().match.phase; }
const s2 = g.snapshot(); return { koDuration: dur, startedAt: t0, realSecondsInKo: steps / 120 + t0, endPhase: ph, round: s2.match.round, fxOn: g.feelState().ko.on, zoom: g.feelState().ko.zoom, heroHp: s2.hero.hp, dummyDamage: s2.dummy.damage };
});
ok('K.O. ronde: penutup selesai tepat pada koDuration', Math.abs(koTiming.realSecondsInKo - koTiming.koDuration) <= 1 / 120 + 1e-6 && koTiming.endPhase === 'intro' && koTiming.round === 2, koTiming);
ok('ronde berikutnya: efek K.O. bersih, nyawa penuh', !koTiming.fxOn && koTiming.zoom === 1 && koTiming.heroHp === 200 && koTiming.dummyDamage === 0, koTiming);
// ronde berlanjut: lawan bisa dipukul lagi dan K.O. akhir memicu hasil
const r2 = await game(() => {
const g = window.__game; let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.advance(.5); g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0; const dmg0 = g.dummy.damage; const hit = g.hitDummy(10, 100, '#fff', g.hero.x, g.hero.y - 90); g.advance(.5);
return { phase: g.snapshot().match.phase, hit, dealt: g.dummy.damage - dmg0 };
});
ok('ronde 2 berjalan normal (fase fight, pukulan mengenai)', r2.phase === 'fight' && r2.hit && r2.dealt === 10, r2);
// K.O. akhir: lebih dramatis
const koFinal = await game(() => {
const g = window.__game; g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true, 1); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
const s = g.snapshot(); return { phase: s.match.phase, winner: s.match.winner, fx: g.feelState().ko, koDuration: s.match.koDuration };
});
ok('K.O. akhir: ditandai final, slow-motion lebih pelan dan lebih lama dari K.O. ronde', koFinal.phase === 'ko' && koFinal.winner === 'player' && koFinal.fx.final && koFinal.fx.scale < T.ko.round.scale && koFinal.fx.cfg.dur > T.ko.round.dur && koFinal.fx.cfg.zoom > T.ko.round.zoom, koFinal);
await game(() => window.__game.advance(.25)); await shot('ko-final-1');
await game(() => window.__game.advance(.45)); await shot('ko-final-2');
const finalEnd = await game(() => {
const g = window.__game; let steps = 0; const s0 = g.snapshot(), dur = s0.match.koDuration, t0 = s0.match.phaseTime, win = []; let ph = s0.match.phase;
while (ph === 'ko' && steps++ < 2000) { g.advance(1 / 120); ph = g.snapshot().match.phase; }
const fm = document.querySelector('#front-menu'), s = g.snapshot();
return { endPhase: ph, realSecondsInKo: steps / 120 + t0, koDuration: dur, menuOpen: s.menuOpen, bodyInMenu: document.body.classList.contains('in-menu'), menuText: (fm?.innerText || '').slice(0, 160), fxOn: g.feelState().ko.on };
});
ok('K.O. akhir: penutup tepat waktu, hasil akhir tampil, efek bersih', finalEnd.endPhase === 'complete' && Math.abs(finalEnd.realSecondsInKo - finalEnd.koDuration) <= 1 / 120 + 1e-6 && finalEnd.menuOpen && finalEnd.bodyInMenu && !finalEnd.fxOn, finalEnd);
await wait(400); await shot('ko-final-result');
// ---------- 6. pemain dikalahkan CPU (zoom ke pemain), double K.O., waktu habis ----------
const heroKo = await game(() => {
const g = window.__game; g.startMatch({ player: 'anice', enemy: 'gufron', stage: 'harbor', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.5); g.hero.hp = 12; g.hero.invuln = 0; const r = g.receiveHit(40, { shake: 1 }); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120);
g.advance(.3); const s = g.snapshot(); return { r, phase: s.match.phase, enemyWins: s.match.enemyWins, fx: g.feelState().ko };
});
ok('K.O. pemain oleh CPU: slow-motion zoom ke pemain', heroKo.r && heroKo.phase === 'ko' && heroKo.enemyWins === 1 && heroKo.fx.on && heroKo.fx.who === 'hero', heroKo);
await shot('ko-hero-slowmo');
const timeUp = await game(() => {
const g = window.__game; g.startMatch({ player: 'soloman', enemy: 'ethanolman', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.dummy.damage = 50; let k = 0; while (g.snapshot().match.phase === 'fight' && k++ < 40) g.advance(5);
const s = g.snapshot(); return { phase: s.match.phase, text: s.match.endText, fxOn: g.feelState().ko.on };
});
ok('waktu habis: tanpa slow-motion K.O.', timeUp.phase === 'ko' && timeUp.text === 'TIME UP' && !timeUp.fxOn, timeUp);
const dbl = await game(() => {
const g = window.__game; g.startMatch({ player: 'soloman', enemy: 'ethanolman', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true;
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.setAI(false); g.advance(.3); g.hero.hp = 0; g.dummy.damage = 200; g.dummy.ko = true; g.dummy.state = 'down'; g.advance(.6); const s = g.snapshot();
return { phase: s.match.phase, text: s.match.endText, fx: g.feelState().ko };
});
ok('double K.O.: kamera di tengah, tanpa galat', dbl.phase === 'ko' && dbl.fx.on && dbl.fx.who === 'both', dbl);
await shot('ko-double');
// K.O. di dekat dinding (pemain 2 manusia): zoom tidak membuka tepi kosong
const edge = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'elderwood', level: 'easy', mode: 'versus' }); g.manual = true; g.setP2(true);
let n = 0; while (g.snapshot().match.phase !== 'fight' && n++ < 90) g.advance(.25);
g.advance(.4); g.hero.x = 190; g.dummy.x = 120; g.dummy.state = 'idle'; g.dummy.invuln = 0; g.dummy.damage = 190;
g.hitDummy(30, 0, '#ffd36a', g.hero.x, g.hero.y - 90, false, true); let steps = 0; while (g.snapshot().match.phase === 'fight' && steps++ < 200) g.advance(1 / 120); g.advance(.35);
const s = g.snapshot(); return { phase: s.match.phase, fx: g.feelState().ko, dummyX: s.dummy.x };
});
ok('K.O. dekat dinding saat 2 pemain: zoom aktif', edge.phase === 'ko' && edge.fx.on && edge.fx.zoom > 1.2, edge);
await shot('ko-edge-left');
// ---------- 7. K.O. di mode latihan (tidak ada event ronde) ----------
const trainKo = await game(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1);
g.dummy.damage = 195; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.hitDummy(12, 100, '#fff', g.hero.x, g.hero.y - 90, false, true); g.advance(.3);
const mid = g.feelState().ko; let t = 0, recovered = false; while (t < 12) { g.advance(.25); t += .25; if (!g.dummy.ko && g.dummy.damage === 0) { recovered = true; break; } }
return { mid, recovered, afterT: t, fxOn: g.feelState().ko.on };
});
ok('latihan: K.O. memicu slow-motion lalu lawan pulih', trainKo.mid.on && trainKo.recovered && !trainKo.fxOn, trainKo);
// ---------- 8. simulasi nyata melawan CPU di 3 karakter: tidak ada galat, angka dan efek tidak macet ----------
const fights = [];
for (const [p, e] of [['browo', 'gufron'], ['ethanolman', 'anice'], ['soloman', 'megachan']]) {
fights.push(await game(({ p, e }) => {
const g = window.__game; g.startMatch({ player: p, enemy: e, stage: 'moonrise', level: 'hard', mode: 'versus' }); g.manual = true; g.setAI(true);
let heroNumbers = 0, dummyNumbers = 0, kinds = new Set(), maxNum = 0, koSeen = false;
for (let i = 0; i < 420; i++) {
g.advance(.25); if (i % 3 === 0) { g.press('d'); g.press(' '); g.release(' '); g.release('d'); } if (i % 7 === 0) { g.press('i'); g.release('i'); g.press('o'); g.release('o'); } if (i % 17 === 0) { g.press('p'); g.release('p'); }
const s = g.feelState(); maxNum = Math.max(maxNum, s.numbers.length); for (const n of s.numbers) kinds.add(n.kind || typeof n.amount); if (s.ko.on) koSeen = true;
if (g.snapshot().menuOpen) break;
}
const s = g.snapshot(); return { p, e, phase: s.match?.phase, round: s.match?.round, wins: [s.match?.playerWins, s.match?.enemyWins], maxNum, kinds: [...kinds], koSeen };
}, { p, e }));
}
ok('simulasi pertandingan melawan CPU berjalan (angka dibatasi, K.O. terjadi)', fights.every(f => f.maxNum <= T.maxNumbers), fights);
// ---------- 8b. jalur nyata (bukan panggilan langsung): 7 karakter memukul lawan, lalu CPU memukul balik ----------
const sweep = [];
for (const id of ['browo', 'megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron']) {
sweep.push(await game(id => {
const g = window.__game; g.startMatch({ player: id, enemy: 'sonofsolo', stage: 'bellora', level: 'hard', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5);
g.dummy.x = g.hero.x + 85; let mine = null, t = 0;
g.press(' '); g.release(' ');
while (t < 3 && !mine) { g.advance(.02); t += .02; const s = g.snapshot(); if (s.comboHits > 0) mine = { t: +t.toFixed(2), flash: g.dummy.flash, numbers: g.feelState().numbers.map(n => n.kind || 'x'), hitstop: g.feelState().hitstop }; }
g.advance(2); g.setAI(true); g.dummy.x = g.hero.x + 100; g.dummy.state = 'idle'; g.hero.hp = 200; let theirs = null; t = 0;
while (t < 25 && !theirs) { g.advance(.05); t += .05; if (g.hero.hp < 200) theirs = { t: +t.toFixed(2), hp: g.hero.hp, flash: g.hero.flash, numbers: g.feelState().numbers.map(n => n.kind || 'x') }; }
return { id, mine, theirs };
}, id));
}
ok('jalur nyata: pemain memukul -> angka + kilat + hit-stop (7 karakter)', sweep.every(r => r.mine && r.mine.numbers.length && r.mine.flash > 0), sweep.map(r => r.mine));
ok('jalur nyata: CPU memukul pemain -> angka muncul di pemain (7 karakter)', sweep.every(r => r.theirs && r.theirs.numbers.length), sweep.map(r => r.theirs));
// ---------- 9. waktu nyata (loop rAF biasa, tanpa advance): K.O. ronde selesai kira-kira pada koDuration ----------
await game(() => { const g = window.__game; g.startMatch({ player: 'ethanolman', enemy: 'anice', stage: 'sunspire', level: 'easy', mode: 'versus' }); g.manual = false; g.setAI(false); });
await page.waitForFunction(() => window.__game.snapshot().match.phase === 'fight', null, { timeout: 30000 });
const live = await page.evaluate(async () => {
const g = window.__game; g.dummy.damage = 190; g.dummy.x = g.hero.x + 120; g.dummy.state = 'idle'; g.dummy.invuln = 0;
g.hitDummy(30, 200, '#ffd36a', g.hero.x, g.hero.y - 90, false, true); const t0 = performance.now(), log = []; let koAt = null, introAt = null, slowSeen = 99;
while (performance.now() - t0 < 7000) {
await new Promise(r => requestAnimationFrame(r)); const s = g.snapshot(), f = g.feelState(), t = performance.now() - t0;
if (s.match.phase === 'ko' && koAt === null) koAt = t; if (f.ko.on) slowSeen = Math.min(slowSeen, f.ko.scale);
if (s.match.phase === 'intro' && introAt === null) { introAt = t; break; }
}
return { koAt: Math.round(koAt), introAt: Math.round(introAt), koSeconds: (introAt - koAt) / 1000, slowSeen, koDuration: g.snapshot().match.koDuration, round: g.snapshot().match.round };
});
ok('waktu nyata: K.O. ronde berjalan ~koDuration lalu ronde 2', live.koSeconds > live.koDuration - .25 && live.koSeconds < live.koDuration + .4 && live.slowSeen <= .26 && live.round === 2, live);
const failed = checks.filter(c => !c.pass);
const out = { total: checks.length, failed: failed.length, failedChecks: failed, passed: checks.filter(c => c.pass).length + ' lulus', info: Object.fromEntries(checks.filter(c => /jalur nyata|waktu nyata|K.O. ronde: penutup|K.O. akhir: penutup/.test(c.name)).map(c => [c.name, c.info])) };
if (failed.length) throw new Error('GAGAL ' + JSON.stringify(out, null, 1));
return out;
};