diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 248a9a5..53e7a93 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -25,6 +25,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - Dialog pasangan dan easter egg berpasangan (Tahap 3.1 dan 3.4): `content/dialogs.js` (dimuat sebelum `vs.js`) mengisi `window.DIALOGS['|'] = {a, b}` untuk 21 pasangan (kunci urut alfabet; a = ucapan idA) dan `window.DIALOGS.rahasia` (empat pasangan tersembunyi, bentuk sama, bukan bagian dari 21 kunci). `VS.dialog(idPemain, idLawan)` -> `{key, secret, l, r, reply}` memetakan ucapan ke sisi kiri (pemain) dan kanan (lawan), atau `null` bila pasangan tidak punya dialog (karakter sama) sehingga kutipan umum `QUOTES` jadi cadangan. Pasangan ber-dialog menampilkan dua balon `.vs-quote.vs-bubble` (pembuka = ucapan `a` muncul dulu, pembalas = ucapan `b` menyusul lewat kelas `.re`, apa pun sisi pemainnya) dan layar VS berlangsung `VS.durationDialog` (3,3 dtk; `VS.duration` 2,2 dtk dipakai tanpa dialog); gaya di bagian "Dialog pasangan" `vs.css`. Dialog rahasia menggantikan dialog biasa setiap pasangan itu bertemu (balon keemasan `.secret`, label `.vs-secret` DIALOG RAHASIA) dan memanggil `Meta.pairEgg(kunci)`: +75 koin dan satu toast hanya sekali per pasangan (`Meta.save.eggs['dialog:']`), pencapaian `reuni` (tampil ??? sampai terbuka, deskripsi umum tanpa menyebut pasangan) setelah semua kunci `DIALOGS.rahasia` ditemukan. Toast ikut tertahan selama VS (aturan `Meta.toast`). Berlaku di versus, 2 pemain, Arcade, Survival; latihan tidak punya layar VS. Pedoman isi sama dengan `quotes.js`; Belum ada skenario otomatis khusus dialog (diuji manual; cek cepat: `__game.startMatch({player:'soloman',enemy:'sonofsolo',stage:'harbor',level:'easy',mode:'versus',vs:true})`, lalu `Meta.save.eggs` dan `VS.dialog(idPemain, idLawan)`). Pengujian yang menunggu layar VS selesai harus menunggu `!__game.vsActive()` (bukan tidur 2,2 dtk) karena waktunya kini bergantung pada ada-tidaknya dialog. - HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`. - Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN ', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan. +- Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual). - `assets//` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter. - `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang. diff --git a/game.js b/game.js index f5184aa..6695bfd 100644 --- a/game.js +++ b/game.js @@ -55,6 +55,8 @@ } const cdRate = (actor, name) => passiveMod(actor, 'cd') * passiveMod(actor, name === 'ultimate' ? 'cdUlt' : 'cdSkill'); function strikeAs(kind, fn) { hitKind = kind; try { fn(); } finally { hitKind = null; } } + // Statistik pertandingan (stats.js): sumber pukulan pemain untuk MatchStats.hit; null = jurus yang sudah lepas (proyektil, ultimate), dikaitkan stats.js ke jurus terakhir. + const hitSource = () => ignoreBlock ? 'throw' : hitKind === 'basic' && hero.action?.type === 'attack' ? 'basic' : hero.action?.special ? 'special' : null; // regen: HP kecil per detik, hanya bila aktor hidup, tidak penuh, dan tidak terkena apa pun selama 3 detik (lastHurt diisi tiap damage masuk). function passiveRegen(dt) { if (match && match.phase !== 'fight') return; @@ -240,6 +242,7 @@ selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode)); setShadowFoe(!!options.shadow&&mode==='versus'); aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins(); + window.MatchStats?.reset(mode==='versus'); $('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus'); if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle},shadow:shadowFoe})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3); return true; @@ -287,6 +290,7 @@ } function advanceMatch(dt) { if(!Rules||!match||match.mode!=='versus'||vsHold)return; + window.MatchStats?.tick(dt,match,hero.hp,dummy.damage,BALANCE.dummyMax); for(const event of Rules.tick(match,dt,hero.hp,dummyHpShown())) { if(event.type==='cue')emitRoundCue(event.cue,event.round); if(event.type==='round-end') { @@ -304,6 +308,7 @@ if(event.type==='complete'){ // bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil. const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0,boss:shadowFoe}; + info.stats=window.MatchStats?.finish(info)||null; const metaHandled=!!window.Meta?.award?.(info); if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null}); } @@ -347,6 +352,7 @@ if (hero.action) { if (hero.action.type === 'attack' && hero.action.index < 3) { hero.action.queued = Math.min(3 - hero.action.index, hero.action.queued + 1); hero.action.cancelInto = null; } return false; } const name = `attack${index}`, duration = selectedCharacter === 'fenr' ? F.move(hero,'attack',index).duration : KITS[selectedCharacter] ? KITS[selectedCharacter].move('attack',index).duration : animationDuration(name, [.39, .43, .57][index - 1]); hero.action = { type: 'attack', index, name, t: 0, duration, hit: false, queued: 0, fired: false }; + window.MatchStats?.cast('attack', hero.action); hero.runDirection = 0; state(name); return true; } // Skills cancel a basic attack at any point, so the kit never waits for the combo to finish. A skill that cannot start @@ -355,6 +361,7 @@ const basic = hero.action?.type === 'attack' ? hero.action : null; if (basic && canAct() && (hero.cooldowns[name] || 0) <= 0) hero.action = null; const ok = castMove(name); + if (ok) window.MatchStats?.cast(name, hero.action); if (!ok && basic && !hero.action) { hero.action = basic; state(basic.name); } return ok; } @@ -537,9 +544,11 @@ 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.MatchStats?.parry(); 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; + window.MatchStats?.block(); 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); } @@ -585,6 +594,7 @@ } function reset() { if(Rules)match=Rules.create(match?.mode||'training'); + window.MatchStats?.reset(match?.mode==='versus'); resetCombat();if(match?.mode==='versus'){announceTimer=0;emitRoundCue('round',1);} } function hitDummy(damage, power, color, sourceX, impactY, knockdown = false, freezeWorld = true, shake = 1) { @@ -594,6 +604,7 @@ 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) * passiveMod(dummy, 'parry')) { const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip; + window.MatchStats?.hit(hitSource(), false, 0, hero.action); 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. @@ -601,6 +612,7 @@ 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; + window.MatchStats?.hit(hitSource(), counter, comboHits, hero.action); // Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's), // so a re-started chain cannot loop it to K.O. Easy has no limit. const cap = F && aiEnabled && match?.mode === 'versus' ? cpuProfile().comboCap : 0; @@ -1561,6 +1573,7 @@ if (target === dummy) { dummy.damage += amount; totalDamage += amount; if (combo) { comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboTimer = 2.3; } + if (combo) window.MatchStats?.hit(null, false, comboHits, null); if (combo || comboTimer > 0) comboDamage += amount; 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; diff --git a/index.html b/index.html index ad9e67f..17b91e9 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,8 @@
✦KONOHA FIGHTING

