Gabungkan wf/t2-vsresult: layar VS, layar hasil baru, dan kutipan karakter (Tahap 2.2, 2.3, 2.6)

Konflik index.html: baris skrip akhir dan panel jeda; dipertahankan panel jeda dua kolom dari HUD dan ditambahkan content/quotes.js serta vs.js dari vsresult.
This commit is contained in:
rizkyaz0 2026-10-03 07:54:35 +07:00
commit 2531a58f65
9 changed files with 631 additions and 23 deletions

42
content/quotes.js Normal file
View File

@ -0,0 +1,42 @@
/* Kutipan karakter (RENCANA 2.2, 2.3, 2.6): window.QUOTES[id] = { intro, win, lose }.
Dipakai layar VS (intro) dan layar hasil (win untuk pemenang, lose untuk yang kalah). Dipilih oleh vs.js dengan pengacak kosmetik.
Pedoman isi: plesetan ringan terhadap citra publik yang sudah menjadi bahan lelucon umum. Tanpa tuduhan fakta atau skandal nyata,
tanpa unsur agama, tanpa merendahkan pribadi atau keluarga. Gufron hanya meme ringan (semut dan 'syududu'). */
window.QUOTES = window.QUOTES || {};
Object.assign(window.QUOTES, {
browo: {
intro: ['Topi sudah rapi, joget sudah siap. Ayo mulai!', 'Santai, ini cuma safari kecil.', 'Joget dulu, pukul belakangan.', 'Hutan ini luas, arenanya kecil. Pas!'],
win: ['Gemoy itu strategi, bukan kelemahan!', 'Satu pohon sawit lagi tertanam di arena.', 'Joget kemenangan, semuanya ikut!', 'Safari hari ini sukses besar.'],
lose: ['Tidak apa-apa, besok jogetnya lebih panjang.', 'Pohonnya belum cukup tinggi. Tunggu musim depan.', 'Ini baru pemanasan, belum sempat joget.', 'Topiku jatuh, konsentrasi buyar!']
},
gufron: {
intro: ['Semut-semut sudah baris. Syududu!', 'Satu, dua, tiga koloni siap berangkat.', 'Jangan injak semutku, nanti dia lapor.', 'Psst... kata koloni, kamu gampang ditebak.'],
win: ['Koloni menang, remah pun dibagi. Syududu!', 'Semut itu kecil, kemenangan itu besar.', 'Terima kasih, semut-semutku. Pesta remah!', 'Bahasa semut: "tuh kan, kami bilang juga apa".'],
lose: ['Semutku istirahat dulu, besok masuk lagi.', 'Koloninya salah belok. Syududu...', 'Ada yang bawa gula? Semutku butuh tenaga.', 'Aku kalah, tapi semutnya masih bangga.']
},
soloman: {
intro: ['Sederhana saja. Kita lihat nanti.', 'Saya blusukan ke arena dulu, ya.', 'Sarung sudah dikibas. Silakan.', 'Esemka sudah dipanasi. Siap jalan.'],
win: ['Ya, begitu saja. Sederhana, kan?', 'Konvoi lewat, lawan minggir. Maaf, ya.', 'Kerja, kerja, kerja. Selesai.', 'Hasilnya terlihat. Kalau belum, blusukan lagi.'],
lose: ['Hmm... kita evaluasi sambil jalan.', 'Esemkanya mogok sebentar. Biasa.', 'Tidak masalah, saya turun ke lapangan lagi.', 'Kalah itu biasa, yang penting tetap sederhana.']
},
anice: {
intro: ['Izinkan saya membuka dengan satu narasi singkat.', 'Gagasan sudah disiapkan. Pukulan menyusul.', 'Mimbar siap, lawan siap. Mari.', 'Dengarkan baik-baik, kalimat saya ada tiga bagian.'],
win: ['Seperti kalimat yang tersusun rapi, begitu pula kemenangan ini.', 'Narasi ditutup dengan titik. Titiknya berupa pukulan.', 'Sekian pidato singkat saya. Yang panjang menyusul.', 'Gagasan yang rapi memang sulit dikalahkan.'],
lose: ['Izinkan saya menyampaikan keberatan secara tertib.', 'Narasinya terpotong. Mohon waktu tambahan.', 'Pidatonya belum selesai, waktunya sudah habis.', 'Kalah satu pukulan, menang seribu kata.']
},
ethanolman: {
intro: ['Hahaha! Tangki penuh, semangat penuh!', 'Tenang, semuanya sudah dihitung. Mungkin.', 'Ayo, kita campur sedikit etanol di arena ini!', 'Putih bersih, hijau terisi. Berangkat!'],
win: ['Hahaha! Bahan bakar hijau selalu menang!', 'Isi penuh, tancap gas, selesai!', 'Tawa saya lebih kencang dari pukulan ini!', 'Ini namanya swasembada tawa.'],
lose: ['Hahaha... tangkinya bocor sedikit.', 'Tidak apa-apa, tertawa dulu biar pulih.', 'Etanolnya kurang. Kita tambah di ronde depan!', 'Ini bukan kalah, ini efisiensi bahan bakar.']
},
sonofsolo: {
intro: ['Tatapan tenang, labu siap. Silakan.', 'Hati-hati, ini bukan air mineral.', 'Santai dulu. Saya cuma mau bertanya sesuatu.', 'Asam sulfat sudah siap. Pertanyaan jebakan juga.'],
win: ['Jawabannya: asam sulfat. Terima kasih.', 'Tenang, tenang. Cuma sedikit percikan.', 'Kenapa berteriak? Karena memang perlu.', 'Pertanyaan terjawab. Skornya sudah jelas.'],
lose: ['Oke. Saya catat dulu, nanti saya tanya balik.', 'Labunya pecah. Saya butuh labu baru.', 'Ini belum selesai. Saya masih punya satu pertanyaan.', 'Tatapan tetap tenang, hasilnya yang goyang.']
},
megachan: {
intro: ['Banteng sudah dilepas. Minggir!', 'Merah itu berani. Siapa mau coba?', 'Dengarkan orasi saya dulu, baru kita mulai!', 'Kepalan ini sudah menunggu dari tadi.'],
win: ['Banteng tidak pernah mundur. Catat itu!', 'Merah menyala, lawan tersapu!', 'Sudah dengar orasi saya? Sekarang lihat hasilnya.', 'Kepalan ini lebih keras dari pidato saya.'],
lose: ['Banteng istirahat sebentar. Jangan senang dulu!', 'Ini bukan akhir. Orasinya baru pemanasan.', 'Merah tidak pernah padam!', 'Kalah satu ronde itu biasa. Banteng akan kembali.']
}
});

32
game.js
View File

@ -32,6 +32,8 @@
const Rules = window.MatchRules, Feel = window.Feel || feelFallback(); const Rules = window.MatchRules, Feel = window.Feel || feelFallback();
let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED; let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED;
let match = Rules?.create('training') || null, roundAnnouncer = null; let match = Rules?.create('training') || null, roundAnnouncer = null;
// Layar VS (vs.js): selama vsHold urutan ronde ditahan sebelum cue 'Round 1' (tidak ada yang berjalan di match.js); matchBest = kombo terbaik di seluruh ronde.
let vsHold = false, vsToken = 0, matchBest = 0;
const roundCues = []; const roundCues = [];
let ready = false, paused = false, settingsOpen = false, hitboxes = false, muted = false, volume = .45; let ready = false, paused = false, settingsOpen = false, hitboxes = false, muted = false, volume = .45;
let scale = 1, offsetX = 0, offsetY = 0, viewW = W, viewH = H, pixelRatio = 1; let scale = 1, offsetX = 0, offsetY = 0, viewW = W, viewH = H, pixelRatio = 1;
@ -182,6 +184,7 @@
} }
function setMenuOpen(value) { function setMenuOpen(value) {
menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;Feel.reset();setPaused(false);} menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;Feel.reset();setPaused(false);}
if(menuOpen)vsAbort();
document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen;
window.FrontEnd?.syncBackground(); window.FrontEnd?.syncBackground();
} }
@ -192,22 +195,30 @@
if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false; if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false;
if(!images['stage-'+stage])return false; if(!images['stage-'+stage])return false;
selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY; selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;
aiEnabled=mode==='versus';match=Rules.create(mode);roundCues.length=0;setMenuOpen(false);resetCombat();warmCutins(); aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins();
$('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus'); $('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus');
if(mode==='versus'){announceTimer=0;emitRoundCue('round',1);}else announce('TRAINING READY',1.3); if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
return true; return true;
} }
// Layar VS (opsional: options.vs = true | {label,p2}; hanya mode versus, tidak ada di latihan). Mengembalikan true bila layar tampil;
// cue 'Round 1' baru keluar saat layar selesai/dilewati, sisanya (intro, FIGHT, announcer) berjalan persis seperti tanpa layar VS.
function vsStart(opt,info){
const tok=++vsToken;vsHold=true;
const shown=window.VS?.show({...info,...(typeof opt==='object'?opt:{})},()=>{if(tok!==vsToken||!vsHold)return;vsHold=false;announceTimer=0;emitRoundCue('round',1);});
if(!shown)vsHold=false;return !!shown;
}
function vsAbort(){vsToken++;vsHold=false;window.VS?.cancel?.();}
function emitRoundCue(cue,round) { function emitRoundCue(cue,round) {
const event={cue,round,text:cue==='round'?`Round ${round}`:'Fight!'};roundCues.push(event);if(roundCues.length>12)roundCues.shift(); const event={cue,round,text:cue==='round'?`Round ${round}`:'Fight!'};roundCues.push(event);if(roundCues.length>12)roundCues.shift();
if(audio)menuSound(cue);if(roundAnnouncer)try{roundAnnouncer(event);}catch(_){} if(audio)menuSound(cue);if(roundAnnouncer)try{roundAnnouncer(event);}catch(_){}
systemAnnouncer?.play(cue==='round'?`round_${round}`:'fight',{replace:cue==='round'}); systemAnnouncer?.play(cue==='round'?`round_${round}`:'fight',{replace:cue==='round'});
} }
function advanceMatch(dt) { function advanceMatch(dt) {
if(!Rules||!match||match.mode!=='versus')return; if(!Rules||!match||match.mode!=='versus'||vsHold)return;
for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) { for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) {
if(event.type==='cue')emitRoundCue(event.cue,event.round); if(event.type==='cue')emitRoundCue(event.cue,event.round);
if(event.type==='round-end') { if(event.type==='round-end') {
clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0; matchBest=Math.max(matchBest,bestCombo);clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0;
if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;} if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;}
if(!event.timeout&&event.winner!=='draw'||event.doubleKO)startKoFx(event.doubleKO?'both':event.winner==='player'?'dummy':'hero',!!match.winner); if(!event.timeout&&event.winner!=='draw'||event.doubleKO)startKoFx(event.doubleKO?'both':event.winner==='player'?'dummy':'hero',!!match.winner);
match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy'); match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy');
@ -218,9 +229,12 @@
systemAnnouncer?.play([call,outcome]); systemAnnouncer?.play([call,outcome]);
} }
if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);} if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);}
let metaHandled=false; if(event.type==='complete'){
if(event.type==='complete'){metaHandled=!!window.Meta?.award?.({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,level:difficulty,bestCombo,perfect:match.winner==='player'&&match.enemyWins===0});} // bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil.
if(event.type==='complete'&&!metaHandled)window.FrontEnd?.showResult({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,mode:'versus'}); 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};
const metaHandled=!!window.Meta?.award?.(info);
if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null});
}
} }
} }
function updateMatchHud() { function updateMatchHud() {
@ -230,7 +244,7 @@
$('.match-center').setAttribute('aria-label',versus?'Pertandingan, menang dua ronde dari maksimal tiga':'Mode latihan tanpa batas waktu'); $('.match-center').setAttribute('aria-label',versus?'Pertandingan, menang dua ronde dari maksimal tiga':'Mode latihan tanpa batas waktu');
$('#match-score').textContent=versus?`${match.playerWins} — ${match.enemyWins}`:'VS';$('#stage-name').textContent=s.short;$('#stage-subtitle').textContent=s.subtitle; $('#match-score').textContent=versus?`${match.playerWins} — ${match.enemyWins}`:'VS';$('#stage-name').textContent=s.short;$('#stage-subtitle').textContent=s.subtitle;
$('#round-wins-player').textContent=versus?'◆'.repeat(match.playerWins)+'◇'.repeat(2-match.playerWins):'';$('#round-wins-enemy').textContent=versus?'◆'.repeat(match.enemyWins)+'◇'.repeat(2-match.enemyWins):''; $('#round-wins-player').textContent=versus?'◆'.repeat(match.playerWins)+'◇'.repeat(2-match.playerWins):'';$('#round-wins-enemy').textContent=versus?'◆'.repeat(match.enemyWins)+'◇'.repeat(2-match.enemyWins):'';
const card=$('#round-call'),show=!menuOpen&&versus&&['intro','ko'].includes(match.phase); const card=$('#round-call'),show=!menuOpen&&versus&&!vsHold&&['intro','ko'].includes(match.phase);
card.hidden=!show;card.dataset.phase=match?.phase||'fight'; card.hidden=!show;card.dataset.phase=match?.phase||'fight';
$('#round-call-title').textContent=!show?'':match.phase==='intro'?(match.fightCue?'FIGHT':`ROUND ${match.round}`):match.endText||'K.O.'; $('#round-call-title').textContent=!show?'':match.phase==='intro'?(match.fightCue?'FIGHT':`ROUND ${match.round}`):match.endText||'K.O.';
$('#round-call-detail').textContent=match?.phase==='ko'?(match.lastWinner==='draw'?'DRAW · ROUND REPLAY':(match.lastWinner==='player'?selectedCharacter:opponentCharacter).toUpperCase()+' WINS THE ROUND'):'FIRST TO TWO WINS'; $('#round-call-detail').textContent=match?.phase==='ko'?(match.lastWinner==='draw'?'DRAW · ROUND REPLAY':(match.lastWinner==='player'?selectedCharacter:opponentCharacter).toUpperCase()+' WINS THE ROUND'):'FIRST TO TWO WINS';
@ -2871,6 +2885,8 @@
}; };
// Pemeriksaan efek jurus untuk uji headless (tests/t1-fx.mjs). // Pemeriksaan efek jurus untuk uji headless (tests/t1-fx.mjs).
window.__game.fx = { snapshot: () => ({ ghosts: cfx.ghosts.length, hazards: cfx.hazards.filter(h => h.fire).length, dots: cfx.dots.length, later: cfx.later.length, particles: particles.length, effects: effects.map(e => e.type), tele: { hero: fxTeleP(hero), dummy: fxTeleP(dummy) }, projectiles: projectiles.map(p => ({ fx: p.fx, x: Math.round(p.x), y: Math.round(p.y), age: +(p.age || 0).toFixed(3), damage: p.damage, wait: p.wait || 0, orbit: !!(p.sw && !p.sw.go) })) }) }; window.__game.fx = { snapshot: () => ({ ghosts: cfx.ghosts.length, hazards: cfx.hazards.filter(h => h.fire).length, dots: cfx.dots.length, later: cfx.later.length, particles: particles.length, effects: effects.map(e => e.type), tele: { hero: fxTeleP(hero), dummy: fxTeleP(dummy) }, projectiles: projectiles.map(p => ({ fx: p.fx, x: Math.round(p.x), y: Math.round(p.y), age: +(p.age || 0).toFixed(3), damage: p.damage, wait: p.wait || 0, orbit: !!(p.sw && !p.sw.go) })) }) };
// Uji headless layar VS (tests/t2-vsresult.mjs): vsActive = urutan ronde sedang menunggu layar VS; vsSkip melewatinya.
Object.assign(window.__game, { vsActive: () => vsHold, vsSkip: () => !!window.VS?.force?.() });
// Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka. // Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka.
Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) }); Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) });
if(F)reset(); if(F)reset();

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="id"> <html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="stylesheet" href="feel.css"></head> <link rel="stylesheet" href="feel.css"><link rel="stylesheet" href="vs.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span></div></div><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi"> <body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span></div></div><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div> <canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header"> <header class="combat-header">
@ -24,6 +24,6 @@
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><span id="load-message">Menyiapkan arena…</span></div> <div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Video loop pada main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; 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="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Video loop pada main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; 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> <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>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html> </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/quotes.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="menu.js"></script><script src="meta.js"></script></body></html>

