From 4262fb5d956492d4646d0dde1b412d2571c0a8a7 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Sun, 4 Oct 2026 06:59:25 +0700 Subject: [PATCH] Tambah pasif dan kelemahan per karakter serta pilihan gaya bertarung (RENCANA 3.2 dan 3.3): satu titik kait passiveMod untuk pemain, CPU, dan P2; gaya Agresif/Seimbang/Bertahan di menu Karakter (Meta.save.style, simpanan lama tetap terbaca), CPU memakai gaya acak per pertandingan; tampil di menu Karakter, layar VS, dan Daftar gerakan --- content/passives.js | 55 +++++++++++++++++++++++++++ docs/ENGINE_NOTES.md | 1 + game.js | 89 ++++++++++++++++++++++++++++++++++---------- index.html | 2 +- meta.css | 18 +++++++++ meta.js | 12 ++++-- passives.js | 87 +++++++++++++++++++++++++++++++++++++++++++ vs.js | 1 + 8 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 content/passives.js create mode 100644 passives.js diff --git a/content/passives.js b/content/passives.js new file mode 100644 index 0000000..68035a9 --- /dev/null +++ b/content/passives.js @@ -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 } } +}; diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index d18bd28..db7ce99 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -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`. diff --git a/game.js b/game.js index aabb928..fb6ed69 100644 --- a/game.js +++ b/game.js @@ -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 * (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)=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(); diff --git a/index.html b/index.html index 1aa0bcf..9091108 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,6 @@
✦KONOHA FIGHTING

Menyiapkan arena…
KONOHA FIGHTING / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat (2× W = lompat ganda)   S · Menunduk
SPACE · Kombo 3 pukulan   ↓ ↓ + SPACE · Uppercut   ↓ → + SPACE · Tinju maju
I / O · Skill   P · Ultimate   K · Jurus toko
L · Blok (ketuk = parry)   J · Lempar
Esc · Jeda, berisi daftar gerakan karakter   R · Mulai ulang
Dua pemain (P2): ← → · Gerak   ↑ · Lompat   N · Serang   M / , / . · Skill 1 / 2 / Ultimate
Meter di bawah HP: bar ultimate (SIAP = bisa dipakai), saat mode aktif jadi bar waktu mode.

KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+
diff --git a/meta.css b/meta.css index 6baca66..1376f0d 100644 --- a/meta.css +++ b/meta.css @@ -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}} diff --git a/meta.js b/meta.js index 11b73d7..37785d3 100644 --- a/meta.js +++ b/meta.js @@ -60,7 +60,7 @@ twoplayer: ['TEMAN BERMAIN', 'Mainkan satu pertandingan dua pemain.'], special: ['SPESIALIS', 'Beli satu varian skill karakter.'] }; - 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 (_) {} }; @@ -96,6 +96,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) { @@ -214,8 +217,8 @@ const skins = save.owned.skin.map(k => ``).join(''); const moves = [``].concat(save.owned.move.map(k => ``)).join(''); const altRows = ['skill1', 'skill2'].map(slot => { const opts = altOptions(who).filter(o => o.slot === slot && save.owned.alt.includes(o.key)); const cur = save.alt?.[who]?.[slot] || ''; return `
Skill ${slot === 'skill1' ? '1 (I)' : '2 (O)'}
${opts.map(o => ``).join('')}
`; }).join(''); - const n = fnames(who); - return `
${pick}

${fname(who)}

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

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

${altRows}
Daftar gerakan
    ${n.length ? `
  • SPASI ${esc(n[0] || '')}
  • I ${esc(n[1] || '')}
  • O ${esc(n[2] || '')}
  • P ${esc(n[3] || '')}
  • ` : ''}${COMMANDS.map(c => `
  • ${c[0]} ${c[1]} — ${c[2]}
  • `).join('')}
`; + const n = fnames(who), pas = window.Passives?.lockerHtml(who, styleOf(who)) || ''; + return `
${pick}

${fname(who)}

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

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

${altRows}
Daftar gerakan
    ${n.length ? `
  • SPASI ${esc(n[0] || '')}
  • I ${esc(n[1] || '')}
  • O ${esc(n[2] || '')}
  • P ${esc(n[3] || '')}
  • ` : ''}${COMMANDS.map(c => `
  • ${c[0]} ${c[1]} — ${c[2]}
  • `).join('')}
`; } function renderBook() { const got = Object.keys(ACH).filter(k => save.ach[k]).length, st = save.stats; @@ -229,6 +232,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) { @@ -296,5 +300,5 @@ } mountCheatRow(); - window.Meta = { get lastAward() { return lastAward; }, redeem, 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, shout, cancelRun() { run = null; }, altSkill, event, startRun, skinFilter, equippedMove, hud, award, open, close, toast, addCoins, unlock, get coins() { return save.coins; }, get save() { return save; } }; })(); diff --git a/passives.js b/passives.js new file mode 100644 index 0000000..fc12682 --- /dev/null +++ b/passives.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ? '' : `GAYA ${esc(style(sk).name)}`; + return `

▲ ${esc(d.perk.name)}▼ ${esc(d.flaw.name)}${tag}

`; + } + 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) => `
${mark} ${esc(name)}${esc(desc)}${fx ? `${esc(fx)}` : ''}
`; + const pills = STYLE_ORDER.filter(k => styles()[k]).map(k => ``).join(''); + return `
Pasif dan kelemahan
${row('up', '▲', d.perk.name, d.perk.desc, d.perk.fx)}${row('dn', '▼', d.flaw.name, d.flaw.desc, d.flaw.fx)}
` + + `
Gaya bertarung
${pills}

${esc(sv.name)} — ${esc(sv.desc)}${sv.fx ? `${esc(sv.fx)}` : ''}

`; + } + + window.Passives = { KEYS, STYLE_ORDER, DEFAULT_STYLE, info, style, styleKey, merge, effects, describe, styleView, randomStyle, vsHtml, decorateVS, lockerHtml }; +})(); diff --git a/vs.js b/vs.js index d869a59..1eeff94 100644 --- a/vs.js +++ b/vs.js @@ -47,6 +47,7 @@ + vsSide('l', L, 'PEMAIN 1', quote(L.id, 'intro')) + vsSide('r', R, two ? 'PEMAIN 2' : 'KOMPUTER', quote(R.id, 'intro')) + `
KLIK ATAU SPASI UNTUK LEWATI
`; document.body.append(el); + window.Passives?.decorateVS(el, info); const c = cur = { el, onDone, t0: performance.now(), done: false, timers: [] }; el.style.setProperty('--vs-run', (DURATION - FADE) + 'ms'); c.timers.push(setTimeout(() => finish('time'), DURATION - FADE), setTimeout(() => { try { window.__game?.menuSound?.('pick'); } catch (_) {} }, 520));