Layar statistik pertandingan (RENCANA 5.5): modul stats.js dengan kait kecil di game.js, kartu Statistik di layar hasil, ringkasan seumur hidup di Koleksi
- stats.js (window.MatchStats): statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan tidak): damage keluar/diterima (selisih HP per langkah), kombo terpanjang, counter hit, parry, blok, lempar, akurasi serangan dasar/skill 1/skill 2/ultimate (jurus dihitung mengenai sekali per pemakaian), ronde dan durasi (jumlah waktu fase fight, waktu simulasi tanpa Date.now) - game.js: kait satu baris di startMatch/reset (reset), advanceMatch (tick), cast/startAttack (cast), hitDummy/dotDamage (hit lewat hitSource), heroGuard (parry/block), event 'complete' (finish -> info.stats) - vs.js + stats.css: tombol STATISTIK di kepala layar hasil membuka kartu 4x3 di atas tombol aksi (digulir sendiri bila ruang kurang, Esc menutup), muat di 520 px dan ponsel lanskap; tanpa info.stats tidak ada tombol - meta.js: award memanggil commit ke Meta.save.stats (kunci baru dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches; simpanan lama terbaca 0; bestCombo ikut naik ke kombo terbaik seluruh ronde); tab Koleksi menampilkan ringkasan seumur hidup - index.html: dua tag baru di baris sendiri; catatan mesin diperbarui. Mode ringan: node --check dan smoke lulus, tampilan dan angka diuji manual oleh pemilik
This commit is contained in:
parent
b4a32e5c4a
commit
b40925ad88
|
|
@ -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['<idA>|<idB>'] = {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:<kunci>']`), 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 <NAMA>', 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 `</main>` 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/<id>/` 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.
|
||||
|
||||
|
|
|
|||
13
game.js
13
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;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
|
||||
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) S · Menunduk<br>SPACE · Kombo 3 pukulan ↓ ↓ + SPACE · Uppercut ↓ → + SPACE · Tinju maju<br>I / O · Skill P · Ultimate K · Jurus toko<br>L · Blok (ketuk = parry) J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak ↑ · Lompat N · Serang M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
||||
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
||||
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
|
||||
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
|
||||
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="content/mystery.js"></script><script src="mystery.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
|
||||
|
||||
|
||||
|
|
|
|||
3
meta.js
3
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 `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p>${window.MatchStats?.lifetimeHtml(st) || ''}<h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Suara</h3><p><button class="pill ${save.voice ? 'on' : ''}" data-voice>TERIAKAN JURUS: ${save.voice ? 'AKTIF' : 'MATI'}</button> <small class="meta-hint">Memakai suara bawaan browser.</small></p><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||
}
|
||||
function onClick(e) {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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 = '') => `<div${cls ? ` class="${cls}"` : ''}${tip ? ` title="${esc(tip)}"` : ''}><dt>${label}</dt><dd>${value}${sub ? `<small>${esc(sub)}</small>` : ''}</dd></div>`;
|
||||
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 `<div class="acc${p === null ? ' none' : ''}" style="--p:${p ?? 0}"${tip ? ` title="${esc(tip)}"` : ''}><dt>${label}</dt><dd>${p === null ? '—' : `${h}/${u}<small>${p}%</small>`}</dd></div>`;
|
||||
};
|
||||
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: `<button type="button" class="rs-ms-btn" data-ms="open" aria-haspopup="dialog" aria-expanded="false" aria-controls="rs-ms"><span>STATISTIK<em> PERTANDINGAN</em></span><i aria-hidden="true">▸</i></button>`,
|
||||
panel: `<div class="rs-ms" id="rs-ms" hidden><section class="rs-ms-card" role="dialog" aria-label="Statistik pertandingan"><header class="rs-ms-head"><div><small>${who}</small><h3>STATISTIK PERTANDINGAN</h3></div>`
|
||||
+ `<button type="button" class="rs-ms-x" data-ms="close">TUTUP <b aria-hidden="true">✕</b></button></header><dl class="rs-ms-grid">${tiles.join('')}</dl></section></div>`
|
||||
};
|
||||
}, { 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) => `<div><dt>${label}</dt><dd>${value}</dd></div>`;
|
||||
return `<dl class="ms-life" aria-label="Statistik seumur hidup">${tile('Total damage', num(n('dmgDealt')))}${tile('Damage diterima', num(n('dmgTaken')))}${tile('Kombo terbaik', `${num(n('bestCombo'))}<small>pukulan</small>`)}`
|
||||
+ `${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')))}</dl>`
|
||||
+ `<p class="meta-hint">${n('statMatches') ? `Dihitung dari ${num(n('statMatches'))} pertandingan selesai sejak ada layar statistik` : 'Belum ada pertandingan yang tercatat'} (latihan tidak dihitung).</p>`;
|
||||
}, '');
|
||||
|
||||
window.MatchStats = { reset, tick, cast, hit, parry, block, finish, snapshot, commit, resultHtml, mount, lifetimeHtml, WINDOW, fmt: { num, clock, span } };
|
||||
})();
|
||||
7
vs.js
7
vs.js
|
|
@ -122,6 +122,8 @@
|
|||
const aw = r.award, lines = aw?.lines || [];
|
||||
const coins = aw ? `<section class="rs-coins" aria-label="Rincian koin"><h3>KOIN</h3><ul>${lines.map((l, i) => `<li style="--i:${i}"><span>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ''}</span><b>+${l.amount}</b></li>`).join('')}</ul><div class="rs-total" style="--i:${lines.length}"><span>TOTAL</span><b data-count="${aw.gain}">◆ +${aw.gain}</b></div></section>` : '';
|
||||
const stat = (k, v) => `<div><dt>${k}</dt><dd>${esc(v)}</dd></div>`;
|
||||
// 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 = `<div class="rs" data-tone="${tone}" style="--cw:${esc(Wf.color)};--cl:${esc(Lf.color)}">`
|
||||
+ (tone === 'win' ? `<div class="rs-confetti" aria-hidden="true">${confetti(Wf.color, 26)}</div>` : '')
|
||||
+ `<div class="rs-fighter win"><div class="rs-glow"></div><img class="rs-art" src="${esc(artOf(Wf))}" alt="${esc(Wf.name)}" draggable="false"><div class="rs-plate"><small>${boss === 'win' ? esc(window.Story?.title || 'PEMENANG') : 'PEMENANG'}</small><strong>${esc(Wf.name)}</strong></div></div>`
|
||||
+ `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}</div>`
|
||||
+ `<div class="rs-main"><div class="rs-head"><small class="rs-kicker">${esc(kicker)}</small><h1>${title}</h1>${scoreHtml}${ms.btn}</div>`
|
||||
+ `<blockquote class="rs-quote"><p>“${esc(q.win)}”</p><cite>${esc(Wf.name)}</cite></blockquote>`
|
||||
+ `<div class="rs-cols">${coins}<section class="rs-stats" aria-label="Statistik"><h3>STATISTIK</h3><dl>${stats}</dl></section></div>`
|
||||
+ `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div></div>`
|
||||
+ `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div>${ms.panel}</div>`
|
||||
+ `<div class="rs-fighter lose${Lf.shadow ? ' shadow' : ''}"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>“${esc(q.lose)}”</p></div></div></div>`;
|
||||
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 };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue