Gabung wf/t3-passive: pasif, kelemahan, dan gaya bertarung (RENCANA 3.2 dan 3.3)

Konflik index.html, meta.js, vs.js diselesaikan dengan mempertahankan kedua sisi: urutan skrip content/passives.js, passives.js, content/quotes.js, content/dialogs.js sebelum vs.js; Meta mengekspor styleOf, setStyle, dan pairEgg; VS.show memanggil Passives.decorateVS lalu memakai durasi dur milik dialog.
This commit is contained in:
rizkyaz0 2026-10-04 07:36:06 +07:00
commit 5a74cc5317
8 changed files with 241 additions and 24 deletions

55
content/passives.js Normal file
View File

@ -0,0 +1,55 @@
/* Pasif dan kelemahan per karakter (RENCANA 3.2) serta gaya bertarung (RENCANA 3.3). Hanya data; mesinnya ada di passives.js (helper) dan game.js (titik kait passiveMod).
Satu perk kecil khas DAN satu kelemahan kecil per karakter agar tidak ada yang bagus di segalanya. Berlaku sama persis untuk pemain, CPU, dan P2.
`mods` memuat angka gabungan perk + kelemahan. Kunci pengali (1 = tanpa perubahan):
dmgOut semua damage yang dikeluarkan dmgBasic tambahan khusus kombo 3 pukulan dasar
dmgSkill tambahan untuk semua yang bukan kombo dasar: skill, ultimate, gerakan perintah, jurus toko, racun/terbakar
dmgIn semua damage yang diterima (di bawah 1 = lebih tahan)
walk kecepatan jalan dan lari cd kecepatan pulih semua cooldown (di atas 1 = lebih cepat)
cdSkill kecepatan pulih skill 1 dan 2 cdUlt kecepatan pulih ultimate
parry jendela parry (pemain) dan peluang blok (CPU) modeDur durasi mode pasca-ultimate
Kunci penambah (0 = tanpa perubahan): regen = HP per detik yang pulih bila tidak terkena serangan selama 3 detik.
Angka sengaja kecil (sekitar 5 sampai 12 persen). Teks angka yang tampil ke pemain disusun otomatis dari `mods`, jadi deskripsi di sini hanya cerita
dan tidak boleh memuat angka (supaya tidak ketinggalan saat angka disetel ulang). Pemisahan perk/kelemahan dibaca dari arah angkanya. */
window.PASSIVES = {
browo: {
name: 'AMUKAN PANJANG', desc: 'Amukan sawitnya awet: mode Mengamuk bertahan lebih lama.',
flaw: { name: 'JOGET DULU', desc: 'Setiap ultimate harus dibuka dengan joget, jadi pulihnya lebih lama.' },
mods: { modeDur: 1.12, cdUlt: .92 }
},
megachan: {
name: 'KEPALA BANTENG', desc: 'Keras kepala, keras tekad: pukulan lawan terasa lebih ringan.',
flaw: { name: 'LANGKAH BERAT', desc: 'Badan kokoh membuat jalannya pelan.' },
mods: { dmgIn: .92, walk: .92 }
},
sonofsolo: {
name: 'RACIKAN PEKAT', desc: 'Asam racikannya makin pekat: skill dan ultimate lebih nyelekit.',
flaw: { name: 'FISIK TIPIS', desc: 'Lebih piawai meracik daripada menahan pukulan, jadi lebih mudah luka.' },
mods: { dmgSkill: 1.08, dmgIn: 1.06 }
},
ethanolman: {
name: 'BAHAN BAKAR HIJAU', desc: 'Energi terbarukan: perlahan memulihkan HP bila lama tidak diserang.',
flaw: { name: 'MESIN DINGIN', desc: 'Selalu butuh pemanasan, jadi semua cooldown pulih lebih lambat.' },
mods: { regen: .5, cd: .95 }
},
anice: {
name: 'JAWABAN TEPAT', desc: 'Bicara terukur, bertahan terukur: jendela parry lebih lebar.',
flaw: { name: 'SANTUN BERLEBIHAN', desc: 'Terlalu sopan untuk memukul keras, jadi pukulan dasarnya lebih lemah.' },
mods: { parry: 1.25, dmgBasic: .92 }
},
soloman: {
name: 'LANGKAH RINGAN', desc: 'Si sederhana melangkah enteng: jalan dan lari lebih cepat.',
flaw: { name: 'TENAGA SEADANYA', desc: 'Serba sederhana, termasuk tenaganya: semua damage lebih kecil.' },
mods: { walk: 1.1, dmgOut: .94 }
},
gufron: {
name: 'BAHASA SEMUT', desc: 'Koloni cepat siap lagi: kedua skill pulih lebih cepat.',
flaw: { name: 'JAB NYENTRIK', desc: 'Pukulan dasarnya kurang meyakinkan, jadi damage kombo dasar lebih kecil.' },
mods: { cdSkill: 1.12, dmgBasic: .88 }
}
};
// Gaya bertarung per karakter: dipilih pemain di menu Karakter (disimpan di Meta.save.style[id]); CPU memakai gaya acak per pertandingan.
window.STYLES = {
agresif: { name: 'AGRESIF', desc: 'Maju terus: pukulan lebih keras, tetapi lebih rapuh dan skill lebih lama pulih.', mods: { dmgOut: 1.06, dmgIn: 1.06, cdSkill: .94 } },
seimbang: { name: 'SEIMBANG', desc: 'Tanpa perubahan angka. Cocok untuk mengenal karakter.', mods: {} },
bertahan: { name: 'BERTAHAN', desc: 'Sabar dan kokoh: lebih tahan pukulan dan jendela parry lebih lebar, tetapi damage lebih kecil.', mods: { dmgIn: .92, dmgOut: .95, parry: 1.2 } }
};

