/* 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 } }; })();