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
-
+Konoha Fighting
+KONOHAFIGHTING
Menyiapkan…