View File

@ -14,6 +14,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- CPU: `updateFenrAI`, `cpuProfile` (tingkat easy/medium/hard/excellent dari `match.js`).
- Aturan ronde: `match.js` (`MatchRules`), dipakai `advanceMatch` di game.js.
- `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`.
- Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2/3.3): data di `content/passives.js` (`window.PASSIVES[id] = {name, desc, flaw, mods}`, `window.STYLES` agresif/seimbang/bertahan), helper dan teks angka otomatis di `passives.js` (`window.Passives`: `merge`, `describe`, `lockerHtml`, `decorateVS`). Mesin: `refreshPassives(styles)` di `startMatch` (juga `selectCharacter`, `setP2`) menggabungkan perk + kelemahan + gaya per sisi ke `sideMods`; semua pembacaan lewat satu titik kait `passiveMod(actor, key, base)` (hero = sisi pemain; selain itu = lawan, CPU atau P2, jadi simetris). Kunci: `dmgOut`, `dmgBasic`, `dmgSkill`, `dmgIn`, `walk`, `cd`, `cdSkill`, `cdUlt`, `parry`, `modeDur` (pengali) dan `regen` (HP/dtk, penambah). Damage dikali di `hitDummy`/`receiveHit`/`dotDamage` lewat `passiveDamage` (dibulatkan dengan sisa yang dibawa, tanpa pengacak); cooldown di loop cooldown `step`/`updateDummy`; jalan di `step`/`updateP2`/`updateFenrAI`; parry di `press('l')` dan peluang blok CPU di `hitDummy`. Gaya pemain disimpan di `Meta.save.style[id]` (`Meta.styleOf/setStyle`, simpanan lama tanpa kunci = Seimbang); `startMatch({styles:{player,enemy}})` menimpa, dan CPU di pertandingan ber-VS memakai gaya acak (pengacak terpisah `styleRand`). Teks tampil di menu Karakter (`renderLocker`), layar VS (`.vs-perk`), dan Daftar gerakan (`passiveRows`). API uji: `__game.passives()`, `__game.passiveMod(actor,key,base)`, `__game.setStyles({player,enemy})`. Catatan: P2 manusia tidak punya tombol blok, jadi kunci `parry` tidak berefek bagi P2.
- `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`.
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.

89
game.js
View File

