diff --git a/README.md b/README.md index 9f0d8c7..253c27e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,45 @@ > [!NOTE] > **Konoha Fighting** adalah proyek turunan dari **AETHER CLASH** karya **Bang Tutorial** ([repo asli](https://github.com/bangtutorial/aether-clash), [tutorial YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)). Kode asli berlisensi [MIT](LICENSE); aset bawaan (karakter, seni, audio, panduan) berlisensi [CC BY-NC 4.0](LICENSE-ASSETS.md), jadi **tidak untuk penggunaan komersial**. Perubahan di sini: roster karakter diganti dengan karakter parodi buatan sendiri. Bagian di bawah ini masih dokumentasi asli dan akan diperbarui bertahap. +# Konoha Fighting + +Game fighting 2D di browser dengan tokoh publik versi parodi: **Bro Wo, Mega Chan, Son of Solo Man, Ethanol Man, A Nice, Solo Man, dan Gufron**, plus 5 slot misteri. Semua karakter adalah karikatur untuk hiburan; nama dan jurus hanya plesetan. Tanpa instalasi, tanpa dependensi. + +## Menjalankan + +```bash +python3 serve.py 8141 # server lokal tanpa cache +``` + +Buka `http://localhost:8141/?touch=0`. (Mode sentuh otomatis di HP.) + +## Kontrol + +| Tombol | Fungsi | +| --- | --- | +| A D / W / S | jalan, lompat (W dua kali = lompat ganda), jongkok | +| Spasi | serang (ketuk 3x = kombo) | +| ↓ ↓ + Spasi | uppercut (menjatuhkan lawan) | +| ↓ → + Spasi | tinju maju | +| I / O / P | skill 1, skill 2, ultimate | +| K | jurus toko | +| L (tahan) / L (ketuk) | blok / parry | +| J | lempar (menembus blok) | +| P2: ← → ↑ N M , . | jalan, lompat, serang, skill 1, skill 2, ultimate | + +## Fitur + +- Mode: VS Komputer (4 tingkat), Arcade, Survival, Dua Pemain, Latihan. +- Ultimate sinematik dan mode pasca-ultimate untuk setiap karakter (A Nice berubah jadi Senator). +- Koin, toko (jurus toko, varian skill per karakter, skin warna), menu Karakter, 20 pencapaian, easter egg. +- Rencana pengembangan: [ROADMAP.md](ROADMAP.md). Alur membuat karakter baru: `work/WORKFLOW.md` di repo induk. + +## Kredit dan lisensi + +Berbasis **AETHER CLASH** oleh **Bang Tutorial**. Kode berlisensi MIT, aset bawaan CC BY-NC 4.0 (tidak untuk penggunaan komersial). Dokumentasi asli proyek induk tetap di bawah ini. + +--- +
# ⚔️ AETHER CLASH diff --git a/ROADMAP.md b/ROADMAP.md index cb77c16..b087a0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,9 @@ Status per 2026-10-02. Dokumen ini adalah usulan; urutan dan isi sepenuhnya bisa **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). +**Tambahan (branch `feat/guard-arcade-audio`):** blok (tahan L), parry (ketuk L), lempar (J, menembus blok), blok CPU per tingkat, juggle di udara dan hit-stop bertingkat; varian skill per karakter (GANDA/CEPAT dan BERAT, dibeli di toko, dipilih di menu Karakter); mode Arcade (6 lawan, satu nyawa), Survival (tanpa akhir, makin sulit), Dua Pemain lokal; teriakan jurus lewat suara bawaan browser (bisa dimatikan); tombol sentuh untuk perintah, K, blok, dan lempar; nama dan tampilan diganti menjadi Konoha Fighting (kredit Bang Tutorial tetap); README baru. +**Masih belum:** wajah Bro Wo/Mega Chan/Son of Solo Man dibuat ulang dengan foto acuan (butuh Anda memilih hasil gambar), skill baru bergambar khas tiap karakter, skin kostum, musik per arena, gamepad, rebind tombol, rekaman suara asli, hosting. + ## 0. Kondisi sekarang **Sudah ada** diff --git a/files.json b/files.json index f291874..a5dd939 100644 --- a/files.json +++ b/files.json @@ -1,14 +1,14 @@ { "files": 216, - "bytes": 30910020, + "bytes": 30918536, "list": [ { "file": "index.html", - "bytes": 12066 + "bytes": 12429 }, { "file": "mobile.html", - "bytes": 3638 + "bytes": 3620 }, { "file": "style.css", @@ -24,11 +24,11 @@ }, { "file": "game.js", - "bytes": 224243 + "bytes": 231657 }, { "file": "menu.js", - "bytes": 21426 + "bytes": 22183 }, { "file": "match.js", diff --git a/game.js b/game.js index b1f4403..90bf95f 100644 --- a/game.js +++ b/game.js @@ -29,6 +29,7 @@ let ready = false, paused = false, settingsOpen = false, hitboxes = false, muted = false, volume = .45; let scale = 1, offsetX = 0, offsetY = 0, viewW = W, viewH = H, pixelRatio = 1; let time = 0, realTime = 0, lastTime = 0, accumulator = 0, hitstop = 0, trauma = 0, cinematic = 0, announceTimer = 0; + let p2Mode = false; const keys2 = new Set(); let ignoreBlock = false; let seed = 18741, stepCount = 0, totalDamage = 0, comboHits = 0, bestCombo = 0, comboDamage = 0, comboTimer = 0, fps = 60; const keys = new Set(), taps = { a: -10, d: -10, s: -10 }; const particles = [], effects = [], projectiles = [], damageNumbers = []; @@ -158,7 +159,7 @@ const duration = type === 'ultimate' ? .7 : heavy ? .26 : type === 'cast' ? .22 : .13; const osc = audio.createOscillator(), gain = audio.createGain(); osc.type = type === 'jump' || type === 'cast' ? 'sine' : 'triangle'; - const frequency = ({ hit: 210, heavy: 110, cast: 430, jump: 360, ultimate: 72, step: 75, click: 550 })[type] || 190; + const frequency = ({ hit: 210, heavy: 110, cast: 430, jump: 360, ultimate: 72, step: 75, click: 550, guard: 640, parry: 1250 })[type] || 190; osc.frequency.setValueAtTime(frequency, start); osc.frequency.exponentialRampToValueAtTime(type === 'jump' || type === 'cast' ? frequency * 2.5 : frequency * .22, start + duration); gain.gain.setValueAtTime(.001, start); gain.gain.exponentialRampToValueAtTime(heavy ? .32 : .12, start + .008); gain.gain.exponentialRampToValueAtTime(.001, start + duration); osc.connect(gain); gain.connect(master); osc.start(start); osc.stop(start + duration + .02); audioSources++; @@ -180,7 +181,7 @@ function playable() { return ['arco','fenr',...Object.keys(KITS)]; } function startMatch(options={}) { if(!ready||!Rules)return false; - const {player='arco',enemy='fenr',stage='bellora',level='medium',mode='versus'}=options; + const {player='arco',enemy='fenr',stage='bellora',level='medium',mode='versus'}=options; p2Mode=false; keys2.clear(); if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false; if(!images['stage-'+stage])return false; selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY; @@ -209,8 +210,9 @@ 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'}); + let metaHandled=false; + if(event.type==='complete'){metaHandled=!!window.Meta?.award?.({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,level:difficulty,bestCombo,perfect:match.winner==='player'&&match.enemyWins===0});} + if(event.type==='complete'&&!metaHandled)window.FrontEnd?.showResult({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,mode:'versus'}); } } function updateMatchHud() { @@ -277,7 +279,7 @@ if(!KITS[selectedCharacter].start(hero,name))return false; hero.animTime=0;hero.runDirection=0; if(name==='ultimate')startSummon(hero); - sound(name==='ultimate'?'ultimate':'cast');return true; + sound(name==='ultimate'?'ultimate':'cast');if(name==='ultimate')window.Meta?.shout?.(KITS[selectedCharacter].names?.[3]);return true; } if (!canAct() || hero.action || hero.cooldowns[name] > 0 || !Object.hasOwn(cooldownMax, name) || (name === 'ultimate' && squad)) return false; if (hero.doublePose > 0) { hero.rollBuffer = name; return true; } @@ -293,8 +295,11 @@ if(menuOpen) {if(key==='escape'&&settingsOpen)closeSettings();return;} if (key === 'escape') { if (settingsOpen) closeSettings(); else setPaused(!paused); return; } if (key === 'r') { reset(); return; } + if (p2Mode && P2_KEYS.includes(key)) { p2Press(key); return; } if (!canAct() || keys.has(key)) return; keys.add(key); logInput(key); + if (key === 'l') { hero.parry = .16; } + if (key === 'j') startThrow(); 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; } } @@ -338,7 +343,7 @@ 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; + hero.runDirection = 0; state(c.state); announce(c.label, .6); sound('cast'); window.Meta?.shout?.(c.label.replace('!', '')); return true; } const SHOP_MOVES = { seruduk: { name: 'SERUDUK KILAT', cd: 7, state: 'attack2', duration: .45, hitAt: .3, damage: 14, knockback: 340 }, @@ -368,6 +373,10 @@ if (Math.abs(dx) < 230 && dummy.y > CONFIG.groundY - 125) hitDummy(SHOP_MOVES.hentak.damage, 200, col, hero.x, y, true); return; } + if (a.special === 'throw') { + if (Math.abs(dx) < 125 && dummy.y >= CONFIG.groundY - 1 && !['down', 'recover'].includes(dummy.state)) { dummy.invuln = 0; ignoreBlock = true; hitDummy(14, 330, col, hero.x, y, true); ignoreBlock = false; dummy.x = clamp(hero.x + hero.facing * 150, 110, W - 100); sound('heavy'); trauma = Math.min(1, trauma + .3); } else sound('step'); + 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) { @@ -377,8 +386,48 @@ } 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; } + + // ---- Guard (hold L), parry (tap L just before a hit), throw (J), P2, CPU guard ---- + function heroGuard(damage, options) { + if (!(hero.blocking || hero.parry > 0) || hero.action || options?.unblockable) return false; + if (hero.parry > 0) { + hero.parry = 0; hero.invuln = Math.max(hero.invuln, .35); announce('PARRY!', .6); sound('parry'); window.Meta?.shout?.('Parry!'); hitstop = Math.max(hitstop, .1); trauma = Math.min(1, trauma + .2); + spawnParticles(hero.x + hero.facing * 40, hero.y - 100, '#fff3b0', 22, 320); + if (!['down', 'recover'].includes(dummy.state)) { dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = -.3; dummy.vx = -hero.facing * 120; } + window.Meta?.event?.('parry'); return true; + } + const chip = Math.max(1, Math.round(damage * .2)); hero.hp = Math.max(1, hero.hp - chip); hero.guard += damage * 1.6; hero.vx = -hero.facing * 90; + spawnParticles(hero.x + hero.facing * 38, hero.y - 90, '#ffd36a', 10, 200); sound('guard'); hitstop = Math.max(hitstop, .03); + damageNumbers.push({ x: hero.x, y: hero.y - 170, amount: 'GUARD', life: .6, maxLife: .6, color: '#ffd36a' }); + if (hero.guard >= 60) { hero.guard = 0; hero.guardBreak = 1.2; hero.blocking = false; hero.hurtTime = .7; state('hurt'); announce('GUARD BREAK', .7); } + return true; + } + function startThrow() { + if (!canAct() || hero.action || hero.doublePose > 0 || !hero.grounded || (hero.throwCd || 0) > 0) return false; + hero.throwCd = 3; hero.action = { type: 'attack', name: 'attack2', index: 3, t: 0, duration: .5, hit: false, queued: 0, fired: false, hitAt: .3, special: 'throw', color: '#ffb36a' }; + hero.runDirection = 0; state('attack2'); announce('LEMPAR!', .5); window.Meta?.shout?.('Lempar!'); return true; + } + // P2 (local two-player): the second fighter is driven by these keys instead of the CPU. + const P2_KEYS = ['arrowleft', 'arrowright', 'arrowup', 'n', 'm', ',', '.']; + let p2Chain = { i: 0, t: -9 }; + function p2Press(key) { + keys2.add(key); if (menuOpen || paused || (match && match.phase !== 'fight') || dummy.state === 'hurt' || dummy.state === 'down' || dummy.state === 'recover') return; + if (key === 'arrowup' && dummy.y >= CONFIG.groundY - 1 && !dummy.action) { dummy.vy = -CONFIG.jumpSpeed; dummy.y -= 1; dummy.jumps = 1; dummy.state = 'jump'; dummy.stateTime = 0; sound('jump'); return; } + if (key === 'n') { const i = time - p2Chain.t < .7 && p2Chain.i < 3 ? p2Chain.i + 1 : 1; if (startEnemyAction('attack', i)) p2Chain = { i, t: time }; return; } + if (key === 'm') startEnemyAction('skill1'); if (key === ',') startEnemyAction('skill2'); if (key === '.') startEnemyAction('ultimate'); + } + function updateP2(dt) { + const dir = (keys2.has('arrowright') ? 1 : 0) - (keys2.has('arrowleft') ? 1 : 0), grounded = dummy.y >= CONFIG.groundY - 1; + if (hero.hp <= 0 || (match && match.phase !== 'fight')) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (grounded) dummy.state = 'idle'; return; } + if (!grounded) { dummy.state = 'jump'; if (dir) dummy.vx = approach(dummy.vx, dir * 260, dt * 1600); return; } + dummy.facing = hero.x >= dummy.x ? 1 : -1; + dummy.vx = approach(dummy.vx, dir * CONFIG.walkSpeed, dt * 2800); + dummy.state = dir ? 'walk' : 'idle'; dummy.walkPhase = (dummy.walkPhase || 0) + Math.abs(dummy.vx) * dt / 148; + } + function forceCommand(id) { const c = COMMANDS[id]; if (!c) return false; inputLog.length = 0; const f = hero.facing > 0 ? 'd' : 'a'; if (id === 'uppercut') inputLog.push({ k: 's', t: time - .1 }, { k: 's', t: time }); else inputLog.push({ k: 's', t: time - .1 }, { k: f, t: time }); return startCommand(); } + const CPU_BLOCK = { easy: .05, medium: .14, hard: .24, excellent: .32 }; + function release(key) { key = key.toLowerCase(); keys.delete(key); keys2.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; } + function clearInput() { keys.clear(); keys2.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; } function resetCombat() { systemAnnouncer?.clear(); stopUltimateVoice(); @@ -399,6 +448,10 @@ 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; + if (!ignoreBlock && F && aiEnabled && !p2Mode && match?.mode === 'versus' && dummy.y >= CONFIG.groundY - 1 && !dummy.action && (dummy.state === 'idle' || dummy.state === 'walk') && !knockdown && random() < (CPU_BLOCK[difficulty] ?? .1)) { + const chip = Math.min(Math.max(1, Math.round(damage * .2)), Math.max(0, BALANCE.dummyMax - dummy.damage - 1)); dummy.damage += chip; totalDamage += chip; + spawnParticles(dummy.x - dir * 30, impactY, '#ffd36a', 8, 180); damageNumbers.push({ x: dummy.x, y: impactY - 40, amount: 'GUARD', life: .6, maxLife: .6, color: '#ffd36a' }); sound('step'); hitstop = Math.max(hitstop, .03); return true; + } // 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)))); @@ -409,9 +462,10 @@ dummy.stunHits = dummy.state === 'hurt' ? (dummy.stunHits || 0) + 1 : 1; if (cap && dummy.stunHits >= cap) dummy.invuln = .5 + cpuProfile().immunity; dummy.flash = 0; dummy.vx = dir * power; dummy.stateTime = 0; dummy.state = 'hurt'; + if (dummy.y >= CONFIG.groundY - 1) dummy.juggle = 0; else if ((dummy.juggle = (dummy.juggle || 0) + 1) <= 4) dummy.vy = Math.min(dummy.vy, -260); dummy.action=null; if(F&&dummy.transformPending>0)F.end(dummy); - if (freezeWorld) hitstop = Math.max(hitstop, damage >= 30 ? .085 : .045); + if (freezeWorld) hitstop = Math.max(hitstop, damage >= 30 ? .085 : damage >= 14 ? .065 : .045); trauma = Math.min(1, trauma + (damage >= 30 ? .45 : .18) * shake); damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, life: .9, maxLife: .9, color }); spawnParticles(dummy.x - dir * 21, impactY, color, damage >= 30 ? 22 : 14, damage >= 30 ? 320 : 220); @@ -797,8 +851,12 @@ if (a.action || a.state === 'hurt' || a.state === 'down' || a.state === 'recover') return false; if (name !== 'attack' && a.cooldowns[name] > 0) return false; const rg = !!a.bwRage && !!d.mode && name !== 'attack'; + const alt = a === hero && !rg && (name === 'skill1' || name === 'skill2') ? window.Meta?.altSkill?.(d.id, name) : null; if (name !== 'attack') a.cooldowns[name] = rg ? (name === 'ultimate' ? 999 : (d.mode.cooldowns?.[name] ?? 6)) : d.balance.cooldowns[name]; - a.action = { ...move(name, index, rg), t: 0, fired: false, queued: 0 }; a.state = a.action.name; a.stateTime = 0; return true; + a.action = { ...move(name, index, rg), t: 0, fired: false, queued: 0 }; + if (alt) { const m = a.action, proj = ['projectile', 'wave', 'homing'].includes(m.kind); + if (alt === 'ganda') { if (proj) { m.count = (m.count || 1) * 2; m.spread = m.spread || 55; m.damage = Math.max(1, Math.round(m.damage * .65)); a.cooldowns[name] *= 1.1; } else { m.damage = Math.round(m.damage * .8); m.duration *= .75; a.cooldowns[name] *= .7; } } + if (alt === 'berat') { m.damage = Math.round(m.damage * 1.6); m.knockback = (m.knockback || 160) * 1.3; m.duration += .14; if (m.size) m.size = Math.round(m.size * 1.35); if (m.radius) m.radius = Math.round(m.radius * 1.35); if (m.speed) m.speed = Math.round(m.speed * .85); if (m.dash) m.dash *= 1.3; if (m.reach) m.reach = Math.round(m.reach * 1.15); a.cooldowns[name] *= 1.5; } } a.state = a.action.name; a.stateTime = 0; return true; }; const refund = a => { for (const n of Object.keys(d.balance.cooldowns)) a.cooldowns[n] = Math.max(0, a.cooldowns[n] - d.balance.cooldowns[n] * (d.balance.basicRefund ?? .05)); }; return { balance: d.balance, names: d.names, combo: d.combo, move, start, refund, def: d }; @@ -1260,16 +1318,18 @@ 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.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); + hero.airTime = hero.grounded ? 0 : hero.airTime + dt; hero.spCd = Math.max(0, (hero.spCd || 0) - dt); hero.throwCd = Math.max(0, (hero.throwCd || 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; - const dir = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0), crouching = keys.has('s') && hero.grounded && !hero.action; + hero.parry = Math.max(0, (hero.parry || 0) - dt); hero.guardBreak = Math.max(0, (hero.guardBreak || 0) - dt); hero.guard = Math.max(0, (hero.guard || 0) - dt * 22); + hero.blocking = keys.has('l') && hero.grounded && !hero.action && hero.hurtTime <= 0 && hero.hp > 0 && hero.guardBreak <= 0 && hero.doublePose <= 0; + const dir = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0), crouching = (keys.has('s') || hero.blocking) && hero.grounded && !hero.action; if (!hero.action && hero.hurtTime <= 0 && hero.hp > 0) { if (dir && hero.doublePose <= 0) hero.facing = dir; if (hero.runDirection && dir !== hero.runDirection) hero.runDirection = 0; const speed = (hero.runDirection === dir && dir ? CONFIG.runSpeed : CONFIG.walkSpeed)*(selectedCharacter==='fenr'&&hero.form==='wolf'?1.08:1)*(bwx.rage.player>0?(modeOf(selectedCharacter)?.speedMul||1):1); - hero.vx = approach(hero.vx, crouching ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600)); + hero.vx = approach(hero.vx, crouching || hero.blocking ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600)); if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle'); else state(hero.doublePose > 0 ? 'doublejump' : 'jump'); } else hero.vx = approach(hero.vx, 0, dt * 1900); @@ -1307,7 +1367,7 @@ function updateDummy(dt) { if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt);if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);} dummy.stateTime += dt; dummy.flash = Math.max(0, dummy.flash - dt); dummy.invuln = Math.max(0, dummy.invuln - dt); - dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || !aiEnabled || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950); + dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || (!aiEnabled && !p2Mode) || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950); if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.x)=hero.x?bodyGap():-bodyGap()),110,W-100); // Versus CPU follows the player's post-hurt immunity rule by difficulty, so a mashed chain cannot loop it forever. // Its stun lasts 0.5 s there, which still covers every 3-hit chain gap; the training dummy stays open for practice. @@ -1786,11 +1846,12 @@ if (!a.fired && a.type !== 'ultimate' && a.t >= a.duration * a.hitAt) { a.fired = true; enemyStrike(a); } if (a.t >= a.duration || (opponentCharacter !== 'fenr' && a.type === 'skill1' && a.fired)) { dummy.action = null; dummy.state = 'idle'; dummy.stateTime = 0; dummy.aiThink = P.recovery; - if (hero.hp > 0) cpuFollowUp(a, P); + if (hero.hp > 0 && !p2Mode) cpuFollowUp(a, P); } return; } const airborne = dummy.y < CONFIG.groundY || dummy.vy < 0; + if (p2Mode) { updateP2(dt); return; } if (!aiEnabled || hero.hp <= 0) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (!airborne) dummy.state = 'idle'; return; } if (airborne) { if (cpu.doubleAt && time >= cpu.doubleAt && dummy.jumps < 2) { dummy.jumps = 2; dummy.vy = -CONFIG.doubleJumpSpeed; cpu.doubleAt = 0; } @@ -1977,6 +2038,7 @@ 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 * stateK(metrics, hero.state), skin = window.Meta?.skinFilter?.(selectedCharacter); + if (hero.blocking) { ctx.save(); ctx.strokeStyle = '#ffd36a'; ctx.lineWidth = 5; ctx.globalAlpha = .75; ctx.beginPath(); ctx.arc(hero.x + hero.facing * 20, hero.y - 90, 85, hero.facing > 0 ? -1.1 : Math.PI - 1.1, hero.facing > 0 ? 1.1 : Math.PI + 1.1); ctx.stroke(); ctx.restore(); } 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); } @@ -2288,7 +2350,7 @@ 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 openSettings() { settingsOpen = true; clearInput(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); $('#close-settings').innerHTML=(menuOpen?'Kembali ke menu':'Kembali ke arena')+' ↗'; $('#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','escape','r']); + const handled = new Set(['w','a','s','d',' ','i','o','p','k','l','j','escape','r','arrowleft','arrowright','arrowup','n','m',',','.']); window.addEventListener('keydown', e => { if (!handled.has(e.key.toLowerCase()) || /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return; if (e.target.tagName === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return; e.preventDefault(); if (!e.repeat) press(e.key); }); window.addEventListener('keyup', e => { if (handled.has(e.key.toLowerCase())) { if(!/INPUT|TEXTAREA|SELECT|BUTTON/.test(e.target.tagName))e.preventDefault(); release(e.key); } }); window.addEventListener('blur', clearInput); document.addEventListener('visibilitychange', () => { clearInput(); lastTime = 0; accumulator = 0;if(document.hidden)systemAnnouncer?.clear(); syncUltimateVoice(); syncMusic(); }); @@ -2311,7 +2373,7 @@ function tick(stamp) { if (lastTime) { const elapsed = Math.min((stamp - lastTime) / 1000, .1); fps += (1 / Math.max(elapsed, .001) - fps) * .05; if (!window.__game?.manual) { accumulator += elapsed; while (accumulator >= 1 / 120) { step(1 / 120); accumulator -= 1 / 120; } } } lastTime = stamp; draw(); requestAnimationFrame(tick); } // Deterministic QA interface: set manual=true, reset(), press/release(), advance(seconds). // Positions represent feet. No networking or persistent state is changed by this interface. - window.__game = { hero, dummy, config: CONFIG, balance:BALANCE, particles, effects, projectiles, pose: heroPose, press: key => press(key, true), release, input(key, down) { if (down) press(key); else release(key); }, reset, cast, jump, attack: startAttack, clearInput, draw, manual: false, + window.__game = { hero, dummy, config: CONFIG, balance:BALANCE, particles, effects, projectiles, pose: heroPose, press: key => press(key, true), release, input(key, down) { if (key.startsWith('cmd:')) { if (down) forceCommand(key.slice(4)); return; } if (down) press(key); else release(key); }, setP2(on) { p2Mode = !!on; if (on) { aiEnabled = false; const t = $('#ai-toggle'); if (t) t.checked = false; } }, command: forceCommand, reset, cast, jump, attack: startAttack, clearInput, draw, manual: false, advance(seconds) { for (let i = 0; i < Math.round(seconds * 120); i++) step(1 / 120); draw(); return this.snapshot(); }, snapshot() { return { ready, paused, settingsOpen, menuOpen, selectedCharacter, opponentCharacter, difficulty, stageId, match:match?{...match}:null,roundCues:[...roundCues],aiEnabled, time, stepCount, hero: JSON.parse(JSON.stringify(hero)), dummy: { ...dummy }, healthBars:{hero:hpLayers(hero.hp),dummy:hpLayers(currentDummyHP())}, squad: squad ? JSON.parse(JSON.stringify(squad)) : null, enemySquad:enemySquad?JSON.parse(JSON.stringify(enemySquad)):null, parade: parade ? JSON.parse(JSON.stringify(parade)) : null, enemyParade: enemyParade ? JSON.parse(JSON.stringify(enemyParade)) : null, flock: flock ? JSON.parse(JSON.stringify(flock)) : null, enemyFlock: enemyFlock ? JSON.parse(JSON.stringify(enemyFlock)) : null, serpent: serpent ? JSON.parse(JSON.stringify(serpent)) : null, enemySerpent: enemySerpent ? JSON.parse(JSON.stringify(enemySerpent)) : null, quake: quake ? JSON.parse(JSON.stringify(quake)) : null, enemyQuake: enemyQuake ? JSON.parse(JSON.stringify(enemyQuake)) : null, finale: finale ? { ...finale } : null, enemyFinale: enemyFinale ? { ...enemyFinale } : null, skyfall: skyfall ? { ...skyfall } : null, enemySkyfall: enemySkyfall ? { ...enemySkyfall } : null, orrery: orrery ? { ...orrery } : null, enemyOrrery: enemyOrrery ? { ...enemyOrrery } : null, sunroar: sunroar ? { ...sunroar } : null, enemySunroar: enemySunroar ? { ...enemySunroar } : null, delivery: delivery ? { ...delivery } : null, enemyDelivery: enemyDelivery ? { ...enemyDelivery } : null, tortoise: tortoise ? { ...tortoise, spirit: undefined } : null, enemyTortoise: enemyTortoise ? { ...enemyTortoise, spirit: undefined } : null, playable: playable(), cutinVisible: !!squad && squad.t < SQUAD.cutinDuration, totalDamage, comboHits, comboDamage, hitstop, cinematic, trauma, projectileCount: projectiles.length, audioState: audio?.state || 'uninitialized', audioSources, stageLoaded: !!images.stage, spriteLoaded: !!images.hero, manifestLoaded: !!manifest }; }, startMatch,setMenuOpen,menuSound,openSettings,toggleFullscreen,music(){return music?{on:musicOn,volume:musicVolume,playing:!music.paused,src:MUSIC_PATH,element:music}:null;},onRoundCue(fn){roundAnnouncer=typeof fn==='function'?fn:null;}, @@ -2319,7 +2381,7 @@ stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;}, selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose, setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; }, - receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; } + receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; } }; if(F)reset(); if(window.FRONTEND_ENABLED)setMenuOpen(true); diff --git a/index.html b/index.html index 92d2f4e..10d0e20 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,7 @@ -AETHER CLASH — Mecha vs Demi-Human -
Menyiapkan…
+Konoha Fighting +
Menyiapkan…
@@ -19,10 +19,10 @@
BELLORACOURTYARD
TOTAL DAMAGE0 DMG
BEST CHAIN0 HITS
R · RESETESC · PAUSE
- -
✦AETHER CLASHMenyiapkan arena…
-
AETHER CLASH / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat   S · Menunduk / turun cepat
2× S · Salto   SPACE · Kombo 3 pukulan
I / O · Skill   P · Ultimate

-
AETHER CLASH / PAUSE

PAUSED

Pertarungan dijeda. Siap melanjutkan?

-
+ +
✦KONOHA FIGHTINGMenyiapkan arena…
+
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat   S · Menunduk / turun cepat
2× S · Salto   SPACE · Kombo 3 pukulan
I / O · Skill   P · Ultimate

+
KONOHA FIGHTING / PAUSE

PAUSED

Pertarungan dijeda. Siap melanjutkan?

+
diff --git a/menu.js b/menu.js index eac8351..2da2303 100644 --- a/menu.js +++ b/menu.js @@ -28,7 +28,7 @@ // lockIn: the pending step change while a confirmed fighter flashes 3 times (PICK_FLASH s each); input waits for it. const PICK_FLASH=.3;let lockIn=null,lockTicks=[]; const ids=Object.keys(fighters); - let screen='home',step=0,mode='versus',player=ids[0],enemy=ids[1]||ids[0],hover=ids[0],stage='bellora',level='medium',result=null,interacted=false,lastSound=0; + let p2=false,runKind=null,screen='home',step=0,mode='versus',player=ids[0],enemy=ids[1]||ids[0],hover=ids[0],stage='bellora',level='medium',result=null,interacted=false,lastSound=0; const reducedMotion=window.matchMedia?.('(prefers-reduced-motion: reduce)'); let backgroundEnabled=!reducedMotion?.matches,backgroundVideo=null,videoPlayToken=0; const backgroundToggle=document.querySelector('#menu-video-toggle'); @@ -56,7 +56,7 @@ document.addEventListener('visibilitychange',syncBackground); function sfx(kind='move',explicit=false){if(!interacted&&!explicit)return;interacted=true;const now=performance.now();if(kind==='move'&&now-lastSound<75)return;lastSound=now;game.menuSound(kind);} root.addEventListener('pointerdown',()=>{interacted=true;},true); - const brand='AETHERCLASH✦'; + const brand='KONOHAFIGHTING✦'; const footer=()=>''; function navHeader(title,subtitle){return `
${subtitle}

${title}

${brand}
`;} function fighterPanel(id,side){const f=info(id);return `
${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}${f?.name||'Karakter belum dipilih'}
${f?.tag||'AWAITING CHALLENGER'}

${f?.name||'???'}

${f?.race||'ROSTER EXPANDING'}

${f?.detail||'A new challenger is on the way.'}
`;} @@ -65,14 +65,14 @@ 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=`
FIGHTING DEMO / VOL. 02${canFullscreen?``:''}

AETHER
CLASH✦

Two factions. One arena.

MECHAVSDEMI-HUMANTECHNOLOGY MEETS INSTINCT
`; + root.innerHTML=`
PARODY FIGHTER / VOL. 03${canFullscreen?``:''}

KONOHA
FIGHTING✦

Parodi tokoh publik. Satu arena.

BRO WOVSSEMUANYAPARODI · SATIRE · JANGAN DIANGGAP SERIUS
`; } else if(screen==='select'){ - root.innerHTML=`
${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?'VERSUS COMPUTER':'TRAINING ROOM')}
01 PLAYER02 RIVAL03 ARENA
${fighterPanel(step===0?hover:player,'player')}
${step===0?'PLAYER 1':'COMPUTER'}CHOOSE A FIGHTER
${roster.map((id,i)=>{const f=info(id);return ``;}).join('')}

${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}

${fighterPanel(step===1?hover:null,'enemy')}
${footer()}`; + root.innerHTML=`
${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?(runKind?runKind.toUpperCase():p2?'DUA PEMAIN':'VERSUS COMPUTER'):'TRAINING ROOM')}
01 PLAYER02 RIVAL03 ARENA
${fighterPanel(step===0?hover:player,'player')}
${step===0?'PLAYER 1':'COMPUTER'}CHOOSE A FIGHTER
${roster.map((id,i)=>{const f=info(id);return ``;}).join('')}

${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}

${fighterPanel(step===1?hover:null,'enemy')}
${footer()}`; updatePreview(false); } else if(screen==='arena'){ const st=MatchRules.stages[stage];root.innerHTML=`
${navHeader('SET THE STAGE',mode==='versus'?'VERSUS COMPUTER / MATCH SETUP':'TRAINING / ARENA SETUP')}
P1${fighters[player].name}
VS
CPU${fighters[enemy].name}
${String(Object.keys(MatchRules.stages).indexOf(stage)+1).padStart(2,'0')} / ${String(Object.keys(MatchRules.stages).length).padStart(2,'0')}
${st.tag}

${st.name}

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

Unlimited time · Passive rival · Reset anytime

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

${footer()}`; } else if(screen==='about'){ - root.innerHTML=`
${navHeader('ABOUT AETHER CLASH','MECHA VS DEMI-HUMAN')}
CHRONICLES OF AETHER

Steel meets
the untamed.

Di dunia Renaisans yang ditenagai aether, dua faksi bertemu di arena: Mecha dengan rekayasa tubuh dan mesin, serta Demi-Human dengan kekuatan manusia dan hewan. Para petarung membawa asal-usul, kemampuan, dan tujuan mereka sendiri.

MECHAPerpaduan manusia dan teknologi. Rekayasa mekanis, persenjataan aether, dan teknik bertarung yang beragam.

