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 74b2418..1284bab 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -33,6 +33,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - 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). - Kode tantangan dan kebiasaan bermain (RENCANA 7.5 dan 7.6): `challenge.js` (`window.Challenge`, `window.Habits`) + `challenge.css`; tag-nya sebelum `menu.js` di `index.html`. **Kode** = 8 karakter dari 30 simbol tanpa 0 1 I L O U (contoh `TNNB-TPZM`), isinya 24 bit (versi, variasi, aturan, tingkat, arena, lawan, pemain) + 15 bit pemeriksa yang dicampur; `Challenge.encode({player, enemy, stage, level, rule})` / `Challenge.decode(teks)` (menerima huruf kecil, spasi, tanda hubung, tautan `?kode=`, atau kalimat yang memuat kode; hasil `{ok, cfg}` atau `{ok:false, err, msg}`). Daftar karakter, arena, tingkat, dan aturan di berkas itu menentukan arti kode: **urutannya tidak boleh diubah, hanya ditambah di belakang** (karakter/arena baru yang belum didaftarkan ikut otomatis di belakang menurut urutan deklarasi). **Aturan khusus** (`RULES`, satu per kode): `blitz` Adu cepat (satu ronde, HP awal separuh), `one` Satu ronde, `fast` Lawan gesit (jalan +25%, cooldown pulih +35%), `noblock` Tanpa blok (blok dan parry mati untuk kedua sisi), `heavy` Pukulan berat (damage +50% kedua sisi); kuncinya hanya hidup di kode, tidak disimpan di localStorage (jadi tidak bentrok dengan aturan harian). **Alur**: tombol KODE TANTANGAN di pojok kiri atas menu utama (di sebelah TUTORIAL, absolut supaya tinggi menu tidak berubah) membuka dialog `#ch-dialog` yang memvalidasi sambil mengetik dan langsung memulai (`Challenge.start(cfg)` memanggil `startMatch({... mode:'versus', vs:{label}, codeRule:{rule, code}})`); `?kode=XXXXXXXX` di alamat membuka dialog yang sama sebagai undangan setelah boot (parameter dihapus dari alamat sesudahnya; `mobile.html` meneruskan query ke iframe). Layar arena (hanya versus melawan CPU) mendapat baris `ATURAN` (menggilir aturan, berlaku juga untuk LET'S FIGHT dan MAIN LAGI lewat `Challenge.pending(mode, p2)` di `menu.js start()`; menu utama mengosongkannya) / `SALIN KODE` / `TAUTAN` di kolom kanan `.match-rules`; layar hasil mendapat `SALIN KODE` / `TAUTAN` di pojok kanan atas `.rs-head` (tidak untuk Arcade, Survival, dua pemain, atau Bayangan). Penyalinan: `navigator.clipboard`, lalu `execCommand('copy')`, lalu `prompt()`. **Kait** (semua `window.Challenge?.x?.()`, dibungkus `guard`: galat hanya dicatat di konsol, tanpa berkas ini game berjalan seperti dulu): game.js `begin(options)` di `startMatch` sesudah validasi dan sebelum `refreshPassives` (menetapkan aturan aktif, judul layar VS "TANTANGAN · ...", penanda `.ch-badge` di bawah jam HUD, dan mencatat kebiasaan), `tune(sideMods)` di akhir `refreshPassives` (aturan = pengali kunci `passiveMod`), `roundStart(hero, dummy, BALANCE)` di akhir `resetCombat` (HP awal), `roundEnd(match, event)` dan `finish(info)` di `advanceMatch` (satu ronde menetapkan `match.winner` sebelum efek K.O. akhir; `finish` mengingat pengaturan untuk SALIN KODE dan mematikan `info.perfect` pada aturan satu ronde supaya bonus/pencapaian BERSIH tidak murah), `hud(match)` di `updateMatchHud` (teks "SATU RONDE"), `noBlock` di `press` (tombol L) dan `heroGuard`; menu.js `decorate(root, screen, {player, enemy, stage, level, mode, p2, runKind, result})` di akhir `render()`. `startMatch` mengabaikan `codeRule` untuk latihan, dua pemain, dan Bayangan. Aturan tidak memakai pengacak, jadi hasil pertandingan tetap deterministik. **Kebiasaan bermain** (`Habits`): `localStorage['konoha.habits.v1']` = `{v:1, n, ch, mode, stage, foe}` (hitungan per karakter yang dipilih, mode, arena, dan karakter lawan; tanpa waktu dan identitas), dicatat di `begin()` tiap pertandingan dimulai (latihan juga; Bayangan tidak dihitung sebagai lawan; mode dikenali dari `options.mode`, `vs.p2`, awalan label ARCADE/SURVIVAL, `codeRule`, atau `options.kind` untuk mode baru). Terpisah dari `Meta.save`, jadi simpanan lama tak tersentuh dan data rusak dibaca sebagai nol. Bagian "Kebiasaan bermainmu" (karakter, mode, arena favorit, lawan paling sering, tombol HAPUS STATISTIK) disisipkan ke tab Koleksi oleh `MutationObserver` pada `#meta-overlay` (sebelum daftar pencapaian; `meta.js` tidak diubah); catatan privasi satu baris di layar About lewat `decorate`. Belum ada skenario otomatis dan UI belum diperiksa di peramban oleh penulisnya (hanya `node --check`, smoke, dan pemeriksaan penyandi sekali pakai: 5.880 kombinasi bolak-balik tanpa tabrakan): uji manual di 1024x520 dan 844x390. `precache.js`/`files.json` perlu dibangun ulang agar `challenge.js`/`challenge.css` ikut tersimpan offline. Konsol: `Challenge.state()`, `Challenge.open()`, `Habits.data()`. +- Tantangan harian dan hadiah harian (RENCANA 7.1 dan 7.2): `daily.js` (`window.Daily`) + `daily.css`, tag `Konoha Fighting - +
Menyiapkan…

@@ -29,6 +29,6 @@ -
+