@ -30,6 +30,49 @@
ko: { start: no, scale: () => 1, zoom: () => 1, drawOverlay: no, state: { on: false, cfg: { trauma: 0 } } } };
}
const Rules = window.MatchRules, Feel = window.Feel || feelFallback();
// ---- Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2 / 3.3; data content/passives.js, helper passives.js) ----
// Titik kait tunggal: passiveMod(actor, key, base) membaca angka sisi aktor (hero = pemain; selain itu = lawan, CPU atau P2), jadi semua efek berlaku sama persis untuk kedua sisi.
// Kunci pengali dikalikan ke base; kunci penambah (regen) dijumlahkan. Tanpa window.Passives semua angka netral dan game tetap berjalan.
// Damage di game ini bilangan bulat, jadi pengali kecil (6%) dibulatkan dengan sisa yang dibawa ke pukulan berikutnya (pcarry): rata-rata tepat, tanpa pengacak.
const sideMods = { player: {}, enemy: {} }, sideStyle = { player: 'seimbang', enemy: 'seimbang' }, pcarry = { player: 0, enemy: 0 }, pregen = { player: 0, enemy: 0 };
let hitKind = null, styleSeed = 7331; // hitKind = 'basic' selama kombo dasar sedang mengenai (untuk dmgBasic)
function passiveMod(actor, key, base = 1) { const v = (actor === hero ? sideMods.player : sideMods.enemy)[key]; return v === undefined ? base : window.Passives?.KEYS?.[key]?.add ? base + v : base * v; }
function styleRand() { styleSeed = (Math.imul(styleSeed, 1664525) + 1013904223) >>> 0; return styleSeed / 4294967296; } // pengacak terpisah: tidak menggeser random() mesin
// styles = { player, enemy }: player kosong = pilihan di menu Karakter (Meta.styleOf), enemy kosong = Seimbang, 'acak' = gaya acak per pertandingan (CPU),
// 'p2' = pilihan menu Karakter untuk karakter lawan (pemain kedua manusia; gaya melekat pada karakter, jadi simetris dengan sisi pemain).
function refreshPassives(styles = {}) {
const P = window.Passives; if (!P) { sideMods.player = {}; sideMods.enemy = {}; return; }
sideStyle.player = P.styleKey(styles.player ?? window.Meta?.styleOf?.(selectedCharacter));
sideStyle.enemy = P.styleKey(styles.enemy === 'acak' ? P.randomStyle(styleRand) : styles.enemy === 'p2' ? window.Meta?.styleOf?.(opponentCharacter) : styles.enemy);
sideMods.player = P.merge(selectedCharacter, sideStyle.player); sideMods.enemy = P.merge(opponentCharacter, sideStyle.enemy);
}
// Pertandingan sungguhan melawan CPU (punya layar VS, bukan P2) memakai gaya acak untuk CPU; dua pemain memakai gaya pilihan tiap karakter; latihan dan uji tanpa options.styles memakai Seimbang.
function matchStyles(options, mode) { const o = options.styles || {}; return { player: o.player, enemy: o.enemy ?? (mode === 'versus' && options.vs ? (options.vs.p2 ? 'p2' : 'acak') : 'seimbang') }; }
function passiveDamage(attacker, target, amount, basic) {
if (!(amount > 0)) return amount;
const mul = passiveMod(attacker, 'dmgOut') * passiveMod(attacker, basic ? 'dmgBasic' : 'dmgSkill') * passiveMod(target, 'dmgIn'); if (mul === 1) return amount;
const side = attacker === hero ? 'player' : 'enemy', v = amount * mul + pcarry[side], r = Math.round(v); pcarry[side] = v - r; return r;
}
const cdRate = (actor, name) => passiveMod(actor, 'cd') * passiveMod(actor, name === 'ultimate' ? 'cdUlt' : 'cdSkill');
function strikeAs(kind, fn) { hitKind = kind; try { fn(); } finally { hitKind = null; } }
// regen: HP kecil per detik, hanya bila aktor hidup, tidak penuh, dan tidak terkena apa pun selama 3 detik (lastHurt diisi tiap damage masuk).
function passiveRegen(dt) {
if (match && match.phase !== 'fight') return;
for (const [side, actor] of [['player', hero], ['enemy', dummy]]) {
const r = passiveMod(actor, 'regen', 0); if (!(r > 0) || !bwAlive(actor) || time - (actor.lastHurt ?? -99) < 3) continue;
if (actor === hero ? hero.hp >= BALANCE.heroMax : dummy.damage <= 0) { pregen[side] = 0; continue; }
pregen[side] += r * dt; if (pregen[side] < 1) continue;
const n = Math.floor(pregen[side]); pregen[side] -= n; if (actor === hero) hero.hp = Math.min(BALANCE.heroMax, hero.hp + n); else dummy.damage = Math.max(0, dummy.damage - n);
}
}
// Baris untuk Daftar gerakan: [tanda, nama, angka, cerita] perk dan kelemahan sebuah sisi, ditambah baris GAYA hanya bila bukan Seimbang (hemat tinggi daftar).
// Cerita hanya tampil di jendela cukup tinggi (CSS), angka selalu.
function passiveRows(id, side) {
const P = window.Passives, d = P?.describe(id, true); if (!d) return null;
const rows = [['\u25B2', d.perk.name, d.perk.fx, d.perk.desc], ['\u25BC', d.flaw.name, d.flaw.fx, d.flaw.desc]];
if (sideStyle[side] !== P.DEFAULT_STYLE) { const st = P.styleView(sideStyle[side], true); rows.push(['GAYA', st.name, st.fx, st.desc]); }
return rows;
}
let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED;
let match = Rules?.create('training') || null, roundAnnouncer = null;
// Layar VS (vs.js): selama vsHold urutan ronde ditahan sebelum cue 'Round 1' (tidak ada yang berjalan di match.js); matchBest = kombo terbaik di seluruh ronde.
@ -194,10 +237,10 @@
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;
selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY;refreshPassives(matchStyles(options,mode));
aiEnabled=mode==='versus';match=Rules.create(mode);matchBest=0;roundCues.length=0;vsAbort();setMenuOpen(false);resetCombat();warmCutins();
$('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus');
if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
if(mode==='versus'){announceTimer=0;if(!(options.vs&&vsStart(options.vs,{player,enemy,stage,level,styles:{...sideStyle}})))emitRoundCue('round',1);}else announce('TRAINING READY',1.3);
return true;
}
// Layar VS (opsional: options.vs = true | {label,p2}; hanya mode versus, tidak ada di latihan). Mengembalikan true bila layar tampil;
@ -320,7 +363,7 @@
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 === 'l') { hero.parry = .16 * passiveMod(hero, 'parry'); }
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.
@ -492,7 +535,7 @@
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.vx = approach(dummy.vx, dir * CONFIG.walkSpeed * passiveMod(dummy, 'walk'), 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(); }
@ -503,7 +546,7 @@
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;
hero.spCd = hero.shopCd = hero.shield = 0; inputLog.length = 0; pcarry.player = pcarry.enemy = pregen.player = pregen.enemy = 0; hero.lastHurt = dummy.lastHurt = -99;
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='';
@ -518,8 +561,9 @@
function hitDummy(damage, power, color, sourceX, impactY, knockdown = false, freezeWorld = true, shake = 1) {
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;
if (damage > 0) damage = Math.max(1, passiveDamage(hero, dummy, damage, hitKind === 'basic')); dummy.lastHurt = time;
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)) {
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) * passiveMod(dummy, 'parry')) {
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', kind: 'guard', life: .6, maxLife: .6, color: Feel.table.colors.guard }); sound('step'); feelImpact('dummy', 'guard', { dir, x: dummy.x - dir * 30, y: impactY }); return true;
}
@ -1456,10 +1500,12 @@
// cooldowns are put back when the rampage ends.
// The post-ultimate "mode": BRO WO's Mengamuk (bespoke) or CF[id].mode (data). Same machinery for both: timer in bwx.rage[side],
// a.bwRage tells the kit to use the mode's skills, cooldowns swap in and out, and an optional `form` swaps the whole atlas.
// Durasi mode memakai perk karakter (modeDur); gaya tidak mengubahnya, jadi angkanya sama di kedua sisi dan di bar ultimate.
const modeDurMod = id => window.Passives?.info(id)?.mods?.modeDur ?? 1;
function modeOf(id) {
if (id === 'browo') return BW ? { duration: BW.rage.duration, aura: '#ff6e28', label: 'MENGAMUK', announce: 'MENGAMUK!', names: BW.rageNames, icons: ['basic', 'rage-skill1', 'rage-skill2', 'rage-ultimate'], cooldowns: BW.rageBalance.cooldowns, speedMul: BW.rage.speedMul } : null;
if (id === 'browo') return BW ? { duration: BW.rage.duration * modeDurMod(id), aura: '#ff6e28', label: 'MENGAMUK', announce: 'MENGAMUK!', names: BW.rageNames, icons: ['basic', 'rage-skill1', 'rage-skill2', 'rage-ultimate'], cooldowns: BW.rageBalance.cooldowns, speedMul: BW.rage.speedMul } : null;
const m = CF[id]?.mode; if (!m) return null;
return { duration: m.duration || 10, aura: m.aura || '#ff5a3c', label: m.label || 'MODE', announce: m.announce || 'MODE!', names: m.names, icons: ['basic', 'mode-skill1', 'mode-skill2', 'mode-ultimate'], cooldowns: { skill1: m.cooldowns?.skill1 ?? 5, skill2: m.cooldowns?.skill2 ?? 8, ultimate: 999 }, speedMul: m.speedMul || 1.15 };
return { duration: (m.duration || 10) * modeDurMod(id), aura: m.aura || '#ff5a3c', label: m.label || 'MODE', announce: m.announce || 'MODE!', names: m.names, icons: ['basic', 'mode-skill1', 'mode-skill2', 'mode-ultimate'], cooldowns: { skill1: m.cooldowns?.skill1 ?? 5, skill2: m.cooldowns?.skill2 ?? 8, ultimate: 999 }, speedMul: m.speedMul || 1.15 };
}
function cfFormOn(id, side) { return !!(CF[id]?.mode?.form && bwx.rage[side] > 0); }
function bwBaseCooldowns(id) { return id === 'browo' ? BW.balance.cooldowns : CF[id] ? CF[id].balance.cooldowns : null; }
@ -1482,6 +1528,7 @@
// Only discrete scripted bursts (combo = true) count as combo hits; per-tick DoT must not feed the combo rank, the damage reducer or coins.
function dotDamage(target, amount, color = '#9bd05a', numbers = true, combo = false) {
if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false;
amount = passiveDamage(target === dummy ? hero : dummy, target, amount, false); target.lastHurt = time; if (amount <= 0) return true;
if (target === dummy) {
dummy.damage += amount; totalDamage += amount;
if (combo) { comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboTimer = 2.3; }
@ -1741,7 +1788,7 @@
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.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);
for (const name of Object.keys(hero.cooldowns)) hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - dt * cdRate(hero, name)); passiveRegen(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;
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);
@ -1750,7 +1797,7 @@
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);
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)*passiveMod(hero,'walk');
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');
@ -1761,7 +1808,7 @@
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.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.type !== 'ultimate' && !a.fired && a.t >= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; strikeAs(a.type === 'attack' && !a.special ? 'basic' : null, () => { 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'); }
}
@ -1787,7 +1834,7 @@
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) {
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);}
if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt*cdRate(dummy,name));if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);}
dummy.stateTime += dt; dummy.invuln = Math.max(0, dummy.invuln - dt);
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)<bodyGap())dummy.x=clamp(hero.x+(dummy.x>=hero.x?bodyGap():-bodyGap()),110,W-100);
@ -1860,7 +1907,7 @@
}
function selectCharacter(id) {
if(!ready || match?.mode==='versus' || !playable().includes(id) || (id==='fenr'&&!window.FENR_HUMAN_MANIFEST))return false;
selectedCharacter=id;reset();syncPlayerForm();warmCutins();$('#character-select').value=id;updateHud();return true;
selectedCharacter=id;refreshPassives({enemy:sideStyle.enemy});reset();syncPlayerForm();warmCutins();$('#character-select').value=id;updateHud();return true;
}
function showFenrTransformation(actor) {
fenrCutin={t:0,owner:actor===hero?'PLAYER':'RIVAL'};cinematic=.65;
@ -2267,7 +2314,7 @@
if (dummy.action) {
const a = dummy.action; a.t += dt; dummy.state = a.name;
if (a.dash && a.t > a.duration * .2 && a.t < a.duration * .6) dummy.vx = dummy.facing * a.dash;
if (!a.fired && a.type !== 'ultimate' && a.t >= a.duration * a.hitAt) { a.fired = true; enemyStrike(a); }
if (!a.fired && a.type !== 'ultimate' && a.t >= a.duration * a.hitAt) { a.fired = true; strikeAs(a.type === 'attack' ? 'basic' : null, () => 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 && !p2Mode) cpuFollowUp(a, P);
@ -2373,7 +2420,7 @@
const goal = keepOut ? Math.max(reach, threat ? threat + 18 : outside) : reach - (protectedNow && hero.hurtTime > 0 ? 20 : 12);
wanted = dist > goal ? dummy.facing * (dist > 300 || (rushing && !punishWait) || immuneFor > 0 ? 420 : 240) : dist < Math.min(55, goal - 20) ? -dummy.facing * 150 : keepOut && dist < goal - 6 ? -dummy.facing * 300 : 0;
}
dummy.vx = approach(dummy.vx, clamp(wanted * P.speed, -520, 520), dt * 2200); dummy.walkPhase += Math.abs(dummy.vx) * dt / 150;
const ws = passiveMod(dummy, 'walk'); dummy.vx = approach(dummy.vx, clamp(wanted * P.speed * ws, -520 * ws, 520 * ws), dt * 2200); dummy.walkPhase += Math.abs(dummy.vx) * dt / 150;
const next = Math.abs(dummy.vx) > 5 ? (Math.abs(dummy.vx) > 300 ? 'run' : 'walk') : 'idle'; if (next !== dummy.state) { dummy.state = next; dummy.stateTime = 0; }
if (dummy.aiThink > 0 || cpu.plan?.kind === 'chain' || cpu.plan?.kind === 'sidestep') return;
// 4. Decisions on the think timer.
@ -2768,11 +2815,13 @@
const shop = SHOP_MOVES[window.Meta?.equippedMove?.()];
sec('Kontrol dasar').append(row(['A', 'D'], 'Jalan', 'ketuk 2× = lari', 1), row(['W'], 'Lompat', '2× = lompat ganda', 1), row(['S'], 'Menunduk'), row(['L'], 'Blok', 'ketuk cepat = parry', 1), row(['J'], 'Lempar'), row(['K'], 'Jurus toko', shop ? shop.name : 'belum dipasang', 1));
sec('Serangan dan skill', me.name).append(row(['Spasi'], me.names[0], 'kombo 3 pukulan'), row(['I'], me.names[1], 'skill 1 · cooldown ' + cd('skill1')), row(['O'], me.names[2], 'skill 2 · cooldown ' + cd('skill2')), row(['P'], me.names[3], 'ultimate · cooldown ' + cd('ultimate')));
const prow = ([k, n, fx, desc]) => { const r = row([k], n, fx, 1); if (desc) r.lastChild.append(el('small', 'pas-desc', desc)); return r; };
const mine = passiveRows(id, 'player'); if (mine) sec('Pasif dan gaya', me.name).append(...mine.map(prow));
sec('Gerakan perintah', null, 'cmd').append(row(['↓', '↓', '+', 'Spasi'], 'Uppercut', 'peluncur ke atas'), row(['↓', '→', '+', 'Spasi'], 'Tinju maju', '→ = arah lawan'));
if (md) { const ms = sec('Mode ' + md.label, Math.round(md.duration) + ' dtk'); ['Spasi', 'I', 'O', 'P'].forEach((k, i) => ms.append(row([k], md.names[i], i === 3 ? 'sekali pakai, mengakhiri mode' : '', i !== 3))); }
const other = sec('Lainnya'); other.append(row(['Esc'], 'Jeda / lanjut'), row(['R'], 'Mulai ulang'));
if (document.documentElement.classList.contains('touch')) other.append(el('small', 'mv-note', 'Layar sentuh: UP, →P, K, BLOK, dan LEMPAR ada di sisi kanan.'));
if (p2Mode) { const rv = fighterInfo(opponentCharacter, 'human'), p2 = sec('Pemain 2', rv.name); p2.append(row(['←', '→'], 'Jalan'), row(['↑'], 'Lompat'), row(['N'], rv.names[0], 'kombo 3 pukulan'), row(['M'], rv.names[1], 'skill 1'), row([','], rv.names[2], 'skill 2'), row(['.'], rv.names[3], 'ultimate')); }
if (p2Mode) { const rv = fighterInfo(opponentCharacter, 'human'), p2 = sec('Pemain 2', rv.name); p2.append(row(['←', '→'], 'Jalan'), row(['↑'], 'Lompat'), row(['N'], rv.names[0], 'kombo 3 pukulan'), row(['M'], rv.names[1], 'skill 1'), row([','], rv.names[2], 'skill 2'), row(['.'], rv.names[3], 'ultimate')); const rp = passiveRows(opponentCharacter, 'enemy'); if (rp) sec('Pasif pemain 2', rv.name).append(...rp.map(prow)); }
}
// Pilihan lipat/buka diingat per perangkat; tanpa pilihan, terbuka bila jendela cukup lebar. Hanya klik pengguna pada ringkasan yang menyimpan.
function openMoveList() {
@ -2873,20 +2922,22 @@
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 (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(); },
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) { refreshPassives({ player: sideStyle.player, enemy: 'p2' }); aiEnabled = false; const t = $('#ai-toggle'); if (t) t.checked = false; } }, command: forceCommand, reset, cast, jump, attack: startAttack, clearInput, draw, manual: false,
advance(seconds, noDraw) { for (let i = 0; i < Math.round(seconds * 120); i++) step(1 / 120); if (noDraw) return null; 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;},
announceSelection(id){return menuOpen&&playable().includes(id)?(systemAnnouncer?.play('select_'+id)||false):false;},
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 (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; const counter = hero.action?.type === 'attack' && !hero.action.fired; 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); feelHeroHit(damage, options, counter); 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 (damage > 0) damage = Math.max(1, passiveDamage(dummy, hero, damage, hitKind === 'basic')); hero.lastHurt = time; if (heroGuard(damage, options)) return true; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; const counter = hero.action?.type === 'attack' && !hero.action.fired; 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); feelHeroHit(damage, options, counter); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; }
};
// Pemeriksaan efek jurus untuk uji headless (tests/t1-fx.mjs).
window.__game.fx = { snapshot: () => ({ ghosts: cfx.ghosts.length, hazards: cfx.hazards.filter(h => h.fire).length, dots: cfx.dots.length, later: cfx.later.length, particles: particles.length, effects: effects.map(e => e.type), tele: { hero: fxTeleP(hero), dummy: fxTeleP(dummy) }, projectiles: projectiles.map(p => ({ fx: p.fx, x: Math.round(p.x), y: Math.round(p.y), age: +(p.age || 0).toFixed(3), damage: p.damage, wait: p.wait || 0, orbit: !!(p.sw && !p.sw.go) })) }) };
// Uji headless layar VS (tests/t2-vsresult.mjs): vsActive = urutan ronde sedang menunggu layar VS; vsSkip melewatinya.
Object.assign(window.__game, { vsActive: () => vsHold, vsSkip: () => !!window.VS?.force?.() });
// Pasif dan gaya (tests/t3-passive.mjs): angka dan gaya tiap sisi, titik kait, dan pengaturan gaya langsung.
Object.assign(window.__game, { passiveMod, passives: () => ({ seed, player: { id: selectedCharacter, style: sideStyle.player, mods: { ...sideMods.player } }, enemy: { id: opponentCharacter, style: sideStyle.enemy, mods: { ...sideMods.enemy } } }), setStyles: styles => { refreshPassives(styles); hudIdentity = ''; return sideStyle; } });
// Hanya untuk uji headless (tests/t1-feel.mjs): pukulan langsung ke lawan, tabel rasa kena, status K.O./angka.
Object.assign(window.__game, { hitDummy, feel: Feel, feelState: () => ({ ko: { ...Feel.ko.state, scale: Feel.ko.scale(), zoom: Feel.ko.zoom() }, kick: Feel.kickOffset(), numbers: damageNumbers.map(n => ({ amount: n.amount, kind: n.kind, label: n.label, color: n.color, life: n.life })), hitstop, trauma, realTime }) });
if(F)reset();

