Tambah layar VS sebelum ronde 1, layar hasil baru dengan rincian koin, dan kutipan karakter
- vs.js + vs.css: layar VS sekitar 2,2 detik (dua potret, nama, tag, kutipan pembuka, teks VS, bisa dilewati klik/Spasi/Enter/Esc), hanya untuk versus/Arcade/Survival/dua pemain, tidak di latihan. Cue Round 1 ditunda sampai layar selesai; urutan intro, FIGHT, dan announcer tetap utuh - ResultView: pemenang besar dengan kutipan menang, yang kalah redup dengan kutipan kalah, rincian koin per baris, statistik singkat, tombol Main lagi / Pilih ulang / Ke toko / Menu utama; akhir Arcade/Survival memakai tampilan yang sama - Meta.award menyimpan rincian koin di Meta.lastAward tanpa mengubah jumlah koin - content/quotes.js: kutipan intro/win/lose untuk 7 karakter; build_precache.py ikut memuat folder content/ - tests/t2-vsresult.mjs: uji headless VS, lewati/batal, hasil, Arcade/Survival, jalur menu, ponsel lanskap
This commit is contained in:
parent
b9a0a1b06c
commit
2315bbe5ed
|
|
@ -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
32
game.js
|
|
@ -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';
|
||||||
|
|
@ -2807,6 +2821,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();
|
||||||
|
|
|
||||||
|
|
@ -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 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) S · Menunduk<br>SPACE · Kombo 3 pukulan ↓ ↓ + SPACE · Uppercut ↓ → + SPACE · Tinju maju<br>I / O · Skill P · Ultimate K · Jurus toko<br>L · Blok (ketuk = parry) J · Lempar</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 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) S · Menunduk<br>SPACE · Kombo 3 pukulan ↓ ↓ + SPACE · Uppercut ↓ → + SPACE · Tinju maju<br>I / O · Skill P · Ultimate K · Jurus toko<br>L · Blok (ketuk = parry) J · Lempar</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
||||||
<dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</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></dialog>
|
<dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</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></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>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
9
menu.js
9
menu.js
|
|
@ -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
36
meta.js
|
|
@ -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; } };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
|
};
|
||||||
|
|
@ -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]])
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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 `<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">“${esc(q)}”</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>“${esc(q.win)}”</p><cite>${esc(Wf.name)}</cite></blockquote>`
|
||||||
|
+ `<div class="rs-cols">${coins}<section class="rs-stats" aria-label="Statistik"><h3>STATISTIK</h3><dl>${stats}</dl></section></div>`
|
||||||
|
+ `<div class="rs-actions"><button class="menu-primary rs-again" data-cmd="rematch">MAIN LAGI <b>↗</b></button><button class="result-secondary" data-cmd="reselect">PILIH ULANG</button><button class="result-secondary" data-cmd="shop">KE TOKO</button><button class="result-secondary" data-cmd="home">MENU UTAMA</button></div></div>`
|
||||||
|
+ `<div class="rs-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>“${esc(q.lose)}”</p></div></div></div>`;
|
||||||
|
return { title: run ? (run.kind === 'arcade' ? 'MODE ARCADE' : 'MODE SURVIVAL') : 'PERTANDINGAN SELESAI', eyebrow: stName ? stName.toUpperCase() : 'KONOHA FIGHTING', html };
|
||||||
|
}
|
||||||
|
// 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 };
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue