From 33a3318e4807d1b72c44b5ff0f7ddd03128b0041 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Mon, 5 Oct 2026 14:46:05 +0700 Subject: [PATCH] Tantangan harian dan hadiah hari pertama (RENCANA 7.1 dan 7.2): daily.js dan daily.css. Satu tantangan per tanggal lokal dari hash tanggal (aturan, lawan, arena, tingkat) dengan 10 aturan (Darah satu, Hanya ultimate, Tanpa blok, Lawan cepat, Lantai licin, Tanpa lompat, Kombo wajib, Satu ronde, Counter saja, Raksasa). Tombol HARIAN di menu utama membuka kartu tantangan (aturan, deskripsi, lawan, arena, tingkat, hadiah, streak, pilih petarung). Menang = 150 koin + 25 per hari beruntun, sekali per tanggal, masuk rincian koin layar hasil; MAIN LAGI mengulang tantangan yang sama. Baris aturan di layar VS dan penanda aturan di arena. Hadiah hari pertama +25 koin tiap tanggal dan kejutan tanggal (1 Jan, 14 Mar, 8 Agu, 17 Agu) sekali per tahun. Simpanan di Meta.save.daily. Kait kecil di game.js (arm, deny, hit, parry, cooldowns, slip, foeScale, heroHpShown, match.toWin di HUD) yang no-op tanpa tantangan aktif. Uji manual: ?daily=YYYY-MM-DD atau Daily.setDate(...) di konsol. --- daily.css | 74 +++++++++ daily.js | 364 +++++++++++++++++++++++++++++++++++++++++++ docs/ENGINE_NOTES.md | 1 + game.js | 39 +++-- index.html | 4 +- 5 files changed, 465 insertions(+), 17 deletions(-) create mode 100644 daily.css create mode 100644 daily.js diff --git a/daily.css b/daily.css new file mode 100644 index 0000000..e4a5633 --- /dev/null +++ b/daily.css @@ -0,0 +1,74 @@ +/* Tantangan harian (daily.js, RENCANA 7.1 dan 7.2): tombol HARIAN di menu utama, kartu tantangan, baris aturan di layar VS, penanda aturan di arena. + Tinggi minimum yang didukung 520 px; ada aturan ringkas untuk jendela pendek (<= 560 px) dan ponsel lanskap (<= 420 px). */ + +/* ---- Tombol HARIAN di menu utama: di dalam .home-top-right, tepat di kiri teks versi, sebagai item flex biasa. Margin atas dan bawahnya negatif sebesar + setengah tingginya, jadi kotak margin-nya setinggi 0: bilah atas tidak bertambah tinggi sedikit pun (menu tetap muat di 520 px) dan tombol + tetap di tengah vertikal. Sengaja tidak memakai position:absolute atau menjadikan .home-top-right sebagai induk posisi: tombol layar penuh + (.menu-fullscreen, absolut) memakai .home-top sebagai induknya. ---- */ +#front-menu .daily-entry{display:inline-flex;align-items:center;gap:8px;min-height:26px;margin:-13px 18px -13px 0;padding:3px 12px 3px 10px;background:#102b37c4;border:1px solid #d7b87d77;border-left:3px solid #d7b87d;color:#f6eedb;font:700 13px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:2px;white-space:nowrap;cursor:pointer;transform:skewX(-7deg);transition:background .14s,border-color .14s,color .14s} +#front-menu .daily-entry>*{transform:skewX(7deg)} +#front-menu .daily-entry i{font-style:normal;font-size:11px;color:#d7b87d} +#front-menu .daily-entry .de-n{color:#f0d49d} +#front-menu .daily-entry b{padding:1px 6px;font-size:10px;letter-spacing:1.5px;color:#102b37;background:#e6c78f} +#front-menu .daily-entry em{font-style:normal;font-size:11px;letter-spacing:1px;color:#a9c0c4} +#front-menu .daily-entry:not(.done){animation:daily-nudge 2.4s ease-in-out infinite} +#front-menu .daily-entry:hover,#front-menu .daily-entry:focus-visible{background:#f0d49d;color:#122837;border-color:#fff0c8;animation:none} +#front-menu .daily-entry:hover i,#front-menu .daily-entry:focus-visible i,#front-menu .daily-entry:hover .de-n,#front-menu .daily-entry:focus-visible .de-n,#front-menu .daily-entry:hover em,#front-menu .daily-entry:focus-visible em{color:#122837} +#front-menu .daily-entry:hover b,#front-menu .daily-entry:focus-visible b{background:#122837;color:#f0d49d} +#front-menu .daily-entry.done{border-left-color:#8dffa8} +#front-menu .daily-entry.done i{color:#8dffa8} +#front-menu .daily-entry.done b{background:#8dffa8} +@keyframes daily-nudge{0%,100%{box-shadow:0 0 0 #d7b87d00}50%{box-shadow:0 0 14px #d7b87d88}} +html.touch #front-menu .daily-entry{min-height:32px;margin-top:-16px;margin-bottom:-16px} +@media(max-width:700px){#front-menu .daily-entry .de-n,#front-menu .daily-entry em{display:none}} +/* Ponsel lanskap: bilah atas hanya 9 px; kotak margin tetap 0 tetapi tombol digeser 3 px ke bawah supaya tepi atasnya tidak terpotong bila teks versi tersembunyi. */ +@media(max-height:420px){#front-menu .daily-entry{min-height:22px;margin-top:-8px;margin-bottom:-14px;padding:2px 9px 2px 8px;font-size:11px;letter-spacing:1.5px}html.touch #front-menu .daily-entry{min-height:28px;margin-top:-11px;margin-bottom:-17px}} + +/* ---- Kartu tantangan: modal memakai gaya dialog bawaan (style.css) dengan isi yang dipadatkan ---- */ +.daily-dialog{width:min(580px,calc(100% - 24px));padding:16px 22px 14px} +.daily-dialog .dialog-kicker{margin-bottom:2px} +.dl-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap} +.daily-dialog h2{margin:4px 0 0;font-size:34px;font-style:italic;letter-spacing:1px} +.dl-state{padding:2px 10px;font-size:12px;font-weight:700;letter-spacing:2px;color:#102b37;background:var(--gold);transform:skewX(-8deg)} +.dl-state.ok{background:#8dffa8} +.daily-dialog .dl-desc{margin:6px 0 10px;font-size:16px;line-height:1.3;color:#cfdde0} +.dl-facts{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px;margin:0 0 10px} +.dl-f{grid-column:span 2;min-width:0;padding:5px 9px;background:#0b1d29;border:1px solid #ffffff1c;border-left:3px solid #7fa698} +.dl-f:nth-child(n+4){grid-column:span 3} +.dl-f dt{font-size:10px;font-weight:700;letter-spacing:2px;color:#9fb3c4} +.dl-f dd{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:2px 0 0;font-size:16px;font-weight:700;letter-spacing:.5px;color:#f6eedb} +.dl-f dd img{width:24px;height:24px;object-fit:cover;border-radius:3px} +.dl-f dd small{flex-basis:100%;font-size:12px;font-weight:500;letter-spacing:0;color:#9fb3c4} +.dl-pick>span{display:block;margin:0 0 5px;font-size:10px;font-weight:700;letter-spacing:2px;color:#9fb3c4} +.dl-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px} +.dl-chip{display:grid;justify-items:center;gap:2px;width:62px;padding:3px 2px 4px;background:#13222d;border:1px solid #ffffff33;font:700 10px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:.4px;color:#dfe9ea} +.dl-chip img{width:34px;height:34px;object-fit:cover;border-radius:3px} +.dl-chip span{max-width:58px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dl-chip:hover{background:#315263} +.dl-chip[aria-pressed=true]{background:var(--gold);color:#102b37;border-color:#fff0c8} +.dl-chip:disabled{opacity:.4;cursor:not-allowed} +.dl-chip:disabled:hover{background:#13222d} +.daily-dialog .dialog-buttons .dl-go{background:var(--gold);color:#132b37;border-color:#fff0c8} +.daily-dialog .dialog-buttons .dl-go:hover{background:#fae0b4} +.dl-note{display:block;margin-top:8px;font-size:12px;letter-spacing:.3px;color:#8fa3b3} +html.touch .dl-chip{width:66px;padding:5px 2px 6px} +html.touch .daily-dialog .dialog-buttons button{padding:12px 8px} +@media(max-height:560px){.daily-dialog{padding:12px 18px 10px}.daily-dialog h2{font-size:28px}.daily-dialog .dl-desc{font-size:14px;margin:4px 0 7px}.dl-f{padding:3px 8px}.dl-f dd{font-size:14px}.dl-f dd small{font-size:11px}.dl-chip{width:56px}.dl-chip img{width:28px;height:28px}.dl-chips{margin-bottom:8px}.dl-note{margin-top:5px}} +@media(max-height:420px){.daily-dialog{padding:8px 14px 8px}.daily-dialog h2{font-size:22px}.dl-state{font-size:10px}.daily-dialog .dl-desc{font-size:13px;margin:2px 0 5px}.dl-facts{gap:4px;margin-bottom:6px}.dl-f dd{font-size:13px}.dl-f dd img{width:18px;height:18px}.dl-chip{width:48px;padding:2px 1px 3px}.dl-chip img{width:22px;height:22px}.dl-chip span{max-width:46px;font-size:9px}.dl-pick>span{margin-bottom:3px}.dl-chips{gap:4px;margin-bottom:6px}.daily-dialog .dialog-buttons button{padding:5px;font-size:13px}.dl-note{display:none}} + +/* ---- Layar VS: satu baris aturan di bawah bilah atas (label DIALOG RAHASIA turun satu baris bila ada) ---- */ +.vs-daily{position:absolute;z-index:6;left:50%;top:calc(clamp(8px,2.4vh,22px) + clamp(22px,3.6vh + 2px,32px));transform:translateX(-50%);display:flex;align-items:baseline;gap:10px;max-width:92vw;padding:2px 14px;white-space:nowrap;background:#06121bd9;border-left:3px solid #e0bf85;border-right:3px solid #e0bf85;font-family:Rajdhani,Arial,sans-serif;animation:vs-fade .4s .5s both} +.vs-daily b{font-size:clamp(9px,1.1vh + 3px,12px);letter-spacing:3px;color:#e0bf85} +.vs-daily strong{font-size:clamp(13px,1.8vh + 4px,19px);font-style:italic;letter-spacing:2px;color:#fff0c8} +.vs-daily span{overflow:hidden;text-overflow:ellipsis;font-size:clamp(11px,1.4vh + 3px,15px);font-weight:600;color:#cfdde0} +#vs-screen.has-daily .vs-secret{top:calc(clamp(8px,2.4vh,22px) + clamp(22px,3.6vh + 2px,32px) + clamp(24px,3.6vh + 4px,34px))} +@media(max-width:760px),(max-height:420px){.vs-daily b,.vs-daily span{display:none}} + +/* ---- Arena: penanda aturan di tepi atas tengah (di atas bilah HUD, tidak mengubah tata letak). Berkedip bila pemain mencoba aksi yang dimatikan aturan. ---- */ +#daily-tag{position:absolute;left:50%;top:0;z-index:3;transform:translateX(-50%);padding:0 12px;white-space:nowrap;pointer-events:none;font:700 10px/12px Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#f0d49d;background:#06121bcc;border:1px solid #d7b87d66;border-top:0;transition:background .12s,color .12s} +#daily-tag.hit{background:#e6c78f;color:#102b37} +.in-menu #daily-tag{display:none} +@media(max-height:500px){#daily-tag{font-size:9px;line-height:11px;letter-spacing:1.5px}} +/* Hanya ultimate: kartu dan tombol sentuh yang dimatikan diredupkan. */ +body[data-daily-rule=ultimate] .skill-card:not(.ultimate),body[data-daily-rule=ultimate] .tbtn-attack,body[data-daily-rule=ultimate] .tbtn-s1,body[data-daily-rule=ultimate] .tbtn-s2{opacity:.4;filter:grayscale(.8)} +@media(prefers-reduced-motion:reduce){#daily-tag{transition:none}} diff --git a/daily.js b/daily.js new file mode 100644 index 0000000..28dc096 --- /dev/null +++ b/daily.js @@ -0,0 +1,364 @@ +/* Tantangan harian dan hadiah harian (RENCANA 7.1 dan 7.2). Gaya: daily.css. Dimuat paling akhir di index.html, sesudah meta.js. + Tanggal hanya dipakai memilih aturan harian dan menentukan hadiah; di dalam simulasi pertandingan tidak ada Date maupun Math.random. + + 7.1 Tantangan harian: tiap tanggal lokal (YYYY-MM-DD) punya satu tantangan yang sama untuk semua pemain: aturan, lawan, arena, dan tingkat dipilih dari hash + tanggal (hash % jumlah pilihan; aturan juga digeser bila kebetulan sama dengan aturan kemarin). Pemain memilih petarungnya sendiri di kartu tantangan. + Tombol kecil HARIAN di pojok atas menu utama membuka kartu (nama aturan, deskripsi, lawan, arena, tingkat, hadiah, streak, pilihan petarung). Tantangan + dimainkan sebagai satu pertandingan versus biasa lewat __game.startMatch({..., daily: tanggal}); MAIN LAGI di layar hasil mengulang tantangan yang sama. + Hadiah: menang = 150 koin + 25 per hari beruntun (maksimal +150), sekali per tanggal; kalah boleh coba lagi. Streak = hari beruntun yang selesai; terputus + bila ada hari terlewat. Simpanan di Meta.save.daily = { last, streak, best, total, greet, fest, pick } (ikut kode simpanan; simpanan lama tanpa kunci ini + terbaca kosong); Meta.addCoins (juga satu-satunya cara memicu simpan) dipanggil sesudah isinya diubah. + 7.2 Hadiah hari pertama: sekali per tanggal saat menu utama pertama tampil (+25 koin, toast), ditambah kejutan tanggal tertentu sekali per tahun (FEST). + + Sepuluh aturan (RULES). Yang tidak butuh kait khusus diterapkan lewat pengubah sisi (sideMods) dan BALANCE yang diberikan game.js ke arm(): + Darah satu (HP kedua pihak 20), Lawan cepat (jalan dan pulih cooldown lawan naik), Tanpa blok (peluang blok CPU 0), Raksasa (HP lawan 1,5x + gambar lebih besar). + Sisanya lewat kait kecil di game.js yang semuanya berbentuk window.Daily?.xxx (tanpa modul ini atau tanpa tantangan aktif semuanya no-op): + startMatch arm(options.daily, {sideMods, balance, versus}) mengaktifkan atau mematikan aturan; dipanggil di setiap startMatch + jump, startAttack, cast, startCommand, startThrow, useShopMove deny(jenis) Tanpa lompat dan Hanya ultimate + press('l') deny('guard') Tanpa blok + step cooldowns(hero.cooldowns) Hanya ultimate (ultimate selalu siap); slip(vx lama, vx baru) Lantai licin + updateDummy slip(...) untuk lawan + hitDummy hit(damage, counter, kombo, waktu mesin) Kombo wajib dan Counter saja + heroGuard (parry berhasil) parry(waktu mesin) + drawDummy foeScale (Raksasa) + Satu ronde: MatchRules.create dan MatchRules.tick dibungkus di sini (match.toWin = 1; pemenang ditetapkan di akhir ronde pertama), HUD game.js membaca match.toWin. + Bungkus lain: VS.show (baris aturan di layar VS), Meta.award (hadiah di rincian koin layar hasil), klik MAIN LAGI (mengulang tantangan yang sama). + + Uji manual: ?daily=YYYY-MM-DD memajukan atau memundurkan tanggal untuk seluruh modul (aturan, hadiah, streak); di konsol Daily.setDate('2026-08-17'), + Daily.setDate(null) (kembali ke tanggal asli), Daily.table(14), Daily.status(), Daily.startup(), Daily.resetState(), Daily.open(), Daily.start('megachan'). */ +(() => { + 'use strict'; + // ---- angka ---- + const REWARD = 150, REWARD_STEP = 25, REWARD_STEPS = 6, GREET = 25; // hadiah tantangan 150 + 25 per hari beruntun (maksimal +150); hadiah hari pertama main + const HP_DARAH = 20, HP_RAKSASA = 1.5, SKALA_RAKSASA = 1.18, LICIN = .22; // HP Darah satu; pengali HP dan skala gambar Raksasa; bagian percepatan yang tersisa di lantai licin + const COUNTER_OPEN = 2.5, COUNTER_BIASA = .45, COUNTER_BUKA = 1.2; // Counter saja: lama celah terbuka (detik waktu mesin), pengali pukulan biasa dan pukulan di celah + const LAWAN_JALAN = 1.4, LAWAN_CD = 1.35; // Lawan cepat: pengali jalan dan kecepatan pulih cooldown + const LEVEL = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' }; + const ALL3 = ['easy', 'medium', 'hard'], EASY2 = ['easy', 'medium']; + // levels = tingkat yang boleh dipilih tanggal; aturan yang menyulitkan pemain hanya memakai tingkat ringan. + const RULES = [ + { id: 'darah', name: 'DARAH SATU', vs: 'Kedua pihak cuma punya 20 HP', levels: EASY2, desc: 'Kedua petarung cuma punya 20 HP. Satu serangan parah dan semuanya selesai, jadi jangan kedip.' }, + { id: 'ultimate', name: 'HANYA ULTIMATE', vs: 'Pukulan dan skill mati, ultimate selalu siap', levels: ALL3, desc: 'Pukulan dan skill mogok kerja. Cuma ultimate yang mau masuk, dan dia selalu siap.' }, + { id: 'blok', name: 'TANPA BLOK', vs: 'Blok dan parry mati untuk semua', levels: ALL3, desc: 'Tombol blok dan parry macet, buat kamu dan lawan. Pertahanan terbaik adalah kaki.' }, + { id: 'cepat', name: 'LAWAN CEPAT', vs: 'Lawan lebih cepat, skill cepat pulih', levels: EASY2, desc: 'Lawanmu habis minum kopi tiga gelas: jalannya kencang dan skillnya cepat pulih.' }, + { id: 'licin', name: 'LANTAI LICIN', vs: 'Semua petarung meluncur di lantai', levels: ALL3, desc: 'Lantai baru dipel. Semua petarung meluncur dan susah berhenti tepat waktu.' }, + { id: 'lompat', name: 'TANPA LOMPAT', vs: 'Kamu tidak bisa lompat', levels: ALL3, desc: 'Kakimu terpaku ke lantai. Lompat dan lompat ganda tidak bisa dipakai.' }, + { id: 'kombo', name: 'KOMBO WAJIB', vs: 'Damage naik di tiap pukulan kombo', levels: ALL3, desc: 'Pukulan tunggal cuma menggelitik. Damage naik di setiap pukulan kombo, jadi rangkai terus.' }, + { id: 'ronde', name: 'SATU RONDE', vs: 'Satu ronde menentukan pemenang', levels: ALL3, desc: 'Tidak ada ronde ulang: satu ronde, satu pemenang. Kalau seri, rondenya diulang.' }, + { id: 'counter', name: 'COUNTER SAJA', vs: 'Damage penuh hanya setelah COUNTER atau PARRY', levels: EASY2, desc: 'Pukulan biasa cuma menggores. Buka celah dengan COUNTER atau PARRY, lalu sikat selama celahnya terbuka.' }, + { id: 'raksasa', name: 'RAKSASA', vs: 'Lawan raksasa dengan HP 1,5 kali', levels: EASY2, desc: 'Lawan membesar dan HP-nya 1,5 kali lipat. Sabar, atur jarak, dan jangan panik.' } + ]; + const DENY = { ultimate: ['attack', 'skill1', 'skill2', 'command', 'throw', 'shop'], lompat: ['jump'], blok: ['guard'] }; + // Kejutan tanggal (bulan-hari): sekali per tahun. Hanya hari netral; teks ringan. + const FEST = { + '01-01': { coins: 100, text: 'SELAMAT TAHUN BARU! Semoga kombomu panjang dan dompetmu tebal.' }, + '03-14': { coins: 50, text: 'HARI PI! Hari ini semua serba bulat: pukulan, kombo, dan koinmu.' }, + '08-08': { coins: 50, text: 'HARI KUCING SEDUNIA! Meong. Satu koin jatuh dari meja, kamu yang pungut.' }, + '08-17': { coins: 100, text: 'DIRGAHAYU INDONESIA! Selamat 17-an, semoga pukulanmu sekencang lomba balap karung.' } + }; + const QUIPS = ['Absen dulu, baru berantem.', 'Koin receh, semangat gede.', 'Pemanasan gratis dari arena.', 'Selamat datang kembali, jagoan.', 'Satu hari, satu hadiah. Adil, kan?', 'Arena kangen kamu.']; + const $ = s => document.querySelector(s); + const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const toast = (m, k) => { try { window.Meta?.toast?.(m, k); } catch (_) { /* toast hanya hiasan */ } }; + + // ---- tanggal: hanya untuk memilih aturan dan hadiah ---- + const DAY = 86400000, pad = n => String(n).padStart(2, '0'), ISO = /^(\d{4})-(\d{2})-(\d{2})$/; + // Nomor hari dari 'YYYY-MM-DD' lewat UTC (selisih hari tidak terpengaruh zona waktu atau DST); null bila bukan tanggal yang sah. + function dayNum(iso) { + const m = ISO.exec(String(iso)); if (!m) return null; + const t = Date.UTC(+m[1], +m[2] - 1, +m[3]), d = new Date(t); + return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? Math.round(t / DAY) : null; + } + const isoOf = n => new Date(n * DAY).toISOString().slice(0, 10); + let override = null; // tanggal uji dari ?daily=YYYY-MM-DD atau Daily.setDate(...) + function today() { if (override) return override; const d = new Date(); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); } + function dateText(iso) { + const n = dayNum(iso); if (n == null) return String(iso); + try { return new Intl.DateTimeFormat('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(n * DAY)); } catch (_) { return String(iso); } + } + + // ---- pemilihan tantangan dari tanggal ---- + // FNV-1a 32 bit dengan pengacakan akhir (murmur), jadi tanggal yang berurutan tidak menghasilkan indeks yang berpola. + function hash(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193); + h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; + return h >>> 0; + } + const idxOf = (iso, salt, n) => hash(salt + ':' + iso) % n; + // Aturan hari ini; bila kebetulan sama dengan indeks mentah kemarin, geser satu supaya hari berturut-turut jarang sama. + function ruleIdx(iso) { + const n = RULES.length, r = idxOf(iso, 'rule', n), dn = dayNum(iso); + return dn != null && r === idxOf(isoOf(dn - 1), 'rule', n) ? (r + 1) % n : r; + } + const roster = () => { const a = Object.keys(window.FIGHTERS || {}); if (window.Browo && window.BROWO_MANIFEST && !a.includes('browo')) a.push('browo'); return a.sort(); }; + const fname = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : String(id).toUpperCase()); + const portrait = id => `assets/${id}/ui/portrait.webp`; + function challenge(iso = today()) { + if (dayNum(iso) == null) return null; + const rule = RULES[ruleIdx(iso)], ids = roster(), stages = Object.keys(window.MatchRules?.stages || {}).sort(); + if (!ids.length || !stages.length) return null; + return { date: iso, rule, foe: ids[idxOf(iso, 'foe', ids.length)], stage: stages[idxOf(iso, 'stage', stages.length)], level: rule.levels[idxOf(iso, 'level', rule.levels.length)] }; + } + + // ---- simpanan: Meta.save.daily ---- + const okIso = v => typeof v === 'string' && dayNum(v) != null, cnt = v => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0); + function view() { // pembacaan aman: kunci hilang atau rusak dibaca kosong + const d = window.Meta?.save?.daily, o = d && typeof d === 'object' ? d : {}; + return { last: okIso(o.last) ? o.last : null, streak: cnt(o.streak), best: cnt(o.best), total: cnt(o.total), greet: okIso(o.greet) ? o.greet : null, pick: typeof o.pick === 'string' ? o.pick : null, fest: o.fest && typeof o.fest === 'object' ? o.fest : {} }; + } + function store() { const s = window.Meta?.save; if (!s) return null; if (!s.daily || typeof s.daily !== 'object') s.daily = {}; return s.daily; } + const persist = () => { try { window.Meta?.addCoins?.(0); } catch (_) { /* tanpa simpanan */ } }; // addCoins menyimpan seluruh Meta.save + const rewardFor = streak => REWARD + Math.min(Math.max(streak - 1, 0), REWARD_STEPS) * REWARD_STEP; + // streak yang masih hidup: selesai hari ini, atau selesai kemarin (tinggal diselesaikan hari ini); selain itu terputus. + function status(iso = today()) { + const v = view(), dn = dayNum(iso), done = v.last === iso, alive = done || (dn != null && v.last === isoOf(dn - 1)), streak = alive ? v.streak : 0; + return { date: iso, done, streak, best: Math.max(v.best, streak), total: v.total, reward: rewardFor(streak + 1) }; + } + // Hadiah tantangan untuk tanggal iso; null bila sudah diambil. Sekali per tanggal. + function claim(iso) { + const d = store(); if (!d || !okIso(iso)) return null; + const v = view(); if (v.last === iso) return null; + const streak = v.last === isoOf(dayNum(iso) - 1) ? v.streak + 1 : 1, coins = rewardFor(streak); + d.last = iso; d.streak = streak; d.best = Math.max(v.best, streak); d.total = v.total + 1; + window.Meta.addCoins(coins); + return { coins, streak, best: d.best }; + } + // 7.2: hadiah hari pertama bermain tiap tanggal, dan kejutan tanggal tertentu sekali per tahun. Aman dipanggil berulang (hanya membayar yang belum diambil). + function startup(iso = today()) { + const d = store(); if (!d || !okIso(iso) || !window.Meta?.addCoins) return null; + const got = { greet: 0, fest: 0 }; + if (view().greet !== iso) { + d.greet = iso; got.greet = GREET; window.Meta.addCoins(GREET); + toast(`HADIAH HARI INI +${GREET} koin · ${QUIPS[hash('quip:' + iso) % QUIPS.length]}`, 'coin'); + } + const key = iso.slice(5), year = iso.slice(0, 4), f = FEST[key]; + if (f && view().fest[key] !== year) { + if (!d.fest || typeof d.fest !== 'object') d.fest = {}; + d.fest[key] = year; got.fest = f.coins; window.Meta.addCoins(f.coins); + setTimeout(() => toast(`${f.text} +${f.coins} koin`), got.greet ? 1100 : 0); + } + return got; + } + + // ---- aturan di mesin (kait dipanggil game.js) ---- + let cur = null, redo = null, again = false, baseHp = null, openUntil = -1; + // cur = tantangan yang sedang dimainkan; redo = tantangan terakhir (untuk MAIN LAGI di layar hasil); again = MAIN LAGI baru ditekan. + // flag = tanggal 'YYYY-MM-DD' (atau true = hari ini) bila startMatch dimulai dari kartu tantangan. Dipanggil di setiap startMatch, jadi pertandingan biasa + // otomatis mematikan aturan dan mengembalikan HP pemain. + function arm(flag, ctx) { // galat di sini tidak boleh menghalangi startMatch: tantangan dimatikan dan pertandingan jalan seperti biasa + try { armRun(flag, ctx); } catch (e) { console.error(e); cur = redo = null; } + } + function armRun(flag, ctx) { + const c = ctx || {}; + let date = typeof flag === 'string' ? flag : flag ? today() : null; + if (!date && again && redo) date = redo.date; + again = false; openUntil = -1; + if (c.balance) { if (baseHp == null) baseHp = c.balance.heroMax; c.balance.heroMax = baseHp; } // setShadowFoe sudah mengembalikan HP lawan + cur = redo = date && c.versus ? challenge(date) : null; + paintTag(); + if (!cur) return; + const id = cur.rule.id, B = c.balance, E = c.sideMods?.enemy; + if (id === 'darah' && B) { B.heroMax = HP_DARAH; B.dummyMax = HP_DARAH; } + else if (id === 'raksasa' && B) B.dummyMax = Math.round(B.dummyMax * HP_RAKSASA); + else if (id === 'cepat' && E) { E.walk = (E.walk ?? 1) * LAWAN_JALAN; E.cd = (E.cd ?? 1) * LAWAN_CD; } + else if (id === 'blok' && E) E.parry = 0; // peluang blok CPU = dasar x parry + } + // true bila aksi pemain dilarang aturan hari ini (dan penanda aturan berkedip sebagai umpan balik). + const deny = kind => { const no = !!cur && !!DENY[cur.rule.id]?.includes(kind); if (no) nudge(); return no; }; + // Damage pukulan pemain ke lawan (dipanggil sesudah bonus counter bawaan). combo = jumlah pukulan yang sudah kena sebelum ini; t = waktu mesin. + function hit(d, counter, combo, t) { + const id = cur?.rule.id; if ((id !== 'kombo' && id !== 'counter') || !(d > 0)) return d; + if (id === 'kombo') d *= Math.min(2, .6 + .2 * combo); + else { if (counter) openUntil = t + COUNTER_OPEN; d *= t < openUntil ? COUNTER_BUKA : COUNTER_BIASA; } + return Math.max(1, Math.round(d)); + } + const parry = t => { if (cur?.rule.id === 'counter') openUntil = t + COUNTER_OPEN; }; + const cooldowns = cd => { if (cur?.rule.id === 'ultimate' && cd) cd.ultimate = 0; }; + // Lantai licin: perubahan kecepatan mendatar per langkah hanya sebagian kecil dari biasanya, jadi percepatan dan gesekan sama-sama melemah. + const slip = (prev, next) => (cur?.rule.id === 'licin' ? prev + (next - prev) * LICIN : next); + + // Satu ronde dan pembungkus lain dipasang sekali; semuanya no-op bila tidak ada tantangan aktif. + function wrapRules() { + const R = window.MatchRules; if (!R || R.__daily || typeof R.create !== 'function' || typeof R.tick !== 'function') return; + R.__daily = true; const create = R.create, tick = R.tick; + R.create = function () { const m = create.apply(this, arguments); if (m && m.mode === 'versus' && cur?.rule.id === 'ronde') m.toWin = 1; return m; }; + R.tick = function (m) { + const ev = tick.apply(this, arguments); + // Satu ronde: pemenang ronde pertama menang pertandingan (seri tetap mengulang ronde). Ditetapkan sebelum game.js membaca match.winner. + if (m?.toWin === 1 && !m.winner) for (const e of ev) if (e.type === 'round-end' && (e.winner === 'player' || e.winner === 'enemy')) m.winner = e.winner; + return ev; + }; + } + function wrapVS() { + const V = window.VS; if (!V || V.__daily || typeof V.show !== 'function') return; + V.__daily = true; const show = V.show; + V.show = function (info, onDone) { + const c = cur && info && !info.shadow ? cur : null; + const ok = show.call(this, c ? { ...info, label: 'TANTANGAN HARIAN' } : info, onDone); + if (ok && c) try { decorateVS(c); } catch (e) { console.error(e); } + return ok; + }; + } + function wrapMeta() { + const M = window.Meta; if (!M || M.__daily || typeof M.award !== 'function') return; + M.__daily = true; const award = M.award; + M.award = function (r) { const h = award.apply(this, arguments); try { finish(r); } catch (e) { console.error(e); } return h; }; + } + // Pertandingan tantangan selesai: menang = hadiah (sekali per tanggal) yang masuk rincian koin layar hasil lewat Meta.lastAward. + function finish(r) { + const c = cur; if (!c || !r) return; + r.daily = { id: c.rule.id, date: c.date }; + if (r.winner !== 'player') return; + const got = claim(c.date); if (!got) return; + const aw = window.Meta.lastAward; + if (aw && Array.isArray(aw.lines)) { aw.lines.push({ key: 'daily', label: 'Tantangan harian', amount: got.coins, note: got.streak > 1 ? 'Beruntun ' + got.streak + ' hari' : c.rule.name }); aw.gain += got.coins; } + if (!(window.ResultView && window.FrontEnd?.showResult)) toast(`TANTANGAN HARIAN SELESAI +${got.coins} koin (beruntun ${got.streak} hari)`, 'coin'); + } + // MAIN LAGI di layar hasil memanggil startMatch tanpa penanda; klik itu (fase tangkap, sebelum penangan tombol) menandai bahwa yang diulang adalah tantangan terakhir. + // startMatch berjalan di klik yang sama; penanda dicabut sebentar kemudian supaya rematch yang gagal tidak bocor ke pertandingan biasa berikutnya. + let againT = 0; + document.addEventListener('click', e => { if (redo && e.target.closest?.('[data-cmd="rematch"]')) { again = true; clearTimeout(againT); againT = setTimeout(() => { again = false; }, 600); } }, true); + + // ---- penanda aturan di arena, baris aturan di layar VS ---- + let tag = null, nudgeT = 0; + function paintTag() { + const b = document.body; if (!b) return; + if (!cur) { tag?.remove(); tag = null; delete b.dataset.dailyRule; return; } + b.dataset.dailyRule = cur.rule.id; + const host = $('#game-shell'); if (!host) return; + if (!tag?.isConnected) { tag = document.createElement('div'); tag.id = 'daily-tag'; tag.setAttribute('aria-hidden', 'true'); host.append(tag); } + tag.textContent = '◆ HARIAN · ' + cur.rule.name; + } + function nudge() { if (!tag) return; tag.classList.add('hit'); clearTimeout(nudgeT); nudgeT = setTimeout(() => tag?.classList.remove('hit'), 450); } + function decorateVS(c) { + const el = $('#vs-screen'); if (!el) return; + el.classList.add('has-daily'); + const d = document.createElement('div'); d.className = 'vs-daily'; + d.innerHTML = `ATURAN HARI INI${esc(c.rule.name)}${esc(c.rule.vs)}`; + el.append(d); + } + + // ---- tombol di menu utama dan kartu tantangan ---- + const root = $('#front-menu'); + let dlg = null, pick = null, started = null, startT = 0; + // Petarung pemain: pilihan di kartu, lalu petarung terakhir yang dimainkan di sesi ini, lalu pilihan tersimpan; tidak boleh sama dengan lawan hari ini. + function chosen(ch, want) { + const ids = roster(), ok = id => id && ids.includes(id) && id !== ch.foe; + if (ok(want)) return want; if (ok(pick)) return pick; + let snap = null; try { snap = window.__game?.snapshot?.().selectedCharacter; } catch (_) { /* tanpa snapshot */ } + if (ok(snap)) return snap; const s = view().pick; if (ok(s)) return s; + return ids.find(id => id !== ch.foe) || ids[0]; + } + function go(who) { + const ch = challenge(), G = window.__game; if (!ch || !G?.startMatch) return false; + const me = chosen(ch, who); pick = me; + const d = store(); if (d) d.pick = me; + try { window.Meta?.cancelRun?.(); } catch (_) { /* tanpa rangkaian aktif */ } + let ok = false; + try { ok = G.startMatch({ player: me, enemy: ch.foe, stage: ch.stage, level: ch.level, mode: 'versus', daily: ch.date, vs: { label: 'TANTANGAN HARIAN' } }); } catch (e) { console.error(e); } + if (!ok) { toast('Aset belum siap. Tunggu sebentar lalu coba lagi.'); return false; } + persist(); closeCard(); $('#arena')?.focus({ preventScroll: true }); + return true; + } + function cardHtml(ch, st, who) { + const R = ch.rule, ids = roster(), stage = window.MatchRules?.stages?.[ch.stage]?.name || ch.stage; + const fact = (k, v, small) => `
${k}
${v}${small ? `${small}` : ''}
`; + const chips = ids.map(id => ``).join(''); + return `
KONOHA FIGHTING / TANTANGAN HARIAN
` + + `

${esc(R.name)}

${st.done ? 'SELESAI ✓' : 'BELUM SELESAI'}
` + + `

${esc(R.desc)}

` + + `
` + + fact('LAWAN', `${esc(fname(ch.foe))}`) + + fact('ARENA', esc(stage)) + fact('TINGKAT', LEVEL[ch.level] || '') + + fact('HADIAH', st.done ? 'SUDAH DIAMBIL' : `◆ ${st.reward}`, st.done ? `besok ◆ ${st.reward}` : st.streak ? `hari ke-${st.streak + 1} beruntun` : 'hari pertama') + + fact('STREAK', st.streak ? `${st.streak} HARI` : '—', st.streak ? `terbaik ${st.best}${st.done ? '' : ', selesaikan hari ini agar lanjut'}` : 'belum ada') + + `
PILIH PETARUNGMU
${chips}
` + + `
` + + `${esc(dateText(ch.date))}${override ? ' · TANGGAL UJI' : ''} · aturan baru tiap hari · hadiah sekali sehari`; + } + function paintCard() { + const ch = challenge(); if (!ch || !dlg) return; + pick = chosen(ch, pick); + dlg.innerHTML = cardHtml(ch, status(ch.date), pick); + } + function onCardClick(e) { + if (e.target === dlg) { const r = dlg.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) closeCard(); return; } // klik di luar kartu + const b = e.target.closest('button'); if (!b) return; + if (b.dataset.pick) { pick = b.dataset.pick; paintCard(); [...dlg.querySelectorAll('[data-pick]')].find(x => x.dataset.pick === pick)?.focus(); } + else if (b.hasAttribute('data-go')) go(); + else if (b.hasAttribute('data-close')) closeCard(); + } + // game.js menelan Esc (preventDefault) sehingga penutupan bawaan dialog tidak jalan. Pendengar fase tangkap di window (dipasang sekali, hanya bekerja selama kartu terbuka) + // menangani Esc dan panah kiri/kanan (pindah antarpilihan petarung) dan tidak meneruskannya ke game.js, bahkan bila fokus sedang tidak di dalam kartu. + function onCardKey(e) { + if (!dlg?.open) return; + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeCard(); return; } + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const chips = [...dlg.querySelectorAll('[data-pick]:not(:disabled)')]; if (!chips.length) return; + e.preventDefault(); e.stopPropagation(); + const i = chips.indexOf(document.activeElement); + chips[i < 0 ? 0 : (i + (e.key === 'ArrowLeft' ? -1 : 1) + chips.length) % chips.length].focus(); + } + function openCard() { + if (!challenge()) { toast('Tantangan belum siap. Tunggu sebentar lalu coba lagi.'); return false; } + if (!dlg) { + dlg = document.createElement('dialog'); dlg.id = 'daily-dialog'; dlg.className = 'daily-dialog'; dlg.setAttribute('aria-labelledby', 'daily-title'); + dlg.addEventListener('click', onCardClick); + dlg.addEventListener('close', () => { if (root && !root.hidden) root.querySelector('.daily-entry')?.focus({ preventScroll: true }); }); + document.body.append(dlg); + } + paintCard(); + if (!dlg.open) dlg.showModal(); + dlg.querySelector('[data-go]')?.focus(); + return true; + } + function closeCard() { if (dlg?.open) dlg.close(); } + function decorate() { + const slot = root.querySelector('.home-top-right') || root.querySelector('.home-top'), ch = challenge(); if (!slot || !ch) return; + const st = status(ch.date); let b = slot.querySelector('.daily-entry'); + if (!b) { + b = document.createElement('button'); b.type = 'button'; b.className = 'daily-entry'; + b.addEventListener('click', () => { try { window.__game?.menuSound?.('confirm'); } catch (_) { /* tanpa suara */ } openCard(); }); + slot.prepend(b); + } + b.classList.toggle('done', st.done); b.title = override ? 'Tanggal uji: ' + override : ''; + b.setAttribute('aria-label', `Tantangan harian: ${ch.rule.name}. ${st.done ? 'Sudah selesai.' : 'Belum selesai.'}${st.streak ? ' Beruntun ' + st.streak + ' hari.' : ''} Buka kartu tantangan.`); + b.innerHTML = `HARIAN${esc(ch.rule.name)}${st.done ? '✓' : 'BARU'}${st.streak > 1 ? `${st.streak} HARI` : ''}`; + } + // Hadiah hari pertama dibayar sedikit sesudah menu utama tampil, dan hanya bila menu utama masih tampil saat itu (pertandingan yang langsung dimulai + // tidak diganggu; hadiah menunggu menu utama berikutnya). Sekali per tanggal per muatan halaman; sisanya dijaga simpanan. + function maybeStartup() { + if (started === today() || startT) return; + startT = setTimeout(() => { + startT = 0; + try { if (root.hidden || root.dataset.screen !== 'home') return; started = today(); startup(started); } catch (e) { console.error(e); } + }, 700); + } + function sync() { // dipanggil tiap menu utama digambar ulang (menu.js mengganti isi #front-menu) dan saat tab kembali terlihat + try { if (!root || root.hidden || root.dataset.screen !== 'home') return; decorate(); maybeStartup(); } catch (e) { console.error(e); } + } + + // ---- uji manual ---- + function setDate(iso) { + if (iso == null || iso === '') override = null; else if (dayNum(iso) != null) override = String(iso); else return false; + started = null; sync(); startup(today()); + return today(); + } + function resetState() { const s = window.Meta?.save; if (!s) return false; delete s.daily; persist(); started = null; sync(); return true; } + function table(n = 14, from = today()) { + const dn = dayNum(from); if (dn == null) return []; + return Array.from({ length: Math.max(1, Math.min(120, n | 0)) }, (_, i) => { const c = challenge(isoOf(dn + i)); return c && { date: c.date, rule: c.rule.id, foe: c.foe, stage: c.stage, level: c.level }; }); + } + try { const q = new URLSearchParams(location.search).get('daily'); if (q && dayNum(q) != null) override = q; } catch (_) { /* tanpa parameter */ } + wrapRules(); wrapVS(); wrapMeta(); + window.Daily = { + rules: RULES, today, challenge, status, setDate, resetState, table, startup, open: openCard, close: closeCard, start: go, + arm, deny, hit, parry, cooldowns, slip, + get foeScale() { return cur?.rule.id === 'raksasa' ? SKALA_RAKSASA : 1; }, + get cur() { return cur; }, get active() { return !!cur; }, get override() { return override; } + }; + window.addEventListener('keydown', onCardKey, true); + if (root) { new MutationObserver(sync).observe(root, { childList: true }); sync(); } + document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); }); +})(); diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 2cd517b..949ffd6 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -31,6 +31,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`. - Mode cerita Arcade dan bos Bayangan (Tahap 3.5 dan 3.6): `content/story.js` (`window.STORY`: `intro[id]` 2-3 kalimat, `between[A][B]` 1-2 kalimat (atau fungsi), `ending[id]` 3 kalimat, `boss` = pembuka, ucapan VS/kalah/menang, kalimat akhir, gelar; `generic` cadangan dengan penampung `{name}` `{rival}` `{n}`) dan `story.js` + `story.css` (`window.Story`). `Story.show({kind: intro|between|boss|ending, player, enemy, step, total, stage, boss}, onDone)` menampilkan kartu layar penuh (`#story-screen`, z-index 45, di atas layar VS 40) dan memanggil onDone tepat satu kali: klik/ketuk/Spasi/Enter/Esc setelah 0,5 dtk, atau otomatis (kartu antar-lawan sekitar 4 dtk, kartu panjang lebih lama); selama tampil semua tombol ditahan dan `Meta` menahan toast (`Story.active`). Mengembalikan false tanpa kartu bila tidak ada teks, jadi alur tetap jalan tanpa cerita. `meta.js` Arcade: `startRun` (kartu pembuka) -> `nextFight` -> menang -> `betweenFights` (kartu antar-lawan, panggung lawan berikutnya jadi latar kartu) ... lawan terakhir kalah -> `arcadeCleared` (hadiah tamat 500 + pencapaian langsung dibayar seperti dulu) -> kartu bos -> `startBoss` -> `finishRun(true, 'win'|'lose')` -> kartu akhir -> layar hasil. Survival tidak memakai cerita. `run.boss` = null | 'pending' | 'fight'. Menang atas Bayangan: +1000 koin, pencapaian BAYANGAN (+50), gelar PENAKLUK BAYANGAN (`Meta.save.title`, tampil di kartu akhir dan plat pemenang layar hasil); kalah: Arcade tetap tamat, tanpa bonus bos. Uji manual: `?arcade=N` memendekkan Arcade menjadi N lawan. Bayangan = `startMatch({player: X, enemy: X, level: 'excellent', shadow: true})` (id sama; state `hero` dan `dummy` tetap terpisah): `setShadowFoe` mengatur `BALANCE.dummyMax` (+40%), `foeDamage` menambah 10% pada damage ke pemain (pembungkus `__game.receiveHit` di akhir game.js dan `dotDamage`), `dummyHpShown` memberi skala HP dasar untuk bar dan penentu waktu habis (`MatchRules.tick`), `drawDummy` memakai `ctx.filter` gelap + lapisan ungu `drawShadowTint` + aura, percikan ungu lewat `fxr()` di `updateVisuals`. HUD: nama BAYANGAN dengan label nama asli, kelas `.shadow-foe` (story.css). VS: game.js memanggil `Story.decorateVs(info)` tepat setelah `VS.show` (nama 'BAYANGAN ', ucapan bos); layar hasil: `ResultView.view` membaca `run.boss` (Bayangan di sisi kalah; kalah dari Bayangan tetap tampil TAMAT). `__game.shadowFoe()` melaporkan keadaan. - Statistik pertandingan (RENCANA 5.5): `stats.js` (`window.MatchStats`) + `stats.css`; tag-nya ada di baris sendiri sebelum `` di `index.html`. Statistik pemain 1 per pertandingan versus (juga 2 pemain, Arcade, Survival, Bayangan; latihan = `reset(false)`, tanpa statistik). Kait di game.js, semuanya satu baris dengan `window.MatchStats?.`: `reset(versus)` di `startMatch` dan `reset`; `tick(dt, match, hero.hp, dummy.damage, BALANCE.dummyMax)` di awal `advanceMatch` (damage keluar/diterima = selisih HP per langkah, durasi = jumlah waktu fase fight, ronde = peralihan fight -> ko); `cast(jenis, hero.action)` di `cast` dan `startAttack`; `hit(hitSource(), counter, comboHits, hero.action)` di `hitDummy` (juga jalur chip blok CPU) dan `hit(null, ...)` di burst `dotDamage`; `parry()`/`block()` di `heroGuard`; `finish(info)` di event 'complete' menaruh ringkasan di `info.stats` (dibawa `Meta.award` dan `FrontEnd.showResult`). Akurasi jurus = pemakaian yang kena sekali; pukulan proyektil/ultimate dikaitkan ke jurus terakhir dalam jendela waktu (`MatchStats.WINDOW`), jadi bila dua jurus bertumpuk bisa meleset satu. Layar hasil: `ResultView.view` memanggil `MatchStats.resultHtml` (tombol STATISTIK di kanan baris skor + kartu `.rs-ms` yang menutup kolom tengah di atas tombol aksi, diatur `MatchStats.mount`; Esc menutup kartu lebih dulu daripada kembali ke menu); tanpa `info.stats` (data uji) tidak ada tombol. Simpanan seumur hidup: `Meta.award` memanggil `MatchStats.commit(save.stats, r.stats)` (kunci baru `dmgDealt, dmgTaken, parries, blocks, throws, counters, rounds, playSec, statMatches`; `bestCombo` ikut dinaikkan ke kombo terbaik seluruh ronde; simpanan lama tanpa kunci terbaca 0), tampil di Koleksi lewat `MatchStats.lifetimeHtml`. Uji manual cepat: `MatchStats.snapshot()` di konsol saat pertandingan berjalan. Belum ada skenario otomatis khusus statistik (mode ringan, diuji manual). +- Tantangan harian dan hadiah harian (RENCANA 7.1 dan 7.2): `daily.js` (`window.Daily`) + `daily.css`, tag `Konoha Fighting - +
Menyiapkan…

@@ -27,6 +27,6 @@
KONOHA FIGHTING / JEDA

JEDA

Pertarungan dijeda. Siap melanjutkan?

Daftar gerakan
-
+