View File

@ -73,6 +73,9 @@
const st=MatchRules.stages[stage];root.innerHTML=`<div class="arena-screen-bg" style="background-image:url('${st.image}')"></div>${navHeader('SET THE STAGE',mode==='versus'?'VERSUS COMPUTER / MATCH SETUP':'TRAINING / ARENA SETUP')}<div class="arena-content"><div class="versus-strip"><div><img class="portrait-image" data-portrait-side="player" src="${fighters[player].portrait}" alt=""><span>P1<strong>${fighters[player].name}</strong></span></div><b>VS</b><div><span>CPU<strong>${fighters[enemy].name}</strong></span><img class="portrait-image" data-portrait-side="enemy" src="${fighters[enemy].portrait}" alt=""></div></div><div class="arena-window"><span class="arena-number">${String(Object.keys(MatchRules.stages).indexOf(stage)+1).padStart(2,'0')} / ${String(Object.keys(MatchRules.stages).length).padStart(2,'0')}</span><div><small>${st.tag}</small><h2>${st.name}</h2></div><span class="arena-day">✦ ${st.time||'SUNNY DAYLIGHT'}</span></div><div class="arena-tiles" role="group" aria-label="Pilih arena">${Object.entries(MatchRules.stages).map(([id,a])=>`<button class="arena-tile ${id===stage?'selected':''}" data-stage="${id}" aria-pressed="${id===stage}"><img src="${a.image}" alt=""><span>${a.name}</span></button>`).join('')}</div><div class="match-options"><div class="difficulty-area"><span class="eyebrow">${mode==='versus'?'CPU DIFFICULTY':'PRACTICE MODE'}</span>${mode==='versus'?`<div class="difficulty-buttons" role="group" aria-label="Tingkat kesulitan">${Object.entries(MatchRules.difficulties).map(([id,d],i)=>`<button data-level="${id}" class="${id===level?'selected':''}" aria-pressed="${id===level}"><i>${'▰'.repeat(i+1)}</i>${d.label}</button>`).join('')}</div>`:'<p class="training-note">Unlimited time · Passive rival · Reset anytime</p>'}</div><div class="match-rules">${mode==='versus'?'<b>FIRST TO 2 WINS</b><span>BEST OF 3 ROUNDS · 90 SECONDS</span>':'<b>MAKE EVERY HIT COUNT</b><span>COMBO TRACKER · FULL MOVE SET</span>'}</div></div><button class="menu-primary start-battle" data-cmd="start" ${game.snapshot().ready?'':'disabled'}>${game.snapshot().ready?(mode==='versus'?'LET’S FIGHT':'ENTER TRAINING'):'PREPARING ARENA…'} <b>↗</b></button><p id="setup-error" role="status"></p></div>${footer()}`; const st=MatchRules.stages[stage];root.innerHTML=`<div class="arena-screen-bg" style="background-image:url('${st.image}')"></div>${navHeader('SET THE STAGE',mode==='versus'?'VERSUS COMPUTER / MATCH SETUP':'TRAINING / ARENA SETUP')}<div class="arena-content"><div class="versus-strip"><div><img class="portrait-image" data-portrait-side="player" src="${fighters[player].portrait}" alt=""><span>P1<strong>${fighters[player].name}</strong></span></div><b>VS</b><div><span>CPU<strong>${fighters[enemy].name}</strong></span><img class="portrait-image" data-portrait-side="enemy" src="${fighters[enemy].portrait}" alt=""></div></div><div class="arena-window"><span class="arena-number">${String(Object.keys(MatchRules.stages).indexOf(stage)+1).padStart(2,'0')} / ${String(Object.keys(MatchRules.stages).length).padStart(2,'0')}</span><div><small>${st.tag}</small><h2>${st.name}</h2></div><span class="arena-day">✦ ${st.time||'SUNNY DAYLIGHT'}</span></div><div class="arena-tiles" role="group" aria-label="Pilih arena">${Object.entries(MatchRules.stages).map(([id,a])=>`<button class="arena-tile ${id===stage?'selected':''}" data-stage="${id}" aria-pressed="${id===stage}"><img src="${a.image}" alt=""><span>${a.name}</span></button>`).join('')}</div><div class="match-options"><div class="difficulty-area"><span class="eyebrow">${mode==='versus'?'CPU DIFFICULTY':'PRACTICE MODE'}</span>${mode==='versus'?`<div class="difficulty-buttons" role="group" aria-label="Tingkat kesulitan">${Object.entries(MatchRules.difficulties).map(([id,d],i)=>`<button data-level="${id}" class="${id===level?'selected':''}" aria-pressed="${id===level}"><i>${'▰'.repeat(i+1)}</i>${d.label}</button>`).join('')}</div>`:'<p class="training-note">Unlimited time · Passive rival · Reset anytime</p>'}</div><div class="match-rules">${mode==='versus'?'<b>FIRST TO 2 WINS</b><span>BEST OF 3 ROUNDS · 90 SECONDS</span>':'<b>MAKE EVERY HIT COUNT</b><span>COMBO TRACKER · FULL MOVE SET</span>'}</div></div><button class="menu-primary start-battle" data-cmd="start" ${game.snapshot().ready?'':'disabled'}>${game.snapshot().ready?(mode==='versus'?'LET’S FIGHT':'ENTER TRAINING'):'PREPARING ARENA…'} <b>↗</b></button><p id="setup-error" role="status"></p></div>${footer()}`;
} else if(screen==='about'){ } else if(screen==='about'){
root.innerHTML=`<div class="home-art dim"></div>${navHeader('TENTANG GAME','KONOHA FIGHTING')}<article class="about-panel"><span class="eyebrow">GAME PARODI</span><h2>Tujuh petarung.<br><em>Satu arena.</em></h2><p>Konoha Fighting adalah game fighting 2D di browser dengan tokoh publik versi parodi dan plesetan nama. Semua karakter adalah karikatur untuk hiburan, bukan pernyataan tentang orang sebenarnya.</p><div class="about-fighters"><p><b>MODE</b><span>VS Komputer, Arcade, Survival, Dua Pemain, dan Latihan.</span></p><p><b>MEKANIK</b><span>Kombo, gerakan perintah, blok, parry, lempar, jurus toko, skin, dan pencapaian.</span></p></div><div class="about-stats"><span><b>07</b>PETARUNG</span><span><b>05</b>SLOT MISTERI</span><span><b>∞</b>RIVALRIES</span></div><small>Berbasis AETHER CLASH oleh Bang Tutorial (kode MIT, aset CC BY-NC) · Typography · Rajdhani<br>Hanya untuk penggunaan non-komersial</small><button class="menu-primary" data-cmd="home">BACK TO MAIN MENU <b>↗</b></button></article>`; root.innerHTML=`<div class="home-art dim"></div>${navHeader('TENTANG GAME','KONOHA FIGHTING')}<article class="about-panel"><span class="eyebrow">GAME PARODI</span><h2>Tujuh petarung.<br><em>Satu arena.</em></h2><p>Konoha Fighting adalah game fighting 2D di browser dengan tokoh publik versi parodi dan plesetan nama. Semua karakter adalah karikatur untuk hiburan, bukan pernyataan tentang orang sebenarnya.</p><div class="about-fighters"><p><b>MODE</b><span>VS Komputer, Arcade, Survival, Dua Pemain, dan Latihan.</span></p><p><b>MEKANIK</b><span>Kombo, gerakan perintah, blok, parry, lempar, jurus toko, skin, dan pencapaian.</span></p></div><div class="about-stats"><span><b>07</b>PETARUNG</span><span><b>05</b>SLOT MISTERI</span><span><b>∞</b>RIVALRIES</span></div><small>Berbasis AETHER CLASH oleh Bang Tutorial (kode MIT, aset CC BY-NC) · Typography · Rajdhani<br>Hanya untuk penggunaan non-komersial</small><button class="menu-primary" data-cmd="home">BACK TO MAIN MENU <b>↗</b></button></article>`;
} else if(screen==='result'&&window.ResultView){
// Layar hasil meriah (vs.js ResultView): rincian koin dari Meta.award, pemenang besar + kutipan, statistik, empat tombol.
const v=window.ResultView.view(result);root.innerHTML=`<div class="result-bg"></div>${navHeader(v.title,v.eyebrow)}${v.html}`;window.ResultView.mount(root);
} else if(screen==='result'){ } else if(screen==='result'){
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`; const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`;
} }
@ -93,7 +96,7 @@
lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000); lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000);
} }
function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();} function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();}
function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,vs:mode==='versus'?{p2}:false})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}}
// Opens the shop/locker/collection. If meta.js is missing (stale cached index.html) it is loaded on demand. // Opens the shop/locker/collection. If meta.js is missing (stale cached index.html) it is loaded on demand.
function openMeta(tab){ function openMeta(tab){
const go=()=>{try{window.Meta.open(tab);}catch(e){console.error(e);alert('Menu gagal dibuka: '+e.message);}}; const go=()=>{try{window.Meta.open(tab);}catch(e){console.error(e);alert('Menu gagal dibuka: '+e.message);}};
@ -110,8 +113,8 @@
if(cmd==='2p'){mode='versus';p2=true;runKind=null;screen='select';step=0;hover=player;render();} if(cmd==='2p'){mode='versus';p2=true;runKind=null;screen='select';step=0;hover=player;render();}
if(cmd==='shop'||cmd==='locker'||cmd==='book')openMeta(cmd);if(cmd==='settings')game.openSettings();if(cmd==='about'){screen='about';render();} if(cmd==='shop'||cmd==='locker'||cmd==='book')openMeta(cmd);if(cmd==='settings')game.openSettings();if(cmd==='about'){screen='about';render();}
if(cmd==='home'){game.stopAnnouncer();screen='home';render();}if(cmd==='start')start(); if(cmd==='home'){game.stopAnnouncer();screen='home';render();}if(cmd==='start')start();
if(cmd==='rematch'){({player,enemy,stage,level,mode}=result);start();} if(cmd==='rematch'){if(result.run&&window.Meta){window.Meta.startRun(result.run.kind,result.player);document.querySelector('#arena').focus();return;}({player,enemy,stage,level,mode}=result);p2=!!result.p2;start();}
if(cmd==='reselect'){screen='select';step=0;hover=player;render();} if(cmd==='reselect'){runKind=result?.run?result.run.kind:null;p2=!!result?.p2;screen='select';step=0;hover=player;render();}
} }
function bind(){ function bind(){
root.querySelectorAll('[data-cmd]').forEach(b=>{b.onclick=()=>command(b.dataset.cmd);b.onpointerenter=()=>sfx('move');b.onfocus=()=>sfx('move');}); root.querySelectorAll('[data-cmd]').forEach(b=>{b.onclick=()=>command(b.dataset.cmd);b.onpointerenter=()=>sfx('move');b.onfocus=()=>sfx('move');});

36
meta.js
View File

@ -103,19 +103,27 @@
chip.hidden = !m; chip.innerHTML = m ? `<kbd>K</kbd><span>${esc(m.name)}</span><b>${info.cd > 0 ? Math.ceil(info.cd) + 's' : 'SIAP'}</b>` : ''; chip.hidden = !m; chip.innerHTML = m ? `<kbd>K</kbd><span>${esc(m.name)}</span><b>${info.cd > 0 ? Math.ceil(info.cd) + 's' : 'SIAP'}</b>` : '';
chip.classList.toggle('cool', info.cd > 0); chip.classList.toggle('cool', info.cd > 0);
} }
// Rincian koin per baris disimpan di lastAward (dibaca layar hasil); jumlah koin tidak berubah.
let lastAward = null, lastMatch = null;
function award(r) { function award(r) {
const won = r.winner === 'player', parts = []; const won = r.winner === 'player', parts = [], lines = [];
let gain = won ? 120 : 30; parts.push(won ? 'Menang +120' : 'Hadiah main +30'); const add = (key, label, amount, partLabel, note) => { lines.push({ key, label, amount, ...(note ? { note } : {}) }); parts.push(partLabel + ' +' + amount); };
let gain = won ? 120 : 30; add(won ? 'win' : 'play', won ? 'Menang' : 'Hadiah main', gain, won ? 'Menang' : 'Hadiah main');
if (won) { if (won) {
if (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; parts.push('Tingkat +' + b); } if (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; add('level', 'Tingkat kesulitan', b, 'Tingkat', ({ medium: 'Sedang', hard: 'Sulit', excellent: 'Sangat sulit' })[r.level]); }
if (r.perfect) { gain += 60; parts.push('Bersih +60'); } if (r.perfect) { gain += 60; add('perfect', 'Bersih', 60, 'Bersih', 'Tanpa kehilangan ronde'); }
} }
const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; parts.push('Kombo +' + cb); } 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++; 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); save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0);
if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10'); if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10');
addCoins(gain); toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin'); const prev = run ? run.earned || 0 : 0; if (run) run.earned = prev + gain;
return run ? advanceRun(won) : false; lastAward = { won, gain, lines, level: r.level || null, prev }; lastMatch = { ...r };
addCoins(gain);
const handled = run ? advanceRun(won) : false;
// Layar hasil (pertandingan biasa atau akhir Arcade/Survival) sudah memuat rincian yang sama; toast hanya untuk lawan di tengah rangkaian.
if (run || !(window.ResultView && window.FrontEnd?.showResult)) toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin');
return handled;
} }
// ---- Arcade (fight everyone once, one life) and Survival (endless, harder each win) ---- // ---- Arcade (fight everyone once, one life) and Survival (endless, harder each win) ----
let run = null; let run = null;
@ -131,7 +139,7 @@
const total = run.kind === 'arcade' ? '/' + run.queue.length : ''; const total = run.kind === 'arcade' ? '/' + run.queue.length : '';
toast((run.kind === 'arcade' ? 'ARCADE' : 'SURVIVAL') + ' · LAWAN ' + (run.wins + 1) + total + ': ' + fname(enemy) + ' (' + level + ')'); toast((run.kind === 'arcade' ? 'ARCADE' : 'SURVIVAL') + ' · LAWAN ' + (run.wins + 1) + total + ': ' + fname(enemy) + ' (' + level + ')');
let ok = false; let ok = false;
for (const st of shuffle(stages.slice()).concat('bellora')) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus' }); if (ok) break; } for (const st of shuffle(stages.slice()).concat('bellora')) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus', vs: { label: (run.kind === 'arcade' ? 'ARCADE · LAWAN ' + (run.wins + 1) + ' / ' + run.queue.length : 'SURVIVAL · LAWAN ' + (run.wins + 1)) } }); if (ok) break; }
if (!ok) { toast('Aset belum siap, coba lagi.'); run = null; window.FrontEnd?.home(); } if (!ok) { toast('Aset belum siap, coba lagi.'); run = null; window.FrontEnd?.home(); }
} }
function advanceRun(won) { function advanceRun(won) {
@ -147,6 +155,16 @@
const r = run; run = null; let bonus = 0; const r = run; run = null; let bonus = 0;
if (r.kind === 'arcade' && cleared) { bonus = 500; unlock('arcade_clear'); } else if (r.kind === 'survival') bonus = r.wins * 30; if (r.kind === 'arcade' && cleared) { bonus = 500; unlock('arcade_clear'); } else if (r.kind === 'survival') bonus = r.wins * 30;
if (bonus) addCoins(bonus); if (bonus) addCoins(bonus);
// Layar akhir sama gayanya dengan layar hasil pertandingan (menu.js 'result' lewat ResultView); layar lama tetap jadi cadangan.
if (lastMatch && window.ResultView && window.FrontEnd?.showResult) {
// Satu baris per kelompok supaya muat: lawan sebelumnya, pertandingan terakhir, bonus rangkaian.
const base = lastAward || { gain: 0, lines: [], prev: 0 }, before = cleared ? r.wins - 1 : r.wins, lines = [];
if (base.prev > 0) lines.push({ key: 'prev', label: 'Lawan sebelumnya', amount: base.prev, note: before + ' pertandingan' });
lines.push({ key: 'last', label: 'Pertandingan terakhir', amount: base.gain, note: cleared ? 'Menang' : 'Kalah' });
if (bonus) lines.push({ key: 'run', label: r.kind === 'arcade' ? 'Bonus Arcade tamat' : 'Bonus rangkaian menang', amount: bonus, note: r.kind === 'arcade' ? '' : r.wins + ' x 30' });
window.FrontEnd.showResult({ ...lastMatch, mode: 'versus', award: { ...base, lines, gain: base.prev + base.gain + bonus }, run: { kind: r.kind, wins: r.wins, total: r.queue.length, cleared, best: save.best.survival, bonus } });
return;
}
if (!root) build(); root.hidden = false; if (!root) build(); root.hidden = false;
const title = r.kind === 'arcade' ? (cleared ? 'ARCADE TAMAT!' : 'ARCADE BERAKHIR') : 'SURVIVAL BERAKHIR'; const title = r.kind === 'arcade' ? (cleared ? 'ARCADE TAMAT!' : 'ARCADE BERAKHIR') : 'SURVIVAL BERAKHIR';
const sub = r.kind === 'arcade' ? (cleared ? 'Semua lawan dikalahkan.' : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival; const sub = r.kind === 'arcade' ? (cleared ? 'Semua lawan dikalahkan.' : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival;
@ -277,5 +295,5 @@
} }
mountCheatRow(); mountCheatRow();
window.Meta = { redeem, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } }; window.Meta = { get lastAward() { return lastAward; }, redeem, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
})(); })();

