diff --git a/feel.css b/feel.css new file mode 100644 index 0000000..378e5cb --- /dev/null +++ b/feel.css @@ -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}} diff --git a/feel.js b/feel.js new file mode 100644 index 0000000..e7fd0e1 --- /dev/null +++ b/feel.js @@ -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 } }; +})(); diff --git a/game.js b/game.js index 567d541..594b535 100644 --- a/game.js +++ b/game.js @@ -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)])) }; let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null; 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 match = Rules?.create('training') || null, roundAnnouncer = null; 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++;} } 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; window.FrontEnd?.syncBackground(); } @@ -202,6 +202,7 @@ 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; 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'); const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko'; const outcome=event.winner==='draw'?'draw':`${event.winner==='player'?selectedCharacter:opponentCharacter}_wins`; @@ -385,20 +386,69 @@ sound('heavy'); } 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 ---- function heroGuard(damage, options) { if (!(hero.blocking || hero.parry > 0) || hero.action || options?.unblockable) return false; 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); - spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#fff3b0', 22, 320); + hero.parry = 0; hero.invuln = Math.max(hero.invuln, .35); announce('PARRY!', .6); sound('parry'); window.Meta?.shout?.('Parry!'); + 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; } 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; - spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); hitstop = Math.max(hitstop, .03); - damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', life: .6, maxLife: .6, color: '#ffd36a' }); + 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', 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); } 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 }); 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=''; - 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; for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0; } @@ -450,10 +500,11 @@ 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)) { 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. - 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)))); 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), @@ -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); dummy.action=null; if(F&&dummy.transformPending>0)F.end(dummy); - if (freezeWorld) hitstop = Math.max(hitstop, damage >= 30 ? .085 : damage >= 14 ? .065 : .045); - trauma = Math.min(1, trauma + (damage >= 30 ? .45 : .18) * shake); - damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, life: .9, maxLife: .9, color }); - spawnParticles(dummy.x - dir * 21, impactY, color, damage >= 30 ? 22 : 14, damage >= 30 ? 320 : 220); - effects.push({ type: 'impact', x: dummy.x - dir * 15, y: impactY, life: .2, maxLife: .2, color, heavy: damage >= 30 }); + const kind = Feel.classify(damage, { counter, shake }), big = kind === 'heavy' || kind === 'ultimate' || kind === 'counter'; + 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, kind, life: .9, maxLife: .9, color, label: counter ? 'COUNTER' : undefined }); + 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: 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); } sound(damage >= 30 ? 'heavy' : 'hit'); return true; } @@ -1411,12 +1462,14 @@ if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false; if (target === dummy) { 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); } return true; } 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); } return true; } @@ -1643,20 +1696,23 @@ 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 }; } - function step(dt) { - systemAnnouncer?.tick(dt); + function step(realDt) { + systemAnnouncer?.tick(realDt); 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') { // 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 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); 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');}} @@ -1700,7 +1756,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); 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(); - 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. function bodyGap() { return CONFIG.bodyGap || 80; } @@ -1708,7 +1764,7 @@ function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};} 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);} - 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); if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.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. @@ -2324,7 +2380,7 @@ 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.g ?? (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680)) * dt; p.vx *= Math.exp(-dt * (p.drag ?? 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 = 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) { @@ -2386,7 +2442,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(); } 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); } - 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() { const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):(cfFormOn(opponentCharacter,'enemy')?opponentCharacter+'-form':opponentCharacter)]; @@ -2394,7 +2450,7 @@ if(!m||!img)return; 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.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') { 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(); @@ -2510,7 +2566,7 @@ else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size); 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) { if (!group || !images.drone) return; @@ -2583,12 +2639,13 @@ } function draw() { 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 // 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) { - 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); ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy); } @@ -2600,7 +2657,7 @@ drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack(); if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } 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) { 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')};} @@ -2670,7 +2727,7 @@ $('#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}`; $('#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} DMG`; + $('#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} DMG`; $('#best-combo').innerHTML = `${bestCombo} HITS`; $('#announcement').classList.toggle('visible', announceTimer > 0); for (const card of document.querySelectorAll('.skill-card')) { @@ -2736,10 +2793,12 @@ stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;}, selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose, 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; } }; // Pemeriksaan efek jurus untuk uji headless (tests/t1-fx.mjs). window.__game.fx = { snapshot: () => ({ ghosts: cfx.ghosts.length, hazards: cfx.hazards.filter(h => h.fire).length, dots: cfx.dots.length, later: cfx.later.length, particles: particles.length, effects: effects.map(e => e.type), tele: { hero: fxTeleP(hero), dummy: fxTeleP(dummy) }, projectiles: projectiles.map(p => ({ fx: p.fx, x: Math.round(p.x), y: Math.round(p.y), age: +(p.age || 0).toFixed(3), damage: p.damage, wait: p.wait || 0, orbit: !!(p.sw && !p.sw.go) })) }) }; + // 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(window.FRONTEND_ENABLED)setMenuOpen(true); resize(); requestAnimationFrame(tick); diff --git a/index.html b/index.html index d89d2f7..ce54105 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,7 @@ -Konoha Fighting +Konoha Fighting +
Menyiapkan…
@@ -11,7 +12,7 @@
DEMI-HUMAN

FENR

HP 200 / 200WOLF RANGER
READY
Portrait FENRCPU
-
0
HITS0 DAMAGE
+
0
HITS0 DAMAGE
ARCOMOVE SET
@@ -23,6 +24,6 @@
✦KONOHA FIGHTINGMenyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar

KONOHA FIGHTING / PAUSE

PAUSED

Pertarungan dijeda. Siap melanjutkan?

-
+
diff --git a/tests/t1-feel.mjs b/tests/t1-feel.mjs new file mode 100644 index 0000000..55d595b --- /dev/null +++ b/tests/t1-feel.mjs @@ -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 /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; +};