DEMI-HUMANPerpaduan manusia dan hewan. Setiap ras memiliki anatomi, naluri, dan kekuatan khasnya sendiri.

02FACTIONS12ROSTER SLOTS∞RIVALRIES
Visuals · Higgsfield   Voices · ElevenLabs   Typography · Rajdhani
Local fighting demo · Single player
`; + root.innerHTML=`
${navHeader('TENTANG GAME','KONOHA FIGHTING')}
GAME PARODI

Tujuh petarung.
Satu arena.

Konoha Fighting adalah game fighting 2D di browser dengan tokoh publik versi parodi dan plesetan nama. Semua karakter adalah karikatur untuk hiburan, bukan pernyataan tentang orang sebenarnya.

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

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

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

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

${fighters[winner].name} WINS

${result.playerWins}—${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}
`; } @@ -90,15 +90,17 @@ sfx('pick',true);game.announceSelection(hover);root.dataset.picking='true'; root.querySelector(`[data-fighter="${hover}"]`)?.classList.add('picked');root.querySelector(step===0?'#player-preview':'#enemy-preview')?.classList.add('picked'); lockTicks=[1,2].map(i=>setTimeout(()=>sfx('blink',true),i*PICK_FLASH*1000)); - lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000); + lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000); } function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();} - function start(){if(game.startMatch({player,enemy,stage,level,mode})){document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} + function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} function command(cmd){ if(lockIn)return; 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==='versus'||cmd==='training'){mode=cmd;p2=false;runKind=null;screen='select';step=0;hover=player;render();} + if(cmd==='arcade'||cmd==='survival'){mode='versus';p2=false;runKind=cmd;screen='select';step=0;hover=player;render();} + if(cmd==='2p'){mode='versus';p2=true;runKind=null;screen='select';step=0;hover=player;render();} if(cmd==='shop'||cmd==='locker'||cmd==='book')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();} @@ -124,7 +126,7 @@ if(screen==='home'&&e.key==='Enter'&&!e.target.closest?.('button')){e.preventDefault();command('versus');return;} if(screen==='home'&&['ArrowUp','ArrowDown','w','s'].includes(e.key)){e.preventDefault();const list=[...root.querySelectorAll('.home-nav button')],current=list.indexOf(document.activeElement),next=(current+(['ArrowUp','w'].includes(e.key)?-1:1)+list.length)%list.length;list[next].focus();} }); - window.FrontEnd={syncBackground,home(){screen='home';game.setMenuOpen(true);render();},showResult(data){result=data;({player,enemy,stage,level,mode}=data);screen='result';game.setMenuOpen(true);render();},assetsReady(ok=true){if(game.snapshot().menuOpen){render();if(!ok){const status=root.querySelector('#menu-ready');if(status)status.textContent='ASSET ERROR · RELOAD REQUIRED';}}}}; + window.FrontEnd={syncBackground,home(){window.Meta?.cancelRun?.();screen='home';game.setMenuOpen(true);render();},showResult(data){result=data;({player,enemy,stage,level,mode}=data);screen='result';game.setMenuOpen(true);render();},assetsReady(ok=true){if(game.snapshot().menuOpen){render();if(!ok){const status=root.querySelector('#menu-ready');if(status)status.textContent='ASSET ERROR · RELOAD REQUIRED';}}}}; // First paint after the loading screen's download, so menu pictures come straight from the cache. Promise.resolve(window.AETHER_PRELOAD).then(render); })(); diff --git a/meta.css b/meta.css index 82a6609..d51287d 100644 --- a/meta.css +++ b/meta.css @@ -32,3 +32,7 @@ @media(max-width:700px){.locker{grid-template-columns:1fr}} .home-nav .home-row{display:flex;gap:8px}.home-nav .home-row button{flex:1;justify-content:center;gap:8px;padding:9px 8px}.home-nav .home-row button>span:nth-child(1){font-size:17px;letter-spacing:.05em} + +.extra-pad{position:absolute;right:calc(16px + env(safe-area-inset-right));bottom:calc(212px + env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(3,auto);gap:6px;pointer-events:none} +.tbtn-x{position:static!important;width:46px;height:46px;font:700 11px Rajdhani,system-ui,sans-serif;color:#fff;letter-spacing:.04em} +@media(max-height:360px){.extra-pad{bottom:calc(178px + env(safe-area-inset-bottom))}.tbtn-x{width:38px;height:38px;font-size:10px}} diff --git a/meta.js b/meta.js index f54a0de..540f410 100644 --- a/meta.js +++ b/meta.js @@ -24,8 +24,20 @@ ['↓ → + 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.'] + ['K', 'JURUS TOKO', 'Memakai jurus toko yang dipasang di menu Karakter.'], + ['TAHAN L', 'BLOK', 'Mengurangi damage 80%. Terlalu lama diserang = guard pecah.'], + ['KETUK L', 'PARRY', 'Ketuk tepat sebelum kena: tanpa damage dan lawan terhenti.'], + ['J', 'LEMPAR', 'Menembus blok. Harus dekat dan di tanah.'] ]; + const ALT_PRICE = 450; + function altOptions(id) { + const f = window.FIGHTERS?.[id]; if (!f) return []; + return ['skill1', 'skill2'].flatMap((slot, i) => { + const kind = f.skills?.[slot]?.kind, proj = ['projectile', 'wave', 'homing'].includes(kind), nm = f.names[i + 1]; + return [{ key: `${id}:${slot}:ganda`, slot, v: 'ganda', name: nm + (proj ? ' GANDA' : ' CEPAT'), desc: proj ? 'Menembak dua kali lipat dengan damage lebih kecil per peluru.' : 'Lebih cepat dan cooldown pendek, damage lebih kecil.' }, + { key: `${id}:${slot}:berat`, slot, v: 'berat', name: nm + ' BERAT', desc: 'Lebih besar dan keras, tetapi lebih lambat dan cooldown panjang.' }]; + }); + } const ACH = { first_win: ['PERTAMA KALI', 'Menangkan satu pertandingan.'], perfect: ['BERSIH', 'Menang tanpa kehilangan satu ronde pun.'], @@ -41,9 +53,14 @@ mbg: ['TRUK LEWAT', 'Panggil truk katering.'], esemka: ['KONVOI', 'Panggil konvoi.'], penasaran: ['PENASARAN', 'Ketuk slot tanda tanya berulang kali.'], - logo: ['TELITI', 'Temukan rahasia di judul.'] + logo: ['TELITI', 'Temukan rahasia di judul.'], + parry: ['TEPAT WAKTU', 'Lakukan parry yang berhasil.'], + arcade_clear: ['TAMAT', 'Selesaikan mode Arcade.'], + survival5: ['BERTAHAN', 'Menang 5 kali berturut-turut di Survival.'], + twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'], + special: ['SPESIALIS', 'Beli satu varian skill karakter.'] }; - 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: {} }); + const blank = () => ({ v: 1, coins: 0, earned: 0, voice: true, owned: { skin: ['standar'], move: [], alt: [] }, skin: {}, move: null, alt: {}, best: { survival: 0 }, 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 (_) {} }; @@ -66,6 +83,14 @@ // ---- 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 altSkill(id, slot) { const v = save.alt?.[id]?.[slot]; return v && save.owned.alt.includes(`${id}:${slot}:${v}`) ? v : null; } + // Shouts use the browser's own text-to-speech (generic Indonesian voice), never an imitation of anyone's voice. + let lastShout = 0; + function shout(text) { + if (!save.voice || !text || !window.speechSynthesis || Date.now() - lastShout < 900) return; lastShout = Date.now(); + try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(String(text).toLowerCase()); u.lang = 'id-ID'; u.rate = 1.15; u.pitch = 1.25; u.volume = .9; speechSynthesis.speak(u); } catch (_) {} + } + function event(name) { if (name === 'parry') unlock('parry'); if (name === 'twoplayer') unlock('twoplayer'); } function equippedMove() { return save.move && save.owned.move.includes(save.move) ? save.move : null; } let chip; function hud(info) { @@ -88,6 +113,42 @@ save.stats.bestCombo = Math.max(save.stats.bestCombo, r.bestCombo || 0); if ((r.bestCombo || 0) >= 5) unlock('combo5'); if ((r.bestCombo || 0) >= 10) unlock('combo10'); addCoins(gain); toast('KOIN +' + gain + ' (' + parts.join(', ') + ')', 'coin'); + return run ? advanceRun(won) : false; + } + // ---- Arcade (fight everyone once, one life) and Survival (endless, harder each win) ---- + let run = null; + const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + function startRun(kind, player) { + run = { kind, player, wins: 0, queue: kind === 'arcade' ? shuffle(roster().filter(x => x !== player)) : [] }; + nextFight(); + } + function nextFight() { + const g = window.__game, stages = Object.keys(window.MatchRules?.stages || { bellora: 1 }); + const enemy = run.kind === 'arcade' ? run.queue[run.wins] : shuffle(roster().filter(x => x !== run.player))[0]; + const level = run.kind === 'arcade' ? 'medium' : run.wins < 3 ? 'easy' : run.wins < 6 ? 'medium' : run.wins < 9 ? 'hard' : 'excellent'; + const total = run.kind === 'arcade' ? '/' + run.queue.length : ''; + toast((run.kind === 'arcade' ? 'ARCADE' : 'SURVIVAL') + ' · LAWAN ' + (run.wins + 1) + total + ': ' + fname(enemy) + ' (' + level + ')'); + let ok = false; + for (const st of shuffle(stages.slice()).concat('bellora')) { ok = g.startMatch({ player: run.player, enemy, stage: st, level, mode: 'versus' }); if (ok) break; } + if (!ok) { toast('Aset belum siap, coba lagi.'); run = null; window.FrontEnd?.home(); } + } + function advanceRun(won) { + if (won) { + run.wins++; + if (run.kind === 'arcade' && run.wins >= run.queue.length) { finishRun(true); return true; } + if (run.kind === 'survival') { save.best.survival = Math.max(save.best.survival, run.wins); if (run.wins >= 5) unlock('survival5'); persist(); } + setTimeout(() => { if (run) nextFight(); }, 2600); return true; + } + finishRun(false); return true; + } + function finishRun(cleared) { + const r = run; run = null; let bonus = 0; + if (r.kind === 'arcade' && cleared) { bonus = 500; unlock('arcade_clear'); } else if (r.kind === 'survival') bonus = r.wins * 30; + if (bonus) addCoins(bonus); + if (!root) build(); root.hidden = false; + const title = r.kind === 'arcade' ? (cleared ? 'ARCADE TAMAT!' : 'ARCADE BERAKHIR') : 'SURVIVAL BERAKHIR'; + const sub = r.kind === 'arcade' ? (cleared ? 'Semua lawan dikalahkan.' : 'Kalah di lawan ke-' + (r.wins + 1) + ' dari ' + r.queue.length + '.') : 'Kemenangan beruntun: ' + r.wins + ' · rekor ' + save.best.survival; + root.innerHTML = `