278
tests/t2-vsresult.mjs Normal file
View File

@ -0,0 +1,278 @@
// Tahap 2.2, 2.3, 2.6: layar VS, layar hasil, kutipan.
// node tests/harness.mjs tests/t2-vsresult.mjs
// VSR_SHOTS=folder simpan screenshot (default: folder sementara sistem); VSR_ONLY=regex hanya bagian yang namanya cocok (quotes|vs|skip|abort|result|run|ui|phone)
// Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const options = { viewport: { width: 1280, height: 520 } };
export default async ({ page, game, errors, characters }) => {
const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, ...(pass ? {} : { info }) });
const only = process.env.VSR_ONLY ? new RegExp(process.env.VSR_ONLY) : null, want = n => !only || only.test(n);
const dir = process.env.VSR_SHOTS || path.join(os.tmpdir(), 't2-vsresult-shots'); fs.mkdirSync(dir, { recursive: true });
const shot = async name => { await page.screenshot({ path: path.join(dir, name + '.png') }); };
const wait = ms => page.waitForTimeout(ms);
// Menyelesaikan pertandingan: sisi 'player' atau 'enemy' menang dua ronde (tanpa uji gerak).
const finish = (side, rounds = 2) => game(({ side, rounds }) => {
const g = window.__game; g.manual = true; let won = 0;
for (let i = 0; i < 900 && !g.snapshot().menuOpen; i++) {
const s = g.snapshot(); if (s.match?.phase === 'fight' && s.match.round >= 1 && won < 99) { if (side === 'player') g.dummy.damage = 1e4; else g.hero.hp = 0; }
g.advance(.1);
}
return { menuOpen: g.snapshot().menuOpen };
}, { side, rounds });
const startVS = (p, e, extra = {}) => game(({ p, e, extra }) => { const g = window.__game; g.manual = false; return g.startMatch({ player: p, enemy: e, stage: extra.stage || 'bellora', level: extra.level || 'medium', mode: extra.mode || 'versus', vs: extra.vs ?? true }); }, { p, e, extra });
const vsState = () => game(() => { const g = window.__game, el = document.querySelector('#vs-screen'); const s = g.snapshot(); return { el: !!el, out: el?.classList.contains('out') || false, active: g.vsActive(), phase: s.match?.phase, phaseTime: +(s.match?.phaseTime || 0).toFixed(3), cues: s.roundCues.map(c => c.cue + c.round), paused: s.paused, menuOpen: s.menuOpen, names: [...document.querySelectorAll('#vs-screen .vs-name')].map(n => n.textContent), quotes: [...document.querySelectorAll('#vs-screen .vs-quote')].map(n => n.textContent), roundCard: !document.querySelector('#round-call').hidden }; });
// ---------- 2.6 data kutipan ----------
if (want('quotes')) {
const q = await game(() => JSON.parse(JSON.stringify(window.QUOTES || {})));
const bad = /allah|tuhan|bismillah|\bamin\b|islam|kristen|hindu|budha|gereja|masjid|ustaz|ustadz|kiai|alhamdulillah|insyaallah|korupsi|tersangka|penjara|ijazah|dinasti|nepotisme|\bsuap\b|skandal|\bayah\b|\bbapak\b|\bibu\b|keluarga|\banak\b|istri|suami/i;
for (const id of characters) {
const d = q[id];
ok(`kutipan ${id}: ada intro/win/lose`, d && ['intro', 'win', 'lose'].every(k => Array.isArray(d[k])), d);
for (const k of ['intro', 'win', 'lose']) {
const list = d?.[k] || [];
ok(`kutipan ${id}.${k}: 3-4 kalimat pendek, unik, tanpa kata terlarang`, list.length >= 3 && list.length <= 4 && new Set(list).size === list.length && list.every(t => typeof t === 'string' && t.length >= 8 && t.length <= 70 && !bad.test(t)), list);
}
}
// Gufron hanya meme ringan: semut / syududu / koloni
const gufron = Object.values(q.gufron || {}).flat();
ok('kutipan gufron: hanya meme ringan semut/syududu', gufron.length > 0 && gufron.every(t => /semut|syududu|koloni|remah|gula/i.test(t)), gufron);
// Pemilih: tidak memakai random() mesin (seed mesin tidak berubah) dan tidak mengulang kutipan yang sama berturut-turut
const pick = await game(() => { const g = window.__game, before = g.snapshot().stepCount; const seen = []; for (let i = 0; i < 40; i++) seen.push(VS.quote('browo', 'win')); return { seen, repeats: seen.filter((t, i) => i && t === seen[i - 1]).length, distinct: new Set(seen).size, stepCount: g.snapshot().stepCount - before }; });
ok('pemilih kutipan: variasi dan tanpa pengulangan berturut-turut', pick.repeats === 0 && pick.distinct >= 3, pick);
const unk = await game(() => VS.quote('tidakada', 'intro'));
ok('pemilih kutipan: karakter tak dikenal memakai kutipan umum', typeof unk === 'string' && unk.length > 3, unk);
}
// ---------- 2.2 layar VS ----------
if (want('vs')) {
// tanpa opsi vs: perilaku lama (cue langsung, tanpa overlay)
await startVS('browo', 'megachan', { vs: false });
let s = await vsState();
ok('tanpa vs: tidak ada overlay dan cue Round 1 langsung', !s.el && !s.active && s.cues.join() === 'round1', s);
// latihan: tanpa layar VS walau diminta
await startVS('browo', 'megachan', { mode: 'training', vs: true });
s = await vsState();
ok('latihan: tidak ada layar VS', !s.el && !s.active, s);
const pairs = [['browo', 'megachan'], ['gufron', 'soloman'], ['sonofsolo', 'anice'], ['ethanolman', 'browo']];
for (const [p, e] of pairs) {
await game(() => { window.VS.seed(7); });
const r = await startVS(p, e, { stage: 'harbor', level: 'hard' });
s = await vsState();
ok(`vs ${p} lawan ${e}: layar tampil, ronde ditahan`, r && s.el && s.active && s.phase === 'intro' && s.phaseTime === 0 && s.cues.length === 0 && !s.roundCard, s);
const names = await game(({ p, e }) => ({ p: window.FIGHTERS?.[p]?.name || 'BRO WO', e: window.FIGHTERS?.[e]?.name || 'BRO WO', q: window.QUOTES }), { p, e });
ok(`vs ${p} lawan ${e}: nama dan kutipan pembuka dari QUOTES`, s.names[0] === names.p && s.names[1] === names.e && names.q[p].intro.some(t => s.quotes[0].includes(t)) && names.q[e].intro.some(t => s.quotes[1].includes(t)), { s, names: [names.p, names.e] });
const adv = await game(() => { const g = window.__game; g.manual = true; g.advance(1.5); return { phaseTime: g.snapshot().match.phaseTime, cues: g.snapshot().roundCues.length }; });
ok(`vs ${p} lawan ${e}: simulasi tidak berjalan selama layar VS`, adv.phaseTime === 0 && adv.cues === 0, adv);
if (p === 'browo') { await game(() => { window.__game.manual = false; }); await wait(300); await shot('vs-1280x520-a-masuk'); await wait(700); await shot('vs-1280x520-b-slam'); await wait(600); await shot('vs-1280x520-c-akhir'); }
await game(() => { window.__game.manual = false; });
}
// waktu: kira-kira 2,2 detik lalu cue Round 1 muncul tepat sekali dan urutan intro normal
const t0 = Date.now(); await startVS('gufron', 'anice');
let first = null; while (Date.now() - t0 < 4000) { s = await vsState(); if (!s.active) { first = Date.now() - t0; break; } await wait(40); }
ok('vs: selesai sendiri dalam 1,8 - 2,6 detik', first !== null && first > 1800 && first < 2600, { first });
s = await vsState();
ok('vs: cue Round 1 keluar tepat sekali setelah layar selesai', s.cues.join() === 'round1', s);
await wait(500); s = await vsState();
ok('vs: overlay hilang dari DOM sesudah fade', !s.el, s);
// urutan intro sesudah VS sama seperti tanpa VS: round -> fight -> fase fight
const seq = await game(() => { const g = window.__game; g.manual = true; const out = []; for (let i = 0; i < 60; i++) { g.advance(.1); const s = g.snapshot(); const k = s.match.phase + ':' + s.roundCues.map(c => c.cue + c.round).join('+'); if (out[out.length - 1] !== k) out.push(k); } return out; });
ok('vs: urutan cue ronde dan fase intro utuh (round1 -> fight -> fight phase)', seq[0].startsWith('intro:round1') && seq.some(x => x === 'intro:round1+fight1') && seq[seq.length - 1] === 'fight:round1+fight1', seq);
await game(() => { window.__game.manual = false; });
}
// gerak dikurangi: tidak ada animasi, tetapi layar tetap selesai dan ronde jalan
if (want('vs')) {
await page.emulateMedia({ reducedMotion: 'reduce' });
await startVS('anice', 'soloman'); await wait(150);
const calm = await game(() => { const art = document.querySelector('#vs-screen .vs-art'), q = document.querySelector('#vs-screen .vs-quote'); return { anim: getComputedStyle(art).animationName, qop: getComputedStyle(q).opacity, active: window.__game.vsActive() }; });
ok('gerak dikurangi: tanpa animasi masuk, kutipan langsung terlihat', calm.anim === 'none' && calm.qop === '1' && calm.active, calm);
await wait(2200); const after = await vsState();
ok('gerak dikurangi: layar tetap selesai dan cue Round 1 keluar', !after.active && after.cues.join() === 'round1', after);
await page.emulateMedia({ reducedMotion: 'no-preference' });
}
// ---------- melewati / membatalkan ----------
if (want('skip')) {
for (const how of ['Space', 'Enter', 'click', 'tidak-awal']) {
await startVS('browo', 'anice'); await wait(how === 'tidak-awal' ? 60 : 450);
if (how === 'Space') await page.keyboard.press(' '); else if (how === 'Enter') await page.keyboard.press('Enter'); else if (how === 'click') await page.mouse.click(640, 260); else { await page.keyboard.press(' '); await page.mouse.click(640, 260); }
await wait(30); let s = await vsState();
if (how === 'tidak-awal') { ok('skip: tidak bisa dilewati dalam 0,38 detik pertama (cegah klik ganda dari menu)', s.active && s.cues.length === 0, s); await wait(450); await page.keyboard.press(' '); await wait(30); s = await vsState(); }
ok(`skip ${how}: layar selesai dan cue Round 1 sekali`, !s.active && s.cues.join() === 'round1' && s.phase === 'intro', s);
await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(60); s = await vsState();
ok(`skip ${how}: menekan lagi tidak menggandakan cue`, s.cues.join() === 'round1', s);
await wait(350);
}
// Esc selama VS tidak membuka jeda; hanya melewati
await startVS('soloman', 'megachan'); await wait(450); await page.keyboard.press('Escape'); await wait(40);
let s = await vsState();
ok('Esc selama VS: melewati, tidak membuka jeda', !s.active && !s.paused && s.cues.join() === 'round1', s);
// tombol game tidak bocor ke bawah layar VS
await startVS('soloman', 'megachan'); await wait(100);
await page.keyboard.down('d'); await page.keyboard.press('i'); await page.keyboard.up('d');
const keys = await game(() => ({ x: Math.round(window.__game.hero.x), cd: window.__game.hero.cooldowns.skill1 }));
ok('tombol gerak/skill tidak bocor saat VS', keys.x === 350 && keys.cd === 0, keys);
await wait(2300);
}
if (want('abort')) {
// kembali ke menu saat VS: overlay hilang, tidak ada cue susulan
await startVS('browo', 'anice'); await wait(200);
await game(() => window.__game.setMenuOpen(true)); await wait(60);
let s = await vsState();
ok('abort: setMenuOpen(true) menutup layar VS tanpa cue', !s.el && !s.active && s.cues.length === 0, s);
await wait(2400); s = await vsState();
ok('abort: tidak ada cue susulan setelah layar dibatalkan', s.cues.length === 0, s);
// pertandingan baru saat VS masih jalan: tidak ada cue ganda dan hanya satu overlay
await startVS('browo', 'anice'); await wait(300); await startVS('gufron', 'megachan'); await wait(100);
s = await vsState(); const n = await game(() => document.querySelectorAll('#vs-screen').length);
ok('abort: pertandingan baru saat VS menggantikan layar lama', n === 1 && s.names[0] === 'GUFRON' && s.active, { s, n });
await wait(2300); s = await vsState();
ok('abort: hanya satu cue Round 1 untuk pertandingan terbaru', s.cues.join() === 'round1' && !s.el, s);
// announcer tidak ditahan: clip round_1 diminta setelah VS (bukan sebelum)
const ann = await game(async () => { const g = window.__game; g.manual = false; const log = []; g.onRoundCue(e => log.push(e.cue + e.round)); g.startMatch({ player: 'browo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus', vs: true }); const t = performance.now(); await new Promise(r => setTimeout(r, 900)); const early = log.length; await new Promise(r => setTimeout(r, 1700)); const late = log.slice(); g.onRoundCue(null); return { early, late, t: Math.round(performance.now() - t) }; });
ok('onRoundCue: tidak terpanggil selama VS, sekali sesudahnya', ann.early === 0 && ann.late.join() === 'round1', ann);
}
// ---------- 2.3 layar hasil ----------
if (want('result')) {
// jumlah koin tidak berubah: bandingkan dengan rumus lama
const legacy = (r) => { const won = r.winner === 'player'; let g = won ? 120 : 30; if (won) { if (r.level && r.level !== 'easy') g += { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; if (r.perfect) g += 60; } g += Math.min(60, (r.bestCombo || 0) * 4); return g; };
const cases = [];
for (const winner of ['player', 'enemy']) for (const level of ['easy', 'medium', 'hard', 'excellent']) for (const perfect of [false, true]) for (const bestCombo of [0, 3, 9, 20]) cases.push({ winner, level, perfect: perfect && winner === 'player', bestCombo, playerWins: 2, enemyWins: 0, player: 'browo', enemy: 'anice' });
const coin = await game(cases => { const M = window.Meta; M.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; const rows = []; for (const c of cases) { const before = M.coins; M.award(c); const a = M.lastAward; rows.push({ c, delta: M.coins - before, gain: a.gain, sum: a.lines.reduce((x, l) => x + l.amount, 0), keys: a.lines.map(l => l.key).join() }); } return rows; }, cases);
const wrong = coin.filter(r => !(r.delta === legacy(r.c) && r.gain === r.delta && r.sum === r.delta));
ok(`koin: ${coin.length} kombinasi sama dengan rumus lama dan rincian berjumlah total`, wrong.length === 0, wrong.slice(0, 3));
ok('koin: rincian memuat baris menang/hadiah main, tingkat, bersih, kombo sesuai syarat', coin.every(r => r.keys.split(',')[0] === (r.c.winner === 'player' ? 'win' : 'play') && (r.c.winner === 'player' && r.c.level !== 'easy') === r.keys.includes('level') && r.c.perfect === r.keys.includes('perfect') && (r.c.bestCombo > 0) === r.keys.includes('combo')), coin.filter(r => false));
// rincian terpenuh (menang bersih, tingkat, kombo) lewat data buatan: harus tetap muat di 520 px
await game(() => { const g = window.__game; g.setMenuOpen(true); FrontEnd.showResult({ winner: 'player', playerWins: 2, enemyWins: 0, player: 'sonofsolo', enemy: 'ethanolman', stage: 'elderwood', level: 'excellent', mode: 'versus', bestCombo: 9, matchBest: 14, perfect: true, award: { won: true, gain: 120 + 120 + 60 + 36, lines: [{ key: 'win', label: 'Menang', amount: 120 }, { key: 'level', label: 'Tingkat kesulitan', amount: 120, note: 'Sangat sulit' }, { key: 'perfect', label: 'Bersih', amount: 60, note: 'Tanpa kehilangan ronde' }, { key: 'combo', label: 'Bonus kombo', amount: 36, note: '9 pukulan, ronde terakhir' }] } }); });
await wait(2300); await shot('hasil-penuh-sonofsolo-ethanolman');
const full = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()), q = document.querySelector('.rs-stats dd'), m = document.querySelector('.rs-main').getBoundingClientRect(); return { bottom: Math.max(...b.map(x => x.bottom)), h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight, mainTop: m.top, mainBottom: m.bottom, rows: document.querySelectorAll('.rs-coins li').length, combo: q.textContent }; });
ok('hasil dengan empat baris koin dan kombo 14 muat di 520 px', full.bottom <= full.h - 8 && full.scroll <= 0 && full.rows === 4 && /14/.test(full.combo), full);
// kemenangan pemain (tanpa kalah ronde = bersih)
await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.__game.manual = false; });
for (const [p, e, side] of [['browo', 'megachan', 'player'], ['sonofsolo', 'ethanolman', 'enemy'], ['anice', 'gufron', 'player'], ['megachan', 'soloman', 'enemy']]) {
await startVS(p, e, { vs: false, level: 'hard', stage: 'moonrise' });
const before = await game(() => window.Meta.coins);
await finish(side);
await wait(120);
const r = await game(() => { const el = document.querySelector('#front-menu .rs'); const rows = [...document.querySelectorAll('.rs-coins li')].map(li => [li.querySelector('span').textContent, li.querySelector('b').textContent]); return { screen: document.querySelector('#front-menu').dataset.screen, tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, winName: el?.querySelector('.rs-fighter.win strong')?.textContent, loseName: el?.querySelector('.rs-fighter.lose .rs-plate small')?.textContent, winArt: el?.querySelector('.rs-fighter.win img')?.getAttribute('src'), quote: el?.querySelector('.rs-quote p')?.textContent, loseQuote: el?.querySelector('.rs-fighter.lose p')?.textContent, rows, total: el?.querySelector('.rs-total b')?.dataset.count, btn: [...document.querySelectorAll('.rs-actions [data-cmd]')].map(b => b.dataset.cmd + ':' + b.textContent.trim()), stats: [...document.querySelectorAll('.rs-stats dl div')].map(d => d.textContent), coins: window.Meta.coins, last: window.Meta.lastAward, bar: document.querySelector('#front-menu').scrollHeight, h: innerHeight }; });
const wn = side === 'player' ? p : e, ln = side === 'player' ? e : p;
const info = await game(({ wn, ln }) => ({ wn: window.FIGHTERS?.[wn]?.name || 'BRO WO', ln: window.FIGHTERS?.[ln]?.name || 'BRO WO', q: window.QUOTES }), { wn, ln });
ok(`hasil ${p} vs ${e} (${side} menang): layar baru tampil`, r.screen === 'result' && !!r.h1 && r.tone === (side === 'player' ? 'win' : 'lose') && r.h1 === (side === 'player' ? 'MENANG' : 'KALAH'), r);
ok(`hasil ${p} vs ${e}: pemenang besar dengan kutipan menang, yang kalah dengan kutipan kalah`, r.winName === info.wn && r.loseName === info.ln && r.winArt.includes(wn) && info.q[wn].win.some(t => r.quote.includes(t)) && info.q[ln].lose.some(t => r.loseQuote.includes(t)), { r, info: [info.wn, info.ln] });
ok(`hasil ${p} vs ${e}: toast koin tidak menumpuk di layar hasil`, await game(() => document.querySelectorAll('.meta-toast.coin').length === 0), 'toast koin masih tampil');
ok(`hasil ${p} vs ${e}: rincian koin berjumlah total dan sama dengan koin bertambah`, r.rows.length >= 1 && r.rows.reduce((x, [, a]) => x + Number(a.replace('+', '')), 0) === r.last.gain && Number(r.total) === r.last.gain && r.coins - before === r.last.gain, { rows: r.rows, total: r.total, delta: r.coins - before, gain: r.last.gain });
ok(`hasil ${p} vs ${e}: empat tombol dan empat statistik`, r.btn.join('|') === 'rematch:MAIN LAGI ↗|reselect:PILIH ULANG|shop:KE TOKO|home:MENU UTAMA' && r.stats.length === 4, r);
await wait(1900); await shot(`hasil-${side === 'player' ? 'menang' : 'kalah'}-${p}-${e}`);
const fit = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()), rs = document.querySelector('.rs').getBoundingClientRect(); return { bottom: Math.max(...b.map(x => x.bottom)), top: Math.min(...b.map(x => x.top)), rsBottom: rs.bottom, h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight, clipped: [...document.querySelectorAll('.rs-main > *')].some(n => { const r = n.getBoundingClientRect(); return r.bottom > innerHeight + 1 || r.top < 0; }) }; });
ok(`hasil ${p} vs ${e}: semua tombol muat di jendela 520 px tanpa gulir`, fit.bottom <= fit.h && fit.scroll <= 0 && !fit.clipped, fit);
}
}
// ---------- Arcade/Survival ----------
if (want('run')) {
await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1, arcade_clear: 1, survival5: 1 }; window.__game.manual = false; });
// Arcade: lawan pertama memakai layar VS berlabel, menang, lalu lawan kedua, lalu kalah -> layar akhir bergaya hasil
await game(() => { window.Meta.startRun('arcade', 'browo'); });
await wait(150); let s = await vsState();
const label = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || '');
ok('arcade: lawan 1 memakai layar VS berlabel', s.el && s.active && /ARCADE · LAWAN 1 \/ 6/.test(label), { s, label });
await wait(450); await shot('vs-arcade'); await game(() => window.__game.vsSkip());
await finish('player', 2).then(() => null).catch(() => null);
await wait(100);
// setelah menang jeda 2,6 detik lalu lawan berikut (VS lagi)
await wait(2800); s = await vsState();
ok('arcade: menang -> lawan 2 muncul dengan layar VS', s.el && /LAWAN 2 \/ 6/.test(await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || '')), s);
await game(() => window.__game.vsSkip());
await finish('enemy');
await wait(200);
const end = await game(() => { const el = document.querySelector('#front-menu .rs'); return { screen: document.querySelector('#front-menu').dataset.screen, tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, rows: [...document.querySelectorAll('.rs-coins li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()), total: el?.querySelector('.rs-total b')?.dataset.count, stats: [...document.querySelectorAll('.rs-stats dl div')].map(d => d.textContent), oldPanel: !!document.querySelector('.run-end'), metaHidden: document.querySelector('#meta-overlay')?.hidden ?? true }; });
ok('arcade: kalah di lawan 2 -> layar akhir memakai tampilan hasil baru', end.screen === 'result' && end.tone === 'lose' && end.h1 === 'BERAKHIR' && /KALAH DI LAWAN KE-2 DARI 6/.test(end.kicker) && !end.oldPanel, end);
ok('arcade: rincian koin memuat lawan sebelumnya dan pertandingan terakhir', end.rows.length >= 2 && /sebelumnya/i.test(end.rows[0]) && /terakhir/i.test(end.rows[end.rows.length - 1]), end);
await wait(1900); await shot('hasil-arcade-berakhir');
// Main lagi memulai rangkaian baru; Pilih ulang kembali ke pilih karakter dengan mode run
await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState();
const lab2 = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || '');
ok('arcade: Main lagi memulai rangkaian baru (lawan 1)', s.el && /LAWAN 1 \/ 6/.test(lab2), { s, lab2 });
await game(() => window.__game.vsSkip()); await finish('enemy'); await wait(200);
await page.click('[data-cmd=reselect]'); await wait(100);
const sel = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, step: document.querySelector('#front-menu').dataset.step, eyebrow: document.querySelector('.select-header .eyebrow')?.textContent }));
ok('arcade: Pilih ulang kembali ke pilih karakter (mode ARCADE)', sel.screen === 'select' && sel.step === '0' && /ARCADE/.test(sel.eyebrow), sel);
// Survival berakhir pada kekalahan pertama
await game(() => { window.Meta.startRun('survival', 'megachan'); }); await wait(100); await game(() => window.__game.vsSkip());
await finish('enemy'); await wait(200);
const sv = await game(() => { const el = document.querySelector('#front-menu .rs'); return { tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, score: el?.querySelector('.rs-score')?.textContent.replace(/\s+/g, ' ') }; });
ok('survival: layar akhir tampil dengan rekor', sv.h1 === 'BERAKHIR' && /SURVIVAL/.test(sv.kicker), sv);
await wait(1900); await shot('hasil-survival');
// Arcade tamat: menang semua lawan (6)
await game(() => { window.Meta.startRun('arcade', 'anice'); });
for (let i = 0; i < 6; i++) { await wait(80); await game(() => window.__game.vsSkip()); await finish('player'); if (i < 5) await wait(2800); }
await wait(300);
const clr = await game(() => { const el = document.querySelector('#front-menu .rs'); return { tone: el?.dataset.tone, h1: el?.querySelector('h1')?.textContent, kicker: el?.querySelector('.rs-kicker')?.textContent, rows: [...document.querySelectorAll('.rs-coins li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()), total: el?.querySelector('.rs-total b')?.dataset.count, confetti: document.querySelectorAll('.rs-confetti i').length }; });
ok('arcade tamat: layar TAMAT! dengan bonus 500 dan konfeti', clr.h1 === 'TAMAT!' && clr.tone === 'win' && clr.rows.some(r => /Bonus Arcade tamat.*\+500/.test(r)) && clr.confetti > 0, clr);
await wait(1900); await shot('hasil-arcade-tamat');
}
// ---------- jalur menu: dua pemain, tombol hasil ----------
if (want('ui')) {
await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.Meta.cancelRun(); window.__game.setMenuOpen(true); window.FrontEnd.home(); });
await page.click('[data-cmd="2p"]'); await wait(80);
await page.click('[data-fighter=browo]'); await wait(1100);
await page.click('[data-fighter=megachan]'); await wait(1100);
await page.click('[data-cmd=start]'); await wait(200);
let s = await vsState();
const meta = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent }));
ok('dua pemain lewat menu: layar VS berlabel PEMAIN 1 / PEMAIN 2', s.active && meta.who.join() === 'PEMAIN 1,PEMAIN 2' && /DUA PEMAIN/.test(meta.top), { s, meta });
await wait(2200); s = await vsState();
ok('dua pemain: ronde mulai normal setelah VS', !s.active && s.cues.join() === 'round1', s);
await finish('enemy'); await wait(150);
const two = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, kicker: document.querySelector('.rs-kicker')?.textContent, tone: document.querySelector('.rs')?.dataset.tone }));
ok('dua pemain: hasil menyebut PEMAIN 2 MENANG', two.screen === 'result' && /PEMAIN 2 MENANG/.test(two.kicker), two);
// tombol: Ke toko membuka toko; tutup kembali ke layar hasil; Main lagi memulai pertandingan lagi (2P)
await page.click('[data-cmd=shop]'); await wait(150);
let m = await game(() => ({ hidden: document.querySelector('#meta-overlay')?.hidden, screen: document.querySelector('#front-menu').dataset.screen }));
ok('tombol KE TOKO membuka toko di atas layar hasil', m.hidden === false && m.screen === 'result', m);
await page.click('#meta-overlay [data-close]'); await wait(100);
m = await game(() => ({ hidden: document.querySelector('#meta-overlay')?.hidden, screen: document.querySelector('#front-menu').dataset.screen, rs: !!document.querySelector('.rs') }));
ok('menutup toko kembali ke layar hasil', m.hidden === true && m.screen === 'result' && m.rs, m);
await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState();
const again = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent) }));
ok('tombol MAIN LAGI memulai ulang (VS tampil, tetap dua pemain)', s.active && again.who.join() === 'PEMAIN 1,PEMAIN 2', { s, again });
await game(() => window.__game.vsSkip()); await finish('player'); await wait(150);
await page.click('[data-cmd=home]'); await wait(100);
m = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen }));
ok('tombol MENU UTAMA kembali ke menu utama', m.screen === 'home', m);
// lawan CPU lewat menu: VS menampilkan KOMPUTER dan tingkat kesulitan
await page.click('[data-cmd=versus]'); await wait(80);
await page.click('[data-fighter=sonofsolo]'); await wait(1100);
await page.click('[data-fighter=ethanolman]'); await wait(1100);
await page.click('[data-level=hard]'); await page.click('[data-cmd=start]'); await wait(450);
const cpu = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent }));
ok('versus CPU lewat menu: label KOMPUTER, arena, tingkat SULIT', cpu.who.join() === 'PEMAIN 1,KOMPUTER' && /VERSUS KOMPUTER/.test(cpu.top) && /SULIT/.test(cpu.top), cpu);
await shot('vs-versus-menu'); await game(() => window.__game.vsSkip());
}
// ---------- ponsel lanskap ----------
if (want('phone')) {
await page.setViewportSize({ width: 667, height: 375 });
await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.__game.setMenuOpen(true); });
await startVS('sonofsolo', 'ethanolman'); await wait(1300); await shot('vs-667x375');
await game(() => window.__game.vsSkip());
await finish('player'); await wait(2300); await shot('hasil-667x375');
const fit = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()); return { bottom: Math.max(...b.map(x => x.bottom)), h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight }; });
ok('hasil di ponsel lanskap 667x375: tombol muat tanpa gulir', fit.bottom <= fit.h && fit.scroll <= 0, fit);
await page.setViewportSize({ width: 1280, height: 520 });
}
const failed = checks.filter(c => !c.pass);
return { total: checks.length, failed: failed.length, shots: dir, ...(failed.length ? { failures: failed } : {}), passed: checks.filter(c => c.pass).map(c => c.name) };
};

View File

@ -3,10 +3,12 @@ import os,json,hashlib,subprocess
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..')) os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..'))
# every top-level page, style and script (new files like feel.js/meta.js are picked up automatically) # every top-level page, style and script (new files like feel.js/meta.js are picked up automatically)
root=sorted(f for f in os.listdir('.') if f.endswith(('.html','.css','.js')) and f not in ('sw.js','precache.js')) root=sorted(f for f in os.listdir('.') if f.endswith(('.html','.css','.js')) and f not in ('sw.js','precache.js'))
# data files under content/ (kutipan, dst.) are runtime files too
content=sorted('content/'+f for f in os.listdir('content') if f.endswith(('.js','.json'))) if os.path.isdir('content') else []
tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n') tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n')
others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n') others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n')
assets=sorted({f for f in tracked+others if f and os.path.isfile(f)}) assets=sorted({f for f in tracked+others if f and os.path.isfile(f)})
files=[f for f in root+assets if os.path.isfile(f)] files=[f for f in root+content+assets if os.path.isfile(f)]
entries=[] entries=[]
for f in files: for f in files:
b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]]) b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]])

119
vs.css Normal file
View File

@ -0,0 +1,119 @@
/* Layar VS (vs.js, RENCANA 2.2) dan layar hasil (RENCANA 2.3). Tinggi minimum yang didukung: 520 px; ada aturan ringkas untuk ponsel lanskap (<= 420 px). */
#vs-screen{--pl:#16857f;--pr:#c4602a;--ah:min(86vh,40vw);--aw:calc(var(--ah)*.767);position:fixed;inset:0;z-index:40;overflow:hidden;background:#07131c;color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation}
#vs-screen.out{opacity:0;transition:opacity .26s ease-in;pointer-events:none}
#vs-screen:after{content:'';position:absolute;inset:0;z-index:8;pointer-events:none;background:#fff;opacity:0;animation:vs-flash .4s .5s ease-out both}
#vs-screen>*{margin:0}
.vs-bg{position:absolute;inset:-3%;background:#0e202c center/cover no-repeat;will-change:transform;animation:vs-zoom 2.6s ease-out both}
.vs-bg:after{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 50% 50%,#050f17b8 25%,#04101af2 100%)}
.vs-panel{position:absolute;inset:0;z-index:1;pointer-events:none}
.vs-panel.l{clip-path:polygon(0 0,58% 0,42% 100%,0 100%);background:linear-gradient(100deg,var(--pl) 0%,#0c1c28cc 85%);opacity:.85;animation:vs-panel-l .44s cubic-bezier(.2,.9,.25,1) both}
.vs-panel.r{clip-path:polygon(58% 0,100% 0,100% 100%,42% 100%);background:linear-gradient(260deg,var(--pr) 0%,#0c1c28cc 85%);opacity:.85;animation:vs-panel-r .44s cubic-bezier(.2,.9,.25,1) both}
.vs-panel:after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(115deg,transparent 0 26px,#ffffff10 26px 28px)}
.vs-slash{position:absolute;inset:0;z-index:2;pointer-events:none;clip-path:polygon(58.2% 0,59.4% 0,43.4% 100%,42.2% 100%);background:linear-gradient(180deg,#fff8dc,#e0bf85);box-shadow:0 0 20px #ffe5ae;transform-origin:50% 0;animation:vs-slash .3s .26s ease-out both}
.vs-top{position:absolute;z-index:6;top:clamp(8px,2.4vh,22px);left:0;right:0;display:flex;justify-content:center;align-items:center;gap:12px;font-weight:700;font-size:clamp(11px,1.5vh + 3px,15px);letter-spacing:3px;color:#e9dcb6;text-shadow:0 2px 6px #000a;animation:vs-fade .4s .2s both}
.vs-top span{padding:2px 12px;background:#06121bc4}.vs-top i{width:6px;height:6px;background:#e0bf85;transform:rotate(45deg)}
.vs-side{position:absolute;inset:0;z-index:3;pointer-events:none}
.vs-art{position:absolute;bottom:0;height:var(--ah);width:auto;max-width:none;filter:drop-shadow(0 14px 18px #000b)}
.vs-side.l .vs-art{right:calc(50% - 1.5vw);animation:vs-art-l .56s .1s cubic-bezier(.2,.9,.25,1) both}
.vs-side.r .vs-art{left:calc(50% - 1.5vw);transform:scaleX(-1);animation:vs-art-r .56s .1s cubic-bezier(.2,.9,.25,1) both}
.vs-info{position:absolute;bottom:clamp(14px,5vh,48px);width:calc(50% - var(--aw) - 3.5vw - 14px)}
.vs-side.l .vs-info{left:3.5vw;animation:vs-info-l .42s .36s cubic-bezier(.2,.9,.25,1) both}
.vs-side.r .vs-info{right:3.5vw;text-align:right;animation:vs-info-r .42s .36s cubic-bezier(.2,.9,.25,1) both}
.vs-who{display:inline-block;font-weight:700;font-size:clamp(10px,1.4vh + 2px,13px);letter-spacing:3px;padding:2px 9px;background:var(--c);color:#0b1620;transform:skewX(-10deg)}
.vs-name{display:block;margin:8px 0 2px;font:700 italic clamp(24px,3.4vw,52px)/.92 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:1px;text-shadow:3px 3px 0 #0009;overflow-wrap:anywhere}
.vs-tag{display:block;font:700 normal clamp(10px,1.3vh + 2px,13px) Rajdhani,sans-serif;letter-spacing:3px;color:#d8c394}
.vs-quote{margin:clamp(6px,1.8vh,16px) 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--c);font:600 italic clamp(15px,2.2vh + 3px,21px)/1.22 Rajdhani,sans-serif;color:#fff3d4;text-shadow:0 1px 4px #000a;animation:vs-quote .45s .95s both}
.vs-side.r .vs-quote{padding:2px 12px 2px 0;border-left:0;border-right:3px solid var(--c)}
.vs-mark{position:absolute;z-index:5;left:50%;top:50%;transform:translate(-50%,-50%);pointer-events:none}
.vs-mark b{display:block;font:700 italic clamp(70px,24vh,190px)/.8 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:-4px;color:#fff0c4;-webkit-text-stroke:2px #1a1205;text-shadow:5px 6px 0 #b8862e,0 0 40px #ffcf6e99;animation:vs-slam .44s .48s cubic-bezier(.2,1.5,.4,1) both,vs-pulse 1.1s 1s ease-in-out infinite}
.vs-skip{position:absolute;z-index:6;bottom:clamp(6px,1.6vh,14px);left:0;right:0;text-align:center;font-weight:700;font-size:11px;letter-spacing:3px;color:#b9c9cc;animation:vs-fade .5s .9s both}
.vs-timer{position:absolute;z-index:7;left:0;right:0;bottom:0;height:3px;background:#ffffff1c}.vs-timer i{display:block;height:100%;background:#e0bf85;transform-origin:0 50%;animation:vs-timer var(--vs-run,1900ms) linear both}
@keyframes vs-flash{0%{opacity:.75}100%{opacity:0}}
@keyframes vs-zoom{from{transform:scale(1.14)}to{transform:scale(1.02)}}
@keyframes vs-panel-l{from{transform:translateX(-100%)}to{transform:none}}
@keyframes vs-panel-r{from{transform:translateX(100%)}to{transform:none}}
@keyframes vs-slash{from{transform:scaleY(0)}to{transform:scaleY(1)}}
@keyframes vs-art-l{from{transform:translateX(-62vw);opacity:0}to{transform:none;opacity:1}}
@keyframes vs-art-r{from{transform:translateX(62vw) scaleX(-1);opacity:0}to{transform:scaleX(-1);opacity:1}}
@keyframes vs-info-l{from{transform:translateX(-30vw);opacity:0}to{transform:none;opacity:1}}
@keyframes vs-info-r{from{transform:translateX(30vw);opacity:0}to{transform:none;opacity:1}}
@keyframes vs-quote{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@keyframes vs-fade{from{opacity:0}to{opacity:1}}
@keyframes vs-slam{from{transform:scale(3.4) skewX(-8deg);opacity:0}60%{opacity:1}to{transform:scale(1) skewX(-8deg);opacity:1}}
@keyframes vs-pulse{0%,100%{transform:scale(1) skewX(-8deg)}50%{transform:scale(1.04) skewX(-8deg)}}
@keyframes vs-timer{from{transform:scaleX(0)}to{transform:scaleX(1)}}
/* Layar sempit / pendek: nama dan kutipan menumpuk di bawah tiap potret. */
@media(max-width:760px),(max-height:420px){
#vs-screen{--ah:min(80vh,44vw)}
.vs-side.l .vs-art{right:calc(50% - 4vw)}.vs-side.r .vs-art{left:calc(50% - 4vw)}
.vs-info{bottom:clamp(8px,3vh,24px);width:calc(50% - var(--aw) + 1vw - 3vw)}
.vs-side.l .vs-info{left:2.5vw}.vs-side.r .vs-info{right:2.5vw}
.vs-name{font-size:clamp(17px,3.2vw,30px);margin:5px 0 1px}.vs-tag{letter-spacing:1px;font-size:9px}.vs-who{font-size:9px;letter-spacing:2px}
.vs-quote{font-size:clamp(11px,1.7vw,14px);padding-left:7px}.vs-side.r .vs-quote{padding-right:7px}
.vs-mark b{font-size:clamp(54px,22vh,120px)}.vs-skip{display:none}
}
@media(prefers-reduced-motion:reduce){#vs-screen,#vs-screen *{animation:none!important}#vs-screen:after{display:none}.vs-timer i{transform:none}}
/* ---- Layar hasil (menu.js screen 'result' memakai ResultView) ---- */
#front-menu[data-screen=result]{display:flex;flex-direction:column;overflow:hidden}
.rs{--cw:#e0bf85;--cl:#7a8f95;position:relative;flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,.9fr) minmax(320px,1.75fr) minmax(0,.62fr);column-gap:clamp(8px,2vw,32px);align-items:stretch;width:100%;max-width:1700px;margin:0 auto;padding:0 3vw}
.rs-confetti{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:4}
.rs-confetti i{position:absolute;top:-16px;left:var(--x);width:var(--w);height:calc(var(--w)*1.6);background:var(--c);opacity:0;animation:rs-fall var(--t) var(--d) ease-in both}
.rs-fighter{position:relative;min-width:0;min-height:0}
.rs-fighter.win{display:flex;align-items:flex-end;justify-content:center}
.rs-glow{position:absolute;left:50%;bottom:0;width:140%;height:92%;transform:translateX(-50%);background:radial-gradient(ellipse at 50% 60%,var(--cw),transparent 62%);opacity:.34;animation:rs-glow 2.4s ease-in-out infinite alternate}
.rs-fighter.win .rs-art{position:relative;display:block;height:min(100%,880px);max-width:130%;object-fit:contain;object-position:50% 100%;filter:drop-shadow(0 16px 20px #000a);animation:rs-pop .62s cubic-bezier(.2,.9,.3,1.15) both}
.rs-fighter.win .rs-plate{position:absolute;left:0;right:0;bottom:0;padding:22px 6px 8px;text-align:center;background:linear-gradient(0deg,#07131cf2 25%,transparent);animation:rs-up .5s .45s both}
.rs-plate small{display:block;font-weight:700;font-size:11px;letter-spacing:4px;color:var(--cw)}
.rs-plate strong{display:block;font:700 italic clamp(22px,3vw,42px)/.95 Rajdhani,sans-serif;letter-spacing:1px;text-shadow:3px 3px 0 #0009}
.rs-fighter.lose{display:flex;flex-direction:column-reverse;align-items:center;justify-content:flex-start;padding-bottom:0}
.rs-fighter.lose .rs-art{display:block;height:min(56%,560px);max-width:100%;object-fit:contain;transform:scaleX(-1);filter:grayscale(.92) brightness(.48) contrast(.9) drop-shadow(0 10px 12px #0008);opacity:.78;animation:rs-sink .7s .2s both}
.rs-fighter.lose .rs-plate{width:100%;padding:0 4px 10px;text-align:center;color:#8d9fa4;animation:rs-up .5s .9s both}
.rs-fighter.lose .rs-plate small{color:#7d9096;letter-spacing:3px}
.rs-fighter.lose .rs-plate p{margin:5px 0 0;font:600 italic clamp(13px,1.9vh + 2px,17px)/1.25 Rajdhani,sans-serif;color:#9fb0b4}
.rs-main{position:relative;z-index:5;display:flex;flex-direction:column;justify-content:center;gap:clamp(6px,1.6vh,14px);min-width:0;padding:0 0 clamp(6px,2vh,22px)}
.rs-kicker{display:block;font-weight:700;font-size:clamp(10px,1.4vh + 2px,13px);letter-spacing:4px;color:#d8c394}
.rs-head h1{margin:2px 0 0;font:700 italic clamp(40px,10vh,120px)/.92 Rajdhani,'Arial Narrow',sans-serif;letter-spacing:2px;color:#f3d796;text-shadow:3px 4px 0 #6a4a14,0 0 30px #ffd47a55;animation:rs-title .55s cubic-bezier(.2,1.4,.4,1) both}
.rs[data-tone=lose] .rs-head h1{color:#e8a597;text-shadow:3px 4px 0 #5a2218,0 0 26px #ff6a4a33}
.rs-score{display:flex;align-items:baseline;gap:14px;font-weight:700;line-height:1;margin-top:clamp(2px,.8vh,8px)}
.rs-score b{font-size:clamp(28px,6.4vh,72px);color:#8da2a8}.rs-score b.w{color:#f3d796}.rs-score i{font-style:normal;font-size:clamp(16px,3vh,26px);color:#6e868c}
.rs-score.run b{font-size:clamp(34px,7vh,60px);color:#f3d796}.rs-score.run span{font-size:clamp(11px,1.6vh + 2px,15px);letter-spacing:3px;color:#b7dacb}
.rs-quote{margin:0;padding:clamp(4px,1vh,9px) 14px;border-left:3px solid var(--cw);background:linear-gradient(90deg,#0b1b25cc,#0b1b2500);animation:rs-up .5s .35s both}
.rs-quote p{margin:0;font:600 italic clamp(15px,2.3vh + 2px,28px)/1.2 Rajdhani,sans-serif;color:#fff3d4}
.rs-quote cite{display:block;margin-top:3px;font:700 normal 11px Rajdhani,sans-serif;letter-spacing:3px;color:var(--cw)}
.rs-cols{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:10px}
.rs-cols:has(.rs-coins:only-child),.rs-cols>.rs-stats:only-child{grid-template-columns:1fr}
.rs-coins,.rs-stats{padding:clamp(6px,1.2vh,10px) 12px;background:#0b1c26d9;border:1px solid #7fa6a533;border-top:2px solid #e0bf85;min-width:0}
.rs-cols h3{margin:0 0 4px;font-size:11px;letter-spacing:4px;color:#d8c394}
.rs-coins ul{list-style:none;margin:0;padding:0}
.rs-coins li{display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:1px 0;font-weight:600;font-size:clamp(13px,1.9vh + 2px,20px);color:#d6e0dc;animation:rs-row .36s calc(.55s + var(--i)*.16s) both}
.rs-coins li span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rs-coins li small{display:inline;margin-left:7px;font-weight:500;font-size:11px;letter-spacing:.5px;color:#93a9ad}
.rs-coins li b{color:#ffe08a;font-variant-numeric:tabular-nums}
.rs-total{display:flex;justify-content:space-between;align-items:baseline;margin-top:3px;padding-top:3px;border-top:1px solid #e0bf8566;font-weight:700;letter-spacing:2px;font-size:12px;color:#d8c394;animation:rs-row .36s calc(.55s + var(--i)*.16s) both}
.rs-total b{font-size:clamp(20px,3.2vh + 4px,40px);letter-spacing:1px;color:#ffe08a;font-variant-numeric:tabular-nums}
.rs-stats dl{display:grid;grid-template-columns:1fr 1fr;gap:clamp(3px,.8vh,8px) 12px;margin:0;animation:rs-up .5s .6s both}
.rs-stats dt{font-weight:700;font-size:10px;letter-spacing:1px;color:#8fa9ac;text-transform:uppercase;white-space:nowrap}
.rs-stats dd{margin:0;font-weight:700;font-size:clamp(15px,2.4vh + 2px,28px);color:#f2ead3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rs-actions{display:grid;grid-template-columns:1.35fr 1fr 1fr 1fr;gap:8px;animation:rs-up .5s .8s both}
.rs-actions .menu-primary,.rs-actions .result-secondary{margin:0;width:100%;min-width:0;padding:clamp(7px,1.5vh,12px) 8px;font-size:clamp(12px,1.8vh + 2px,19px);letter-spacing:1.5px;text-align:center;justify-content:center;gap:8px}
.rs-actions .result-secondary{cursor:pointer;display:block}.rs-actions .result-secondary:hover,.rs-actions .result-secondary:focus-visible{background:#24505e;color:#fff}
.rs-actions .menu-primary b{font-size:18px}
@keyframes rs-fall{0%{opacity:0;transform:translateY(0) rotate(0)}8%{opacity:1}100%{opacity:.9;transform:translateY(105vh) rotate(var(--r))}}
@keyframes rs-glow{from{opacity:.24}to{opacity:.42}}
@keyframes rs-pop{from{transform:translateY(40px) scale(.9);opacity:0}to{transform:none;opacity:1}}
@keyframes rs-sink{from{transform:translateY(24px) scaleX(-1);opacity:0}to{transform:scaleX(-1);opacity:.78}}
@keyframes rs-up{from{transform:translateY(12px);opacity:0}to{transform:none;opacity:1}}
@keyframes rs-row{from{transform:translateX(-14px);opacity:0}to{transform:none;opacity:1}}
@keyframes rs-title{from{transform:scale(1.5) skewX(-6deg);opacity:0}to{transform:none;opacity:1}}
@media(max-width:900px){.rs{grid-template-columns:minmax(0,.8fr) minmax(0,1.6fr)}.rs-fighter.lose{display:none}}
@media(max-width:700px),(max-height:420px){
.rs{grid-template-columns:minmax(0,.7fr) minmax(0,1.7fr);padding:0 2.5vw}
.rs-fighter.lose,.rs-fighter.win .rs-plate small{display:none}
.rs-main{gap:4px;padding-bottom:6px}.rs-head h1{font-size:clamp(30px,11vh,52px)}.rs-score b{font-size:clamp(22px,8vh,34px)}
.rs-quote{padding:3px 10px}.rs-quote p{font-size:13px}.rs-quote cite{display:none}
.rs-cols{gap:6px}.rs-coins,.rs-stats{padding:4px 8px}.rs-cols h3{display:none}.rs-coins li{font-size:12px;padding:0}.rs-coins li small{display:none}.rs-total b{font-size:18px}.rs-stats dt{font-size:8px;letter-spacing:1px}.rs-stats dd{font-size:13px}.rs-stats dl{gap:2px 8px}
.rs-actions{gap:5px}.rs-actions .menu-primary,.rs-actions .result-secondary{padding:6px 4px;font-size:11px;letter-spacing:.5px}.rs-actions .menu-primary b{display:none}
.rs-fighter.win .rs-plate{padding-top:12px}.rs-plate strong{font-size:clamp(16px,5vh,26px)}
}
@media(max-height:420px){.rs-quote p{font-size:12px}.rs-head h1{font-size:34px}.rs-score{margin-top:0}.rs-stats dl{grid-template-columns:1fr 1fr 1fr 1fr}.rs-stats dd{font-size:12px}}
@media(prefers-reduced-motion:reduce){.rs *{animation:none!important}.rs-confetti{display:none}}

130
vs.js Normal file
View File

@ -0,0 +1,130 @@
/* Layar VS (RENCANA 2.2), tampilan layar hasil (RENCANA 2.3) dan pemilih kutipan (RENCANA 2.6). Gaya: vs.css, data kutipan: content/quotes.js.
- window.VS.show(info, onDone): overlay sekitar 2,2 detik sebelum ronde 1. onDone dipanggil tepat satu kali (waktu habis atau dilewati
dengan klik/Spasi/Enter/Esc); setelah itu game memulai urutan cue ronde seperti biasa (game.js startMatch -> emitRoundCue).
- window.ResultView.view(hasil, ctx) -> {title, eyebrow, html}; ResultView.mount(root) menghidupkan hitungan koin dan fokus tombol.
Pengacak di sini kosmetik saja (bukan random() mesin), jadi tidak memengaruhi hasil pertandingan. */
(() => {
'use strict';
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let seed = (Date.now() ^ Math.floor(performance.now() * 1000)) >>> 0;
const rnd = () => { seed = (seed + 0x6D2B79F5) >>> 0; let t = seed; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const LEVELS = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' };
const DURATION = 2200, FADE = 260, MIN_SKIP = 380;
// ---- kutipan ----
const GENERIC = { intro: ['Siap bertarung!', 'Ayo mulai.', 'Tidak ada yang mundur hari ini.'], win: ['Pertandingan yang bagus!', 'Terima kasih sudah bertanding.'], lose: ['Kali ini belum beruntung.', 'Aku akan kembali.'] };
const lastQuote = {};
function quote(id, kind) {
const own = window.QUOTES?.[id]?.[kind], pool = own && own.length ? own : GENERIC[kind] || GENERIC.intro, key = id + ':' + kind;
let i = Math.floor(rnd() * pool.length);
if (pool.length > 1 && pool[i] === lastQuote[key]) i = (i + 1 + Math.floor(rnd() * (pool.length - 1))) % pool.length;
return (lastQuote[key] = pool[i]);
}
function fighter(id) {
const f = window.FIGHTERS?.[id], portrait = `assets/${id}/ui/portrait.webp`;
if (f) return { id, name: f.name, tag: f.tag || '', art: f.select || `assets/menu/${id}-select.webp`, portrait, color: f.color || '#e0bf85' };
if (id === 'browo') return { id, name: 'BRO WO', tag: 'THE PALM RANGER', art: 'assets/menu/browo-select.webp', portrait, color: '#c9b87a' };
return { id, name: String(id || '?').toUpperCase(), tag: '', art: '', portrait: id === 'arco' ? 'assets/ui/arco-avatar.webp' : portrait, color: '#e0bf85' };
}
const artOf = f => f.art || f.portrait;
const stageName = id => window.MatchRules?.stages?.[id]?.name || '';
// ---- layar VS ----
let cur = null;
function vsSide(cls, f, who, q) {
return `<figure class="vs-side ${cls}" style="--c:${esc(f.color)}"><img class="vs-art" src="${esc(artOf(f))}" alt="" draggable="false"><figcaption class="vs-info"><small class="vs-who">${who}</small><strong class="vs-name">${esc(f.name)}</strong><em class="vs-tag">${esc(f.tag)}</em><p class="vs-quote">&ldquo;${esc(q)}&rdquo;</p></figcaption></figure>`;
}
function show(info = {}, onDone) {
cancel();
if (!document.body) return false;
const L = fighter(info.player), R = fighter(info.enemy), two = !!info.p2, st = stageName(info.stage), lv = LEVELS[info.level] || '';
const kicker = info.label || (two ? 'DUA PEMAIN' : 'VERSUS KOMPUTER');
const el = document.createElement('div');
el.id = 'vs-screen'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', `${L.name} melawan ${R.name}. Klik atau tekan Spasi untuk melewati.`);
const bg = window.MatchRules?.stages?.[info.stage]?.image;
el.innerHTML = `<div class="vs-bg"${bg ? ` style="background-image:url('${esc(bg)}')"` : ''}></div><i class="vs-panel l"></i><i class="vs-panel r"></i><i class="vs-slash"></i>`
+ `<div class="vs-top"><span>${esc(kicker)}</span>${st ? `<i></i><span>${esc(st.toUpperCase())}</span>` : ''}${lv && !two ? `<i></i><span>${lv}</span>` : ''}</div>`
+ vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro'))
+ `<div class="vs-mark" aria-hidden="true"><b>VS</b></div><div class="vs-skip">KLIK ATAU SPASI UNTUK LEWATI</div><div class="vs-timer" aria-hidden="true"><i></i></div>`;
document.body.append(el);
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [] };
el.style.setProperty('--vs-run', (DURATION - FADE) + 'ms');
c.timers.push(setTimeout(() => finish('time'), DURATION - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));
el.addEventListener('pointerdown', e => { e.preventDefault(); skip(); });
window.addEventListener('keydown', onKey, true);
return true;
}
// Selama layar VS aktif semua tombol ditahan agar tidak bocor ke game (Esc tidak boleh membuka jeda di bawahnya).
function onKey(e) {
if (!cur || cur.done) return;
e.stopImmediatePropagation(); e.preventDefault();
if (e.repeat) return;
if (e.key === ' ' || e.key === 'Enter' || e.key === 'Escape') skip();
}
function release() { const c = cur; if (!c) return null; window.removeEventListener('keydown', onKey, true); for (const t of c.timers) clearTimeout(t); c.timers = []; return c; }
function finish(reason) {
const c = cur; if (!c || c.done) return false;
c.done = true; release(); c.el.classList.add('out'); c.reason = reason;
setTimeout(() => c.el.remove(), reason === 'time' ? FADE + 40 : 200);
try { c.onDone?.(reason); } catch (e) { console.error(e); }
return true;
}
function skip() { if (!cur || cur.done || performance.now() - cur.t0 < MIN_SKIP) return false; return finish('skip'); }
// Dipanggil saat pertandingan dibatalkan (kembali ke menu / pertandingan baru): tanpa onDone.
function cancel() { const c = release(); if (!c) return; c.done = true; c.el.remove(); cur = null; }
window.VS = { show, skip, cancel, quote, fighter, duration: DURATION, get active() { return !!cur && !cur.done; }, seed(n) { seed = n >>> 0; }, force() { if (cur) { cur.t0 = -1e9; return skip(); } return false; } };
// ---- layar hasil ----
const CONFETTI = ['#ffe08a', '#ffffff', '#8bd4c3', '#ff9a7a'];
function confetti(color, n) {
return Array.from({ length: n }, (_, i) => `<i style="--x:${(rnd() * 100).toFixed(1)}%;--d:${(rnd() * 1.4).toFixed(2)}s;--t:${(2.6 + rnd() * 2.4).toFixed(2)}s;--r:${Math.round(rnd() * 720 - 360)}deg;--w:${6 + Math.round(rnd() * 6)}px;--c:${i % 3 === 0 ? color : CONFETTI[i % CONFETTI.length]}"></i>`).join('');
}
function view(r, ctx = {}) {
const run = r.run || null, two = !!r.p2, playerWon = r.winner === 'player';
const wid = playerWon ? r.player : r.enemy, lid = playerWon ? r.enemy : r.player, Wf = fighter(wid), Lf = fighter(lid);
const tone = run ? (run.cleared ? 'win' : 'lose') : (two || playerWon ? 'win' : 'lose');
const q = r._q || (r._q = { win: quote(wid, 'win'), lose: quote(lid, 'lose') });
const lv = LEVELS[r.level] || '', st = window.MatchRules?.stages?.[r.stage], stName = st ? st.name : '';
let title, kicker, scoreHtml;
if (run) {
title = run.cleared ? 'TAMAT!' : 'BERAKHIR';
kicker = run.kind === 'arcade' ? (run.cleared ? 'SEMUA LAWAN DIKALAHKAN' : `KALAH DI LAWAN KE-${run.wins + 1} DARI ${run.total}`) : `SURVIVAL · REKOR ${run.best}`;
scoreHtml = `<div class="rs-score run"><b>${run.wins}</b><span>${run.kind === 'arcade' ? `DARI ${run.total} LAWAN` : 'MENANG BERUNTUN'}</span></div>`;
} else {
title = two ? 'MENANG' : playerWon ? 'MENANG' : 'KALAH';
kicker = two ? (playerWon ? 'PEMAIN 1' : 'PEMAIN 2') + ' MENANG' : playerWon ? 'PEMAIN 1 MENANG' : 'KOMPUTER MENANG';
scoreHtml = `<div class="rs-score"><b class="${playerWon ? 'w' : ''}">${r.playerWins}</b><i>—</i><b class="${playerWon ? '' : 'w'}">${r.enemyWins}</b></div>`;
}
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>`;
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)) + 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}`)
: stat('Kombo terbaik', combo + ' pukulan') + stat('Ronde', `${r.playerWins} – ${r.enemyWins}`) + stat('Tingkat', two ? '—' : lv) + stat('Arena', st ? (st.short || stName) : '—');
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>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>`
+ `<blockquote class="rs-quote"><p>&ldquo;${esc(q.win)}&rdquo;</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-fighter lose"><img class="rs-art" src="${esc(artOf(Lf))}" alt="${esc(Lf.name)}" draggable="false"><div class="rs-plate"><small>${esc(Lf.name)}</small><p>&ldquo;${esc(q.lose)}&rdquo;</p></div></div></div>`;
return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html };
}
// Setelah html dipasang: hitung naik angka koin dan fokuskan "Main lagi" (kecuali layar sentuh).
function mount(root) {
const rs = root.querySelector('.rs'); if (!rs) return;
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const n = rs.querySelector('[data-count]');
if (n && !calm) {
const to = Number(n.dataset.count) || 0, t0 = performance.now() + 700, dur = 900;
const tick = now => { if (!n.isConnected) return; const k = Math.min(1, Math.max(0, (now - t0) / dur)); n.textContent = '◆ +' + Math.round(to * (1 - Math.pow(1 - k, 3))); if (k < 1) requestAnimationFrame(tick); };
n.textContent = '◆ +0'; requestAnimationFrame(tick);
setTimeout(() => { if (n.isConnected) n.textContent = '◆ +' + to; }, 700 + dur + 400);
}
if (!document.documentElement.classList.contains('touch')) rs.querySelector('.rs-again')?.focus({ preventScroll: true });
}
window.ResultView = { view, mount };
})();