Menyiapkan 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
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
+ +
diff --git a/meta.js b/meta.js index 79059b2..c7292a0 100644 --- a/meta.js +++ b/meta.js @@ -126,6 +126,7 @@ const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; add('combo', 'Bonus kombo', cb, 'Kombo', r.bestCombo + ' pukulan, ronde terakhir'); } if (won) { save.stats.wins++; unlock('first_win'); if (save.stats.wins >= 10) unlock('wins10'); if (r.perfect) { save.stats.perfects++; unlock('perfect'); } if (r.level && r.level !== 'easy') unlock('hard_win'); } else save.stats.losses++; save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0); + window.MatchStats?.commit(save.stats, r.stats); // statistik seumur hidup (kunci baru di save.stats; simpanan lama tanpa kunci dibaca 0), disimpan oleh addCoins di bawah if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10'); const prev = run ? run.earned || 0 : 0; if (run) run.earned = prev + gain; lastAward = { won, gain, lines, level: r.level || null, prev }; lastMatch = { ...r }; @@ -273,7 +274,7 @@ function renderBook() { const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats; const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'penasaran_berat', 'logo', 'reuni'].includes(k); return `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).join(''); - return `

    Statistik

    Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}

    Pencapaian ${got}/${Object.keys(ACH).length}

      ${list}

    Suara

    Memakai suara bawaan browser.

    Simpanan

    `; + return `

    Statistik

    Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}

    ${window.MatchStats?.lifetimeHtml(st) || ''}

    Pencapaian ${got}/${Object.keys(ACH).length}

      ${list}

    Suara

    Memakai suara bawaan browser.

    Simpanan

    `; } function onClick(e) { const b = e.target.closest('button'); if (!b) return; diff --git a/stats.css b/stats.css new file mode 100644 index 0000000..656e0d1 --- /dev/null +++ b/stats.css @@ -0,0 +1,48 @@ +/* Statistik pertandingan (stats.js, RENCANA 5.5): tombol dan kartu di layar hasil (vs.js ResultView), ringkasan seumur hidup di tab Koleksi (meta.js). + Kartu hasil memakai satuan vh/clamp seperti vs.css supaya muat di tinggi 520 px (dan ponsel lanskap <= 420 px); isi yang lebih tinggi dari ruang tersedia digulir di dalam kartu, tidak mendorong tombol aksi. */ + +/* ---- Layar hasil: tombol di kanan baris skor, kartu menutup kolom tengah di atas tombol aksi ---- */ +.rs-head{position:relative} +.rs-ms-btn{position:absolute;right:0;bottom:2px;z-index:2;display:inline-flex;align-items:center;gap:8px;margin:0;padding:clamp(5px,1vh,8px) clamp(9px,1.2vw,14px);font:700 clamp(10px,1.3vh + 2px,13px)/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#f3d796;background:#0b1c26d9;border:1px solid #e0bf85aa;border-radius:0;cursor:pointer;animation:rs-up .5s .9s both} +.rs-ms-btn em{display:none;font-style:normal} +.rs-ms-btn i{display:inline-block;font-style:normal;transition:transform .18s} +.rs-ms-btn:hover,.rs-ms-btn:focus-visible{background:#24505e;color:#fff;border-color:#e0bf85} +.rs-ms-btn[aria-expanded=true] i{transform:rotate(90deg)} +@media(min-width:1180px) and (min-height:640px){.rs-ms-btn em{display:inline}} + +.rs-ms{--ms-b:56px;position:absolute;left:0;right:0;top:0;bottom:var(--ms-b);z-index:9;display:flex;align-items:center;justify-content:center;padding:clamp(4px,1vh,10px) 0;background:#050f17d9;animation:ms-fade .2s ease-out both} +.rs-ms[hidden]{display:none} +.rs-ms-card{width:100%;max-height:100%;overflow:auto;padding:clamp(8px,1.5vh,14px) clamp(10px,1.6vw,16px);background:#0b1c26;border:1px solid #7fa6a533;border-top:2px solid #e0bf85;box-shadow:0 10px 30px #000a} +.rs-ms-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:clamp(5px,1.2vh,10px)} +.rs-ms-head>div{min-width:0} +.rs-ms-head small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:clamp(9px,1.2vh + 2px,12px);letter-spacing:2px;color:#8fa9ac} +.rs-ms-head h3{margin:1px 0 0;font:700 clamp(15px,2.6vh + 2px,24px)/1.05 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:3px;color:#f3d796} +.rs-ms-x{flex:none;margin:0;padding:clamp(4px,.9vh,7px) clamp(8px,1.2vw,12px);font:700 clamp(10px,1.2vh + 2px,12px)/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#cbddd4;background:#183440;border:1px solid #8ba89d66;border-radius:0;cursor:pointer} +.rs-ms-x:hover,.rs-ms-x:focus-visible{background:#24505e;color:#fff} +#front-menu .rs-ms-x:focus-visible{outline-offset:-3px} +.rs-ms-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:clamp(4px,.9vh,8px);margin:0} +.rs-ms-grid>div{min-width:0;padding:clamp(4px,.9vh,8px) clamp(6px,.9vw,10px);background:#ffffff0a;border:1px solid #ffffff14;border-left:2px solid #ffffff2e} +.rs-ms-grid dt{font-weight:700;font-size:clamp(9px,1.3vh + 2px,12px);letter-spacing:1px;color:#8fa9ac;text-transform:uppercase;overflow-wrap:anywhere} +.rs-ms-grid dd{margin:0;font-weight:700;font-size:clamp(15px,2.8vh + 3px,26px);line-height:1.1;color:#f2ead3;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +.rs-ms-grid dd small{display:inline-block;margin-left:5px;font-size:.52em;font-weight:600;letter-spacing:.5px;color:#93a9ad;white-space:nowrap} +.rs-ms-grid .dmg{border-left-color:#ffe08a}.rs-ms-grid .dmg dd{color:#ffe08a} +.rs-ms-grid .hurt{border-left-color:#ff9a7a}.rs-ms-grid .hurt dd{color:#ffb4a6} +.rs-ms-grid .acc{border-left-color:#8bd4c3} +.rs-ms-grid .acc:after{content:'';display:block;height:3px;margin-top:4px;background:linear-gradient(90deg,#8bd4c3 calc(var(--p) * 1%),#ffffff1f 0)} +.rs-ms-grid .acc.none{border-left-color:#ffffff2e}.rs-ms-grid .acc.none dd{color:#6e868c} +@keyframes ms-fade{from{opacity:0}to{opacity:1}} +/* Kolom sempit atau layar pendek: kartu lebih rapat, label dan nilai lebih kecil. */ +@media(max-width:700px),(max-height:420px){ + .rs-ms-btn{padding:4px 8px;gap:6px;letter-spacing:1px} + .rs-ms-card{padding:6px 8px}.rs-ms-head{margin-bottom:5px}.rs-ms-head small{font-size:9px;letter-spacing:1px}.rs-ms-head h3{font-size:15px;letter-spacing:2px} + .rs-ms-grid{gap:4px}.rs-ms-grid>div{padding:3px 6px}.rs-ms-grid dt{font-size:9px;letter-spacing:.5px}.rs-ms-grid dd{font-size:15px}.rs-ms-grid dd small{font-size:9px} + .rs-ms-grid .acc:after{margin-top:2px} +} +@media(pointer:coarse){.rs-ms-btn,.rs-ms-x{min-height:30px}} + +/* ---- Tab Koleksi ---- */ +.ms-life{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin:12px 0 0} +.ms-life>div{min-width:0;padding:8px 12px;background:#101c26;border:1px solid #ffffff1c;border-radius:8px} +.ms-life dt{font-size:13px;letter-spacing:.06em;color:#9fb3c4;text-transform:uppercase} +.ms-life dd{margin:2px 0 0;font:700 22px/1.1 Rajdhani,Arial,sans-serif;color:#f6eedb;font-variant-numeric:tabular-nums} +.ms-life dd small{margin-left:6px;font-size:14px;font-weight:600;color:#8fa3b3} diff --git a/stats.js b/stats.js new file mode 100644 index 0000000..f65e5dc --- /dev/null +++ b/stats.js @@ -0,0 +1,131 @@ +/* Statistik pertandingan (RENCANA 5.5). Gaya: stats.css. Seluruh logika ada di berkas ini; berkas lain hanya memanggil kait kecil: + - game.js: reset(versus) di startMatch dan reset (latihan = null, tanpa statistik); tick(dt, match, hpPemain, damageLawan, hpMaksLawan) tiap langkah advanceMatch (sebelum Rules.tick); + cast(jenis, aksi) di cast dan startAttack; hit(sumber, counter, kombo, aksi) di hitDummy dan di dotDamage (burst); parry() dan block() di heroGuard; finish(info) di event 'complete' -> info.stats. + - meta.js: commit(Meta.save.stats, info.stats) di award, lifetimeHtml(Meta.save.stats) di tab Koleksi. vs.js: resultHtml(info.stats, ctx) dan mount(root) di layar hasil. + Catatan cara hitung: + - Damage keluar/diterima dibaca dari selisih HP per langkah (HP yang benar-benar hilang: kelebihan pukulan penutup tidak dihitung, penyembuhan tidak mengurangi), jadi semua sumber damage + (pukulan, DoT, chip blok) terhitung tanpa kait di tiap jalur damage. Durasi = jumlah waktu fase 'fight' (jam ronde, tanpa intro dan K.O.); ronde = peralihan fight -> ko (ronde seri ikut dihitung). + - Akurasi jurus: satu pemakaian dihitung "mengenai" bila ada pukulan darinya yang kena (sekali per pemakaian). Pukulan dasar, lempar, dan gerakan perintah dikenali lewat hitSource() di game.js; + pukulan lain (proyektil, ultimate, burst) dikaitkan ke jurus yang terakhir dipakai dalam jendela WINDOW detik. Bila dua jurus bertumpuk, kaitannya bisa meleset satu: pendekatan yang disengaja + (proyektil tidak ditandai satu per satu). + - Simpanan seumur hidup: Meta.save.stats mendapat kunci baru (dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches) dan bestCombo yang sudah ada ikut dinaikkan. + Simpanan lama tanpa kunci itu terbaca sebagai 0. Hanya pertandingan yang selesai yang dihitung (keluar di tengah jalan tidak). + Semua fungsi publik aman: galat hanya dicatat di konsol dan tidak pernah menghentikan game. Tidak memakai Date.now/Math.random. */ +(() => { + 'use strict'; + const KINDS = ['attack', 'skill1', 'skill2', 'ultimate']; // urutan sama dengan names[0..3] petarung + const WINDOW = { skill1: 3.2, skill2: 3.2, ultimate: 8 }; // detik waktu bertarung + const zero = () => ({ attack: 0, skill1: 0, skill2: 0, ultimate: 0 }); + const started = new WeakSet(), landed = new WeakSet(); // aksi (hero.action) yang sudah dihitung dipakai / mengenai + let cur = null; + const fresh = () => ({ dealt: 0, taken: 0, combo: 0, counters: 0, parries: 0, blocks: 0, throws: 0, rounds: 0, time: 0, use: zero(), hit: zero(), last: null, prev: null, phase: '', done: false, snap: null }); + const guard = (fn, fallback) => (...a) => { try { return fn(...a); } catch (e) { console.error('MatchStats:', e); return fallback; } }; + + // ---- pengumpulan (dipanggil game.js) ---- + const reset = guard(active => { cur = active ? fresh() : null; }); + const tick = guard((dt, m, hp, foe, foeMax) => { + const s = cur; if (!s || s.done || !m) return; + const f = foeMax > 0 ? Math.min(foe, foeMax) : foe; + if (s.prev) { if (hp < s.prev.hp) s.taken += s.prev.hp - hp; if (f > s.prev.foe) s.dealt += f - s.prev.foe; } + s.prev = { hp, foe: f }; + if (m.phase === 'fight') s.time += dt; else if (m.phase === 'ko' && s.phase === 'fight') s.rounds++; + s.phase = m.phase; + }); + // Dipanggil sesudah castMove/startAttack: aksi baru bertipe sama dengan jenisnya. Pemakaian yang ditahan (buffer lompat ganda) belum punya aksi, jadi baru terhitung saat benar-benar mulai. + const cast = guard((kind, act) => { + const s = cur; if (!s || s.done || !act || act.type !== kind || !(kind in s.use) || started.has(act)) return; + started.add(act); s.use[kind]++; + if (kind !== 'attack') s.last = { kind, t: s.time, hit: false }; + }); + // src: 'throw' | 'basic' | 'special' | null (jurus yang sudah lepas); counter: pukulan menimpa lawan yang sedang menyiapkan serangan; combo: hitungan kombo sesudah pukulan ini. + const hit = guard((src, counter, combo, act) => { + const s = cur; if (!s || s.done) return; + if (counter) s.counters++; + if (combo > s.combo) s.combo = combo | 0; + if (src === 'throw') { s.throws++; return; } + if (src === 'special') return; + if (src === 'basic') { if (act && !landed.has(act)) { landed.add(act); s.hit.attack++; } return; } + const c = s.last; + if (c && !c.hit && s.time - c.t <= WINDOW[c.kind]) { c.hit = true; s.hit[c.kind]++; } + }); + const parry = guard(() => { if (cur && !cur.done) cur.parries++; }); + const block = guard(() => { if (cur && !cur.done) cur.blocks++; }); + + // Ringkasan polos (aman disalin / disimpan): angka bulat, hit <= use per jenis. + function pack(s, info) { + const use = {}, got = {}; + for (const k of KINDS) { use[k] = s.use[k] | 0; got[k] = Math.min(use[k], s.hit[k] | 0); } + return { v: 1, dealt: Math.round(s.dealt), taken: Math.round(s.taken), combo: Math.max(s.combo, +info?.matchBest || 0), counters: s.counters, parries: s.parries, blocks: s.blocks, throws: s.throws, + rounds: Math.max(s.rounds, (info?.playerWins | 0) + (info?.enemyWins | 0)), time: Math.round(s.time * 10) / 10, use, hit: got }; + } + // Event 'complete' di game.js: menutup pengumpulan dan mengembalikan ringkasan (disalin tiap panggilan) atau null bila tidak ada pertandingan berstatistik. + const finish = guard(info => { + const s = cur; if (!s) return null; + if (!s.snap) { s.done = true; s.snap = pack(s, info); } + return JSON.parse(JSON.stringify(s.snap)); + }, null); + const snapshot = guard(() => (cur ? { ...pack(cur), active: !cur.done } : null), null); // untuk uji manual di konsol: MatchStats.snapshot() + + // Simpanan seumur hidup (meta.js award): menambah ke objek Meta.save.stats; kunci yang belum ada dianggap 0. + const commit = guard((st, s) => { + if (!st || !s || typeof st !== 'object') return; + const add = (k, n) => { st[k] = (+st[k] || 0) + (+n || 0); }; + add('dmgDealt', s.dealt); add('dmgTaken', s.taken); add('parries', s.parries); add('blocks', s.blocks); add('throws', s.throws); add('counters', s.counters); + add('rounds', s.rounds); add('playSec', Math.round(s.time)); add('statMatches', 1); + st.bestCombo = Math.max(+st.bestCombo || 0, +s.combo || 0); + }); + + // ---- tampilan ---- + const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const num = n => String(Math.max(0, Math.round(+n || 0))).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); // 12345 -> 12.345 + const clock = sec => { const t = Math.max(0, Math.round(+sec || 0)); return Math.floor(t / 60) + ':' + String(t % 60).padStart(2, '0'); }; + const span = sec => { const t = Math.max(0, Math.round(+sec || 0)), h = Math.floor(t / 3600), m = Math.floor(t % 3600 / 60); return h ? `${h} j ${m} mnt` : m ? `${m} mnt ${t % 60} dtk` : `${t} dtk`; }; + const pct = (h, u) => (u > 0 ? Math.round(100 * h / u) : null); + + // Layar hasil: {btn, panel}. btn = tombol di kepala layar hasil (kanan, sebaris skor); panel = lapisan di kolom tengah yang menutup bagian atas tombol aksi (mount mengaturnya). + const resultHtml = guard((s, ctx = {}) => { + if (!s || typeof s !== 'object') return { btn: '', panel: '' }; + const names = window.FIGHTERS?.[ctx.id]?.names || window.Browo?.names || []; + const tile = (label, value, cls = '', sub = '', tip = '') => `
    ${label}
    ${value}${sub ? `${esc(sub)}` : ''}
    `; + const acc = (label, k) => { + const u = Math.max(0, s.use?.[k] | 0), h = Math.min(u, Math.max(0, s.hit?.[k] | 0)), p = pct(h, u), tip = names[KINDS.indexOf(k)]; + return `
    ${label}
    ${p === null ? '—' : `${h}/${u}${p}%`}
    `; + }; + const tiles = [tile('Damage keluar', num(s.dealt), 'dmg'), tile('Damage diterima', num(s.taken), 'hurt'), tile('Kombo terpanjang', num(s.combo), '', 'pukulan'), tile('Counter hit', num(s.counters)), + acc('Serangan dasar', 'attack'), acc('Skill 1', 'skill1'), acc('Skill 2', 'skill2'), acc('Ultimate', 'ultimate'), + tile('Parry berhasil', num(s.parries)), tile('Blok', num(s.blocks)), tile('Lempar berhasil', num(s.throws)), tile('Durasi', clock(s.time), '', `${num(s.rounds)} ronde`, 'Total waktu bertarung (jam ronde), tanpa intro dan layar K.O.')]; + const who = `PEMAIN 1${ctx.name ? ' · ' + esc(String(ctx.name).toUpperCase()) : ''}${ctx.last ? ' · PERTANDINGAN TERAKHIR' : ''}`; + return { + btn: ``, + panel: `` + }; + }, { btn: '', panel: '' }); + + // Setelah layar hasil dipasang (vs.js mount): tombol membuka/menutup lapisan, Esc dan klik di luar kartu menutupnya. Esc ditangkap lebih dulu supaya tidak ikut kembali ke menu utama. + const mount = guard(root => { + const rs = root?.querySelector?.('.rs'), btn = rs?.querySelector('[data-ms="open"]'), box = rs?.querySelector('.rs-ms'); + if (!btn || !box) return; + const main = box.parentElement, closeBtn = box.querySelector('[data-ms="close"]'); + // Lapisan berhenti tepat di atas tombol aksi (tinggi tombol berbeda tiap ukuran layar). + const place = () => { const a = main.querySelector('.rs-actions'); box.style.setProperty('--ms-b', (a ? Math.max(0, main.clientHeight - a.offsetTop) + 4 : 56) + 'px'); }; + const onKey = e => { if (!box.isConnected) return off(); if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); } }; + const onSize = () => (box.isConnected ? place() : off()); + const off = () => { window.removeEventListener('keydown', onKey, true); window.removeEventListener('resize', onSize); }; + const open = () => { place(); box.hidden = false; btn.setAttribute('aria-expanded', 'true'); window.addEventListener('keydown', onKey, true); window.addEventListener('resize', onSize); closeBtn?.focus({ preventScroll: true }); }; + const close = () => { box.hidden = true; btn.setAttribute('aria-expanded', 'false'); off(); btn.focus({ preventScroll: true }); }; + btn.addEventListener('click', () => (box.hidden ? open() : close())); + box.addEventListener('click', e => { if (e.target === box || e.target.closest('[data-ms="close"]')) close(); }); + }); + + // Tab Koleksi: ringkasan seumur hidup dari Meta.save.stats (kunci yang belum ada = 0). + const lifetimeHtml = guard(st => { + st = st && typeof st === 'object' ? st : {}; + const n = k => Math.max(0, Math.round(+st[k] || 0)), tile = (label, value) => `
    ${label}
    ${value}
    `; + return `
    ${tile('Total damage', num(n('dmgDealt')))}${tile('Damage diterima', num(n('dmgTaken')))}${tile('Kombo terbaik', `${num(n('bestCombo'))}pukulan`)}` + + `${tile('Parry berhasil', num(n('parries')))}${tile('Lempar berhasil', num(n('throws')))}${tile('Counter hit', num(n('counters')))}${tile('Blok', num(n('blocks')))}${tile('Waktu bertarung', span(n('playSec')))}
    ` + + `

    ${n('statMatches') ? `Dihitung dari ${num(n('statMatches'))} pertandingan selesai sejak ada layar statistik` : 'Belum ada pertandingan yang tercatat'} (latihan tidak dihitung).

    `; + }, ''); + + window.MatchStats = { reset, tick, cast, hit, parry, block, finish, snapshot, commit, resultHtml, mount, lifetimeHtml, WINDOW, fmt: { num, clock, span } }; +})(); diff --git a/vs.js b/vs.js index 72f0f85..54c4376 100644 --- a/vs.js +++ b/vs.js @@ -122,6 +122,8 @@ const aw = r.award, lines = aw?.lines || []; const coins = aw ? `

    KOIN

      ${lines.map((l, i) => `
    • ${esc(l.label)}${l.note ? `${esc(l.note)}` : ''}+${l.amount}
    • `).join('')}
    TOTAL◆ +${aw.gain}
    ` : ''; const stat = (k, v) => `
    ${k}
    ${esc(v)}
    `; + // Statistik pertandingan (stats.js, RENCANA 5.5): tombol di kepala dan kartu lipat; kosong bila pertandingan tanpa statistik (mis. data uji). + const ms = window.MatchStats?.resultHtml(r.stats, { id: r.player, name: fighter(r.player).name, last: !!run }) || { btn: '', panel: '' }; const combo = r.matchBest ?? r.bestCombo ?? 0; const stats = run ? stat(run.kind === 'arcade' ? 'Lawan' : 'Menang beruntun', run.kind === 'arcade' ? `${run.wins} / ${run.total}` : String(run.wins)) + (boss ? stat('Bayangan', boss === 'win' ? 'Dikalahkan' : 'Bertahan') : stat(run.kind === 'arcade' ? 'Tingkat' : 'Rekor', run.kind === 'arcade' ? lv : String(run.best))) + stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde terakhir', `${r.playerWins} – ${r.enemyWins}`) @@ -129,10 +131,10 @@ const html = `
    ` + (tone === 'win' ? `` : '') + `
    ${esc(Wf.name)}
    ${boss === 'win' ? esc(window.Story?.title || 'PEMENANG') : 'PEMENANG'}${esc(Wf.name)}
    ` - + `
    ${esc(kicker)}

    ${title}

    ${scoreHtml}
    ` + + `
    ${esc(kicker)}

    ${title}

    ${scoreHtml}${ms.btn}
    ` + `

    “${esc(q.win)}”

    ${esc(Wf.name)}
    ` + `
    ${coins}

    STATISTIK

    ${stats}
    ` - + `
    ` + + `
    ${ms.panel}
    ` + `
    ${esc(Lf.name)}
    ${esc(Lf.name)}

    “${esc(q.lose)}”

    `; return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html }; } @@ -149,6 +151,7 @@ } // Fokus ke wadah (bukan tombol): Spasi/Enter yang masih ditekan dari pertarungan tidak boleh langsung mengaktifkan MAIN LAGI; Tab tetap menjangkau tombol. if (!document.documentElement.classList.contains('touch')) { rs.tabIndex = -1; rs.focus({ preventScroll: true }); } + window.MatchStats?.mount(root); } window.ResultView = { view, mount }; })();