${title}

${sub}

${bonus ? 'Bonus ◆ +' + bonus : ''}

`; } // ---- UI ---- @@ -121,7 +182,9 @@ const own = save.owned.move.includes(k); return `

${m.name}

${m.desc}

Cooldown ${m.cd} detik · tombol K
`; }).join(''); - return `

Jurus toko

${moves}

Skin warna

${skins}

Koin didapat dari menang pertandingan, kombo, dan pencapaian. Pasang barang di menu KARAKTER.

`; + const alts = altOptions(who).map(o => { const own = save.owned.alt.includes(o.key); return `

${esc(o.name)}

${o.desc}

${fname(who)} · ${o.slot === 'skill1' ? 'tombol I' : 'tombol O'}
`; }).join(''); + const whoPick = roster().map(id => ``).join(''); + return `

Jurus toko

${moves}

Varian skill karakter

${whoPick}
${alts || '

Tidak ada varian untuk karakter ini.

'}

Skin warna

${skins}

Koin didapat dari menang pertandingan, kombo, dan pencapaian. Pasang barang di menu KARAKTER.

`; } const portrait = id => `assets/${id}/ui/portrait.webp`; function renderLocker() { @@ -129,28 +192,33 @@ const pick = ids.map(id => ``).join(''); const skins = save.owned.skin.map(k => ``).join(''); const moves = [``].concat(save.owned.move.map(k => ``)).join(''); + const altRows = ['skill1', 'skill2'].map(slot => { const opts = altOptions(who).filter(o => o.slot === slot && save.owned.alt.includes(o.key)); const cur = save.alt?.[who]?.[slot] || ''; return `
Skill ${slot === 'skill1' ? '1 (I)' : '2 (O)'}
${opts.map(o => ``).join('')}
`; }).join(''); const n = fnames(who); - return `
${pick}

${fname(who)}

Skin
${skins}
Jurus toko (tombol K)
${moves}

${save.owned.move.length ? '' : 'Belum punya jurus toko. Beli di tab TOKO.'}

Daftar gerakan
`; + return `
${pick}

${fname(who)}

Skin
${skins}
Jurus toko (tombol K)
${moves}

${save.owned.move.length ? '' : 'Belum punya jurus toko. Beli di tab TOKO.'}

${altRows}
Daftar gerakan
`; } 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 `
  • ${ok ? '★' : '☆'} ${hidden ? '???' : n}${hidden ? 'Pencapaian rahasia.' : d}
  • `; }).join(''); - return `

    Statistik

    Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}

    Pencapaian ${got}/${Object.keys(ACH).length}

    Simpanan

    `; + return `

    Statistik

    Menang ${st.wins} · Kalah ${st.losses} · Bersih ${st.perfects} · Kombo terbaik ${st.bestCombo} · Total koin ${save.earned}

    Pencapaian ${got}/${Object.keys(ACH).length}

    Suara

    Memakai suara bawaan browser.

    Simpanan

    `; } 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.who || b.dataset.shopwho) { who = b.dataset.who || b.dataset.shopwho; return render(); } + if (b.hasAttribute('data-runmenu')) { close(); return window.FrontEnd?.home(); } + if (b.dataset.alt !== undefined && b.hasAttribute('data-alt')) { const [slot, v] = b.dataset.alt.split(':'); (save.alt[who] = save.alt[who] || {})[slot] = v || null; persist(); 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]; + const kind = b.dataset.buy.split(':')[0], id = b.dataset.buy.slice(kind.length + 1), item = kind === 'alt' ? { name: altOptions(who).find(o => o.key === id)?.name || id, price: ALT_PRICE } : (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; + if (kind === 'alt') { const [c, sl, v] = id.split(':'); (save.alt[c] = save.alt[c] || {})[sl] = v; unlock('special'); } notice = item.name + ' dibeli.'; persist(); unlock('shopper'); if (save.owned.skin.length >= 4) unlock('fashion'); return render(); } + if (b.hasAttribute('data-voice')) { save.voice = !save.voice; persist(); 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(); } } @@ -186,5 +254,5 @@ 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; } }; + window.Meta = { shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } }; })(); diff --git a/mobile.html b/mobile.html index 7372572..2bca245 100644 --- a/mobile.html +++ b/mobile.html @@ -6,7 +6,7 @@ -AETHER CLASH — Mecha vs Demi-Human +Konoha Fighting