diff --git a/content/quotes.js b/content/quotes.js new file mode 100644 index 0000000..ce5d367 --- /dev/null +++ b/content/quotes.js @@ -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.'] + } +}); diff --git a/game.js b/game.js index 44eafee..aabb928 100644 --- a/game.js +++ b/game.js @@ -32,6 +32,8 @@ const Rules = window.MatchRules, Feel = window.Feel || feelFallback(); let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED; 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 = []; 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; @@ -182,6 +184,7 @@ } 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);} + if(menuOpen)vsAbort(); document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; 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(!images['stage-'+stage])return false; 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'); - 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; } + // 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) { 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(_){} systemAnnouncer?.play(cue==='round'?`round_${round}`:'fight',{replace:cue==='round'}); } 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())) { if(event.type==='cue')emitRoundCue(event.cue,event.round); 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(!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'); @@ -218,9 +229,12 @@ systemAnnouncer?.play([call,outcome]); } if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);} - let metaHandled=false; - 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});} - 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'}); + if(event.type==='complete'){ + // bestCombo (kombo terbaik ronde terakhir) tetap dasar bonus koin; matchBest (seluruh ronde) hanya untuk layar hasil. + const info={winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,bestCombo,matchBest:Math.max(matchBest,bestCombo),p2:p2Mode,perfect:match.winner==='player'&&match.enemyWins===0}; + const metaHandled=!!window.Meta?.award?.(info); + if(!metaHandled)window.FrontEnd?.showResult({...info,mode:'versus',award:window.Meta?.lastAward||null}); + } } } 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-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):''; - 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'; $('#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'; @@ -2871,6 +2885,8 @@ }; // 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) })) }) }; + // 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. 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(); diff --git a/index.html b/index.html index 1ba82da..43ad4f0 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,7 @@ Konoha Fighting - +
Menyiapkan…
@@ -24,6 +24,6 @@
✦KONOHA FIGHTINGMenyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/menu.js b/menu.js index faa47d2..8ab4d84 100644 --- a/menu.js +++ b/menu.js @@ -73,6 +73,9 @@ const st=MatchRules.stages[stage];root.innerHTML=`
${navHeader('SET THE STAGE',mode==='versus'?'VERSUS COMPUTER / MATCH SETUP':'TRAINING / ARENA SETUP')}
P1${fighters[player].name}
VS
CPU${fighters[enemy].name}
${String(Object.keys(MatchRules.stages).indexOf(stage)+1).padStart(2,'0')} / ${String(Object.keys(MatchRules.stages).length).padStart(2,'0')}
${st.tag}

${st.name}

✦ ${st.time||'SUNNY DAYLIGHT'}
${Object.entries(MatchRules.stages).map(([id,a])=>``).join('')}
${mode==='versus'?'CPU DIFFICULTY':'PRACTICE MODE'}${mode==='versus'?`
${Object.entries(MatchRules.difficulties).map(([id,d],i)=>``).join('')}
`:'

Unlimited time · Passive rival · Reset anytime

'}
${mode==='versus'?'FIRST TO 2 WINSBEST OF 3 ROUNDS · 90 SECONDS':'MAKE EVERY HIT COUNTCOMBO TRACKER · FULL MOVE SET'}

${footer()}`; } else if(screen==='about'){ root.innerHTML=`
${navHeader('TENTANG GAME','KONOHA FIGHTING')}
GAME PARODI

Tujuh petarung.
Satu arena.

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.

MODEVS Komputer, Arcade, Survival, Dua Pemain, dan Latihan.

MEKANIKKombo, gerakan perintah, blok, parry, lempar, jurus toko, skin, dan pencapaian.

07PETARUNG05SLOT MISTERI∞RIVALRIES
Berbasis AETHER CLASH oleh Bang Tutorial (kode MIT, aset CC BY-NC) · Typography · Rajdhani
Hanya untuk penggunaan non-komersial
`; + } 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=`
${navHeader(v.title,v.eyebrow)}${v.html}`;window.ResultView.mount(root); } else if(screen==='result'){ const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`
${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}
${fighters[winner].name}
${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH

${won?'VICTORY':'DEFEAT'}

${fighters[winner].name} WINS

${result.playerWins}—${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}
`; } @@ -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); } 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. function openMeta(tab){ 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==='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==='rematch'){({player,enemy,stage,level,mode}=result);start();} - if(cmd==='reselect'){screen='select';step=0;hover=player;render();} + 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'){runKind=result?.run?result.run.kind:null;p2=!!result?.p2;screen='select';step=0;hover=player;render();} } function bind(){ root.querySelectorAll('[data-cmd]').forEach(b=>{b.onclick=()=>command(b.dataset.cmd);b.onpointerenter=()=>sfx('move');b.onfocus=()=>sfx('move');}); diff --git a/meta.js b/meta.js index 1a15e28..d009460 100644 --- a/meta.js +++ b/meta.js @@ -103,19 +103,27 @@ chip.hidden = !m; chip.innerHTML = m ? `K${esc(m.name)}${info.cd > 0 ? Math.ceil(info.cd) + 's' : 'SIAP'}` : ''; 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) { - const won = r.winner === 'player', parts = []; - let gain = won ? 120 : 30; parts.push(won ? 'Menang +120' : 'Hadiah main +30'); + const won = r.winner === 'player', parts = [], lines = []; + 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 (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; parts.push('Tingkat +' + b); } - if (r.perfect) { gain += 60; parts.push('Bersih +60'); } + 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; 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++; 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'); - addCoins(gain); toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin'); - return run ? advanceRun(won) : false; + const prev = run ? run.earned || 0 : 0; if (run) run.earned = prev + gain; + 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) ---- let run = null; @@ -131,7 +139,7 @@ const total = run.kind === 'arcade' ? '/' + run.queue.length : ''; toast((run.kind === 'arcade' ? 'ARCADE' : 'SURVIVAL') + ' · LAWAN ' + (run.wins + 1) + total + ': ' + fname(enemy) + ' (' + level + ')'); 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(); } } function advanceRun(won) { @@ -147,6 +155,16 @@ 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 (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; 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; @@ -277,5 +295,5 @@ } 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; } }; })(); diff --git a/tests/t2-vsresult.mjs b/tests/t2-vsresult.mjs new file mode 100644 index 0000000..d9700b8 --- /dev/null +++ b/tests/t2-vsresult.mjs @@ -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) }; +}; diff --git a/tools/build_precache.py b/tools/build_precache.py index 2b78320..e28b2fe 100644 --- a/tools/build_precache.py +++ b/tools/build_precache.py @@ -3,10 +3,12 @@ import os,json,hashlib,subprocess 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) 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') 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)}) -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=[] for f in files: b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]]) diff --git a/vs.css b/vs.css new file mode 100644 index 0000000..9f3c452 --- /dev/null +++ b/vs.css @@ -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}} diff --git a/vs.js b/vs.js new file mode 100644 index 0000000..8279489 --- /dev/null +++ b/vs.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 `
${who}${esc(f.name)}${esc(f.tag)}

“${esc(q)}”

`; + } + 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 = `
` + + `
${esc(kicker)}${st ? `${esc(st.toUpperCase())}` : ''}${lv && !two ? `${lv}` : ''}
` + + vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro')) + + `
KLIK ATAU SPASI UNTUK LEWATI
`; + 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) => ``).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 = `
${run.wins}${run.kind === 'arcade' ? `DARI ${run.total} LAWAN` : 'MENANG BERUNTUN'}
`; + } else { + title = two ? 'MENANG' : playerWon ? 'MENANG' : 'KALAH'; + kicker = two ? (playerWon ? 'PEMAIN 1' : 'PEMAIN 2') + ' MENANG' : playerWon ? 'PEMAIN 1 MENANG' : 'KOMPUTER MENANG'; + scoreHtml = `
${r.playerWins}—${r.enemyWins}
`; + } + const aw = r.award, lines = aw?.lines || []; + const coins = aw ? `

KOIN

TOTAL◆ +${aw.gain}
` : ''; + const stat = (k, v) => `
${k}
${esc(v)}
`; + 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 = `
` + + (tone === 'win' ? `` : '') + + `
${esc(Wf.name)}
PEMENANG${esc(Wf.name)}
` + + `
${esc(kicker)}

${title}

${scoreHtml}
` + + `

“${esc(q.win)}”

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

STATISTIK

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

“${esc(q.lose)}”

`; + return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html }; + } + // 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 }; +})();