Rasa bermain, gerakan perintah, toko, skin, menu karakter, easter egg
This commit is contained in:
parent
721b221ad1
commit
2d262773f1
|
|
@ -6,6 +6,11 @@ Status per 2026-10-02. Dokumen ini adalah usulan; urutan dan isi sepenuhnya bisa
|
|||
|
||||
---
|
||||
|
||||
## Status pengerjaan (2026-10-02, branch `feat/gamefeel-shop`)
|
||||
|
||||
**Sudah dikerjakan dan diuji:** jarak berdiri 54→80 px dan jangkauan pukulan +28% (1.1, 1.5); skala sprite lompat ×1.14 dan jongkok ×1.10 (1.2); gerakan perintah ↓↓+Spasi (uppercut, menjatuhkan) dan ↓→+Spasi (tinju maju); salto dua-ketuk-S dipindah (lompat ganda tetap W W) (1.4, keputusan 1); counter hit dan pengurangan damage kombo panjang plus peringkat kombo C/B/A/S (1.3 sebagian); koin, toko (5 jurus, 6 skin warna), menu Karakter (skin, jurus toko tombol K, daftar gerakan), Koleksi (15 pencapaian, statistik, ekspor/impor simpanan) (2, 3, 5 sebagian); easter egg: kode Konami, ketik `syududu` / `mbg` / `esemka`, klik judul 7x, ketuk slot tanda tanya (6 sebagian).
|
||||
**Belum:** launcher/juggle penuh, blok/parry/lempar, skill tambahan per karakter (butuh gambar), skin kostum, mode Arcade/Survival/2P, suara, gamepad, rebind tombol, hit-stop per serangan, kalibrasi ukuran kepala sejati (sekarang pakai faktor tetap).
|
||||
|
||||
## 0. Kondisi sekarang
|
||||
|
||||
**Sudah ada**
|
||||
|
|
|
|||
6
browo.js
6
browo.js
|
|
@ -6,9 +6,9 @@
|
|||
// Safari chain: a quick jab, a wide hook, then a heavy shoulder smash with big knockback. Reach sits inside the measured
|
||||
// fist tip (82/82/97 px in BROWO_METRICS.emitters).
|
||||
const combo = [
|
||||
{damage:6,knockback:80,reach:105,duration:.34,hitAt:.5},
|
||||
{damage:8,knockback:110,reach:105,duration:.38,hitAt:.5},
|
||||
{damage:12,knockback:220,reach:120,duration:.5,hitAt:.58}
|
||||
{damage:6,knockback:80,reach:135,duration:.34,hitAt:.5},
|
||||
{damage:8,knockback:110,reach:135,duration:.38,hitAt:.5},
|
||||
{damage:12,knockback:220,reach:155,duration:.5,hitAt:.58}
|
||||
];
|
||||
// Pohon Sawit: BRO WO slams a palm on the floor and an oil-palm erupts `ahead` px in front; everything inside `radius`
|
||||
// px of that spot on the floor is hit. He does not need to touch the rival; a jump above 150 px clears it.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"files": 216,
|
||||
"bytes": 30348602,
|
||||
"bytes": 30355188,
|
||||
"list": [
|
||||
{
|
||||
"file": "index.html",
|
||||
"bytes": 11996
|
||||
"bytes": 12066
|
||||
},
|
||||
{
|
||||
"file": "mobile.html",
|
||||
|
|
@ -24,11 +24,11 @@
|
|||
},
|
||||
{
|
||||
"file": "game.js",
|
||||
"bytes": 217269
|
||||
"bytes": 223237
|
||||
},
|
||||
{
|
||||
"file": "menu.js",
|
||||
"bytes": 21009
|
||||
"bytes": 21557
|
||||
},
|
||||
{
|
||||
"file": "match.js",
|
||||
|
|
|
|||
93
game.js
93
game.js
|
|
@ -2,7 +2,7 @@
|
|||
(() => {
|
||||
'use strict';
|
||||
const W = 1280, H = 720;
|
||||
const CONFIG = { groundY: 584, walkSpeed: 320, runSpeed: 520, jumpSpeed: 830, doubleJumpSpeed: 790, gravity: 2900, fastFall: 1180, doubleTapWindow: .26, rollDuration: .28, ...window.STAGE_CONFIG };
|
||||
const CONFIG = { bodyGap: 80, reachScale: 1.28, groundY: 584, walkSpeed: 320, runSpeed: 520, jumpSpeed: 830, doubleJumpSpeed: 790, gravity: 2900, fastFall: 1180, doubleTapWindow: .26, rollDuration: .28, ...window.STAGE_CONFIG };
|
||||
const canvas = document.querySelector('#arena'), ctx = canvas.getContext('2d', { alpha: false });
|
||||
const $ = s => document.querySelector(s);
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
|
|
@ -209,6 +209,7 @@
|
|||
systemAnnouncer?.play([call,outcome]);
|
||||
}
|
||||
if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);}
|
||||
if(event.type==='complete'){window.Meta?.award?.({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,level:difficulty,bestCombo,perfect:match.winner==='player'&&match.enemyWins===0});}
|
||||
if(event.type==='complete')window.FrontEnd?.showResult({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,mode:'versus'});
|
||||
}
|
||||
}
|
||||
|
|
@ -293,21 +294,82 @@
|
|||
if (key === 'escape') { if (settingsOpen) closeSettings(); else setPaused(!paused); return; }
|
||||
if (key === 'r') { reset(); return; }
|
||||
if (!canAct() || keys.has(key)) return;
|
||||
keys.add(key);
|
||||
keys.add(key); logInput(key);
|
||||
if (key === 'a' || key === 'd') { const direction = key === 'a' ? -1 : 1; if (time - taps[key] <= CONFIG.doubleTapWindow) hero.runDirection = direction; else if (hero.runDirection !== direction) hero.runDirection = 0; taps[key] = time; taps[key === 'a' ? 'd' : 'a'] = -10; }
|
||||
// A new left/right press cuts a basic attack short: right away after its hit, or as soon as the hit lands.
|
||||
if ((key === 'a' || key === 'd') && hero.action?.type === 'attack') { if (hero.action.fired) { hero.action = null; state(hero.grounded ? 'idle' : 'jump'); } else { hero.action.cancelInto = 'move'; hero.action.queued = 0; } }
|
||||
if (key === 'w') jump();
|
||||
if (key === 's') { if (time - taps.s <= CONFIG.doubleTapWindow) jump(true); taps.s = time; }
|
||||
if (key === ' ') startAttack();
|
||||
if (key === 'i') cast('skill1'); if (key === 'o') cast('skill2'); if (key === 'p') cast('ultimate');
|
||||
if (key === 's') taps.s = time;
|
||||
if (key === ' ') { if (!startCommand()) startAttack(); }
|
||||
if (key === 'k') useShopMove(); if (key === 'i') cast('skill1'); if (key === 'o') cast('skill2'); if (key === 'p') cast('ultimate');
|
||||
}
|
||||
|
||||
// ---- Command moves, shop moves and game-feel helpers (see ROADMAP.md) ----
|
||||
// Down-down+Space is an uppercut launcher, down-forward+Space a lunging punch. They reuse the fighter's own attack poses.
|
||||
const inputLog = [];
|
||||
const STATE_SCALE = { jump: 1.14, crouch: 1.1 };
|
||||
function logInput(key) { if (!'sad'.includes(key)) return; inputLog.push({ k: key, t: time }); if (inputLog.length > 6) inputLog.shift(); }
|
||||
function commandFor() {
|
||||
const fwd = hero.facing > 0 ? 'd' : 'a', n = inputLog.length, last = inputLog[n - 1], prev = inputLog[n - 2];
|
||||
if (!last || time - last.t > .55) return null;
|
||||
if (last.k === 's' && prev?.k === 's' && last.t - prev.t <= .4) return 'uppercut';
|
||||
if (last.k === fwd && prev?.k === 's' && last.t - prev.t <= .4) return 'lunge';
|
||||
return null;
|
||||
}
|
||||
const COMMANDS = {
|
||||
uppercut: { label: 'UPPERCUT!', state: 'attack3', cd: 2.6, duration: .5, hitAt: .36, damage: 15, knockback: 260 },
|
||||
lunge: { label: 'TINJU MAJU!', state: 'attack2', cd: 1.8, duration: .44, hitAt: .3, damage: 11, knockback: 300 }
|
||||
};
|
||||
function startCommand() {
|
||||
const id = commandFor(); if (!id || !canAct() || hero.action || hero.doublePose > 0 || (hero.spCd || 0) > 0 || !hero.grounded) return false;
|
||||
const c = COMMANDS[id]; inputLog.length = 0; hero.spCd = c.cd;
|
||||
hero.action = { type: 'attack', name: c.state, index: 3, t: 0, duration: c.duration, hit: false, queued: 0, fired: false, hitAt: c.hitAt, special: id, color: '#ffe9a0' };
|
||||
hero.runDirection = 0; state(c.state); announce(c.label, .6); sound('cast'); return true;
|
||||
}
|
||||
const SHOP_MOVES = {
|
||||
seruduk: { name: 'SERUDUK KILAT', cd: 7, state: 'attack2', duration: .45, hitAt: .3, damage: 14, knockback: 340 },
|
||||
hentak: { name: 'HENTAKAN BUMI', cd: 10, state: 'attack3', duration: .55, hitAt: .42, damage: 16, knockback: 200 },
|
||||
perisai: { name: 'PERISAI', cd: 16, instant: true },
|
||||
pulih: { name: 'AURA PEMULIH', cd: 30, instant: true },
|
||||
isiulang: { name: 'ISI ULANG', cd: 35, instant: true }
|
||||
};
|
||||
function useShopMove() {
|
||||
const id = window.Meta?.equippedMove?.(), m = SHOP_MOVES[id];
|
||||
if (!m || !canAct() || hero.action || hero.doublePose > 0 || (hero.shopCd || 0) > 0) return false;
|
||||
hero.shopCd = m.cd; announce(m.name, .7); sound('cast');
|
||||
if (m.instant) {
|
||||
if (id === 'perisai') { hero.invuln = Math.max(hero.invuln, 1.6); hero.shield = 1.6; }
|
||||
if (id === 'pulih') { hero.hp = Math.min(BALANCE.heroMax, hero.hp + 25); damageNumbers.push({ x: hero.x, y: hero.y - 150, amount: '+25', life: .9, maxLife: .9, color: '#8dffa8' }); }
|
||||
if (id === 'isiulang') { hero.cooldowns.skill1 = hero.cooldowns.skill2 = 0; rechargePulse.skill1 = rechargePulse.skill2 = .3; }
|
||||
spawnParticles(hero.x, hero.y - 90, id === 'pulih' ? '#8dffa8' : '#9fe8ff', 26, 240); return true;
|
||||
}
|
||||
hero.action = { type: 'attack', name: m.state, index: 3, t: 0, duration: m.duration, hit: false, queued: 0, fired: false, hitAt: m.hitAt, special: id, color: '#9fe8ff' };
|
||||
hero.runDirection = 0; state(m.state); return true;
|
||||
}
|
||||
function specialHit(a) {
|
||||
const dx = (dummy.x - hero.x) * hero.facing, y = hero.y - 100, near = Math.abs(dummy.y - hero.y) < 150, col = a.color || '#ffe9a0';
|
||||
effects.push({ type: 'slash', x: hero.x + hero.facing * 60, y, life: .28, maxLife: .28, facing: hero.facing, index: 3, color: col });
|
||||
if (a.special === 'hentak') {
|
||||
effects.push({ type: 'slam', x: hero.x, y: CONFIG.groundY - 2, life: .8, maxLife: .8 }); spawnParticles(hero.x, CONFIG.groundY - 8, col, 36, 360); trauma = Math.min(1, trauma + .4); sound('heavy');
|
||||
if (Math.abs(dx) < 230 && dummy.y > CONFIG.groundY - 125) hitDummy(SHOP_MOVES.hentak.damage, 200, col, hero.x, y, true);
|
||||
return;
|
||||
}
|
||||
const reach = a.special === 'uppercut' ? 150 : a.special === 'seruduk' ? 190 : 175, dmg = (COMMANDS[a.special] || SHOP_MOVES[a.special]).damage, kb = (COMMANDS[a.special] || SHOP_MOVES[a.special]).knockback;
|
||||
dust(hero.x + hero.facing * 40, 12);
|
||||
if (dx > -30 && dx < reach && near) {
|
||||
const up = a.special === 'uppercut';
|
||||
if (hitDummy(dmg, kb, col, hero.x, y, up) && up) { dummy.vy = -640; dummy.y -= 2; }
|
||||
sound('heavy');
|
||||
} else sound('step');
|
||||
}
|
||||
function comboRank(n) { return n >= 12 ? 'S' : n >= 8 ? 'A' : n >= 5 ? 'B' : n >= 3 ? 'C' : ''; }
|
||||
function release(key) { key = key.toLowerCase(); keys.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; }
|
||||
function clearInput() { keys.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; }
|
||||
function resetCombat() {
|
||||
systemAnnouncer?.clear();
|
||||
stopUltimateVoice();
|
||||
Object.assign(hero, { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } });
|
||||
hero.spCd = hero.shopCd = hero.shield = 0; inputLog.length = 0;
|
||||
Object.assign(dummy, { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0 });
|
||||
if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();}
|
||||
fenrCutin=null;hudIdentity='';
|
||||
|
|
@ -323,6 +385,9 @@
|
|||
if (menuOpen || (match && match.phase!=='fight') || dummy.invuln > 0 || dummy.state === 'down' || dummy.state === 'recover') return false;
|
||||
if (dummy.action?.guard && shellGuard(dummy)) return false;
|
||||
const dir = sourceX <= dummy.x ? 1 : -1;
|
||||
// Counter hit: striking a rival in their wind-up deals extra; long chains scale down so none loops forever.
|
||||
if (dummy.action && !dummy.action.fired && dummy.action.type === 'attack') { damage = Math.round(damage * 1.25); if (damage > 1) announce('COUNTER!', .5); }
|
||||
if (comboHits >= 5) damage = Math.max(1, Math.round(damage * Math.max(.55, 1 - .07 * (comboHits - 4))));
|
||||
dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3;
|
||||
// Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's),
|
||||
// so a re-started chain cannot loop it to K.O. Easy has no limit.
|
||||
|
|
@ -706,7 +771,7 @@
|
|||
const cfx = { ents: [], hazards: [], marks: [] };
|
||||
function cfKit(d) {
|
||||
const move = (name, index = 1, mode = false) => {
|
||||
if (name === 'attack') return { name: `attack${index}`, type: 'attack', index, ...d.combo[index - 1] };
|
||||
if (name === 'attack') { const c = d.combo[index - 1]; return { name: `attack${index}`, type: 'attack', index, ...c, reach: Math.round((c.reach || 105) * (CONFIG.reachScale || 1)) }; }
|
||||
if (name === 'ultimate') return { name: 'ultimate', type: 'ultimate', duration: d.balance.castTime, damage: 0, hitAt: 1 };
|
||||
const k = (mode && d.mode?.skills?.[name]) || d.skills[name], m = { name, type: name, knockback: 160, duration: .6, ...k, hitAt: k.hitAt ?? .5 };
|
||||
if (k.kind === 'projectile' || k.kind === 'wave' || k.kind === 'homing' || k.kind === 'lob') { m.projectile = true; m.range = k.range || 560; m.reach = m.range; }
|
||||
|
|
@ -1181,7 +1246,7 @@
|
|||
time += dt; hero.animTime += dt; hero.invuln = Math.max(0, hero.invuln - dt); hero.doublePose = Math.max(0, (hero.doublePose || 0) - dt);
|
||||
if(fenrCutin) {fenrCutin.t+=dt;if(fenrCutin.t>.78)fenrCutin=null;}
|
||||
if(F&&selectedCharacter==='fenr'){const changes=hero.formChanges;F.advance(hero,dt,hero.hp>0);if(changes!==hero.formChanges){syncPlayerForm();hero.doublePose=0;state(hero.grounded?'idle':'jump');}}
|
||||
hero.airTime = hero.grounded ? 0 : hero.airTime + dt;
|
||||
hero.airTime = hero.grounded ? 0 : hero.airTime + dt; hero.spCd = Math.max(0, (hero.spCd || 0) - dt); hero.shopCd = Math.max(0, (hero.shopCd || 0) - dt); hero.shield = Math.max(0, (hero.shield || 0) - dt);
|
||||
for (const name of Object.keys(hero.cooldowns)) hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - dt);
|
||||
if (hero.doublePose <= 0 && hero.rollBuffer && hero.hurtTime <= 0) { const buffered = hero.rollBuffer; hero.rollBuffer = null; if (buffered === 'attack') startAttack(); else cast(buffered); }
|
||||
comboTimer = Math.max(0, comboTimer - dt); if (!comboTimer) comboHits = comboDamage = 0;
|
||||
|
|
@ -1199,7 +1264,8 @@
|
|||
if (hero.action) {
|
||||
const a = hero.action; a.t += dt; state(a.name);
|
||||
if((selectedCharacter==='fenr'||selectedCharacter==='mira'||selectedCharacter==='haldor'||selectedCharacter==='isolde'||selectedCharacter==='solan'||selectedCharacter==='nib'||CF[selectedCharacter])&&a.dash&&a.t>a.duration*.2&&a.t<a.duration*.6)hero.vx=hero.facing*a.dash;
|
||||
if (a.type !== 'ultimate' && !a.fired && a.t >= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; if (a.type === 'attack') meleeHit(a.index); else fireSkill(a); }
|
||||
if (a.special && a.t < a.duration * .55 && ['lunge', 'seruduk'].includes(a.special)) hero.vx = hero.facing * (a.special === 'seruduk' ? 1000 : 760);
|
||||
if (a.type !== 'ultimate' && !a.fired && a.t >= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; if (a.special) specialHit(a); else if (a.type === 'attack') meleeHit(a.index); else fireSkill(a); }
|
||||
if (a.fired && a.cancelInto && hero.action === a) { const into = a.cancelInto; hero.action = null; state(hero.grounded ? 'idle' : 'jump'); if (into === 'jump') jump(); }
|
||||
if (a.t >= a.duration || (a.type === 'skill1' && a.fired)) { const queued = a.queued && a.index < 3, remaining = Math.max(0, a.queued - 1); const next = (a.index || 0) + 1; hero.action = null; if (queued) { startAttack(next); hero.action.queued = remaining; } else state(hero.grounded ? 'idle' : 'jump'); }
|
||||
}
|
||||
|
|
@ -1221,7 +1287,7 @@
|
|||
advanceMatch(dt);
|
||||
}
|
||||
// MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them.
|
||||
function bodyGap() { return selectedCharacter === 'mira' || opponentCharacter === 'mira' ? 66 : selectedCharacter === 'haldor' || opponentCharacter === 'haldor' ? 62 : 54; }
|
||||
function bodyGap() { return CONFIG.bodyGap || 80; }
|
||||
function currentDummyHP() {return Math.max(0,BALANCE.dummyMax-dummy.damage);}
|
||||
function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};}
|
||||
function updateDummy(dt) {
|
||||
|
|
@ -1896,8 +1962,9 @@
|
|||
const pose = heroPose(), index = pose.frame;
|
||||
const r = list[index], cw = manifest.cell?.width || manifest.cell?.w || r.w, ch = manifest.cell?.height || manifest.cell?.h || r.h;
|
||||
const anchorX = manifest.cell?.anchor_x ?? cw / 2, anchorY = manifest.cell?.anchor_y ?? ch - (manifest.cell?.safe_margin_y || 0);
|
||||
const spriteScale = pose.scale;
|
||||
ctx.save(); ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false;
|
||||
const spriteScale = pose.scale * (STATE_SCALE[hero.state] || 1), skin = window.Meta?.skinFilter?.(selectedCharacter);
|
||||
if (hero.shield > 0) { ctx.save(); ctx.strokeStyle = '#9fe8ff'; ctx.globalAlpha = Math.min(1, hero.shield) * .8; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(hero.x, hero.y - 90, 70, 105, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); }
|
||||
ctx.save(); if (skin) ctx.filter = skin; ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false;
|
||||
if (pose.pivot) { ctx.translate(pose.worldPivot.x, pose.worldPivot.y); ctx.rotate(pose.rotation); ctx.translate(-pose.pivot.x, -pose.pivot.y); }
|
||||
ctx.drawImage(images.hero, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); ctx.restore();
|
||||
}
|
||||
|
|
@ -1906,7 +1973,7 @@
|
|||
shadow(dummy.x,CONFIG.groundY,dummy.state==='down'?65:opponentCharacter==='mira'?61:opponentCharacter==='haldor'?55:43,.20);
|
||||
if(!m||!img)return;
|
||||
const r=m.frame_layout.rows[pose.state][pose.frame];
|
||||
ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));ctx.scale(dummy.facing||-1,1);ctx.imageSmoothingEnabled=false;
|
||||
ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));{const k=STATE_SCALE[pose.state]||1;ctx.scale((dummy.facing||-1)*k,k);}ctx.imageSmoothingEnabled=false;
|
||||
ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);ctx.restore();
|
||||
if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') {
|
||||
ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7;
|
||||
|
|
@ -2172,7 +2239,7 @@
|
|||
$('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`;
|
||||
$('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`;
|
||||
$('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING');
|
||||
$('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE`; $('#total-damage').innerHTML = `${totalDamage} <small>DMG</small>`;
|
||||
$('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE${comboRank(comboHits) ? ' · RANK ' + comboRank(comboHits) : ''}`; window.Meta?.hud?.({ move: SHOP_MOVES[window.Meta?.equippedMove?.()], cd: hero.shopCd || 0, sp: hero.spCd || 0 }); $('#total-damage').innerHTML = `${totalDamage} <small>DMG</small>`;
|
||||
$('#best-combo').innerHTML = `${bestCombo} <small>HITS</small>`;
|
||||
$('#announcement').classList.toggle('visible', announceTimer > 0);
|
||||
for (const card of document.querySelectorAll('.skill-card')) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<!doctype html>
|
||||
<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>AETHER CLASH — Mecha vs Demi-Human</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"></head>
|
||||
<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>AETHER CLASH — Mecha vs Demi-Human</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>
|
||||
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">AETHER<br><em>CLASH</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="Aether Clash, demo game fighting Mecha dan Demi-Human">
|
||||
<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">
|
||||
|
|
@ -23,6 +23,6 @@
|
|||
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>AETHER CLASH</strong><span id="load-message">Menyiapkan arena…</span></div>
|
||||
<dialog id="settings-panel"><div class="dialog-kicker">AETHER CLASH / 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 S · Menunduk / turun cepat<br>2× S · Salto SPACE · Kombo 3 pukulan<br>I / O · Skill P · Ultimate</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
|
||||
<dialog id="pause-panel"><div class="dialog-kicker">AETHER CLASH / 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 Aether Clash"></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>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script></body></html>
|
||||
</main><section id="front-menu" aria-label="Main menu Aether Clash"></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>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>
|
||||
|
||||
|
||||
|
|
|
|||
4
menu.js
4
menu.js
|
|
@ -65,7 +65,7 @@
|
|||
if(backgroundVideo){videoPlayToken++;backgroundVideo.pause();backgroundVideo.remove();}
|
||||
root.dataset.screen=screen;root.dataset.step=String(step);root.dataset.mode=mode;
|
||||
if(screen==='home'){
|
||||
root.innerHTML=`<div class="home-art video-backed"></div><div class="menu-grain"></div><header class="home-top"><span></span><span class="home-top-right"><span class="build-tag">FIGHTING DEMO / VOL. 02</span>${canFullscreen?`<button class="menu-fullscreen${fullscreenHint?' hinted':''}" data-cmd="fullscreen" aria-label="Layar penuh" title="Layar penuh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>`:''}</span></header><div class="home-content"><div class="home-title"><h1>AETHER<br><em>CLASH</em><i>✦</i></h1><p>Two factions. One arena.</p></div><nav class="home-nav" aria-label="Main menu"><button data-cmd="versus"><span class="nav-index">01</span><span>VS COMPUTER<small>Choose your rival. Claim the arena.</small></span><b>↗</b></button><button data-cmd="training"><span class="nav-index">02</span><span>TRAINING<small>Learn your fighter. Refine your combo.</small></span><b>↗</b></button><button data-cmd="settings"><span class="nav-index">03</span><span>PENGATURAN</span><b>↗</b></button><button data-cmd="about"><span class="nav-index">04</span><span>ABOUT</span><b>↗</b></button></nav></div><div class="home-caption"><span>MECHA</span><i>VS</i><span>DEMI-HUMAN</span><small>TECHNOLOGY MEETS INSTINCT</small></div><footer class="home-footer"><span></span><span id="menu-ready">${game.snapshot().ready?'READY TO FIGHT':'PREPARING ARENA…'}</span></footer>`;
|
||||
root.innerHTML=`<div class="home-art video-backed"></div><div class="menu-grain"></div><header class="home-top"><span></span><span class="home-top-right"><span class="build-tag">FIGHTING DEMO / VOL. 02</span>${canFullscreen?`<button class="menu-fullscreen${fullscreenHint?' hinted':''}" data-cmd="fullscreen" aria-label="Layar penuh" title="Layar penuh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>`:''}</span></header><div class="home-content"><div class="home-title"><h1>AETHER<br><em>CLASH</em><i>✦</i></h1><p>Two factions. One arena.</p></div><nav class="home-nav" aria-label="Main menu"><button data-cmd="versus"><span class="nav-index">01</span><span>VS COMPUTER<small>Choose your rival. Claim the arena.</small></span><b>↗</b></button><button data-cmd="training"><span class="nav-index">02</span><span>TRAINING<small>Learn your fighter. Refine your combo.</small></span><b>↗</b></button><button data-cmd="shop" class="meta-nav"><span class="nav-index">03</span><span>TOKO<small>Beli jurus dan skin dengan koin.</small></span><b>◆</b></button><button data-cmd="locker" class="meta-nav"><span class="nav-index">04</span><span>KARAKTER<small>Atur skin, jurus toko, daftar gerakan.</small></span><b>↗</b></button><button data-cmd="book" class="meta-nav"><span class="nav-index">05</span><span>KOLEKSI<small>Pencapaian dan statistik.</small></span><b>★</b></button><button data-cmd="settings"><span class="nav-index">06</span><span>PENGATURAN</span><b>↗</b></button><button data-cmd="about"><span class="nav-index">07</span><span>ABOUT</span><b>↗</b></button></nav></div><div class="home-caption"><span>MECHA</span><i>VS</i><span>DEMI-HUMAN</span><small>TECHNOLOGY MEETS INSTINCT</small></div><footer class="home-footer"><span></span><span id="menu-ready">${game.snapshot().ready?'READY TO FIGHT':'PREPARING ARENA…'}</span></footer>`;
|
||||
} else if(screen==='select'){
|
||||
root.innerHTML=`<div class="selection-backdrop"></div>${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?'VERSUS COMPUTER':'TRAINING ROOM')}<div class="selection-steps"><span class="${step===0?'current':'done'}">01 <b>PLAYER</b></span><i></i><span class="${step===1?'current':''}">02 <b>RIVAL</b></span><i></i><span>03 <b>ARENA</b></span></div><div class="select-stage"><div id="player-preview">${fighterPanel(step===0?hover:player,'player')}</div><div class="roster-center"><div class="roster-caption"><strong>${step===0?'PLAYER 1':'COMPUTER'}</strong><span>CHOOSE A FIGHTER</span></div><div class="roster-grid" role="group" aria-label="Roster karakter">${roster.map((id,i)=>{const f=info(id);return `<button class="roster-tile ${id===hover?'highlight':''} ${!f?'locked':''} ${soon(id)?'soon':''}" data-fighter="${id}" aria-label="${f?f.name+' — '+f.race+(soon(id)?', belum bisa dimainkan':''):'Slot '+(i+1)+' terkunci'}" aria-disabled="${!fighters[id]}"><img class="${f?'portrait-image':''}" data-portrait-side="${step===1?'enemy':'player'}" src="${f?.portrait||'assets/menu/locked.svg'}" alt=""><span>${f?.name||'LOCKED'}</span><b>${step===1&&id===player?'P1':''}</b></button>`;}).join('')}</div><div id="fighter-moves" class="fighter-moves"></div><button class="menu-primary confirm-fighter" data-cmd="confirm">${step===0?'CONFIRM FIGHTER':'CONFIRM RIVAL'} <b>→</b></button><p id="roster-notice" class="roster-notice" aria-live="polite">${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}</p></div><div id="enemy-preview">${fighterPanel(step===1?hover:null,'enemy')}</div></div>${footer()}`;
|
||||
updatePreview(false);
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
if(cmd==='fullscreen'){sfx('move',true);dismissFullscreenHint();game.toggleFullscreen?.();return;}
|
||||
if(cmd==='confirm'){confirm();return;}if(cmd==='back'){back();return;}sfx('confirm',true);
|
||||
if(cmd==='versus'||cmd==='training'){mode=cmd;screen='select';step=0;hover=player;render();}
|
||||
if(cmd==='settings')game.openSettings();if(cmd==='about'){screen='about';render();}
|
||||
if(cmd==='shop'||cmd==='locker'||cmd==='book')window.Meta?.open(cmd);if(cmd==='settings')game.openSettings();if(cmd==='about'){screen='about';render();}
|
||||
if(cmd==='home'){game.stopAnnouncer();screen='home';render();}if(cmd==='start')start();
|
||||
if(cmd==='rematch'){({player,enemy,stage,level,mode}=result);start();}
|
||||
if(cmd==='reselect'){screen='select';step=0;hover=player;render();}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/* Meta layer: shop, locker, achievements, toasts, easter eggs. */
|
||||
#meta-overlay{position:fixed;inset:0;z-index:60;background:rgba(6,14,20,.94);color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;overflow:auto}
|
||||
#meta-overlay[hidden]{display:none}
|
||||
.meta-panel{max-width:1100px;margin:0 auto;padding:18px 20px 40px}
|
||||
.meta-panel header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;border-bottom:1px solid #ffffff22;padding-bottom:12px}
|
||||
.meta-panel nav{display:flex;gap:8px;flex:1}
|
||||
.mt,.meta-x,.pill,.chip,.item button{font:inherit;color:inherit;background:#13222d;border:1px solid #ffffff33;border-radius:6px;padding:8px 14px;cursor:pointer;letter-spacing:.06em}
|
||||
.mt.on,.pill.on,.chip.on{background:#e8b84a;color:#101923;border-color:#e8b84a;font-weight:700}
|
||||
.meta-coin{font-size:22px;color:#e8b84a;font-weight:700}
|
||||
.meta-note{margin:12px 0 0;padding:8px 12px;background:#e8b84a22;border-left:3px solid #e8b84a}
|
||||
.meta-body h3{margin:22px 0 10px;letter-spacing:.12em;color:#e8b84a;font-size:18px}
|
||||
.meta-body h5{margin:14px 0 6px;color:#9fb3c4;letter-spacing:.1em}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
|
||||
.item{background:#101c26;border:1px solid #ffffff1c;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:6px}
|
||||
.item h4{margin:0;letter-spacing:.08em}.item p{margin:0;color:#c7d3dc;font-size:15px;flex:1}.item small{color:#8fa3b3}
|
||||
.item button:not([disabled]){background:#e8b84a;color:#101923;font-weight:700}.item button[disabled]{opacity:.5;cursor:default}
|
||||
.swatch{height:120px;background:#0b1520;border-radius:6px;display:grid;place-items:center;overflow:hidden}.swatch img{height:110px}
|
||||
.roster{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.chip{display:flex;align-items:center;gap:8px;padding:4px 12px 4px 4px}.chip img{width:38px;height:38px;border-radius:4px;object-fit:cover}
|
||||
.locker{display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:24px}.preview{background:#0b1520;border-radius:10px;display:grid;place-items:center;padding:12px}.preview img{width:100%;max-width:300px}
|
||||
.pills{display:flex;gap:8px;flex-wrap:wrap}.moves{list-style:none;padding:0;margin:0;display:grid;gap:4px}.moves li{color:#c7d3dc}.moves b{color:#e8b84a;display:inline-block;min-width:100px}
|
||||
.meta-hint{color:#8fa3b3;font-size:14px}.ach{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px}.ach li{background:#101c26;border:1px solid #ffffff1c;border-radius:8px;padding:10px;display:flex;flex-direction:column;opacity:.6}.ach li.ok{opacity:1;border-color:#e8b84a}.ach span{font-size:14px;color:#9fb3c4}
|
||||
.pill.danger{border-color:#ff6a6a;color:#ff9a9a}
|
||||
#meta-toasts{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:90;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
|
||||
.meta-toast{background:#101923ee;border:1px solid #e8b84a;color:#f6eedb;padding:8px 16px;border-radius:6px;font:700 17px Rajdhani,Arial,sans-serif;letter-spacing:.05em;transition:opacity .5s,transform .5s}.meta-toast.coin{border-color:#8dffa8}.meta-toast.out{opacity:0;transform:translateY(-8px)}
|
||||
#shop-chip{position:fixed;right:14px;bottom:128px;z-index:15;display:flex;gap:8px;align-items:center;background:#101923dd;border:1px solid #9fe8ff;color:#f6eedb;padding:6px 12px;border-radius:6px;font:700 15px Rajdhani,Arial,sans-serif;letter-spacing:.06em}#shop-chip[hidden]{display:none}#shop-chip.cool{border-color:#667788;opacity:.7}#shop-chip kbd{background:#9fe8ff;color:#101923;border-radius:3px;padding:0 6px}
|
||||
.in-menu #shop-chip{display:none}
|
||||
.egg-layer{position:fixed;inset:0;z-index:95;pointer-events:none;overflow:hidden}
|
||||
.egg-layer span{position:absolute;left:-60px;top:var(--y);font-size:var(--s);animation:eggrun var(--t) linear var(--d) forwards}
|
||||
.egg-layer.truck span{font-size:70px}
|
||||
@keyframes eggrun{to{transform:translateX(calc(100vw + 120px)) rotate(8deg)}}
|
||||
.home-nav .meta-nav b{color:#e8b84a}
|
||||
@media(max-width:700px){.locker{grid-template-columns:1fr}}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/* Meta layer: coins, shop, character locker (skin + shop move), achievements and easter eggs.
|
||||
Everything is stored in this browser only (localStorage). The game engine reads it through window.Meta. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const KEY = 'konoha.save.v1';
|
||||
const SKINS = {
|
||||
standar: { name: 'STANDAR', price: 0, filter: '' },
|
||||
senja: { name: 'SENJA', price: 150, filter: 'hue-rotate(-35deg) saturate(1.25)' },
|
||||
rimba: { name: 'HIJAU RIMBA', price: 150, filter: 'hue-rotate(75deg) saturate(1.2)' },
|
||||
laut: { name: 'BIRU LAUT', price: 150, filter: 'hue-rotate(160deg) saturate(1.2)' },
|
||||
neon: { name: 'NEON', price: 300, filter: 'hue-rotate(270deg) saturate(1.9) brightness(1.08)' },
|
||||
emas: { name: 'EMAS', price: 450, filter: 'sepia(1) saturate(3.2) hue-rotate(-12deg) brightness(1.1)' },
|
||||
mono: { name: 'MONOKROM', price: 250, filter: 'grayscale(1) contrast(1.25)' }
|
||||
};
|
||||
const MOVES = {
|
||||
seruduk: { name: 'SERUDUK KILAT', price: 350, cd: 7, desc: 'Menerjang maju dan menghantam lawan di depan. Kuat untuk membuka jarak.' },
|
||||
hentak: { name: 'HENTAKAN BUMI', price: 500, cd: 10, desc: 'Menghentak tanah ke dua sisi; lawan di dekatmu terjatuh.' },
|
||||
perisai: { name: 'PERISAI', price: 450, cd: 16, desc: 'Kebal serangan selama 1,6 detik. Pakai untuk lolos dari ultimate.' },
|
||||
pulih: { name: 'AURA PEMULIH', price: 600, cd: 30, desc: 'Memulihkan 25 HP seketika.' },
|
||||
isiulang: { name: 'ISI ULANG', price: 700, cd: 35, desc: 'Menyegarkan cooldown skill 1 dan 2 seketika.' }
|
||||
};
|
||||
const COMMANDS = [
|
||||
['↓ ↓ + SPASI', 'UPPERCUT', 'Meluncurkan lawan ke atas dan menjatuhkannya.'],
|
||||
['↓ → + SPASI', 'TINJU MAJU', 'Maju sambil memukul, jangkauan panjang.'],
|
||||
['SPASI x3', 'KOMBO', 'Rantai tiga pukulan; pukulan ketiga paling kuat.'],
|
||||
['W W', 'LOMPAT GANDA', 'Lompat kedua di udara (berputar).'],
|
||||
['K', 'JURUS TOKO', 'Memakai jurus toko yang dipasang di menu Karakter.']
|
||||
];
|
||||
const ACH = {
|
||||
first_win: ['PERTAMA KALI', 'Menangkan satu pertandingan.'],
|
||||
perfect: ['BERSIH', 'Menang tanpa kehilangan satu ronde pun.'],
|
||||
combo5: ['KOMBO 5', 'Capai kombo 5 pukulan.'],
|
||||
combo10: ['KOMBO 10', 'Capai kombo 10 pukulan.'],
|
||||
hard_win: ['SANG PEMBURU', 'Menang di tingkat selain Easy.'],
|
||||
wins10: ['SEPULUH KALI', 'Menangkan 10 pertandingan.'],
|
||||
shopper: ['PEMBELI', 'Beli barang pertamamu di toko.'],
|
||||
fashion: ['MODIS', 'Miliki 3 skin.'],
|
||||
rich: ['KAYA', 'Kumpulkan total 2000 koin.'],
|
||||
konami: ['KODE LAMA', 'Temukan kode rahasia lama.'],
|
||||
syududu: ['SYUDUDU', 'Panggil semut.'],
|
||||
mbg: ['TRUK LEWAT', 'Panggil truk katering.'],
|
||||
esemka: ['KONVOI', 'Panggil konvoi.'],
|
||||
penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'],
|
||||
logo: ['TELITI', 'Temukan rahasia di judul.']
|
||||
};
|
||||
const blank = () => ({ v: 1, coins: 0, earned: 0, owned: { skin: ['standar'], move: [] }, skin: {}, move: null, stats: { wins: 0, losses: 0, bestCombo: 0, perfects: 0 }, ach: {}, eggs: {} });
|
||||
let save = blank();
|
||||
try { const raw = JSON.parse(localStorage.getItem(KEY) || 'null'); if (raw && raw.v === 1) save = { ...blank(), ...raw, owned: { ...blank().owned, ...raw.owned }, stats: { ...blank().stats, ...raw.stats } }; } catch (_) {}
|
||||
const persist = () => { try { localStorage.setItem(KEY, JSON.stringify(save)); } catch (_) {} };
|
||||
const $ = s => document.querySelector(s);
|
||||
const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
// ---- toasts ----
|
||||
let toastBox;
|
||||
function toast(msg, kind = '') {
|
||||
if (!toastBox) { toastBox = document.createElement('div'); toastBox.id = 'meta-toasts'; document.body.append(toastBox); }
|
||||
const el = document.createElement('div'); el.className = 'meta-toast ' + kind; el.textContent = msg; toastBox.append(el);
|
||||
setTimeout(() => el.classList.add('out'), 3200); setTimeout(() => el.remove(), 3800);
|
||||
}
|
||||
function addCoins(n) { save.coins += n; if (n > 0) save.earned += n; persist(); checkRich(); refreshCoins(); }
|
||||
function checkRich() { if (save.earned >= 2000) unlock('rich'); }
|
||||
function unlock(id) {
|
||||
if (save.ach[id] || !ACH[id]) return false;
|
||||
save.ach[id] = Date.now(); save.coins += 50; save.earned += 50; persist(); toast('PENCAPAIAN: ' + ACH[id][0] + ' +50 koin', 'ach'); refreshCoins(); return true;
|
||||
}
|
||||
|
||||
// ---- engine API ----
|
||||
function skinFilter(id) { const s = SKINS[save.skin[id] || 'standar']; return s && save.owned.skin.includes(save.skin[id] || 'standar') ? s.filter : ''; }
|
||||
function equippedMove() { return save.move && save.owned.move.includes(save.move) ? save.move : null; }
|
||||
let chip;
|
||||
function hud(info) {
|
||||
if (!chip) { chip = document.createElement('div'); chip.id = 'shop-chip'; document.body.append(chip); }
|
||||
const m = equippedMove() ? info.move : null;
|
||||
const key = m ? m.name + '|' + Math.ceil(info.cd) : '';
|
||||
if (chip.dataset.k === key) return; chip.dataset.k = key;
|
||||
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);
|
||||
}
|
||||
function award(r) {
|
||||
const won = r.winner === 'player', parts = [];
|
||||
let gain = won ? 120 : 30; parts.push(won ? 'Menang +120' : 'Hadiah main +30');
|
||||
if (won) {
|
||||
if (r.level && r.level !== 'easy') { const b = { medium: 40, hard: 80, excellent: 120 }[r.level] || 40; gain += b; parts.push('Tingkat +' + b); }
|
||||
if (r.perfect) { gain += 60; parts.push('Bersih +60'); }
|
||||
}
|
||||
const cb = Math.min(60, (r.bestCombo || 0) * 4); if (cb) { gain += cb; parts.push('Kombo +' + cb); }
|
||||
if (won) { save.stats.wins++; unlock('first_win'); if (save.stats.wins >= 10) unlock('wins10'); if (r.perfect) { save.stats.perfects++; unlock('perfect'); } if (r.level && r.level !== 'easy') unlock('hard_win'); } else save.stats.losses++;
|
||||
save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0);
|
||||
if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10');
|
||||
addCoins(gain); toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin');
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
let root, tab = 'shop', who = null, notice = '';
|
||||
const roster = () => Object.keys(window.FIGHTERS || {}).concat(window.Browo && window.BROWO_MANIFEST ? ['browo'] : []);
|
||||
const fname = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : id.toUpperCase());
|
||||
const fnames = id => window.FIGHTERS?.[id]?.names || window.Browo?.names || [];
|
||||
function refreshCoins() { const c = $('#meta-coins'); if (c) c.textContent = save.coins; }
|
||||
function build() {
|
||||
root = document.createElement('div'); root.id = 'meta-overlay'; root.hidden = true; document.body.append(root);
|
||||
root.addEventListener('click', onClick);
|
||||
}
|
||||
function open(t = 'shop') {
|
||||
if (!root) build(); tab = t; if (!who) who = roster()[0]; root.hidden = false; render();
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
function close() { root.hidden = true; document.removeEventListener('keydown', escClose, true); }
|
||||
function escClose(e) { if (e.key === 'Escape') { e.stopPropagation(); close(); } }
|
||||
function tabs() { return ['shop:TOKO', 'locker:KARAKTER', 'book:KOLEKSI'].map(x => { const [k, l] = x.split(':'); return `<button class="mt ${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`; }).join(''); }
|
||||
function render() {
|
||||
let body = '';
|
||||
if (tab === 'shop') body = renderShop(); else if (tab === 'locker') body = renderLocker(); else body = renderBook();
|
||||
root.innerHTML = `<div class="meta-panel"><header><button class="meta-x" data-close>← KEMBALI</button><nav>${tabs()}</nav><span class="meta-coin">◆ <b id="meta-coins">${save.coins}</b></span></header>${notice ? `<p class="meta-note">${esc(notice)}</p>` : ''}<div class="meta-body">${body}</div></div>`;
|
||||
}
|
||||
function renderShop() {
|
||||
const skins = Object.entries(SKINS).filter(([k]) => k !== 'standar').map(([k, s]) => {
|
||||
const own = save.owned.skin.includes(k);
|
||||
return `<article class="item"><div class="swatch" style="filter:${s.filter || 'none'}"><img src="${portrait(who || roster()[0])}" alt=""></div><h4>${s.name}</h4><p>Warna baru untuk semua karakter.</p><button data-buy="skin:${k}" ${own ? 'disabled' : ''}>${own ? 'DIMILIKI' : '◆ ' + s.price}</button></article>`;
|
||||
}).join('');
|
||||
const moves = Object.entries(MOVES).map(([k, m]) => {
|
||||
const own = save.owned.move.includes(k);
|
||||
return `<article class="item move"><h4>${m.name}</h4><p>${m.desc}</p><small>Cooldown ${m.cd} detik · tombol K</small><button data-buy="move:${k}" ${own ? 'disabled' : ''}>${own ? 'DIMILIKI' : '◆ ' + m.price}</button></article>`;
|
||||
}).join('');
|
||||
return `<h3>Jurus toko</h3><div class="grid">${moves}</div><h3>Skin warna</h3><div class="grid">${skins}</div><p class="meta-hint">Koin didapat dari menang pertandingan, kombo, dan pencapaian. Pasang barang di menu KARAKTER.</p>`;
|
||||
}
|
||||
const portrait = id => `assets/${id}/ui/portrait.webp`;
|
||||
function renderLocker() {
|
||||
const ids = roster(), sk = save.skin[who] || 'standar';
|
||||
const pick = ids.map(id => `<button class="chip ${id === who ? 'on' : ''}" data-who="${id}"><img src="${portrait(id)}" alt=""><span>${fname(id)}</span></button>`).join('');
|
||||
const skins = save.owned.skin.map(k => `<button class="pill ${k === sk ? 'on' : ''}" data-skin="${k}">${SKINS[k].name}</button>`).join('');
|
||||
const moves = [`<button class="pill ${!equippedMove() ? 'on' : ''}" data-move="">KOSONG</button>`].concat(save.owned.move.map(k => `<button class="pill ${save.move === k ? 'on' : ''}" data-move="${k}">${MOVES[k].name}</button>`)).join('');
|
||||
const n = fnames(who);
|
||||
return `<div class="roster">${pick}</div><div class="locker"><div class="preview" style="filter:${SKINS[sk]?.filter || 'none'}"><img src="${portrait(who)}" alt=""></div><div class="info"><h3>${fname(who)}</h3><h5>Skin</h5><div class="pills">${skins}</div><h5>Jurus toko (tombol K)</h5><div class="pills">${moves}</div><p class="meta-hint">${save.owned.move.length ? '' : 'Belum punya jurus toko. Beli di tab TOKO.'}</p><h5>Daftar gerakan</h5><ul class="moves">${n.length ? `<li><b>SPASI</b> ${esc(n[0] || '')}</li><li><b>I</b> ${esc(n[1] || '')}</li><li><b>O</b> ${esc(n[2] || '')}</li><li><b>P</b> ${esc(n[3] || '')}</li>` : ''}${COMMANDS.map(c => `<li><b>${c[0]}</b> ${c[1]} — ${c[2]}</li>`).join('')}</ul></div></div>`;
|
||||
}
|
||||
function renderBook() {
|
||||
const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats;
|
||||
const list = Object.entries(ACH).map(([k, [n, d]]) => { const ok = !!save.ach[k], hidden = !ok && ['konami', 'syududu', 'mbg', 'esemka', 'penasaran', 'logo'].includes(k); return `<li class="${ok ? 'ok' : ''}"><b>${ok ? '★' : '☆'} ${hidden ? '???' : n}</b><span>${hidden ? 'Pencapaian rahasia.' : d}</span></li>`; }).join('');
|
||||
return `<h3>Statistik</h3><p class="stats">Menang <b>${st.wins}</b> · Kalah <b>${st.losses}</b> · Bersih <b>${st.perfects}</b> · Kombo terbaik <b>${st.bestCombo}</b> · Total koin <b>${save.earned}</b></p><h3>Pencapaian ${got}/${Object.keys(ACH).length}</h3><ul class="ach">${list}</ul><h3>Simpanan</h3><p><button class="pill" data-export>SALIN KODE SIMPANAN</button> <button class="pill" data-import>TEMPEL KODE</button> <button class="pill danger" data-reset>HAPUS SEMUA</button></p>`;
|
||||
}
|
||||
function onClick(e) {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
if (b.hasAttribute('data-close')) return close();
|
||||
if (b.dataset.tab) { tab = b.dataset.tab; notice = ''; return render(); }
|
||||
if (b.dataset.who) { who = b.dataset.who; return render(); }
|
||||
if (b.dataset.skin) { save.skin[who] = b.dataset.skin; persist(); return render(); }
|
||||
if (b.dataset.move !== undefined && b.hasAttribute('data-move')) { save.move = b.dataset.move || null; persist(); return render(); }
|
||||
if (b.dataset.buy) {
|
||||
const [kind, id] = b.dataset.buy.split(':'), item = (kind === 'skin' ? SKINS : MOVES)[id];
|
||||
if (save.owned[kind].includes(id)) return;
|
||||
if (save.coins < item.price) { notice = 'Koin belum cukup untuk ' + item.name + '.'; return render(); }
|
||||
save.coins -= item.price; save.owned[kind].push(id); if (kind === 'move' && !save.move) save.move = id;
|
||||
notice = item.name + ' dibeli.'; persist(); unlock('shopper'); if (save.owned.skin.length >= 4) unlock('fashion'); return render();
|
||||
}
|
||||
if (b.hasAttribute('data-export')) { const code = btoa(unescape(encodeURIComponent(JSON.stringify(save)))); navigator.clipboard?.writeText(code).then(() => { notice = 'Kode disalin ke papan klip.'; render(); }, () => { notice = 'Tidak bisa menyalin.'; render(); }); return; }
|
||||
if (b.hasAttribute('data-import')) { const code = prompt('Tempel kode simpanan:'); if (!code) return; try { const d = JSON.parse(decodeURIComponent(escape(atob(code.trim())))); if (d.v !== 1) throw 0; save = { ...blank(), ...d }; persist(); notice = 'Simpanan dimuat.'; } catch (_) { notice = 'Kode tidak valid.'; } return render(); }
|
||||
if (b.hasAttribute('data-reset')) { if (confirm('Hapus semua koin, skin, jurus, dan pencapaian?')) { save = blank(); persist(); notice = 'Simpanan dihapus.'; render(); } }
|
||||
}
|
||||
|
||||
// ---- easter eggs ----
|
||||
const KONAMI = ['arrowup', 'arrowup', 'arrowdown', 'arrowdown', 'arrowleft', 'arrowright', 'arrowleft', 'arrowright', 'b', 'a'];
|
||||
let kbuf = [], tbuf = '';
|
||||
function swarm(emoji, n, cls) {
|
||||
const box = document.createElement('div'); box.className = 'egg-layer ' + cls; document.body.append(box);
|
||||
for (let i = 0; i < n; i++) { const s = document.createElement('span'); s.textContent = emoji; s.style.setProperty('--y', (8 + Math.random() * 84) + 'vh'); s.style.setProperty('--d', (Math.random() * 1.6) + 's'); s.style.setProperty('--t', (3 + Math.random() * 2) + 's'); s.style.setProperty('--s', (22 + Math.random() * 30) + 'px'); box.append(s); }
|
||||
setTimeout(() => box.remove(), 6500);
|
||||
}
|
||||
function egg(id, label, fn) {
|
||||
fn(); if (!save.eggs[id]) { save.eggs[id] = 1; addCoins(100); toast(label + ' +100 koin', 'ach'); } if (ACH[id]) unlock(id);
|
||||
}
|
||||
const CODES = {
|
||||
syududu: () => egg('syududu', 'SYUDUDU!', () => swarm('🐜', 40, 'ants')),
|
||||
mbg: () => egg('mbg', 'TRUK KATERING LEWAT', () => swarm('🚚', 1, 'truck')),
|
||||
esemka: () => egg('esemka', 'KONVOI ESEMKA', () => swarm('🚗', 6, 'truck'))
|
||||
};
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target?.matches?.('input,textarea')) return;
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
||||
kbuf.push(k); if (kbuf.length > KONAMI.length) kbuf.shift();
|
||||
if (kbuf.join() === KONAMI.join()) { kbuf = []; egg('konami', 'KODE RAHASIA TERBUKA', () => swarm('✦', 24, 'ants')); }
|
||||
if (k.length === 1) { tbuf = (tbuf + k).slice(-10); for (const c in CODES) if (tbuf.endsWith(c)) { tbuf = ''; CODES[c](); } }
|
||||
});
|
||||
let logoClicks = 0, lockClicks = 0, lt = 0;
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest?.('.home-title h1')) { if (++logoClicks >= 7) { logoClicks = 0; egg('logo', 'KAMU TELITI', () => swarm('✦', 20, 'ants')); } }
|
||||
const lock = e.target.closest?.('[data-fighter^="locked"]');
|
||||
if (lock) { const now = Date.now(); if (now - lt > 4000) lockClicks = 0; lt = now; lockClicks++; if (lockClicks === 3) toast('Siapa yang akan datang berikutnya…?'); if (lockClicks >= 6) { lockClicks = 0; egg('penasaran', 'RASA INGIN TAHUMU DIHARGAI', () => {}); } }
|
||||
}, true);
|
||||
|
||||
window.Meta = { skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue