Gabungkan wf/t2-hud: meter ultimate di HUD dan daftar gerakan di menu jeda (Tahap 2.1, 2.4)
This commit is contained in:
commit
844912b928
66
game.js
66
game.js
|
|
@ -2704,9 +2704,73 @@
|
|||
$('#player-status').textContent=selectedCharacter==='fenr'?(hero.form==='wolf'?'FERAL ACTIVE':'RANGER READY'):selectedCharacter==='mira'?'PILOT READY':selectedCharacter==='cora'?'WINGS READY':selectedCharacter==='naja'?'SANDS READY':selectedCharacter==='haldor'?'FURNACE HOT':selectedCharacter==='zanni'?'SHOWTIME':selectedCharacter==='isolde'?'LANCE READY':selectedCharacter==='rhea'?'ORBITS SET':selectedCharacter==='solan'?'PRIDE READY':selectedCharacter==='nib'?'ON THE ROUTE':selectedCharacter==='edda'?'STEADY AS STONE':CF[selectedCharacter]?(bwx.rage.player>0&&modeOf(selectedCharacter)?modeOf(selectedCharacter).label+' '+Math.ceil(bwx.rage.player)+'s':CF[selectedCharacter].status):selectedCharacter==='browo'?(bwx.rage.player>0?'MENGAMUK '+Math.ceil(bwx.rage.player)+'s':'SAFARI READY'):'CORE ACTIVE';
|
||||
for(const [actor,id] of [[hero,'player-form-timer'],[dummy,'enemy-form-timer']]){const el=$('#'+id),active=actor.form==='wolf';el.hidden=!active;el.textContent=active?'FERAL '+Math.max(0,actor.formTime).toFixed(1)+' s':'';el.style.setProperty?.('--remaining',String(actor.formTime/F.balance.duration));}
|
||||
}
|
||||
// ---- Meter ultimate di HUD (Tahap 2.1) ----
|
||||
// Satu bar tipis di bawah HP tiap sisi (#ult-player, #ult-enemy). Data yang sama untuk pemain dan CPU/P2: cooldown ultimate
|
||||
// aktor, mode pasca-ultimate dari modeOf()/bwx.rage, bentuk feral FENR dari form/formTime. Bar terisi = cooldown pulih;
|
||||
// SIAP berdenyut; selama mode bar menjadi waktu mode yang menyusut. DOM hanya ditulis saat tampilannya berubah.
|
||||
const ULT_COLOR = { arco: '#67e4dd', fenr: '#e0a060', browo: '#d9b44a' }, ultMeters = {};
|
||||
function ultMax(side) {
|
||||
if (side === 'player') return cooldownMax.ultimate;
|
||||
const id = opponentCharacter;
|
||||
return (id === 'fenr' ? F?.balance?.cooldowns?.ultimate : KITS[id]?.balance?.cooldowns?.ultimate) || BALANCE.ultimate.cooldown;
|
||||
}
|
||||
function ultState(side) {
|
||||
const mine = side === 'player', actor = mine ? hero : dummy, id = mine ? selectedCharacter : opponentCharacter;
|
||||
if (id === 'fenr' && F && (actor.form === 'wolf' || actor.transformPending > 0)) return { mode: true, label: 'FERAL', left: actor.formTime || F.balance.duration, total: F.balance.duration, color: ULT_COLOR.fenr };
|
||||
const md = bwx.rage[side] > 0 ? modeOf(id) : null;
|
||||
if (md) return { mode: true, label: md.label, left: bwx.rage[side], total: md.duration, color: md.aura, again: (actor.cooldowns.ultimate || 0) <= 0 };
|
||||
return { mode: false, cd: Math.max(0, actor.cooldowns.ultimate || 0), max: Math.min(ultMax(side), 120), color: CF[id]?.color || ULT_COLOR[id] || '#e6c78f' };
|
||||
}
|
||||
function updateUltMeter(side) {
|
||||
let m = ultMeters[side];
|
||||
if (m === undefined) { const root = $('#ult-' + side); m = ultMeters[side] = root ? { root, fill: root.querySelector('.ult-fill'), name: root.querySelector('.ult-name'), val: root.querySelector('.ult-val'), kbd: root.querySelector('.ult-kbd'), key: '', p: -1 } : null; }
|
||||
if (!m) return;
|
||||
const live = side === 'player' || aiEnabled || p2Mode, st = ultState(side);
|
||||
let state, p, name, val, low = false;
|
||||
if (st.mode) { state = 'mode'; p = clamp(st.left / st.total, 0, 1); name = st.label; val = st.left.toFixed(1).replace('.', ',') + ' dtk'; low = st.left <= 2; }
|
||||
else if (st.cd <= 0) { state = 'ready'; p = 1; name = 'ULTIMATE'; val = 'SIAP'; }
|
||||
else { state = 'charging'; p = clamp(1 - st.cd / st.max, 0, 1); name = 'ULTIMATE'; val = Math.ceil(st.cd) + ' dtk'; }
|
||||
const kbd = side === 'player' ? 'P' : '', key = [live, state, name, val, low, st.again, st.color, kbd].join('|');
|
||||
if (key !== m.key) {
|
||||
m.key = key; m.root.hidden = !live; m.root.dataset.state = state; m.root.classList.toggle('low', low); m.root.classList.toggle('again', !!st.again);
|
||||
m.root.style.setProperty('--ult', st.color); m.name.textContent = name; m.val.textContent = val; m.kbd.textContent = kbd; m.kbd.hidden = !kbd;
|
||||
m.root.setAttribute('aria-label', (side === 'player' ? 'Ultimate pemain: ' : 'Ultimate lawan: ') + (st.mode ? 'mode ' + name + ' aktif' : state === 'ready' ? 'siap' : 'mengisi'));
|
||||
}
|
||||
if (Math.abs(p - m.p) > .002) { m.p = p; m.fill.style.transform = 'scaleX(' + p.toFixed(3) + ')'; }
|
||||
}
|
||||
function updateUltMeters() { if (menuOpen) return; updateUltMeter('player'); updateUltMeter('enemy'); }
|
||||
// ---- Daftar gerakan di menu jeda (Tahap 2.4) ----
|
||||
// Diisi ulang tiap jeda dari data karakter yang sedang dimainkan (nama skill, mode, jurus toko) dan dari mode dua pemain.
|
||||
const MOVE_KEY_PREF = 'konoha.moveList';
|
||||
function buildMoveList() {
|
||||
const body = $('#move-list-body'); if (!body) return;
|
||||
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
|
||||
const kb = list => { const k = el('span', 'mv-keys'); list.forEach(x => { if (x === '+') k.append(el('span', '', '+')); else k.append(el('kbd', 'mv-key', x)); }); return k; };
|
||||
// hint satu baris di samping nama (inline) atau di baris kedua (nama skill yang panjang)
|
||||
const row = (list, name, hint, inline) => { const r = el('div', 'mv-row'), t = el('span'); t.append(el('span', '', name)); if (hint) t.append(el('small', inline ? 'inl' : '', hint)); r.append(kb(list), t); return r; };
|
||||
const sec = (title, sub, cls) => { const s = el('section', 'mv-sec' + (cls ? ' ' + cls : '')), h = el('h3', '', title); if (sub) h.append(el('small', '', sub)); s.append(h); body.append(s); return s; };
|
||||
body.textContent = '';
|
||||
const id = selectedCharacter, me = fighterInfo(id, 'human'), base = bwBaseCooldowns(id) || cooldownMax, md = modeOf(id), cd = n => Math.round(base[n]) + ' dtk';
|
||||
const shop = SHOP_MOVES[window.Meta?.equippedMove?.()];
|
||||
sec('Kontrol dasar').append(row(['A', 'D'], 'Jalan', 'ketuk 2× = lari', 1), row(['W'], 'Lompat', '2× = lompat ganda', 1), row(['S'], 'Menunduk'), row(['L'], 'Blok', 'ketuk cepat = parry', 1), row(['J'], 'Lempar'), row(['K'], 'Jurus toko', shop ? shop.name : 'belum dipasang', 1));
|
||||
sec('Serangan dan skill', me.name).append(row(['Spasi'], me.names[0], 'kombo 3 pukulan'), row(['I'], me.names[1], 'skill 1 · cooldown ' + cd('skill1')), row(['O'], me.names[2], 'skill 2 · cooldown ' + cd('skill2')), row(['P'], me.names[3], 'ultimate · cooldown ' + cd('ultimate')));
|
||||
sec('Gerakan perintah', null, 'cmd').append(row(['↓', '↓', '+', 'Spasi'], 'Uppercut', 'peluncur ke atas'), row(['↓', '→', '+', 'Spasi'], 'Tinju maju', '→ = arah lawan'));
|
||||
if (md) { const ms = sec('Mode ' + md.label, Math.round(md.duration) + ' dtk'); ['Spasi', 'I', 'O', 'P'].forEach((k, i) => ms.append(row([k], md.names[i], i === 3 ? 'sekali pakai, mengakhiri mode' : '', i !== 3))); }
|
||||
const other = sec('Lainnya'); other.append(row(['Esc'], 'Jeda / lanjut'), row(['R'], 'Mulai ulang'));
|
||||
if (document.documentElement.classList.contains('touch')) other.append(el('small', 'mv-note', 'Layar sentuh: UP, →P, K, BLOK, dan LEMPAR ada di sisi kanan.'));
|
||||
if (p2Mode) { const rv = fighterInfo(opponentCharacter, 'human'), p2 = sec('Pemain 2', rv.name); p2.append(row(['←', '→'], 'Jalan'), row(['↑'], 'Lompat'), row(['N'], rv.names[0], 'kombo 3 pukulan'), row(['M'], rv.names[1], 'skill 1'), row([','], rv.names[2], 'skill 2'), row(['.'], rv.names[3], 'ultimate')); }
|
||||
}
|
||||
// Pilihan lipat/buka diingat per perangkat; tanpa pilihan, terbuka bila jendela cukup lebar. Hanya klik pengguna pada ringkasan yang menyimpan.
|
||||
function openMoveList() {
|
||||
buildMoveList();
|
||||
const d = $('#move-list'); if (!d) return;
|
||||
const saved = prefs.get(MOVE_KEY_PREF, null); d.open = saved === null ? window.innerWidth >= 700 : saved === '1';
|
||||
}
|
||||
$('#move-list > summary')?.addEventListener('click', () => prefs.set(MOVE_KEY_PREF, $('#move-list').open ? '0' : '1'));
|
||||
function updateHud() {
|
||||
updateFighterIdentity();
|
||||
updateMatchHud();
|
||||
updateUltMeters();
|
||||
if(systemAnnouncer){const a=systemAnnouncer.snapshot();canvas.dataset.announcerVoice=a.voice;canvas.dataset.announcerCue=a.cue;canvas.dataset.announcerLastCue=a.lastCue;canvas.dataset.announcerActive=String(a.active);canvas.dataset.announcerPaused=String(a.paused);canvas.dataset.announcerTime=String(a.time);canvas.dataset.announcerStarts=String(a.starts);canvas.dataset.announcerError=a.error;}
|
||||
canvas.dataset.character=selectedCharacter;canvas.dataset.playerForm=hero.form||'human';canvas.dataset.opponentForm=dummy.form||'human';canvas.dataset.opponentState=dummy.state;canvas.dataset.aiEnabled=String(aiEnabled);canvas.dataset.opponentFrame=String(opponentPose().frame);
|
||||
canvas.dataset.ready = String(ready);
|
||||
|
|
@ -2769,7 +2833,7 @@
|
|||
for(const id of Object.keys(CF)){CUTIN_ART[id]='assets/'+id+'/ui/cutin.webp';if(CF[id].mode)CUTIN_ART[id+'~mode']='assets/'+id+'/ui/cutin-mode.webp';}
|
||||
let cutinWarm=[];
|
||||
function warmCutins(){cutinWarm=[...new Set([selectedCharacter,opponentCharacter])].flatMap(id=>id==='browo'?[CUTIN_ART.browo,CUTIN_ART.browofire]:[CUTIN_ART[id],CUTIN_ART[id+'~mode']]).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});}
|
||||
function setPaused(value) { paused = value; clearInput(); syncUltimateVoice(); $('#pause-btn').textContent = value ? '▶' : 'Ⅱ'; const panel = $('#pause-panel'); if (value && !panel.open) panel.showModal(); if (!value && panel.open) panel.close(); if (!value) canvas.focus({ preventScroll: true }); }
|
||||
function setPaused(value) { paused = value; clearInput(); syncUltimateVoice(); $('#pause-btn').textContent = value ? '▶' : 'Ⅱ'; const panel = $('#pause-panel'); if (value && !panel.open) { try { openMoveList(); } catch (e) { console.error(e); } panel.showModal(); } if (!value && panel.open) panel.close(); if (!value) canvas.focus({ preventScroll: true }); }
|
||||
function openSettings() { settingsOpen = true; clearInput(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); $('#close-settings').innerHTML=(menuOpen?'Kembali ke menu':'Kembali ke arena')+' <span>↗</span>'; $('#settings-panel').showModal(); }
|
||||
function closeSettings() { settingsOpen = false; $('#settings-panel').close(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); canvas.focus({ preventScroll: true }); }
|
||||
const handled = new Set(['w','a','s','d',' ','i','o','p','k','l','j','escape','r','arrowleft','arrowright','arrowup','n','m',',','.']);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
<header class="combat-header">
|
||||
<section class="fighter fighter-one" aria-label="Status pemain">
|
||||
<div class="portrait-frame"><img id="player-portrait" class="portrait-image" data-portrait-side="player" src="assets/ui/arco-avatar.webp" alt="Portrait ARCO"><b class="player-tag">P1</b></div>
|
||||
<div class="fighter-fields"><div class="name-row"><h1 id="player-name">ARCO</h1><span class="class-tag" id="player-class">MECHA</span></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="health-reserve" class="life-fill reserve-fill"></div><div id="health-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="player-title">AETHER ARM</span><span id="health-label">200 / 200</span></div><div class="fighter-mark"><i></i><i></i><i></i><b class="round-wins" id="round-wins-player"></b><span id="player-status">CORE ACTIVE</span></div></div></section>
|
||||
<div class="fighter-fields"><div class="name-row"><h1 id="player-name">ARCO</h1><span class="class-tag" id="player-class">MECHA</span></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="health-reserve" class="life-fill reserve-fill"></div><div id="health-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="player-title">AETHER ARM</span><span id="health-label">200 / 200</span></div><div class="ult-meter" id="ult-player" data-state="ready" role="img" aria-label="Meter ultimate"><span class="ult-name">ULTIMATE</span><span class="ult-track"><i class="ult-fill"></i></span><span class="ult-val">SIAP</span><kbd class="ult-kbd">P</kbd></div><div class="fighter-mark"><i></i><i></i><i></i><b class="round-wins" id="round-wins-player"></b><span id="player-status">CORE ACTIVE</span></div></div></section>
|
||||
<section class="match-center" aria-label="Mode latihan tanpa batas waktu"><div class="mode-kicker" id="mode-label">FREE TRAINING</div><div class="match-clock" id="match-clock">∞</div><div class="match-rule"><i></i><span id="match-score">VS</span><i></i></div></section>
|
||||
<section class="fighter fighter-two" aria-label="Status lawan FENR"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">DEMI-HUMAN</span><h2 id="enemy-name">FENR</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">WOLF RANGER</span></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Portrait FENR"><b class="player-tag">CPU</b></div></section>
|
||||
<section class="fighter fighter-two" aria-label="Status lawan FENR"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">DEMI-HUMAN</span><h2 id="enemy-name">FENR</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">WOLF RANGER</span></div><div class="ult-meter" id="ult-enemy" data-state="ready" role="img" aria-label="Meter ultimate"><span class="ult-name">ULTIMATE</span><span class="ult-track"><i class="ult-fill"></i></span><span class="ult-val">SIAP</span><kbd class="ult-kbd" hidden>P</kbd></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Portrait FENR"><b class="player-tag">CPU</b></div></section>
|
||||
</header><div class="form-timers"><div id="player-form-timer" class="form-timer" hidden></div><div id="enemy-form-timer" class="form-timer" hidden></div></div>
|
||||
<nav class="match-actions" aria-label="Menu arena"><button id="settings-btn" aria-label="Buka pengaturan" title="Pengaturan"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></svg></button><button id="pause-btn" aria-label="Jeda game" title="Jeda (Esc)">Ⅱ</button></nav>
|
||||
<section id="combo" class="combo-counter" aria-live="polite"><span id="combo-count">0</span><div><strong>HITS</strong><small id="combo-damage">0 DAMAGE</small></div></section><div id="announcement" class="announcement" role="status"></div><div id="combo-rank" class="combo-rank" aria-hidden="true"><b></b><small></small><i></i></div>
|
||||
|
|
@ -22,8 +22,8 @@
|
|||
</div></section><div class="stage-sign"><span id="stage-name">BELLORA</span><small id="stage-subtitle">COURTYARD</small><i></i></div><section class="training-data" aria-label="Hasil latihan"><div><span>TOTAL DAMAGE</span><strong id="total-damage">0 <small>DMG</small></strong></div><div><span>BEST CHAIN</span><strong id="best-combo">0 <small>HITS</small></strong></div><small class="reset-hint">R · RESET<span>ESC · PAUSE</span></small></section></footer>
|
||||
<section id="touch-controls" aria-hidden="true"><div class="dpad"><button class="dbtn dbtn-up" data-key="w" tabindex="-1" aria-label="Atas / lompat"><i></i></button><button class="dbtn dbtn-left" data-key="a" tabindex="-1" aria-label="Kiri"><i></i></button><button class="dbtn dbtn-down" data-key="s" tabindex="-1" aria-label="Bawah / jongkok"><i></i></button><button class="dbtn dbtn-right" data-key="d" tabindex="-1" aria-label="Kanan"><i></i></button></div><div class="action-pad"><button class="tbtn tbtn-attack" data-key=" " data-slot="attack" tabindex="-1"><img src="assets/ui/attack.webp" alt=""></button><button class="tbtn tbtn-skill tbtn-s1" data-key="i" data-slot="skill1" tabindex="-1"><img src="assets/ui/skill1.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-s2" data-key="o" data-slot="skill2" tabindex="-1"><img src="assets/ui/skill2.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-ult" data-key="p" data-slot="ultimate" tabindex="-1"><img src="assets/ui/squadron-icon.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button></div><div class="extra-pad"><button class="tbtn tbtn-x" data-key="cmd:uppercut" tabindex="-1">UP</button><button class="tbtn tbtn-x" data-key="cmd:lunge" tabindex="-1">→P</button><button class="tbtn tbtn-x" data-key="k" tabindex="-1">K</button><button class="tbtn tbtn-x" data-key="l" tabindex="-1">BLOK</button><button class="tbtn tbtn-x" data-key="j" tabindex="-1">LEMPAR</button></div></section>
|
||||
<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="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="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<br>Esc · Jeda, berisi daftar gerakan karakter R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak ↑ · Lompat N · Serang M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
||||
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
|
||||
</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>
|
||||
|
||||
|
||||
|
|
|
|||
61
style.css
61
style.css
|
|
@ -99,3 +99,64 @@ html.touch .vignette{background:linear-gradient(180deg,#051724a6,transparent 28%
|
|||
.boot-bar{height:6px;margin:clamp(18px,5vmin,34px) 0 12px;background:#ffffff1a;border:1px solid #e8d4a340;overflow:hidden}
|
||||
.boot-bar i{display:block;height:100%;background:linear-gradient(90deg,#86d9c4,#e0bf85);transform:scaleX(0);transform-origin:0 50%;transition:transform .15s linear}
|
||||
.boot-status{font-size:13px;letter-spacing:1.5px;color:#a9c7c9}
|
||||
|
||||
/* Meter ultimate (Tahap 2.1): bar tipis di bawah HP. Terisi mengikuti pemulihan cooldown ultimate, berdenyut saat SIAP,
|
||||
dan menjadi bar waktu yang menyusut selama mode pasca-ultimate. Warna (--ult) diisi game.js dari data karakter. */
|
||||
.ult-meter{--ult:#e6c78f;display:flex;align-items:center;gap:7px;height:14px;margin:6px 2px 0;font-size:10px;font-weight:700;letter-spacing:1.3px;line-height:1;color:#dbe7e4;text-shadow:1px 1px 2px #051923;white-space:nowrap}
|
||||
.ult-meter[hidden]{display:flex;visibility:hidden}
|
||||
.ult-name{flex:0 0 auto}
|
||||
.ult-track{position:relative;flex:1;min-width:36px;height:9px;background:#0d1b25;border:1px solid #c0bbb18c;transform:skewX(-15deg);overflow:hidden;box-shadow:0 2px 0 #07162199}
|
||||
.ult-track:after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0,transparent calc(25% - 1px),#061a2380 calc(25% - 1px),#061a2380 25%);pointer-events:none}
|
||||
.ult-fill{position:absolute;inset:0;transform:scaleX(0);transform-origin:0 50%;background-color:var(--ult);background-image:linear-gradient(180deg,#ffffff8c,#fff0 48%,#0000004d)}
|
||||
.ult-val{flex:0 0 auto;min-width:36px;text-align:right}
|
||||
.ult-kbd{flex:0 0 auto;min-width:15px;padding:0 4px;font:inherit;line-height:14px;text-align:center;background:#ecddbb;color:#102731;text-shadow:none}
|
||||
.ult-kbd[hidden]{display:none}
|
||||
.ult-meter[data-state=charging] .ult-fill{opacity:.82}
|
||||
.ult-meter[data-state=charging] .ult-kbd{opacity:.45}
|
||||
.ult-meter[data-state=ready] .ult-name,.ult-meter[data-state=ready] .ult-val{color:#fff3d4;text-shadow:0 0 8px var(--ult),1px 1px 2px #051923}
|
||||
.ult-meter[data-state=ready] .ult-track{border-color:#fff3d4;animation:ult-glow .9s ease-in-out infinite alternate}
|
||||
.ult-meter[data-state=ready] .ult-fill:after{content:'';position:absolute;top:0;bottom:0;left:0;width:45%;background:linear-gradient(100deg,transparent,#ffffffd9,transparent);animation:ult-sweep 1.5s linear infinite}
|
||||
.ult-meter[data-state=ready] .ult-val{animation:ult-blink .9s ease-in-out infinite alternate}
|
||||
.ult-meter[data-state=mode] .ult-name,.ult-meter[data-state=mode] .ult-val{color:#fff;text-shadow:0 0 9px var(--ult),0 0 3px var(--ult),1px 1px 2px #051923}
|
||||
.ult-meter[data-state=mode] .ult-track{border-color:var(--ult);box-shadow:0 0 10px var(--ult),0 2px 0 #07162199}
|
||||
.ult-meter[data-state=mode] .ult-fill{animation:ult-flame .6s ease-in-out infinite alternate}
|
||||
.ult-meter[data-state=mode].low .ult-fill{animation:ult-warn .32s steps(2,jump-none) infinite}
|
||||
.ult-meter[data-state=mode] .ult-kbd{opacity:.45}.ult-meter[data-state=mode].again .ult-kbd{opacity:1}
|
||||
.fighter-two .ult-meter{flex-direction:row-reverse;height:12px;font-size:9px;margin-top:5px}
|
||||
.fighter-two .ult-track{height:7px;transform:skewX(15deg)}
|
||||
.fighter-two .ult-fill{transform-origin:100% 50%}
|
||||
.fighter-two .ult-fill:after{left:auto;right:0}
|
||||
.fighter-two .ult-val{text-align:left}
|
||||
html.touch .ult-kbd{display:none}
|
||||
@keyframes ult-glow{from{box-shadow:0 0 0 #0000,0 2px 0 #07162199}to{box-shadow:0 0 13px var(--ult),0 0 3px var(--ult),0 2px 0 #07162199}}
|
||||
@keyframes ult-sweep{from{transform:translateX(-110%)}to{transform:translateX(330%)}}
|
||||
@keyframes ult-blink{from{opacity:.62}to{opacity:1}}
|
||||
@keyframes ult-flame{from{filter:brightness(1)}to{filter:brightness(1.45) saturate(1.2)}}
|
||||
@keyframes ult-warn{from{opacity:1}to{opacity:.35}}
|
||||
@media(prefers-reduced-motion:reduce){.ult-meter .ult-track,.ult-meter .ult-fill,.ult-meter .ult-fill:after,.ult-meter .ult-val{animation:none!important}}
|
||||
@media(max-width:950px){.ult-meter{height:12px;font-size:9px;letter-spacing:.8px;margin-top:5px;gap:5px}.ult-track{height:8px}.fighter-two .ult-meter{height:11px;font-size:8px}.fighter-two .ult-track{height:6px}}
|
||||
@media(max-width:620px){.ult-meter{margin-left:54px;font-size:7px;letter-spacing:.2px;gap:4px;height:10px}.fighter-two .ult-meter{margin-left:2px;margin-right:54px}.ult-track{height:6px;min-width:28px}.fighter-two .ult-track{height:5px}.ult-val{min-width:26px}.ult-kbd{line-height:10px;padding:0 3px}}
|
||||
@media(max-height:500px) and (min-width:620px){.ult-meter{height:10px;margin-top:3px;font-size:8px;letter-spacing:.6px;gap:5px}.ult-track{height:7px}.fighter-two .ult-meter{height:10px;margin-top:3px}.fighter-two .ult-track{height:6px}.ult-kbd{line-height:10px}}
|
||||
/* Layar sentuh pendek (361-400 px, mis. 844x390): tombol tambahan sedikit lebih kecil supaya baris atasnya tidak menimpa meter lawan. */
|
||||
@media(max-height:400px) and (min-height:361px){html.touch .tbtn-x{width:40px;height:40px}}
|
||||
|
||||
/* Daftar gerakan di menu jeda (Tahap 2.4): kiri judul dan tombol, kanan daftar yang dapat dilipat; kolom daftar menyesuaikan lebar. */
|
||||
#pause-panel{width:min(940px,calc(100% - 24px))}
|
||||
#pause-panel[open]{display:grid;grid-template-columns:minmax(230px,260px) minmax(0,1fr);gap:0 30px;align-items:start}
|
||||
.pause-main{min-width:0}
|
||||
.move-list{min-width:0;border-left:1px solid #98abb72e;padding-left:28px}
|
||||
.move-list summary{cursor:pointer;font-size:19px;font-weight:700;letter-spacing:1.4px;color:var(--gold);padding:2px 0 6px;list-style-position:inside}
|
||||
.move-body{columns:225px;column-gap:26px;margin-top:6px}
|
||||
.mv-sec{break-inside:avoid;margin:0 0 14px;--kw:58px}
|
||||
.mv-sec.cmd{--kw:112px}
|
||||
.mv-sec h3{margin:0 0 4px;font-size:13px;font-weight:700;letter-spacing:1.6px;color:#8fe3d0;text-transform:uppercase;line-height:1.2}
|
||||
.mv-sec h3 small{margin-left:8px;font-size:12px;font-weight:500;letter-spacing:.3px;color:#91a7b0}
|
||||
.mv-row{display:grid;grid-template-columns:var(--kw) minmax(0,1fr);gap:8px;align-items:baseline;padding:3px 0;font-size:15px;font-weight:700;line-height:1.2;border-bottom:1px solid #98abb71c}
|
||||
.mv-row small{display:block;font-size:12px;font-weight:500;color:#91a7b0}
|
||||
.mv-row small.inl{display:inline;margin-left:7px}
|
||||
.mv-note{display:block;margin-top:6px;font-size:12px;color:#91a7b0}
|
||||
.mv-keys{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px;font-size:12px;color:#91a7b0}
|
||||
.mv-key{display:inline-block;min-width:20px;padding:1px 5px;font:700 12px/16px Rajdhani,sans-serif;text-align:center;background:#ecddbb;color:#102731}
|
||||
@media(max-width:760px){#pause-panel[open]{display:block}.move-list{border-left:0;border-top:1px solid #98abb72e;padding:12px 0 0;margin-top:14px}}
|
||||
@media(max-height:560px){#pause-panel[open]{padding:16px 22px;gap:0 22px;grid-template-columns:minmax(210px,250px) minmax(0,1fr)}.move-list{padding-left:22px}#pause-panel h2{font-size:28px;margin:4px 0}#pause-panel p{margin:6px 0 10px;font-size:14px}#pause-panel .primary-button{padding:9px 13px;font-size:16px}#pause-panel .text-button{margin-top:8px;font-size:14px}.move-list summary{font-size:17px;padding-bottom:2px}.move-body{margin-top:2px}.mv-sec{margin-bottom:9px}.mv-row{font-size:14px;padding:2px 0}.mv-row small{font-size:11px}.mv-key{font-size:11px;line-height:15px}}
|
||||
@media(max-height:420px){#pause-panel[open]{padding:12px 18px}#pause-panel h2{font-size:26px}#pause-panel p{margin:4px 0 8px}.mv-sec{margin-bottom:7px}.mv-sec h3{margin-bottom:2px;font-size:12px}.mv-row{padding:1px 0;font-size:13.5px}.mv-row small{font-size:10.5px}.mv-key{line-height:14px}.move-list summary{font-size:16px}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
// Tahap 2.1 + 2.4: meter ultimate di HUD, indikator mode, daftar gerakan di menu jeda.
|
||||
// node tests/harness.mjs tests/t2-hud.mjs
|
||||
// HUD_SHOTS=folder simpan screenshot (default: tmp/t2-hud di folder sementara sistem)
|
||||
// HUD_TOUCH=1 jalankan di layar sentuh 844x390 (html.touch); tanpa itu 1280x520
|
||||
// HUD_ONLY=regex hanya jalankan bagian yang namanya cocok (meter|enemy|cpu|pause|layout)
|
||||
// Jendela 520 px dipakai sengaja: tampilan harus muat di tinggi 520 px.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const touch = !!process.env.HUD_TOUCH;
|
||||
export const options = touch ? { query: '?touch=1&embed=1', viewport: { width: 844, height: 390 } } : { viewport: { width: 1280, height: 520 } };
|
||||
|
||||
export default async ({ page, game, characters, errors }) => {
|
||||
const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info: pass ? undefined : info });
|
||||
const only = process.env.HUD_ONLY ? new RegExp(process.env.HUD_ONLY) : null, want = n => !only || only.test(n);
|
||||
const dir = process.env.HUD_SHOTS || path.join(os.tmpdir(), 't2-hud'); fs.mkdirSync(dir, { recursive: true });
|
||||
const tag = touch ? 'touch' : 'desk';
|
||||
const shot = async name => { await game(() => window.__game.draw()); await page.screenshot({ path: path.join(dir, `${tag}-${name}.png`) }); };
|
||||
const MODES = { browo: 'MENGAMUK', gufron: 'KOLONI', soloman: 'BLUSUKAN', anice: 'SENATOR', ethanolman: 'LEDAKAN', sonofsolo: 'ASAM', megachan: 'MURKA' };
|
||||
const COLORS = { browo: '#ff6e28', gufron: '#c9a05a', soloman: '#ffd24a', anice: '#ffd24a', ethanolman: '#ff9a2a', sonofsolo: '#9dff3c', megachan: '#ff2a2a' };
|
||||
const BASE = { browo: '#d9b44a', gufron: '#c9a05a', soloman: '#e0b04a', anice: '#5aa9f0', ethanolman: '#2fbf7a', sonofsolo: '#7bd23c', megachan: '#d62828' };
|
||||
// Membaca keadaan meter dari DOM (apa yang benar-benar dilihat pemain).
|
||||
const read = side => game(side => {
|
||||
const r = document.querySelector('#ult-' + side), f = r.querySelector('.ult-fill'), t = f.style.transform.match(/scaleX\(([\d.]+)\)/);
|
||||
const box = r.getBoundingClientRect(), tr = r.querySelector('.ult-track').getBoundingClientRect();
|
||||
return { state: r.dataset.state, name: r.querySelector('.ult-name').textContent, val: r.querySelector('.ult-val').textContent, p: t ? +t[1] : null, hidden: r.hidden, low: r.classList.contains('low'), again: r.classList.contains('again'),
|
||||
color: r.style.getPropertyValue('--ult'), kbd: r.querySelector('.ult-kbd').hidden ? '' : r.querySelector('.ult-kbd').textContent, label: r.getAttribute('aria-label'),
|
||||
box: { x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.width), h: Math.round(box.height) }, trackW: Math.round(tr.width), vis: getComputedStyle(r).visibility };
|
||||
}, side);
|
||||
const start = (p, e, extra = {}) => game(({ p, e, extra }) => {
|
||||
const g = window.__game; g.manual = true; g.startMatch({ player: p, enemy: e, stage: 'bellora', level: 'easy', mode: extra.mode || 'versus' });
|
||||
g.setAI(!!extra.ai); if (extra.p2) g.setP2(true); g.advance(extra.wait ?? 3); return g.snapshot().hero.cooldowns;
|
||||
}, { p, e, extra });
|
||||
const press = (key, hold = 1 / 30) => game(({ key, hold }) => { const g = window.__game; g.press(key); g.advance(hold); g.release(key); g.advance(1 / 60); }, { key, hold });
|
||||
const adv = s => game(s => { window.__game.advance(s); }, s);
|
||||
const near = (a, b, t = .02) => Math.abs(a - b) <= t;
|
||||
|
||||
// ---------- 1. meter pemain: SIAP -> mengisi -> mode (7 karakter) ----------
|
||||
if (want('meter')) for (const [i, ch] of characters.entries()) {
|
||||
const rival = characters[(i + 3) % characters.length];
|
||||
await start(ch, rival, { ai: false });
|
||||
const a = await read('player');
|
||||
ok(`${ch}: awal ronde SIAP berdenyut`, a.state === 'ready' && a.val === 'SIAP' && a.name === 'ULTIMATE' && near(a.p, 1, .001) && !a.hidden && (touch ? true : a.kbd === 'P'), a);
|
||||
ok(`${ch}: warna meter = warna karakter`, a.color.toLowerCase() === BASE[ch], { got: a.color, want: BASE[ch] });
|
||||
if (ch === characters[0]) await shot('ready');
|
||||
await press('p');
|
||||
// lewati sinematik ultimate sampai mode atau cooldown berjalan
|
||||
let seen = null, mid = null, prevP = null, dec = true, steps = 0, charging = null;
|
||||
for (; steps < 200 && !seen; steps++) {
|
||||
await adv(.1); const r = await read('player');
|
||||
if (r.state === 'mode') seen = r; else if (r.state === 'charging' && !charging) charging = r;
|
||||
}
|
||||
ok(`${ch}: setelah ultimate masuk mode ${MODES[ch]}`, seen && seen.name === MODES[ch] && seen.color.toLowerCase() === COLORS[ch], { seen, steps });
|
||||
if (seen) {
|
||||
ok(`${ch}: bar mode penuh di awal lalu menyusut`, seen.p > .85, seen);
|
||||
const vals = [seen.p];
|
||||
for (let k = 0; k < 4; k++) { await adv(1.5); const r = await read('player'); if (r.state !== 'mode') break; vals.push(r.p); mid = r; }
|
||||
for (let k = 1; k < vals.length; k++) if (!(vals[k] < vals[k - 1])) dec = false;
|
||||
ok(`${ch}: bar mode menyusut monoton`, dec && vals.length >= 4, vals);
|
||||
ok(`${ch}: teks sisa waktu mengikuti bar`, mid && /^\d+,\d dtk$/.test(mid.val) && near(parseFloat(mid.val.replace(',', '.')) / 10, mid.p, .03), mid);
|
||||
if (ch === 'browo' || ch === 'anice') await shot('mode-' + ch);
|
||||
for (let k = 0; k < 12; k++) { await adv(1); const r = await read('player'); if (r.state !== 'mode') { prevP = r; break; } }
|
||||
ok(`${ch}: mode berakhir kembali ke bar cooldown`, prevP && prevP.state === 'charging' && prevP.p < 1 && /^\d+ dtk$/.test(prevP.val), prevP);
|
||||
}
|
||||
// cooldown menurun: bar naik, teks sesuai cooldown asli
|
||||
const before = await read('player'); await adv(5); const after = await read('player');
|
||||
const cdNow = await game(() => window.__game.hero.cooldowns.ultimate);
|
||||
ok(`${ch}: bar cooldown naik dan teks = ceil(cooldown)`, after.state === 'charging' && after.p > before.p && after.val === Math.ceil(cdNow) + ' dtk', { before, after, cdNow });
|
||||
if (ch === characters[0]) await shot('charging');
|
||||
// pulih penuh -> SIAP lagi
|
||||
await adv(40); const done = await read('player');
|
||||
ok(`${ch}: cooldown habis kembali SIAP`, done.state === 'ready' && near(done.p, 1, .001), done);
|
||||
}
|
||||
|
||||
// ---------- 2. meter lawan (sisi P2 dan CPU) memakai data yang sama ----------
|
||||
if (want('enemy')) for (const [i, ch] of characters.entries()) {
|
||||
const me = characters[(i + 1) % characters.length];
|
||||
await start(me, ch, { ai: false, p2: true });
|
||||
const a = await read('enemy');
|
||||
ok(`lawan ${ch}: terlihat dan SIAP di awal (ringkas, tanpa tombol)`, !a.hidden && a.vis === 'visible' && a.state === 'ready' && a.kbd === '', a);
|
||||
ok(`lawan ${ch}: warna meter = warna karakter`, a.color.toLowerCase() === BASE[ch], { got: a.color, want: BASE[ch] });
|
||||
await press('.');
|
||||
let seen = null, steps = 0;
|
||||
for (; steps < 200 && !seen; steps++) { await adv(.1); const r = await read('enemy'); if (r.state === 'mode') seen = r; }
|
||||
ok(`lawan ${ch}: mode ${MODES[ch]} tampil di sisi lawan`, seen && seen.name === MODES[ch] && seen.color.toLowerCase() === COLORS[ch] && seen.p > .8, { seen, steps });
|
||||
// efek mode sungguhan terjadi di sisi lawan (cooldown skill diganti) sehingga indikator tidak sekadar kosmetik
|
||||
const rage = await game(() => window.__game.dummy.bwRage);
|
||||
ok(`lawan ${ch}: mode aktif juga di mesin (dummy.bwRage)`, rage === true, rage);
|
||||
if (ch === 'anice' || ch === 'browo') await shot('enemy-mode-' + ch);
|
||||
await adv(12); const back = await read('enemy'); const cdNow = await game(() => window.__game.dummy.cooldowns.ultimate);
|
||||
ok(`lawan ${ch}: setelah mode bar cooldown sesuai`, back.state === 'charging' && back.val === Math.ceil(cdNow) + ' dtk', { back, cdNow });
|
||||
}
|
||||
|
||||
// ---------- 3. lawan CPU memakai ultimate sendiri; latihan tanpa AI menyembunyikan meter lawan ----------
|
||||
if (want('cpu')) {
|
||||
await start('gufron', 'megachan', { ai: true, wait: .2 });
|
||||
const states = new Set(); let modeSeen = null;
|
||||
for (let k = 0; k < 400 && !modeSeen; k++) { await adv(.25); const r = await read('enemy'); states.add(r.state); if (r.state === 'mode') modeSeen = r; }
|
||||
ok('CPU: meter lawan melewati SIAP lalu mode saat CPU memakai ultimate', states.has('ready') && modeSeen && modeSeen.name === 'MURKA', { states: [...states], modeSeen });
|
||||
await start('gufron', 'megachan', { mode: 'training', ai: false });
|
||||
const off = await read('enemy'); ok('latihan tanpa AI: meter lawan tersembunyi', off.hidden && off.vis === 'hidden', off);
|
||||
await game(() => window.__game.setAI(true)); await adv(.1);
|
||||
const on = await read('enemy'); ok('latihan dengan AI: meter lawan tampil', !on.hidden && on.vis === 'visible', on);
|
||||
const pl = await read('player'); ok('latihan: meter pemain tetap tampil', !pl.hidden, pl);
|
||||
}
|
||||
|
||||
// ---------- 4. karakter lama (ARCO, FENR) tidak merusak meter ----------
|
||||
if (want('legacy')) {
|
||||
for (const [p, e] of [['arco', 'fenr'], ['fenr', 'arco']]) {
|
||||
await start(p, e, { ai: true, wait: 3 });
|
||||
const a = await read('player'), b = await read('enemy');
|
||||
ok(`${p} vs ${e}: meter terisi`, a.state === 'ready' && b.state === 'ready', { a, b });
|
||||
await press('p'); await adv(4);
|
||||
const c = await read('player'); ok(`${p}: setelah ultimate meter berubah (mode/cooldown)`, ['mode', 'charging'].includes(c.state), c);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 4b. menu jeda untuk karakter lama dan jendela sempit ----------
|
||||
if (want('pause')) {
|
||||
await game(() => { for (const s of [...document.querySelectorAll('style')]) if (s.textContent.includes('#pause-panel{display:none!important}')) s.remove(); try { localStorage.removeItem('konoha.moveList'); } catch (_) {} });
|
||||
for (const ch of ['arco', 'fenr', 'mira']) {
|
||||
await start(ch, 'gufron', { ai: true, wait: .5 });
|
||||
const r = await game(() => { window.__game.setPaused(true); const d = document.querySelector('#pause-panel'), rows = d.querySelectorAll('.mv-row').length, keys = [...d.querySelectorAll('.mv-key')].map(k => k.textContent); window.__game.setPaused(false); return { open: true, rows, keys }; });
|
||||
ok(`jeda ${ch} (karakter lama): daftar terisi tanpa galat`, r.rows >= 12 && ['Spasi', 'I', 'O', 'P', 'K', 'L', 'J'].every(k => r.keys.includes(k)), r);
|
||||
}
|
||||
if (!touch) {
|
||||
await page.setViewportSize({ width: 600, height: 420 }); await page.waitForTimeout(100);
|
||||
await start('browo', 'anice', { ai: true, wait: .5 });
|
||||
await game(() => window.__game.setPaused(true));
|
||||
const n = await game(() => { const d = document.querySelector('#pause-panel'), r = d.getBoundingClientRect(), b = document.querySelector('#resume-btn').getBoundingClientRect(), det = document.querySelector('#move-list'); const was = det.open; det.open = true;
|
||||
return new Promise(res => setTimeout(() => { d.scrollTop = d.scrollHeight; const rows = [...d.querySelectorAll('.mv-row')], last = rows[rows.length - 1].getBoundingClientRect(), dr = d.getBoundingClientRect(); res({ was, fits: r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth, resume: b.top >= 0 && b.bottom <= innerHeight, lastVisible: last.bottom <= dr.bottom + 1, canScroll: d.scrollHeight > d.clientHeight, oy: getComputedStyle(d).overflowY, hs: d.scrollWidth > d.clientWidth }); }, 60)); });
|
||||
ok('jeda 600x420: terlipat di jendela sempit, dialog muat, dapat digulir saat dibuka, tanpa gulir samping', n.was === false && n.fits && n.resume && n.lastVisible && n.oy === 'auto' && !n.hs, n);
|
||||
await shot('pause-600x420'); await game(() => { document.querySelector('#pause-panel').scrollTop = 0; window.__game.setPaused(false); });
|
||||
await page.setViewportSize({ width: 1280, height: 520 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 5. menu jeda: daftar gerakan ----------
|
||||
if (want('pause')) {
|
||||
await game(() => { for (const s of [...document.querySelectorAll('style')]) if (s.textContent.includes('#pause-panel{display:none!important}')) s.remove(); try { localStorage.removeItem('konoha.moveList'); } catch (_) {} });
|
||||
const dump = () => game(() => {
|
||||
const d = document.querySelector('#pause-panel'), det = document.querySelector('#move-list'), body = document.querySelector('#move-list-body');
|
||||
const r = d.getBoundingClientRect(), rb = document.querySelector('#resume-btn').getBoundingClientRect();
|
||||
return { open: d.open, detOpen: det.open, text: d.innerText, keys: [...body.querySelectorAll('.mv-key')].map(k => k.textContent), names: [...body.querySelectorAll('.mv-row > span:last-child > span')].map(n => n.textContent),
|
||||
titles: [...body.querySelectorAll('h3')].map(h => h.childNodes[0].textContent), rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), b: Math.round(r.bottom) }, resume: { y: Math.round(rb.y), b: Math.round(rb.bottom) },
|
||||
scroll: { sh: d.scrollHeight, ch: d.clientHeight, oy: getComputedStyle(d).overflowY }, vw: innerWidth, vh: innerHeight, paused: window.__game.snapshot().paused };
|
||||
});
|
||||
const FIGHT = await game(() => Object.fromEntries(Object.entries(window.FIGHTERS).map(([id, f]) => [id, { names: f.names, mode: f.mode && { label: f.mode.label, names: f.mode.names } }])));
|
||||
const BW = await game(() => ({ names: window.Browo.names, rage: window.Browo.rageNames }));
|
||||
for (const ch of characters) {
|
||||
await start(ch, characters[(characters.indexOf(ch) + 2) % characters.length], { ai: true, wait: .5 });
|
||||
await game(() => window.__game.setPaused(true));
|
||||
const d = await dump();
|
||||
const want4 = ch === 'browo' ? BW.names : FIGHT[ch].names, wantMode = ch === 'browo' ? BW.rage : FIGHT[ch].mode.names;
|
||||
ok(`jeda ${ch}: dialog terbuka dan game dijeda`, d.open && d.paused, d);
|
||||
ok(`jeda ${ch}: berisi 11 tombol dasar`, ['A', 'D', 'W', 'S', 'Spasi', 'I', 'O', 'P', 'K', 'L', 'J'].every(k => d.keys.includes(k)), d.keys);
|
||||
ok(`jeda ${ch}: gerakan perintah`, d.text.includes('Uppercut') && d.text.includes('Tinju maju') && d.keys.filter(k => k === '↓').length === 3 && d.keys.includes('→'), d.keys);
|
||||
ok(`jeda ${ch}: nama skill karakter`, want4.every(n => d.names.includes(n)), { want4, names: d.names });
|
||||
ok(`jeda ${ch}: nama skill mode ${MODES[ch]}`, wantMode.every(n => d.names.includes(n)) && d.titles.some(t => t === 'Mode ' + MODES[ch]), { wantMode, titles: d.titles });
|
||||
ok(`jeda ${ch}: tanpa bagian Pemain 2 di mode lawan CPU`, !d.titles.includes('Pemain 2') && !d.keys.includes('N'), d.titles);
|
||||
ok(`jeda ${ch}: teks Indonesia, tidak ada "PAUSED"`, !/PAUSED/.test(d.text) && d.text.includes('JEDA'), d.text.slice(0, 80));
|
||||
ok(`jeda ${ch}: panel muat di jendela (atau dapat digulir)`, d.rect.y >= 0 && d.rect.b <= d.vh && d.resume.y >= 0 && d.resume.b <= d.vh, d);
|
||||
ok(`jeda ${ch}: seluruh daftar terlihat tanpa menggulir`, d.scroll.sh <= d.scroll.ch + 1, d.scroll);
|
||||
if (ch === 'browo' || ch === 'anice') await shot('pause-' + ch);
|
||||
await game(() => window.__game.setPaused(false));
|
||||
}
|
||||
// dua pemain: kontrol P2 + nama skill lawan
|
||||
await start('gufron', 'anice', { ai: false, p2: true, wait: .5 });
|
||||
await game(() => window.__game.setPaused(true));
|
||||
let d = await dump();
|
||||
ok('jeda 2P: bagian Pemain 2 dengan tombol P2', d.titles.includes('Pemain 2') && ['←', '→', '↑', 'N', 'M', ',', '.'].every(k => d.keys.includes(k)), d);
|
||||
ok('jeda 2P: nama skill lawan (ANICE) di bagian P2', FIGHT.anice.names.every(n => d.names.includes(n)), d.names);
|
||||
ok('jeda 2P: panel muat atau dapat digulir', d.rect.y >= 0 && d.rect.b <= d.vh && d.resume.b <= d.vh, d);
|
||||
await shot('pause-2p');
|
||||
const reach = await game(() => { const dlg = document.querySelector('#pause-panel'); dlg.scrollTop = dlg.scrollHeight; const rows = [...dlg.querySelectorAll('.mv-row')], last = rows[rows.length - 1].getBoundingClientRect(), dr = dlg.getBoundingClientRect(); const r = { canScroll: dlg.scrollHeight > dlg.clientHeight, lastVisible: last.bottom <= dr.bottom + 1 && last.top >= dr.top, oy: getComputedStyle(dlg).overflowY }; dlg.scrollTop = 0; return r; });
|
||||
ok('jeda 2P: baris terakhir dapat dijangkau (muat atau digulir)', reach.lastVisible && (!reach.canScroll || reach.oy === 'auto'), reach);
|
||||
// tombol lipat + preferensi diingat
|
||||
const toggled = await game(() => { const det = document.querySelector('#move-list'), was = det.open; det.querySelector('summary').click(); return new Promise(r => setTimeout(() => r({ was, now: det.open, pref: localStorage.getItem('konoha.moveList') }), 60)); });
|
||||
ok('jeda: ringkasan melipat/membuka daftar dan menyimpan pilihan', toggled.was !== toggled.now && toggled.pref === (toggled.now ? '1' : '0'), toggled);
|
||||
d = await dump(); await shot('pause-' + (toggled.now ? 'open' : 'folded'));
|
||||
ok('jeda: terlipat tetap muat di jendela', d.rect.y >= 0 && d.rect.b <= d.vh, d);
|
||||
await game(() => window.__game.setPaused(false)); await game(() => window.__game.setPaused(true));
|
||||
d = await dump(); ok('jeda: pilihan lipat diingat saat dibuka lagi', d.detOpen === toggled.now, { d: d.detOpen, want: toggled.now });
|
||||
// Esc melanjutkan
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(80);
|
||||
d = await dump(); ok('jeda: Esc melanjutkan permainan', !d.open && !d.paused, d);
|
||||
// jeda -> lanjut tidak merusak meter
|
||||
const m = await read('player'); ok('jeda: meter pemain tetap berfungsi setelah lanjut', ['ready', 'charging', 'mode'].includes(m.state), m);
|
||||
}
|
||||
|
||||
// ---------- 6. tata letak: meter muat, tidak menimpa HP, tombol sentuh, dan kartu skill ----------
|
||||
if (want('layout')) {
|
||||
await start('anice', 'browo', { ai: true, wait: .5 });
|
||||
const lay = await game(() => {
|
||||
const R = s => { const e = document.querySelector(s); if (!e || e.offsetParent === null && getComputedStyle(e).position !== 'fixed') return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, r: r.right, b: r.bottom, w: r.width, h: r.height }; };
|
||||
const hit = (a, b) => a && b && a.x < b.r && b.x < a.r && a.y < b.b && b.y < a.b;
|
||||
const pm = R('#ult-player'), em = R('#ult-enemy'), pl = R('.fighter-one .life-frame'), el = R('.fighter-two .life-frame'), pc = R('.fighter-one .bar-caption'), ec = R('.fighter-two .bar-caption');
|
||||
const pp = R('.fighter-one .portrait-frame'), ep = R('.fighter-two .portrait-frame'), clock = R('.match-center'), acts = R('.match-actions'), ft = R('#player-form-timer');
|
||||
const touchBtns = [...document.querySelectorAll('#touch-controls .dbtn, #touch-controls .tbtn')].map(b => b.getBoundingClientRect()).filter(r => r.width > 0);
|
||||
const meters = [pm, em].filter(Boolean);
|
||||
return { vw: innerWidth, vh: innerHeight, pm, em, overlapHp: hit(pm, pl) || hit(em, el), overlapCaption: hit(pm, pc) || hit(em, ec), overlapPortrait: hit(pm, pp) || hit(em, ep), overlapClock: hit(pm, clock) || hit(em, clock), overlapActions: hit(pm, acts) || hit(em, acts),
|
||||
overlapTouch: touchBtns.some(t => meters.some(m => m.x < t.right && t.x < m.r && m.y < t.bottom && t.y < m.b)), touchCount: touchBtns.length, inView: meters.every(m => m.x >= 0 && m.r <= innerWidth && m.y >= 0 && m.b <= innerHeight),
|
||||
hscroll: document.documentElement.scrollWidth > innerWidth, vscroll: document.documentElement.scrollHeight > innerHeight, cards: R('.skill-deck') };
|
||||
});
|
||||
ok(`layout ${tag}: meter pemain dan lawan terlihat di dalam jendela`, lay.pm && lay.em && lay.inView, lay);
|
||||
ok(`layout ${tag}: tidak menimpa bar HP, keterangan, potret, jam, atau tombol jeda`, !lay.overlapHp && !lay.overlapCaption && !lay.overlapPortrait && !lay.overlapClock && !lay.overlapActions, lay);
|
||||
ok(`layout ${tag}: tidak menimpa tombol sentuh`, !lay.overlapTouch && (!touch || lay.touchCount >= 8), lay);
|
||||
ok(`layout ${tag}: tidak ada gulir halaman`, !lay.hscroll && !lay.vscroll, lay);
|
||||
await shot('layout');
|
||||
// jendela lain pada pengujian desktop: sempit (950 / 620) dan tinggi (720)
|
||||
if (!touch) for (const [w, h] of [[1280, 720], [960, 520], [844, 390], [600, 420]]) {
|
||||
await page.setViewportSize({ width: w, height: h }); await page.waitForTimeout(120);
|
||||
const l = await game(() => { const R = s => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x, y: r.y, r: r.right, b: r.bottom }; }, hit = (a, b) => a.x < b.r && b.x < a.r && a.y < b.b && b.y < a.b;
|
||||
const pm = R('#ult-player'), em = R('#ult-enemy'); return { pm, em, hp: hit(pm, R('.fighter-one .life-frame')) || hit(em, R('.fighter-two .life-frame')), cap: hit(pm, R('.fighter-one .bar-caption')) || hit(em, R('.fighter-two .bar-caption')), por: hit(pm, R('.fighter-one .portrait-frame')) || hit(em, R('.fighter-two .portrait-frame')), mid: hit(pm, R('.match-center')) || hit(em, R('.match-center')), inView: pm.x >= 0 && em.r <= innerWidth && em.b <= innerHeight, vs: document.documentElement.scrollHeight > innerHeight }; });
|
||||
ok(`layout ${w}x${h}: meter tidak bertabrakan dan muat`, !l.hp && !l.cap && !l.por && !l.mid && l.inView && !l.vs, l);
|
||||
await shot(`layout-${w}x${h}`);
|
||||
}
|
||||
if (!touch) await page.setViewportSize({ width: 1280, height: 520 });
|
||||
}
|
||||
|
||||
const failed = checks.filter(c => !c.pass);
|
||||
return { passed: checks.length - failed.length, total: checks.length, failed, pageErrors: errors.length };
|
||||
};
|
||||
Loading…
Reference in New Issue