View File

@ -24,6 +24,6 @@
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar 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 &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar<br>Esc · Jeda, berisi daftar gerakan karakter &nbsp; R · Mulai ulang<br>Dua pemain (P2): ← → · Gerak &nbsp; ↑ · Lompat &nbsp; N · Serang &nbsp; M / , / . · Skill 1 / 2 / Ultimate<br>Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script src="content/passives.js"></script><script src="passives.js"></script><script src="content/quotes.js"></script><script src="content/dialogs.js"></script><script src="vs.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

View File

@ -43,3 +43,21 @@
@media(max-height:760px) and (min-width:800px){.home-title h1{font-size:60px;margin:6px 0}.home-title p{margin-bottom:10px}.home-nav{gap:5px}.home-nav>button{padding:6px 14px}.home-nav>button small{display:none}.home-nav .home-row button{padding:6px 8px}.home-content{margin-top:0;margin-bottom:24px}}
@media(max-height:760px){#settings-panel{padding:16px 20px}#settings-panel h2{margin:4px 0}#settings-panel>p{margin:4px 0 8px}#settings-panel .setting-row,#settings-panel .volume-row{padding-top:6px;padding-bottom:6px}}
#settings-panel{max-height:calc(100vh - 16px)}
/* ---- Pasif, kelemahan, dan gaya bertarung (RENCANA 3.2 / 3.3): menu Karakter, layar VS, Daftar gerakan ---- */
.locker .preview{align-self:start;position:sticky;top:8px}
.pas{display:grid;gap:6px}
.pas-row{display:grid;gap:1px;padding:7px 10px;background:#101c26;border:1px solid #ffffff1c;border-left:3px solid #8dffa8;border-radius:6px;min-width:0}
.pas-row.dn{border-left-color:#ff8a7a}
.pas-row b{letter-spacing:.06em;color:#8dffa8}.pas-row.dn b{color:#ff9d8f}
.pas-row span{color:#c7d3dc;font-size:15px;line-height:1.25}
.pas-row small{color:#8fa3b3;font-size:13px;letter-spacing:.03em}
.pas-style{margin:8px 0 0;line-height:1.3}.pas-style b{color:#e8b84a;letter-spacing:.08em}.pas-style small{display:block;margin-top:2px;font-size:13px;color:#9fb3c4}
.vs-perk{margin:clamp(5px,1.2vh,10px) 0 0;display:flex;flex-wrap:wrap;gap:1px 10px;font:700 clamp(10px,1.3vh + 2px,13px)/1.2 Rajdhani,sans-serif;letter-spacing:1.2px;text-shadow:0 1px 3px #000c;animation:vs-quote .45s .8s both}
.vs-side.r .vs-perk{justify-content:flex-end}
.vs-perk .up{color:#9dffb8}.vs-perk .dn{color:#ffa797}.vs-perk em{font-style:normal;color:#ffe08a}
@media(max-width:760px),(max-height:420px){.vs-perk{font-size:9px;letter-spacing:.4px;gap:0 6px}}
.mv-row small.pas-desc{margin-top:1px;font-style:italic}
@media(max-height:700px){.mv-row small.pas-desc{display:none}}
@media(min-width:900px){#pause-panel{width:min(1160px,calc(100% - 24px))}.move-body{columns:200px;column-gap:20px}}
@media(min-height:421px) and (max-height:640px){.mv-sec{margin-bottom:9px}.mv-row{padding:2px 0}#pause-panel[open]{padding-top:20px;padding-bottom:20px}}

12
meta.js
View File

@ -61,7 +61,7 @@
special: ['SPESIALIS', 'Beli satu varian skill karakter.'],
reuni: ['REUNI', 'Temukan semua dialog rahasia.']
};
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: {} });
const blank = () => ({ v: 1, coins: 0, earned: 0, voice: true, owned: { skin: ['standar'], move: [], alt: [] }, skin: {}, style: {}, 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 (_) {} };
@ -97,6 +97,9 @@
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'); }
// Gaya bertarung per karakter (RENCANA 3.3): agresif | seimbang | bertahan. Simpanan lama tanpa kunci style atau dengan nilai asing dibaca sebagai Seimbang.
function styleOf(id) { const k = save.style && typeof save.style === 'object' ? save.style[id] : null; return window.Passives ? window.Passives.styleKey(k) : 'seimbang'; }
function setStyle(id, k) { if (!window.Passives || !window.STYLES?.[k]) return false; if (!save.style || typeof save.style !== 'object') save.style = {}; save.style[id] = k; persist(); return true; }
function equippedMove() { return save.move && save.owned.move.includes(save.move) ? save.move : null; }
let chip;
function hud(info) {
@ -215,8 +218,8 @@
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 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 `<h5>Skill ${slot === 'skill1' ? '1 (I)' : '2 (O)'}</h5><div class="pills"><button class="pill ${!cur ? 'on' : ''}" data-alt="${slot}:">ASLI</button>${opts.map(o => `<button class="pill ${cur === o.v ? 'on' : ''}" data-alt="${slot}:${o.v}">${esc(o.name)}</button>`).join('')}</div>`; }).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>${altRows}<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>`;
const n = fnames(who), pas = window.Passives?.lockerHtml(who, styleOf(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>${pas}<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>${altRows}<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;
@ -230,6 +233,7 @@
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.style) { setStyle(who, b.dataset.style); 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) {
@ -306,5 +310,5 @@
}
mountCheatRow();
window.Meta = { get lastAward() { return lastAward; }, redeem, pairEgg, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } };
window.Meta = { get lastAward() { return lastAward; }, styleOf, setStyle, redeem, pairEgg, 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; } };
})();

87
passives.js Normal file
View File

@ -0,0 +1,87 @@
/* Helper pasif, kelemahan, dan gaya bertarung (RENCANA 3.2 dan 3.3). Data: content/passives.js (window.PASSIVES, window.STYLES).
Berkas ini tidak menyentuh mesin: game.js memanggil Passives.merge(id, gaya) saat pertandingan dimulai lalu membaca angkanya lewat passiveMod(actor, key, base).
Di sini juga teks angka yang tampil ke pemain (menu Karakter, layar VS, daftar gerakan), disusun otomatis dari `mods` agar selalu sama dengan yang dipakai mesin. */
(() => {
'use strict';
// Kunci yang dikenal. up = 'good' bila angka naik menguntungkan pemilik (damage keluar, kecepatan, ...), 'bad' bila naik merugikan (damage masuk). add = kunci penambah.
const KEYS = {
dmgOut: { label: 'Damage', short: 'Damage', up: 'good' },
dmgBasic: { label: 'Damage pukulan dasar', short: 'Pukulan dasar', up: 'good' },
dmgSkill: { label: 'Damage skill dan ultimate', short: 'Skill dan ultimate', up: 'good' },
dmgIn: { label: 'Damage yang diterima', short: 'Damage diterima', up: 'bad' },
walk: { label: 'Kecepatan jalan', short: 'Jalan', up: 'good' },
cd: { label: 'Kecepatan pulih cooldown', short: 'Pulih cooldown', up: 'good' },
cdSkill: { label: 'Kecepatan pulih skill', short: 'Pulih skill', up: 'good' },
cdUlt: { label: 'Kecepatan pulih ultimate', short: 'Pulih ultimate', up: 'good' },
parry: { label: 'Jendela parry', short: 'Parry', up: 'good' },
modeDur: { label: 'Durasi mode', short: 'Durasi mode', up: 'good' },
regen: { label: 'Pemulihan HP', short: 'Pulih HP', up: 'good', add: true, unit: ' HP/dtk' }
};
const STYLE_ORDER = ['agresif', 'seimbang', 'bertahan'], DEFAULT_STYLE = 'seimbang';
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const styles = () => window.STYLES || {};
const styleKey = k => (styles()[k] ? k : DEFAULT_STYLE);
const info = id => { const p = window.PASSIVES?.[id]; return p ? { id, name: p.name, desc: p.desc, flaw: p.flaw || { name: '', desc: '' }, mods: p.mods || {} } : null; };
const style = k => { const key = styleKey(k), s = styles()[key] || { name: 'SEIMBANG', desc: '', mods: {} }; return { key, name: s.name, desc: s.desc, mods: s.mods || {} }; };
// Gabungan angka perk + kelemahan karakter dan gaya yang dipilih: pengali dikalikan, kunci penambah dijumlahkan. Hanya kunci yang dikenal.
function merge(id, styleName) {
const out = {};
for (const src of [window.PASSIVES?.[id]?.mods, styles()[styleKey(styleName)]?.mods]) {
if (!src) continue;
for (const [k, v] of Object.entries(src)) { const def = KEYS[k]; if (!def || !Number.isFinite(v)) continue; out[k] = def.add ? (out[k] || 0) + v : (out[k] ?? 1) * v; }
}
return out;
}
// Satu butir efek sebagai teks: "Damage yang diterima -8%", "Pemulihan HP +0,5 HP/dtk". Koma desimal sesuai bahasa Indonesia.
function effectText(k, v, short) {
const def = KEYS[k]; if (!def) return '';
const label = short ? def.short || def.label : def.label;
if (def.add) return `${label} +${String(Math.round(v * 10) / 10).replace('.', ',')}${def.unit || ''}`;
const pct = Math.round((v - 1) * 100);
return pct ? `${label} ${pct > 0 ? '+' : '−'}${Math.abs(pct)}%` : '';
}
// Pisahkan efek menguntungkan (good) dan merugikan (bad) dari arah angkanya.
function effects(mods, short) {
const good = [], bad = [];
for (const [k, v] of Object.entries(mods || {})) {
const def = KEYS[k]; if (!def) continue;
const delta = def.add ? v : v - 1, text = effectText(k, v, short); if (!text || !delta) continue;
((delta > 0) === (def.up === 'good') ? good : bad).push(text);
}
return { good, bad };
}
// Perk dan kelemahan untuk ditampilkan: nama, cerita, dan angka otomatis.
function describe(id, short) {
const p = info(id); if (!p) return null;
const fx = effects(p.mods, short);
return { id, perk: { name: p.name, desc: p.desc, fx: fx.good.join(', ') }, flaw: { name: p.flaw.name, desc: p.flaw.desc, fx: fx.bad.join(', ') } };
}
function styleView(k, short) { const s = style(k), fx = effects(s.mods, short); return { key: s.key, name: s.name, desc: s.desc, good: fx.good, bad: fx.bad, fx: [...fx.good, ...fx.bad].join(', ') }; }
// Gaya acak untuk CPU. `rand` (0..1) dari pengacak kosmetik penelepon; bukan random() mesin.
const randomStyle = rand => STYLE_ORDER[Math.min(STYLE_ORDER.length - 1, Math.floor((typeof rand === 'function' ? rand() : Math.random()) * STYLE_ORDER.length))];
// ---- layar VS: satu baris kecil per sisi (perk, kelemahan, dan gaya bila bukan Seimbang) ----
function vsHtml(id, styleName) {
const d = describe(id); if (!d) return '';
const sk = styleKey(styleName), tag = sk === DEFAULT_STYLE ? '' : `<em>GAYA ${esc(style(sk).name)}</em>`;
return `<p class="vs-perk" aria-label="Pasif ${esc(d.perk.name)}. Kelemahan ${esc(d.flaw.name)}."><span class="up">▲ ${esc(d.perk.name)}</span><span class="dn">▼ ${esc(d.flaw.name)}</span>${tag}</p>`;
}
function decorateVS(el, vs = {}) {
try {
const sides = [['.vs-side.l .vs-info', vs.player, vs.styles?.player], ['.vs-side.r .vs-info', vs.enemy, vs.styles?.enemy]];
for (const [sel, id, st] of sides) { const box = el.querySelector(sel); if (box && !box.querySelector('.vs-perk')) box.insertAdjacentHTML('beforeend', vsHtml(id, st)); }
} catch (_) { /* hiasan saja; layar VS tetap tampil */ }
}
// ---- menu Karakter (meta.js renderLocker): blok Pasif dan blok Gaya bertarung ----
function lockerHtml(id, styleName) {
const d = describe(id); if (!d) return '';
const sk = styleKey(styleName), sv = styleView(sk);
const row = (cls, mark, name, desc, fx) => `<div class="pas-row ${cls}"><b>${mark} ${esc(name)}</b><span>${esc(desc)}</span>${fx ? `<small>${esc(fx)}</small>` : ''}</div>`;
const pills = STYLE_ORDER.filter(k => styles()[k]).map(k => `<button class="pill ${k === sk ? 'on' : ''}" data-style="${k}">${esc(styles()[k].name)}</button>`).join('');
return `<h5>Pasif dan kelemahan</h5><div class="pas">${row('up', '▲', d.perk.name, d.perk.desc, d.perk.fx)}${row('dn', '▼', d.flaw.name, d.flaw.desc, d.flaw.fx)}</div>`
+ `<h5>Gaya bertarung</h5><div class="pills">${pills}</div><p class="meta-hint pas-style"><b>${esc(sv.name)}</b> — ${esc(sv.desc)}${sv.fx ? `<small>${esc(sv.fx)}</small>` : ''}</p>`;
}
window.Passives = { KEYS, STYLE_ORDER, DEFAULT_STYLE, info, style, styleKey, merge, effects, describe, styleView, randomStyle, vsHtml, decorateVS, lockerHtml };
})();

1
vs.js
View File

@ -65,6 +65,7 @@
+ vsSide('l', L, 'PEMAIN 1', dlg ? dlg.l : quote(L.id, 'intro'), dlg) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', dlg ? dlg.r : quote(R.id, 'intro'), dlg)
+ `<div class="vs-mark" aria-hidden="true"><b>VS</b></div><div class="vs-skip">KLIK ATAU SPASI UNTUK LEWATI</div><div class="vs-timer" aria-hidden="true"><i></i></div>`;
document.body.append(el);
window.Passives?.decorateVS(el, info);
const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [], dialog: dlg };
el.style.setProperty('--vs-run', (dur - FADE) + 'ms');
c.timers.push(setTimeout(() => finish('time'), dur - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));