Gabung wf/t6-arena: latar arena bergerak dan cuaca

This commit is contained in:
rizkyaz0 2026-10-05 15:03:51 +07:00
commit 0f343f0306
6 changed files with 562 additions and 1 deletions

19
arena.css Normal file
View File

@ -0,0 +1,19 @@
/* Latar hidup dan cuaca (arena.js, RENCANA 6.1 dan 6.3): baris Cuaca di layar pilih arena.
Baris ini berdampingan dengan tombol mulai (di sisi kirinya, yang sebelumnya kosong), jadi tinggi layar tidak bertambah di 1280x720 maupun 1024x520.
Baris Pengaturan "Efek latar" memakai kelas .setting-row bawaan, tanpa gaya tambahan. */
.arena-row{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:14px 18px}
.arena-row .start-battle{flex:none;margin-left:auto}
.arena-weather{flex:1 1 340px;min-width:0}
.arena-weather .eyebrow{display:flex;align-items:baseline;flex-wrap:wrap;gap:2px 10px}
.aw-note{font-style:normal;font-weight:600;font-size:10px;letter-spacing:.6px;color:#8fb0b3}
.aw-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.aw-chips button{min-width:0;background:#142d38;color:#c4d3cf;border:1px solid #789a984d;padding:5px 10px;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase}
.aw-chips button.selected{background:#c19b60;color:#0c242e;border-color:#e8cd91}
.aw-chips button:disabled{opacity:.4;cursor:default}
@media(max-height:420px){.aw-chips{margin-top:3px}.aw-chips button{padding:3px 8px;font-size:11px}.aw-note{display:none}}
html.touch .aw-chips button{min-height:30px}
/* Vinyet dan kerudung warna depan: elemen DOM yang dipasang arena.js tepat di atas kanvas (di bawah HUD). Nilainya lewat --av (rgb vinyet), --ak (kekuatan di tepi), --aw (kerudung). */
#arena-vig{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;transition:opacity .5s;background:radial-gradient(ellipse at 50% 52%,rgba(var(--av,0,0,0),0) 50%,rgba(var(--av,0,0,0),var(--ak,0)) 100%),var(--aw,transparent)}
html.acc-calm #arena-vig{transition:none}
@media(prefers-reduced-motion:reduce){#arena-vig{transition:none}}

536
arena.js Normal file
View File

@ -0,0 +1,536 @@
/* Latar hidup dan cuaca (RENCANA 6.1 dan 6.3). Semua digambar prosedural di kanvas: tanpa gambar baru, tanpa pustaka.
Kait di game.js (draw): Arena.back(ctx, env) tepat sesudah drawBackground, di belakang petarung (warna cuaca, awan, burung, kilau air, perahu, debu cahaya,
daun, kunang-kunang, hujan jauh, kabut, sorotan lantai); Arena.front(ctx) paling akhir, layar penuh (penonton di tepi bawah, kabut depan, hujan dekat, kilat).
Vinyet dan kerudung warna depan adalah elemen DOM #arena-vig (gaya di arena.css) tepat di atas kanvas: nol biaya gambar, dan tidak ikut zoom kamera.
Biaya: operasi layar penuh dijaga sedikit (vinyet berskala dan blend besar sangat mahal pada rasterisasi perangkat lunak), tekstur kabut dibuat sesuai ukuran layar
lalu digambar 1:1, gradasi langit memakai strip 1x256 yang diregangkan, dan blend besar memakai source-over.
env = { viewW, viewH, scale, offsetX, offsetY, shakeX, shakeY, groundY, stage, time (realTime), menu, combo (comboHits), cin (cinematic) }.
Kait di menu.js: Arena.decorate(root, screen) di akhir render() menambah baris Cuaca di layar arena, dan startMatch({weather: Arena.pick}). Pengaturan mendapat
baris "Efek latar" (Penuh/Ringan/Mati) yang disisipkan modul ini sendiri.
Aturan: hanya tampilan. Semua gerak adalah fungsi waktu tanpa status (hash bilangan bulat sebagai pengacak kosmetik, bukan random() mesin dan bukan Date), jadi hasil
pertandingan tidak berubah, jeda membekukan latar, dan lompatan waktu dari advance() di uji tidak merusak apa pun. Satu-satunya status adalah semangat penonton.
Gerak dikurangi (Access.motion = 0 atau prefers-reduced-motion): waktu latar dibekukan, burung/perahu/daun/debu/bintang jatuh/kilat/lompatan penonton dimatikan.
Konsol: Arena.state(), Arena.force('hujan') (null = lepas), Arena.setFx('light'); URL ?cuaca=hujan memaksa cuaca di semua pertandingan.
Bila ada galat gambar, modul mematikan diri setelah beberapa kali supaya loop game tidak pernah terganggu. */
(() => {
'use strict';
const W = 1280, H = 720, TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, k) => a + (b - a) * k;
const fract = v => v - Math.floor(v);
const smooth = u => u * u * (3 - 2 * u);
// Pengacak kosmetik tanpa status (hash bilangan bulat -> 0..1). Tiap efek punya salt sendiri; i = nomor elemen (+ siklus * 64), k = nomor parameter (< 16).
function hash(n) { n |= 0; n = Math.imul(n ^ (n >>> 16), 0x7feb352d); n = Math.imul(n ^ (n >>> 15), 0x846ca68b); return ((n ^ (n >>> 16)) >>> 0) / 4294967296; }
const rnd = (salt, i, k) => hash(salt * 100003 + i * 16 + k);
const rgb = (r, g, b) => 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')';
const rgba = (c, a) => 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
const KEY = 'konoha.arena.v1';
const PICKS = ['auto', 'cerah', 'hujan', 'kabut', 'senja', 'malam'];
const NAME = { auto: 'Otomatis', cerah: 'Cerah', hujan: 'Hujan', kabut: 'Kabut', senja: 'Senja', malam: 'Malam' };
const FX = ['full', 'light', 'off'], FXNAME = { full: 'Penuh', light: 'Ringan', off: 'Mati' };
const DEF_FX = document.documentElement.classList.contains('touch') ? 'light' : 'full'; // HP: Ringan sebagai bawaan
const S = { fx: DEF_FX, pick: 'auto', active: 'auto', forced: null }; // pick = pilihan di layar arena; active = cuaca pertandingan berjalan; forced = ?cuaca= atau Arena.force
const E = { ok: false, dead: false, fails: 0, depth: 0, last: 0, dt: 0, spot: 640, on: false };
const C = { ex: 0, hold: 0, clap: 0, last: 0, ko: false }; // semangat penonton
// ---- Data arena (koordinat 1280x720 pada gambar latar) ----
// horizon = garis perbukitan; sky = batas bawah langit terbuka untuk bintang; sun = letak matahari saat senja; cloud/bird = jalur awan dan burung (v0..v1; tanpa cloud = tanpa langit yang terlihat); keep = kotak [u0, u1, v0, v1]
// bangunan tinggi di depan langit (awan dan bintang memudar saat menutupinya, karena awan digambar di atas gambar latar); water = kolam kilau air (cu/cv = sudut dermaga yang dikecualikan);
// boat = perahu kecil; dust = debu cahaya; fire = kunang-kunang; auto = [cuaca, kekuatan] untuk pilihan Otomatis; crowd = siluet penonton.
const ARENA = {
_: { auto: ['cerah', 1], horizon: 340, sun: [900, 250], cloud: { n: 4, v0: 25, v1: 240 }, bird: { style: 'swallow', v0: 60, v1: 250 }, crowd: { tone: '#2a2433', rim: '#9a8fbf' } },
bellora: { auto: ['cerah', 1], horizon: 335, sky: 200, sun: [1010, 235], cloud: { n: 4, v0: 25, v1: 200 }, keep: [[375, 500, 0, 330], [0, 190, 115, 330], [1000, 1280, 105, 330], [760, 800, 222, 330]], bird: { style: 'swallow', v0: 60, v1: 240 }, crowd: { tone: '#2b2236', rim: '#8a76b6', acc: 'flag', flags: ['#2f5aa8', '#e0b84a'] } },
sunspire: { auto: ['cerah', 1], horizon: 470, sky: 330, sun: [640, 330], cloud: { n: 5, v0: 15, v1: 240 }, keep: [[105, 185, 150, 480], [155, 375, 200, 500], [855, 1280, 290, 530], [1145, 1240, 170, 500]], bird: { style: 'swallow', v0: 50, v1: 300 }, dust: { n: 26, v0: 150, v1: 540, col: 'gold' }, shaft: 1, crowd: { tone: '#3a2630', rim: '#e8b27a', acc: 'flag', flags: ['#c04a4a', '#f0c860'] } },
harbor: { auto: ['cerah', 1], horizon: 405, sky: 300, sun: [560, 300], cloud: { n: 3, v0: 10, v1: 190 }, keep: [[855, 950, 55, 345], [1045, 1280, 95, 265], [300, 455, 235, 440]], bird: { style: 'gull', v0: 55, v1: 290 }, water: { u0: 238, u1: 604, v0: 432, v1: 504, cu: 540, cv: 448 }, boat: { v: 470, u0: 255, u1: 585 }, crowd: { tone: '#1c2a38', rim: '#7fb6d8', acc: 'hat' } },
elderwood: { auto: ['kabut', .5], horizon: 400, sun: [470, 100], leaves: { n: 14 }, dust: { n: 22, v0: 120, v1: 520, col: 'leaf' }, crowd: { tone: '#101f14', rim: '#8fd08a', acc: 'hat' } },
moonrise: { auto: ['malam', 1], night: 1, horizon: 420, sky: 330, sun: [200, 410], cloud: { n: 3, v0: 30, v1: 300 }, keep: [[960, 1130, 100, 520]], bird: { style: 'bat', v0: 80, v1: 330 }, water: { u0: 440, u1: 640, v0: 445, v1: 515, dim: .6 }, fire: { n: 16 }, crowd: { tone: '#080b22', rim: '#8fa4ff', acc: 'lantern' } }
};
// ---- Cuaca ----
// mul = warna pengali di atas latar [r, g, b, kekuatan]; veil = kerudung warna biasa; top = gradasi dari atas (langit); glow = cahaya cakrawala; cloud = nada awan, cn = jumlah awan;
// rain/fog/mist = tingkat 0..1; vig = [nada vinyet, kekuatan]; spot = sorotan lantai; bolt = kilat.
const WX = {
cerah: {},
hujan: { mul: [170, 184, 204, .9], veil: [96, 110, 126, .14], top: [54, 66, 82, .5], cloud: 'storm', cn: 7, rain: 1, mist: .4, vig: ['dark', .34], bolt: 1 },
kabut: { veil: [222, 230, 235, .24], top: [234, 240, 244, .3], cloud: 'mist', cn: 3, fog: 1, vig: ['dark', .1] },
senja: { mul: [255, 186, 134, .85], top: [88, 54, 130, .34], glow: [255, 140, 78, .34], sun: 1, spot: 'warm', cloud: 'dusk', vig: ['dusk', .3] },
malam: { mul: [86, 104, 172, .78], spot: 'cool', cloud: 'night', vig: ['night', .46] }
};
const FRONT_VEIL = { hujan: [70, 84, 100, .07], kabut: [222, 230, 236, .07], senja: [255, 150, 70, .06], malam: [10, 20, 60, .1] };
// ---- Sprite kecil: digambar sekali di kanvas luar layar lalu dipakai ulang lewat drawImage ----
const SPR = {};
function sprite(key, w, h, paint) {
let c = SPR[key];
if (!c) { c = document.createElement('canvas'); c.width = Math.max(1, Math.round(w)); c.height = Math.max(1, Math.round(h)); paint(c.getContext('2d'), c.width, c.height); SPR[key] = c; }
return c;
}
const GLOW = { gold: [255, 218, 128], leaf: [214, 238, 150], fly: [214, 255, 120], warm: [255, 198, 112], sun: [255, 190, 110], cool: [200, 222, 255] };
const glow = name => sprite('g' + name, 64, 64, (x, w) => { const c = GLOW[name], g = x.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); g.addColorStop(0, rgba(c, 1)); g.addColorStop(.3, rgba(c, .55)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.fillRect(0, 0, w, w); });
const CLOUD = {
day: { hi: [255, 255, 255], lo: [182, 206, 232], a: .92, o: .85 }, dusk: { hi: [255, 218, 196], lo: [150, 100, 150], a: .9, o: .85 }, night: { hi: [128, 146, 208], lo: [40, 50, 104], a: .8, o: .55 },
storm: { hi: [156, 166, 178], lo: [72, 82, 96], a: .95, o: .9 }, mist: { hi: [228, 234, 238], lo: [172, 184, 196], a: .8, o: .6 }
};
const CLOUD_SHAPE = [
{ w: 300, h: 130, sy: 1, c: [[60, 76, 32], [105, 58, 42], [160, 48, 50], [215, 60, 42], [256, 80, 30], [150, 78, 38], [100, 86, 32], [205, 88, 32]] },
{ w: 176, h: 96, sy: 1, c: [[44, 56, 26], [84, 44, 34], [128, 54, 28], [90, 56, 30]] },
{ w: 350, h: 64, sy: .5, c: [[50, 52, 36], [112, 50, 44], [182, 54, 46], [252, 52, 40], [304, 56, 28]] }
];
function cloud(kind, tone) {
const sh = CLOUD_SHAPE[kind], T = CLOUD[tone], K = 1.5;
return sprite('c' + kind + tone, sh.w * K, sh.h * K, x => {
x.scale(K, K * sh.sy);
const blob = (cx, cy, r, c, a) => { const g = x.createRadialGradient(cx, cy, r * .12, cx, cy, r); g.addColorStop(0, rgba(c, a)); g.addColorStop(.6, rgba(c, a * .7)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.beginPath(); x.arc(cx, cy, r, 0, TAU); x.fill(); };
for (const [cx, cy, r] of sh.c) blob(cx, cy + r * .26, r, T.lo, T.a * .8);
for (const [cx, cy, r] of sh.c) blob(cx, cy - r * .06, r * .9, T.hi, T.a);
});
}
const FOG = { day: [224, 232, 238], gray: [170, 182, 194], night: [96, 112, 156] };
const fogTex = (tone, v) => sprite('f' + tone + v, 512, 160, (x, w, h) => {
const c = FOG[tone];
for (let i = 0; i < 16; i++) {
const cx = rnd(10 + v, i, 1) * w, cy = h * (.38 + .3 * rnd(10 + v, i, 2)), r = 58 + 70 * rnd(10 + v, i, 3), a = .35 + .3 * rnd(10 + v, i, 4);
for (const dx of [-w, 0, w]) { // tiga salinan supaya ubin menyambung di sisi kiri dan kanan
x.save(); x.translate(cx + dx, cy); x.scale(1, .42);
const g = x.createRadialGradient(0, 0, 0, 0, 0, r); g.addColorStop(0, rgba(c, a)); g.addColorStop(.55, rgba(c, a * .45)); g.addColorStop(1, rgba(c, 0));
x.fillStyle = g; x.beginPath(); x.arc(0, 0, r, 0, TAU); x.fill(); x.restore();
}
}
x.globalCompositeOperation = 'destination-in'; // pudar di atas dan bawah supaya pita tidak bertepi keras
const m = x.createLinearGradient(0, 0, 0, h); m.addColorStop(0, 'rgba(0,0,0,0)'); m.addColorStop(.4, 'rgba(0,0,0,1)'); m.addColorStop(.7, 'rgba(0,0,0,1)'); m.addColorStop(1, 'rgba(0,0,0,0)'); x.fillStyle = m; x.fillRect(0, 0, w, h);
});
const spotSpr = tone => sprite('s' + tone, 128, 64, (x, w, h) => {
const c = GLOW[tone === 'warm' ? 'warm' : 'cool'];
x.translate(w / 2, h / 2); x.scale(1, h / w); const g = x.createRadialGradient(0, 0, 0, 0, 0, w / 2); g.addColorStop(0, rgba(c, 1)); g.addColorStop(.5, rgba(c, .45)); g.addColorStop(1, rgba(c, 0)); x.fillStyle = g; x.beginPath(); x.arc(0, 0, w / 2, 0, TAU); x.fill();
});
// Berkas cahaya (Sunspire): gradasi vertikal lalu ditipiskan di kedua sisi (destination-in).
const shaftSpr = () => sprite('shaft', 64, 256, (x, w, h) => {
const g = x.createLinearGradient(0, 0, 0, h); g.addColorStop(0, 'rgba(255,246,214,.9)'); g.addColorStop(1, 'rgba(255,246,214,0)'); x.fillStyle = g; x.fillRect(0, 0, w, h);
x.globalCompositeOperation = 'destination-in'; const m = x.createLinearGradient(0, 0, w, 0); m.addColorStop(0, 'rgba(0,0,0,0)'); m.addColorStop(.5, 'rgba(0,0,0,1)'); m.addColorStop(1, 'rgba(0,0,0,0)'); x.fillStyle = m; x.fillRect(0, 0, w, h);
});
// Strip gradasi 1x256 (alpha penuh di atas lalu memudar, atau puncak di tengah bila peak diberikan); diregangkan lebar saat digambar (jauh lebih murah daripada gradasi per frame).
const strip = (c, peak) => sprite('t' + c[0] + ',' + c[1] + ',' + c[2] + (peak || ''), 1, 256, (x, w, h) => {
const g = x.createLinearGradient(0, 0, 0, h);
if (peak) { g.addColorStop(0, rgba(c, 0)); g.addColorStop(peak, rgba(c, 1)); g.addColorStop(1, rgba(c, 0)); } else { g.addColorStop(0, rgba(c, 1)); g.addColorStop(1, rgba(c, 0)); }
x.fillStyle = g; x.fillRect(0, 0, w, h);
});
const VIG = { dark: [0, 0, 0], night: [4, 8, 36], dusk: [44, 10, 40] };
// ---- Penyimpanan, pilihan, pengaturan ----
function load() { try { const o = JSON.parse(localStorage.getItem(KEY) || '{}'); if (FX.includes(o.fx)) S.fx = o.fx; } catch (_) { /* tanpa penyimpanan: pakai bawaan */ } }
function save() { try { if (S.fx === DEF_FX) localStorage.removeItem(KEY); else localStorage.setItem(KEY, JSON.stringify({ fx: S.fx })); } catch (_) { /* penyimpanan diblokir */ } }
try { const q = new URLSearchParams(location.search).get('cuaca'); if (PICKS.includes(q) && q !== 'auto') S.forced = q; } catch (_) { /* tanpa URL */ }
const motion = (() => { let mq = null; try { mq = matchMedia('(prefers-reduced-motion: reduce)'); } catch (_) { /* tanpa matchMedia */ } return () => { try { return window.Access ? window.Access.motion : mq && mq.matches ? 0 : 1; } catch (_) { return 1; } }; })();
function resolve(stage) {
const p = S.forced || S.active;
if (p && p !== 'auto' && WX[p]) return [p, 1];
return (ARENA[stage] || ARENA._).auto;
}
const autoLabel = stage => { const r = (ARENA[stage] || ARENA._).auto; return NAME[r[0]] + (r[1] < 1 ? ' tipis' : ''); };
// ---- Lapisan DOM: vinyet dan kerudung warna depan (#arena-vig di atas kanvas, di bawah HUD); DOM hanya ditulis saat nilainya berubah ----
let OV = null, ovKey = '', ovSig = null;
function syncOverlay() {
if (!OV) { const cv = document.getElementById('arena'); if (!cv || !cv.parentNode) return; OV = document.createElement('div'); OV.id = 'arena-vig'; OV.setAttribute('aria-hidden', 'true'); cv.after(OV); }
const sig = E.on ? E.wx + '|' + E.wk + '|' + E.light + '|' + !!E.A.night : '';
if (sig === ovSig) return; ovSig = sig;
let av = '0,0,0', ak = '0', aw = 'transparent', key = '';
if (E.on) {
const v = E.P.vig, w = FRONT_VEIL[E.wx], k = E.wk;
if (v) { av = VIG[v[0]].join(','); ak = clamp(v[1] * k, 0, 1).toFixed(2); }
if (w && !E.light) aw = rgba(w, w[3] * k * (w[0] > 200 && E.A.night ? .35 : 1));
if (v || w) key = av + '|' + ak + '|' + aw;
}
if (key === ovKey) return; ovKey = key;
if (!key) { OV.style.opacity = '0'; return; }
OV.style.setProperty('--av', av); OV.style.setProperty('--ak', ak); OV.style.setProperty('--aw', aw); OV.style.opacity = '1';
}
// ---- Per frame ----
function push(ctx) { ctx.save(); E.depth++; }
function pop(ctx) { ctx.restore(); E.depth--; }
function prep(e) {
E.vw = e.viewW; E.vh = e.viewH; E.sc = e.scale; E.ox = e.offsetX; E.oy = e.offsetY; E.shx = e.shakeX * e.scale; E.shy = e.shakeY * e.scale;
E.gy = e.groundY; E.stage = e.stage; E.t = e.time; E.menu = !!e.menu; E.combo = e.combo | 0; E.fade = 1 - Math.min(.65, (e.cin || 0) * 1.5); // fade = redup latar saat sinematik ultimate
const dt = clamp(e.time - E.last, 0, .1); E.last = e.time; E.dt = dt;
E.on = !E.menu && S.fx !== 'off';
if (E.menu) { C.ex = C.hold = C.clap = C.last = 0; C.ko = false; }
if (!E.on) { syncOverlay(); return; }
E.light = S.fx === 'light'; E.calm = motion() < 1; E.T = E.calm ? 0 : e.time;
// Pemetaan gambar latar ke layar sama dengan drawBackground (menutupi layar, lantai gambar sejajar lantai tempur).
const bs = Math.max(E.vw / W, E.vh / H); E.bs = bs; E.ix = (E.vw - W * bs) / 2; E.iy = E.vh * .815 - E.gy * bs;
E.uL = -E.ix / bs; E.uR = (E.vw - E.ix) / bs; E.vT = -E.iy / bs; E.vB = (E.vh - E.iy) / bs;
E.wL = -E.ox / E.sc; E.wR = (E.vw - E.ox) / E.sc; E.wB = (E.vh - E.oy) / E.sc; // sisi tampak dunia (lantai di E.gy)
const r = resolve(E.stage); E.wx = r[0]; E.wk = r[1]; E.A = ARENA[E.stage] || ARENA._; E.P = WX[E.wx];
E.night = E.wx === 'malam' || (!!E.A.night && E.wx === 'cerah'); // langit malam: bintang, kelelawar, kunang-kunang
syncOverlay();
if (!E.calm) crowdTick(dt);
}
function fail(ctx, err) {
while (E.depth > 0) { try { ctx.restore(); } catch (_) { /* tumpukan sudah kosong */ } E.depth--; }
E.ok = false; if (++E.fails <= 2) console.error('[arena] galat gambar:', err);
if (E.fails >= 6) { E.dead = true; console.error('[arena] dimatikan sesudah galat berulang'); if (OV) OV.style.opacity = '0'; }
}
function back(ctx, e) {
E.ok = false;
try { if (E.dead || !ctx || !e) return; prep(e); if (!E.on) return; drawBack(ctx); E.ok = true; } catch (err) { fail(ctx, err); }
}
function front(ctx) {
if (!E.ok) return; E.ok = false;
try { drawFront(ctx); } catch (err) { fail(ctx, err); }
}
// ---- Penonton: semangat naik saat kombo besar (5, 8, 12 pukulan), saat kombo besar berakhir (tepuk), dan saat K.O. ----
function crowdTick(dt) {
const F = window.Feel, ko = !!(F && F.ko && F.ko.state && F.ko.state.on), cb = E.combo;
const pulse = v => { if (v > C.ex) C.ex = v; };
if (cb > C.last) { if (C.last < 12 && cb >= 12) pulse(1); else if (C.last < 8 && cb >= 8) pulse(.8); else if (C.last < 5 && cb >= 5) pulse(.55); }
else if (cb === 0 && C.last >= 5) { C.clap = 1.3; pulse(.45); }
C.last = cb;
if (ko && !C.ko) { C.hold = F.ko.state.final ? 2.6 : 1.7; pulse(1); }
C.ko = ko;
C.hold = Math.max(0, C.hold - dt); C.clap = Math.max(0, C.clap - dt);
C.ex = C.hold > 0 ? Math.max(C.ex, .9) : Math.max(0, C.ex - dt * .5);
}
// ---- Lapisan belakang ----
function drawBack(ctx) {
const A = E.A, P = E.P, k = E.wk, ga = E.fade;
push(ctx); ctx.imageSmoothingEnabled = true;
tintBack(ctx, A, P, k);
push(ctx); ctx.translate(E.ix + E.shx, E.iy + E.shy); ctx.scale(E.bs, E.bs); imageLayers(ctx, A, P, k, ga); pop(ctx);
if (P.rain) rain(ctx, true, k);
if (P.fog) { fogBand(ctx, 0, k); if (!E.light) fogBand(ctx, 1, k); }
else if (P.mist) fogBand(ctx, 0, P.mist * k);
push(ctx); ctx.translate(E.ox + E.shx, E.oy + E.shy); ctx.scale(E.sc, E.sc);
if (P.rain && !E.calm) splashes(ctx, ga);
pop(ctx);
if (P.spot) spotlight(ctx, P, k, ga);
pop(ctx);
}
function tintBack(ctx, A, P, k) {
const lt = A.night ? .35 : 1; // arena yang sudah malam: kerudung terang dikurangi, tint gelap tidak digandakan
if (P.mul) {
const s = P.mul[3] * k * (A.night && E.wx === 'malam' ? .3 : 1);
ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = rgb(lerp(255, P.mul[0], s), lerp(255, P.mul[1], s), lerp(255, P.mul[2], s)); ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalCompositeOperation = 'source-over';
}
if (P.veil && !E.light) { ctx.globalAlpha = P.veil[3] * k * (P.veil[0] > 200 ? lt : 1); ctx.fillStyle = rgb(P.veil[0], P.veil[1], P.veil[2]); ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalAlpha = 1; }
}
// Semua yang menempel pada gambar latar (koordinat gambar 1280x720; sudah di-translate dan di-scale).
function imageLayers(ctx, A, P, k, ga) {
const uL = E.uL, uR = E.uR, vT = E.vT, light = E.light, calm = E.calm, lt = A.night ? .35 : 1;
if (P.top && !light) { const bot = A.horizon + 30; ctx.globalAlpha = clamp(P.top[3] * k * (P.top[0] > 200 ? lt : 1), 0, 1); ctx.drawImage(strip(P.top), 0, 0, 1, 256, uL, vT, uR - uL, Math.max(40, bot - vT)); ctx.globalAlpha = 1; }
if (P.glow && !light) { ctx.globalAlpha = clamp(P.glow[3] * k * 2, 0, 1); ctx.drawImage(strip(P.glow, .7), 0, 0, 1, 256, uL, A.horizon - 170, uR - uL, 240); ctx.globalAlpha = 1; }
if (P.sun) { const sp = glow('sun'), su = A.sun[0], sv = A.sun[1]; ctx.globalAlpha = .55 * k * ga; ctx.drawImage(sp, su - 260, sv - 260, 520, 520); ctx.globalAlpha = .6 * k * ga; ctx.drawImage(sp, su - 80, sv - 80, 160, 160); ctx.globalAlpha = 1; }
if (E.night && A.cloud) stars(ctx, A, ga);
if (A.shaft && !calm && !light && (E.wx === 'cerah' || E.wx === 'senja')) shafts(ctx, k, ga);
if (A.cloud) clouds(ctx, A, P, ga);
if (!calm && A.bird) { const st = E.night ? 'bat' : (E.wx === 'cerah' || E.wx === 'senja') ? A.bird.style : ''; if (st) birds(ctx, A, st, ga); }
if (A.water && (E.wx === 'cerah' || E.wx === 'senja' || E.wx === 'malam')) water(ctx, A, ga);
if (!calm && A.dust && (E.wx === 'cerah' || E.wx === 'senja' || E.wx === 'kabut')) dust(ctx, A, k, ga);
if (!calm && A.leaves && E.wx !== 'malam' && E.wx !== 'hujan') leaves(ctx, A, ga);
if (E.night) { fireflies(ctx, A, ga); if (!calm && !light && A.cloud) shooting(ctx, ga); }
}
function stars(ctx, A, ga) {
const n = E.light ? 12 : 26, T = E.T, v1 = Math.max(E.vT + 40, A.sky || A.horizon - 50);
ctx.fillStyle = '#eaf2ff';
for (let i = 0; i < n; i++) {
const tw = .5 + .5 * Math.sin(T * (1 + 2 * rnd(1, i, 2)) + rnd(1, i, 3) * TAU), s = 1 + 1.4 * rnd(1, i, 4), u = lerp(E.uL, E.uR, rnd(1, i, 1)), v = lerp(E.vT + 4, v1, rnd(1, i, 5)), K = A.keep;
let hid = false; if (K) for (let j = 0; j < K.length; j++) { const q = K[j]; if (u > q[0] && u < q[1] && v > q[2] && v < q[3]) { hid = true; break; } }
if (hid) continue;
ctx.globalAlpha = (.25 + .65 * tw) * ga * (A.night ? .8 : 1); ctx.fillRect(u, v, s, s);
}
ctx.globalAlpha = 1;
}
function shafts(ctx, k, ga) { // berkas cahaya miring dari kiri atas (Sunspire)
const spr = shaftSpr(), T = E.T, h = Math.min(E.vB, 560) - E.vT;
for (let i = 0; i < 3; i++) {
const w = 130 + 70 * rnd(2, i, 1), x = lerp(E.uL, E.uR, (i + .3 + .4 * rnd(2, i, 2)) / 3);
ctx.globalAlpha = (.07 + .05 * (.5 + .5 * Math.sin(T * .22 + i * 2.1))) * k * ga;
push(ctx); ctx.translate(x, E.vT); ctx.transform(1, 0, .32, 1, 0, 0); ctx.drawImage(spr, -w / 2, 0, w, h); pop(ctx);
}
ctx.globalAlpha = 1;
}
function clouds(ctx, A, P, ga) {
const n = Math.ceil((P.cn || A.cloud.n) * (E.light ? .6 : 1)), tone = P.cloud || (A.night ? 'night' : 'day'), co = CLOUD[tone].o, T = E.T, uL = E.uL, uR = E.uR;
const vlo = Math.max(A.cloud.v0, E.vT + 6), vhi = Math.max(vlo + 20, Math.min(A.cloud.v1, A.horizon - 24)), fast = tone === 'storm' ? 1.7 : 1;
for (let i = 0; i < n; i++) {
const sh = CLOUD_SHAPE[i % 3], sz = .75 + .6 * rnd(3, i, 1), w = sh.w * sz, h = sh.h * sz, span = (uR - uL) + 2 * w;
const x = uL - w + fract((rnd(3, i, 2) * span + T * (4 + 7 * rnd(3, i, 3)) * fast) / span) * span, y = lerp(vlo, vhi, rnd(3, i, 4)) + Math.sin(T * .05 + i * 1.7) * 3, K = A.keep;
let vis = 1;
if (K) for (let j = 0; j < K.length; j++) { const q = K[j], ox = Math.min(x + w, q[1]) - Math.max(x, q[0]), oy = Math.min(y + h / 2, q[3]) - Math.max(y - h / 2, q[2]); if (ox > 0 && oy > 0) vis *= 1 - clamp(ox * oy / (w * h) * 4, 0, 1); }
if (vis < .02) continue;
ctx.globalAlpha = co * (.78 + .22 * rnd(3, i, 5)) * ga * vis; ctx.drawImage(cloud(i % 3, tone), x, y - h / 2, w, h);
}
ctx.globalAlpha = 1;
}
// Burung melintas sesekali: tiap slot punya siklus (30-60 dtk) dan melintas satu kali per siklus; arah, tinggi, dan jumlah diundi per siklus.
function birds(ctx, A, style, ga) {
const T = E.t, B = A.bird, slots = E.light ? 1 : 3, vlo = Math.max(B.v0, E.vT + 10), vhi = Math.max(vlo + 20, B.v1), span = E.uR - E.uL;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (let i = 0; i < slots; i++) {
const per = 30 + 30 * rnd(4, i, 1), ph = rnd(4, i, 2) * per, cyc = Math.floor((T + ph) / per), age = T + ph - cyc * per, id = i + cyc * 64;
const sp = (style === 'bat' ? 110 : 85) + 55 * rnd(4, id, 3), pass = (span + 160) / sp;
if (age >= pass) continue;
const dir = rnd(4, id, 4) < .5 ? 1 : -1, n = 1 + Math.floor(rnd(4, id, 5) * (style === 'gull' ? 2.99 : 3.99)), base = lerp(vlo, vhi, rnd(4, id, 6));
const sz = (style === 'gull' ? 9 : style === 'bat' ? 8 : 6.5) * (.85 + .3 * rnd(4, id, 7)), x0 = dir > 0 ? E.uL - 80 + age * sp : E.uR + 80 - age * sp;
for (let j = 0; j < n; j++) {
const bx = x0 - dir * j * sz * 3.2, by = base + Math.sin(age * 1.3 + j * .9 + id) * (style === 'bat' ? 16 : 7) + j * sz * 1.4 * (j % 2 ? 1 : -1);
birdShape(ctx, style, bx, by, sz, Math.sin(T * (style === 'bat' ? 15 : 9) + j * 1.7 + i * 3.1), ga);
}
}
}
function birdShape(ctx, style, x, y, s, fl, ga) {
const a = fl * s * .55;
ctx.beginPath();
if (style === 'bat') {
ctx.moveTo(x, y + s * .15);
ctx.quadraticCurveTo(x - s * .7, y - s * (.9 + .5 * fl), x - s * 1.5, y - s * .1 * fl); ctx.quadraticCurveTo(x - s * .95, y + s * .05, x - s * .55, y + s * .5); ctx.quadraticCurveTo(x - s * .15, y + s * .2, x, y + s * .5);
ctx.quadraticCurveTo(x + s * .15, y + s * .2, x + s * .55, y + s * .5); ctx.quadraticCurveTo(x + s * .95, y + s * .05, x + s * 1.5, y - s * .1 * fl); ctx.quadraticCurveTo(x + s * .7, y - s * (.9 + .5 * fl), x, y + s * .15);
ctx.closePath(); ctx.globalAlpha = .88 * ga; ctx.fillStyle = '#080a1e'; ctx.fill();
} else {
ctx.moveTo(x - s * 1.25, y - a); ctx.quadraticCurveTo(x - s * .5, y - s * .7 + a * .3, x, y); ctx.quadraticCurveTo(x + s * .5, y - s * .7 + a * .3, x + s * 1.25, y - a);
if (style === 'gull') { ctx.globalAlpha = .4 * ga; ctx.strokeStyle = '#3d5568'; ctx.lineWidth = s * .42; ctx.stroke(); ctx.globalAlpha = .95 * ga; ctx.strokeStyle = '#fbfdff'; ctx.lineWidth = s * .26; ctx.stroke(); }
else { ctx.globalAlpha = .8 * ga; ctx.strokeStyle = '#28323f'; ctx.lineWidth = Math.max(1.3, s * .24); ctx.stroke(); }
}
ctx.globalAlpha = 1;
}
// Kilau air (garis silang kecil, satu goresan) dan perahu layar kecil yang melintas pelan (Azure Harbor); Moonrise hanya kilau di sungai.
function water(ctx, A, ga) {
const Wt = A.water, T = E.T, n = Math.round((E.light ? 12 : 26) * (Wt.dim || 1));
ctx.globalCompositeOperation = 'lighter'; ctx.strokeStyle = E.night ? 'rgb(190,214,255)' : E.wx === 'senja' ? 'rgb(255,214,170)' : 'rgb(236,250,255)'; ctx.lineWidth = 1.2; ctx.lineCap = 'round'; ctx.globalAlpha = .85 * ga * (E.night ? .75 : 1);
ctx.beginPath();
for (let i = 0; i < n; i++) {
const per = 1.5 + 2.4 * rnd(5, i, 1), ph = rnd(5, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, b = Math.pow(Math.sin(age * Math.PI), 3);
if (b < .08) continue;
const u = lerp(Wt.u0, Wt.u1, rnd(5, i + c * 64, 3)), v = lerp(Wt.v0, Wt.v1, rnd(5, i + c * 64, 4)), s = 1 + 5 * b;
if (Wt.cu && u > Wt.cu && v < Wt.cv) continue; // sudut dermaga bukan air
ctx.moveTo(u - s, v); ctx.lineTo(u + s, v); ctx.moveTo(u, v - s * .7); ctx.lineTo(u, v + s * .7);
}
ctx.stroke(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
const B = A.boat;
if (B && !E.calm && (E.wx === 'cerah' || E.wx === 'senja')) {
const per = 120, c = Math.floor((E.t + 23) / per), age = E.t + 23 - c * per, dur = 80;
if (age < dur) {
const dir = c % 2 ? -1 : 1, p = age / dur, x = dir > 0 ? lerp(B.u0, B.u1, p) : lerp(B.u1, B.u0, p);
push(ctx); ctx.translate(x, B.v + Math.sin(E.t * 1.1) * 1.4); ctx.rotate(Math.sin(E.t * .8) * .03); ctx.scale(dir, 1); ctx.globalAlpha = Math.min(1, p * 8, (1 - p) * 8) * ga;
ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(-26, 8); ctx.lineTo(-58, 10); ctx.moveTo(-22, 10); ctx.lineTo(-48, 13); ctx.stroke(); // jejak air
ctx.fillStyle = '#3b2c25'; ctx.beginPath(); ctx.moveTo(-24, 0); ctx.lineTo(24, 0); ctx.lineTo(17, 8); ctx.lineTo(-17, 8); ctx.closePath(); ctx.fill(); // lambung
ctx.fillStyle = '#f2e8cf'; ctx.beginPath(); ctx.moveTo(1, -2); ctx.lineTo(1, -38); ctx.lineTo(21, -4); ctx.closePath(); ctx.fill(); // layar utama
ctx.fillStyle = '#e2d3b0'; ctx.beginPath(); ctx.moveTo(-2, -4); ctx.lineTo(-2, -30); ctx.lineTo(-17, -4); ctx.closePath(); ctx.fill(); // layar depan
ctx.fillStyle = '#4a3a30'; ctx.fillRect(-.5, -40, 1.6, 40); // tiang
pop(ctx);
}
}
}
function dust(ctx, A, k, ga) { // debu cahaya melayang pelan (Sunspire emas, Elderwood hijau keemasan)
const D = A.dust, n = Math.round(D.n * (E.light ? .5 : 1)), T = E.T, spr = glow(D.col), span = E.uR - E.uL, v0 = Math.max(D.v0, E.vT + 10), v1 = Math.max(v0 + 30, Math.min(D.v1, E.vB - 10));
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < n; i++) {
const x = E.uL + fract((rnd(6, i, 1) * span + T * (2 + 3 * rnd(6, i, 2))) / span) * span, y = lerp(v0, v1, rnd(6, i, 3)) + Math.sin(T * (.2 + .3 * rnd(6, i, 4)) + rnd(6, i, 5) * TAU) * 14;
const tw = .5 + .5 * Math.sin(T * (.6 + .9 * rnd(6, i, 6)) + rnd(6, i, 7) * TAU), s = 6 + 9 * rnd(6, i, 8);
ctx.globalAlpha = (.12 + .38 * tw) * ga * k; ctx.drawImage(spr, x - s / 2, y - s / 2, s, s);
}
ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
}
const LEAF = ['#7fb84a', '#a6c95a', '#d7c05a', '#5f9d4a', '#c9a24a', '#f4a9c7']; // warna daun dan kelopak (indeks 5 = kelopak merah muda) yang jatuh pelan di Elderwood
function leaves(ctx, A, ga) {
const T = E.T, n = Math.round(A.leaves.n * (E.light ? .55 : 1)), span = E.uR - E.uL;
for (let i = 0; i < n; i++) {
const per = 12 + 10 * rnd(7, i, 1), ph = rnd(7, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, id = i + c * 64;
const x = E.uL + rnd(7, id, 3) * span + Math.sin(T * (.6 + .8 * rnd(7, id, 4)) + rnd(7, id, 5) * TAU) * 28 + age * per * (-8 + 16 * rnd(7, id, 6)), y = lerp(E.vT - 16, E.gy + 28, age);
const flip = Math.abs(Math.cos(T * (1.2 + 1.2 * rnd(7, id, 7)) + rnd(7, id, 8) * TAU)), sz = 4.5 + 4 * rnd(7, id, 9), ci = Math.floor(rnd(7, id, 10) * LEAF.length);
ctx.globalAlpha = Math.min(1, age * 10) * Math.min(1, (1 - age) * 7) * .9 * ga; ctx.fillStyle = LEAF[ci];
ctx.beginPath(); ctx.ellipse(x, y, ci === 5 ? sz * .75 : sz, Math.max(.8, sz * .42 * flip), T * (.7 + 1.1 * rnd(7, id, 11)) + rnd(7, id, 12) * TAU, 0, TAU); ctx.fill();
}
ctx.globalAlpha = 1;
}
function fireflies(ctx, A, ga) {
const n = Math.round(((A.fire && A.fire.n) || 12) * (E.light ? .5 : 1)), T = E.T, spr = glow('fly'), span = E.uR - E.uL, v0 = Math.max(E.vT + 40, E.gy - 280), v1 = Math.max(v0 + 40, Math.min(E.vB - 20, E.gy + 20));
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < n; i++) {
const x = E.uL + rnd(8, i, 1) * span + Math.sin(T * (.22 + .3 * rnd(8, i, 3)) + rnd(8, i, 4) * TAU) * 30 + Math.sin(T * .09 + rnd(8, i, 5) * TAU) * 44;
const y = lerp(v0, v1, rnd(8, i, 2)) + Math.cos(T * (.27 + .3 * rnd(8, i, 6)) + rnd(8, i, 7) * TAU) * 18;
const bl = .5 + .5 * Math.sin(T * (.7 + 1.1 * rnd(8, i, 8)) + rnd(8, i, 9) * TAU), a = smooth(clamp((bl - .2) / .6, 0, 1)), s = 14 + 14 * rnd(8, i, 10);
if (a < .03) continue;
ctx.globalAlpha = a * .9 * ga; ctx.drawImage(spr, x - s / 2, y - s / 2, s, s); ctx.globalAlpha = a * ga; ctx.drawImage(spr, x - 3, y - 3, 6, 6);
}
ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
}
function shooting(ctx, ga) { // bintang jatuh sesekali (tiap 31 dtk, 0,9 dtk)
const per = 31, c = Math.floor((E.t + 9) / per), age = E.t + 9 - c * per;
if (age > .9) return;
const p = age / .9, dx = -330, dy = 190, hx = lerp(E.uL + 100, E.uR - 200, rnd(9, c, 1)) + dx * p, hy = E.vT + 20 + 70 * rnd(9, c, 2) + dy * p, tx = hx - dx * .22, ty = hy - dy * .22;
const g = ctx.createLinearGradient(tx, ty, hx, hy); g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(255,255,255,.95)');
ctx.globalAlpha = Math.sin(p * Math.PI) * ga; ctx.strokeStyle = g; ctx.lineWidth = 1.6; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(hx, hy); ctx.stroke(); ctx.globalAlpha = 1;
}
// Hujan: garis miring, satu goresan per lapisan; posisi = fungsi waktu (membungkus di tepi layar). far = di belakang petarung, dekat = di depan.
function rain(ctx, far, k) {
const vw = E.vw, vh = E.vh, T = E.T, n = Math.round((far ? 64 : 48) * (E.light ? .5 : 1) * k), spd = (far ? .85 : 1.25) * vh, len = (far ? .03 : .052) * vh, sl = -.2, m = Math.hypot(sl, 1), ux = sl / m, uy = 1 / m;
const PW = vw + 80, PH = vh + len + 20, salt = far ? 11 : 12;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const x = fract(rnd(salt, i, 1) + T * spd * ux / PW) * PW - 40, y = fract(rnd(salt, i, 2) + T * spd * uy / PH) * PH - len;
ctx.moveTo(x, y); ctx.lineTo(x + ux * len, y + uy * len);
}
ctx.lineWidth = far ? 1 : 1.3; ctx.strokeStyle = far ? 'rgba(210,225,240,.3)' : 'rgba(225,238,250,.42)'; ctx.lineCap = 'butt'; ctx.stroke(); // garis tipis tanpa ujung bulat: jauh lebih murah dirasterisasi
}
function splashes(ctx, ga) { // riak kecil di lantai (koordinat dunia)
const n = E.light ? 8 : 16, T = E.T, depth = clamp(E.wB - E.gy - 10, 8, 86);
ctx.lineWidth = 1.4; ctx.strokeStyle = 'rgb(225,238,250)';
for (let i = 0; i < n; i++) {
const per = .5 + .45 * rnd(13, i, 1), ph = rnd(13, i, 2) * per, c = Math.floor((T + ph) / per), age = (T + ph) / per - c, id = i + c * 64, r = 2 + 9 * age;
ctx.globalAlpha = (1 - age) * (1 - age) * .5 * ga; ctx.beginPath(); ctx.ellipse(lerp(E.wL, E.wR, rnd(13, id, 3)), E.gy + 6 + rnd(13, id, 4) * depth, r, r * .32, 0, 0, TAU); ctx.stroke();
}
ctx.globalAlpha = 1;
}
// Pita kabut: ubin selebar layar dibuat sekali (dari tekstur dasar) lalu digambar 1:1 pada x bulat dan digeser pelan (koordinat layar). which: 0 belakang bawah, 1 belakang atas, 2 depan.
const FOGB = [[-.27, .36, .5, 14, 0], [-.54, .3, .3, -9, 1], [-.1, .24, .2, 22, 2]], FOGT = [null, null, null];
function fogTile(which, tone, v, tw, th) {
const key = tone + v + ':' + tw + 'x' + th, e = FOGT[which];
if (e && e.key === key) return e.c;
const c = document.createElement('canvas'); c.width = tw; c.height = th; c.getContext('2d').drawImage(fogTex(tone, v), 0, 0, tw, th); FOGT[which] = { key, c }; return c;
}
function fogBand(ctx, which, k) {
const f = FOGB[which], tone = E.A.night ? 'night' : E.wx === 'hujan' ? 'gray' : 'day', th = Math.max(8, Math.round(f[1] * E.vh)), tw = Math.max(64, Math.round(E.vw)), top = Math.round(E.oy + E.gy * E.sc + E.shy + f[0] * E.vh);
const tile = fogTile(which, tone, f[4], tw, th);
ctx.globalAlpha = clamp(f[2] * k, 0, 1);
for (let x = -Math.floor(fract((E.T * f[3] * E.sc) / tw + .13 * which) * tw); x < E.vw; x += tw) ctx.drawImage(tile, x, top);
ctx.globalAlpha = 1;
}
function spotlight(ctx, P, k, ga) { // genangan cahaya di lantai yang mengikuti petarung (dunia; source-over supaya murah)
const g = window.__game, hx = g && g.hero && g.hero.x, dx = g && g.dummy && g.dummy.x, mid = typeof hx === 'number' && typeof dx === 'number' && isFinite(hx + dx) ? (hx + dx) / 2 : 640;
E.spot += (mid - E.spot) * Math.min(1, E.dt * 3);
push(ctx); ctx.translate(E.ox + E.shx, E.oy + E.shy); ctx.scale(E.sc, E.sc);
ctx.globalAlpha = clamp((P.spot === 'warm' ? .5 : .62) * k * (E.A.night ? .55 : 1) * ga, 0, 1); ctx.drawImage(spotSpr(P.spot), E.spot - 300, E.gy - 62, 600, 150);
pop(ctx);
}
// ---- Lapisan depan (layar penuh) ----
function drawFront(ctx) {
const A = E.A, P = E.P, k = E.wk;
push(ctx); ctx.imageSmoothingEnabled = true;
crowd(ctx, A);
if (P.fog && k >= .75 && !E.light) fogBand(ctx, 2, k);
if (P.rain) rain(ctx, false, k);
if (P.bolt && !E.light && !E.calm) { // kilat lembut sesekali (tiap 19 dtk, dua kedipan singkat, redup)
const age = E.t + 4 - Math.floor((E.t + 4) / 19) * 19, f = age < .12 ? (1 - age / .12) * .16 : age > .2 && age < .3 ? (1 - (age - .2) / .1) * .1 : 0;
if (f > 0) { ctx.globalAlpha = f; ctx.fillStyle = 'rgb(205,220,255)'; ctx.fillRect(0, 0, E.vw, E.vh); ctx.globalAlpha = 1; }
}
pop(ctx);
}
// Siluet penonton di tepi bawah layar: satu path untuk semua badan per baris. Melompat dan mengangkat tangan menurut semangat C.ex, bertepuk saat kombo besar berakhir.
const MX = new Float32Array(64), MY = new Float32Array(64), MS = new Float32Array(64), MH = new Uint8Array(64), HX = new Float32Array(64), HY = new Float32Array(64);
function crowd(ctx, A) {
const cr = A.crowd || ARENA._.crowd, u = E.sc, light = E.light, calm = E.calm, t = calm ? 0 : E.t, ex = calm ? 0 : C.ex, clap = calm ? 0 : C.clap, holder = cr.acc === 'flag' || cr.acc === 'lantern';
const n = clamp(Math.round(E.vw / (62 * u) * (light ? .7 : 1)), 6, 36), step = E.vw / n, ar = smooth(clamp(ex * 1.7, 0, 1)), cw = clap > 0 ? smooth(Math.min(1, clap * 2)) : 0;
for (let row = light ? 0 : 1; row >= 0; row--) { // baris belakang (1, lebih tinggi di layar) lebih dulu, lalu baris depan (0)
const back = row === 1, by = E.vh + (back ? -8 : 5) * u, sz0 = back ? .8 : 1;
for (let i = 0; i < n; i++) {
const id = i + row * 64, f = 2 + 1.5 * rnd(14, id, 5), ph = rnd(14, id, 6) * TAU;
const jump = ex > .02 ? ex * (8 + 10 * rnd(14, id, 7)) * u * Math.abs(Math.sin(t * f * Math.PI + ph)) : 0;
MX[i] = (i + .5 + (rnd(14, id, 3) - .5) * .7 + (back ? .5 : 0)) * step; MY[i] = by - jump + Math.sin(t * .8 + ph) * .8 * u; MS[i] = sz0 * (.9 + .24 * rnd(14, id, 4)) * u * 1.1;
MH[i] = rnd(14, id, 8) < (cr.acc === 'hat' ? .4 : .3) ? 1 : 0; // pembawa aksesori (topi kerucut, bendera, lentera)
}
ctx.globalAlpha = back ? .74 : .94; ctx.fillStyle = cr.tone; ctx.strokeStyle = cr.tone;
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x + 21 * s, y - 9 * s); ctx.ellipse(x, y - 9 * s, 21 * s, 15 * s, 0, 0, TAU); ctx.moveTo(x + 11 * s, y - 34 * s); ctx.arc(x, y - 34 * s, 11 * s, 0, TAU); }
ctx.fill();
if (cr.acc === 'hat') { // topi kerucut
ctx.beginPath();
for (let i = 0; i < n; i++) if (MH[i]) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x - 19 * s, y - 41 * s); ctx.lineTo(x, y - 62 * s); ctx.lineTo(x + 19 * s, y - 41 * s); ctx.closePath(); }
ctx.fill();
}
// Lengan: terangkat saat bersemangat, merapat di depan dada saat bertepuk. Pembawa bendera/lentera menahan satu tangan (kanan) walau penonton tenang.
ctx.beginPath(); ctx.lineWidth = 7 * sz0 * u; ctx.lineCap = 'round';
for (let i = 0; i < n; i++) {
const hold = holder && MH[i] === 1;
if (ar < .03 && !hold) continue;
const x = MX[i], y = MY[i], s = MS[i];
for (let d = -1; d <= 1; d += 2) {
if (d < 0 && ar < .03) continue;
const hx = x + d * ((22 + 6 * Math.sin(t * 9 + i * 1.9 + d)) * (1 - cw) + (6 + 10 * (.5 + .5 * Math.sin(t * 15 + i))) * cw) * s, hy = y - (24 + 30 * ar * (1 - .55 * cw)) * s;
ctx.moveTo(x + d * 15 * s, y - 17 * s); ctx.lineTo(hx, hy);
if (d > 0) { HX[i] = hx; HY[i] = hy; }
}
}
ctx.stroke();
if (!light && !back) { // cahaya tepi di atas kepala
ctx.globalAlpha = .45; ctx.strokeStyle = cr.rim; ctx.lineWidth = 1.6 * u; ctx.beginPath();
for (let i = 0; i < n; i++) { const x = MX[i], y = MY[i], s = MS[i]; ctx.moveTo(x + 11 * s * Math.cos(3.5), y - 34 * s + 11 * s * Math.sin(3.5)); ctx.arc(x, y - 34 * s, 11 * s, 3.5, 5.9); }
ctx.stroke();
}
if (!back && holder) accessories(ctx, cr, n, u, t);
}
ctx.globalAlpha = 1;
}
function accessories(ctx, cr, n, u, t) { // bendera kecil atau lentera di tangan kanan pembawa
for (let i = 0; i < n; i++) {
if (!MH[i]) continue;
const s = MS[i], hx = HX[i], hy = HY[i];
if (cr.acc === 'flag') {
const sw = Math.sin(t * 5 + i) * 3 * s, tx = hx + sw * .3;
ctx.globalAlpha = .95; ctx.strokeStyle = cr.tone; ctx.lineWidth = 2 * u; ctx.beginPath(); ctx.moveTo(hx, hy + 4 * s); ctx.lineTo(tx, hy - 30 * s); ctx.stroke();
ctx.fillStyle = cr.flags[i % cr.flags.length]; ctx.beginPath(); ctx.moveTo(tx, hy - 30 * s); ctx.lineTo(tx + 17 * s + sw, hy - 25 * s); ctx.lineTo(tx, hy - 19 * s); ctx.closePath(); ctx.fill();
} else {
const g = 34 * s; ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = .9; ctx.drawImage(glow('warm'), hx - g / 2, hy - 8 * s - g / 2, g, g); ctx.globalCompositeOperation = 'source-over';
}
}
}
// ---- Menu: baris Cuaca di layar pilih arena ----
let syncRow = null;
function decorate(root, screen) {
try {
if (screen !== 'arena' || !root || root.querySelector('.arena-row')) return;
const btn = root.querySelector('.start-battle'); if (!btn) return;
const row = document.createElement('div'), box = document.createElement('div');
row.className = 'arena-row'; box.className = 'arena-weather'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Cuaca arena');
box.innerHTML = '<span class="eyebrow">CUACA <em class="aw-note"></em></span><div class="aw-chips">' + PICKS.map(id => '<button type="button" data-w="' + id + '">' + NAME[id] + '</button>').join('') + '</div>';
btn.before(row); row.append(box, btn);
const stage = root.querySelector('.arena-tile.selected')?.dataset.stage || 'bellora', note = box.querySelector('.aw-note');
const sync = () => {
const off = S.fx === 'off';
box.querySelectorAll('button[data-w]').forEach(b => { const on = b.dataset.w === S.pick; b.classList.toggle('selected', on); b.setAttribute('aria-pressed', String(on)); b.disabled = off; });
note.textContent = off ? 'Efek latar dimatikan di Pengaturan' : S.pick === 'auto' ? 'Otomatis: ' + autoLabel(stage) : 'Tampilan saja, tanpa memengaruhi pertarungan';
};
box.addEventListener('click', ev => { const b = ev.target.closest('button[data-w]'); if (!b || b.disabled) return; S.pick = b.dataset.w; sync(); });
syncRow = sync; sync();
} catch (err) { console.error('[arena] menu:', err); }
}
// ---- Pengaturan: baris Efek latar ----
function setFx(v) {
if (!FX.includes(v)) return false;
S.fx = v; save(); const sel = document.getElementById('arena-fx'); if (sel) sel.value = v;
try { syncRow?.(); } catch (_) { /* baris menu sudah dilepas */ }
return true;
}
function mountSettings() {
const panel = document.getElementById('settings-panel'); if (!panel || panel.querySelector('#arena-fx-row')) return;
const row = document.createElement('label'); row.className = 'setting-row'; row.id = 'arena-fx-row';
row.innerHTML = '<span>Efek latar<small>Awan, burung, cuaca, dan penonton di belakang petarung. Pilih Ringan atau Mati bila game terasa berat.</small></span><select id="arena-fx"></select>';
const sel = row.querySelector('select');
for (const id of FX) { const o = document.createElement('option'); o.value = id; o.textContent = FXNAME[id] + (id === DEF_FX ? ' (bawaan)' : ''); sel.append(o); }
sel.value = S.fx; sel.addEventListener('change', () => setFx(sel.value));
(panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(row);
}
// Cuaca per pertandingan: menu.js mengirim {weather} lewat startMatch; pembungkus ini mengingatnya hanya bila pertandingan benar-benar mulai.
// Arcade, Survival, tutorial, dan pemanggil lain tidak mengirim apa pun, jadi memakai Otomatis (deterministik dari arena).
function hookGame() {
const g = window.__game; if (!g || typeof g.startMatch !== 'function' || g.startMatch.__arena) return;
const sm = g.startMatch, wrapped = function (o) { const w = o && o.weather, ok = sm.apply(this, arguments); if (ok) S.active = PICKS.includes(w) ? w : 'auto'; return ok; };
wrapped.__arena = true; g.startMatch = wrapped;
}
load();
try { mountSettings(); } catch (err) { console.error('[arena] pengaturan:', err); }
const boot = () => { try { hookGame(); mountSettings(); } catch (err) { console.error('[arena] awal:', err); } };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
window.Arena = {
back, front, decorate, setFx, names: NAME,
get pick() { return S.pick; }, set pick(v) { if (PICKS.includes(v)) S.pick = v; syncRow?.(); },
get fx() { return S.fx; },
force(w) { S.forced = PICKS.includes(w) && w !== 'auto' ? w : null; return S.forced; },
state() { return { on: E.on, dead: E.dead, fails: E.fails, stage: E.stage, weather: E.wx, strength: E.wk, fx: S.fx, pick: S.pick, active: S.active, forced: S.forced, calm: E.calm, night: E.night, crowd: { ...C } }; }
};
})();

View File

@ -18,6 +18,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `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`.
- Latar hidup dan cuaca (RENCANA 6.1 dan 6.3): `arena.js` (`window.Arena`) + `arena.css`; tagnya di baris sendiri sebelum `</main>` di `index.html` (urutan muat bebas: kait dipasang saat DOMContentLoaded). Kait: `game.js` `draw()` memanggil `Arena.back(ctx, {viewW, viewH, scale, offsetX, offsetY, shakeX, shakeY, groundY, stage, time: realTime, menu, combo: comboHits, cin: cinematic})` tepat sesudah `drawBackground` dan `Arena.front(ctx)` sebagai pernyataan terakhir; `menu.js` memanggil `Arena.decorate(root, screen)` di akhir `render()` dan mengirim `weather: Arena.pick` di `startMatch`. Pembungkus `__game.startMatch` (dipasang `arena.js`) mengingat `options.weather` hanya bila pertandingan benar-benar mulai; tanpa opsi (Arcade, Survival, tutorial, uji) cuaca = Otomatis, yaitu `ARENA[stage].auto` (semua cerah kecuali Elderwood kabut tipis dan Moonrise malam; arena yang belum ada di tabel memakai entri `_`). Cuaca (hanya tampilan, tidak menyentuh mekanik): Cerah, Hujan, Kabut, Senja, Malam; preset di `WX`. Semua gerak adalah fungsi waktu tanpa status (hash bilangan bulat `rnd(salt, i, k)`, bukan `random()` mesin dan bukan `Date`), jadi `advance()` yang melompat jauh, jeda (realTime berhenti), dan hasil pertandingan aman. Lapisan belakang (kanvas, antara latar dan petarung): tint pengali, awan (memudar di kotak `keep` bangunan karena digambar di atas gambar), burung/camar/kelelawar, kilau air dan perahu (Harbor, sungai Moonrise), debu cahaya dan berkas cahaya (Sunspire), daun dan kelopak (Elderwood), kunang-kunang, bintang, bintang jatuh (malam), hujan jauh dan percikan lantai, kabut, sorotan lantai. Lapisan depan (kanvas, layar penuh, tidak ikut zoom kamera): siluet penonton di tepi bawah (semangat `C.ex` naik saat kombo 5/8/12 dan K.O. dari `Feel.ko.state`, bertepuk saat kombo >= 5 berakhir), kabut dan hujan dekat, kilat lembut. Vinyet dan kerudung warna depan adalah elemen DOM `#arena-vig` tepat di atas kanvas (nol biaya gambar). Biaya dijaga: tanpa vinyet berskala, blend besar memakai source-over, kabut berupa ubin siap-ukuran digambar 1:1, gradasi langit lewat strip 1x256; dengan rasterisasi perangkat lunak tiap operasi layar penuh terukur ms, jadi tambah efek besar dengan hati-hati. Pengaturan: baris "Efek latar" Penuh/Ringan/Mati disisipkan `arena.js` ke `#settings-panel` (`localStorage['konoha.arena.v1']`, bawaan Ringan bila `html.touch`); Mati = tidak ada yang digambar dan baris Cuaca di layar arena dimatikan. Layar arena: baris Cuaca (Otomatis/Cerah/Hujan/Kabut/Senja/Malam) disisipkan di sisi kiri tombol mulai (ruang yang tadinya kosong), jadi tinggi layar tidak berubah di 1280x720 dan 1024x520. Gerak dikurangi (`Access.motion` atau prefers-reduced-motion): waktu latar dibekukan, burung/perahu/daun/debu/bintang jatuh/kilat dan lompatan penonton dimatikan. Arena baru: tambah satu entri di `ARENA` (horizon, cloud, keep, crowd, dst; lihat komentar di atas tabel). Konsol: `Arena.state()`, `Arena.force('hujan')` (null melepas), `Arena.setFx('light')`; URL `?cuaca=malam` memaksa cuaca di semua pertandingan. Kegagalan gambar mematikan modul sendiri (loop game tidak terganggu). Belum ada skenario otomatis (mode ringan, diuji manual); `precache.js`/`files.json` perlu dibangun ulang oleh integrator agar `arena.js` dan `arena.css` ikut tersimpan offline.
- Slot misteri yang hidup (Tahap 3.7): lima petak `locked-N` di layar pilih karakter. Data `content/mystery.js` (`window.MYSTERY`, satu entri per petak menurut urutan; skema dan pedoman isi ada di komentar berkas itu), logika `mystery.js` (`window.Mystery`), gaya `mystery.css`; dimuat di `index.html` tepat sebelum `menu.js`. Tiap slot punya 3–4 petunjuk berurutan dengan syarat `need {type, n}` yang dibaca dari `Meta.save` (wins, matches, perfects, combo, survival, arcade, ach, eggs, pairs = `save.eggs['dialog:*']`, shop, coins; daftar `GOALS` di mystery.js; tahap `pairs` dilewati bila `window.DIALOGS.rahasia` tidak ada). Petak: siluet SVG sebaris (tubuh disalin dari `assets/menu/locked.svg`, benda khas muncul samar di tahap kedua terakhir dan jelas di tahap akhir) makin tajam lewat `--ms-p`, lencana `n/total`, tanda tanya berubah (`?` `??` `?!` `!?` `!`), titik BARU. Panel pratinjau (yang menampilkan sorotan: kiri saat memilih P1, kanan saat memilih lawan) memuat petunjuk terbuka, syarat petunjuk berikutnya dengan bilah progres, dan "Slot misteri n / total". Hook di menu.js: `Mystery.panel` (di `fighterPanel`), `decorate` (akhir `render()`), `preview` (di `updatePreview`: teks `#fighter-moves` dan `#roster-notice`), `notice` (di `confirm()` saat slot ditekan). Semua fungsi publik aman: bila gagal, tampilan lama slot terkunci dipakai. Petak tetap `data-fighter="locked-N"` karena easter egg `penasaran` (meta.js) membaca ketukan di atasnya. Semua petunjuk terbuka = pencapaian tersembunyi `penasaran_berat` (PENASARAN BERAT, +50 koin; dicek tiap layar pilih karakter dirender). Ingatan "sudah dilihat" untuk titik BARU dan toast ada di `localStorage['konoha.mystery.seen']` (bukan di `Meta.save`). Uji manual cepat: buka `?mystery=2` (atau `all`) untuk mempratinjau tahap 2 pada semua slot tanpa menyimpan atau membuka pencapaian; `Mystery.states()` di konsol meringkas kemajuan. Saat petarung sungguhan menggantikan satu slot, hapus entri slotnya dari `window.MYSTERY` supaya urutan tetap sama dengan petak `locked-N` yang tersisa.
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): `record.js` (`window.Record`) + `record.css`; tag di baris sendiri sebelum `</main>` di `index.html`, **game.js tidak diubah**. Kait lain hanya di `vs.js`: `ResultView.view` menambahkan `window.Record?.resultHtml(r)` di dalam `.rs-main` (toolbar `.rs-rec` yang melayang di pojok kanan atas kolom tengah, jadi tinggi layar hasil tidak bertambah) dan `ResultView.mount` memanggil `Record.mount(root)`. Keadaan permainan dibaca dari DOM tiap 150 ms (`poll`): `body.in-menu`, `#arena[data-mode|data-match-phase|data-character|data-opponent]` (diisi `updateHud` tiap frame), `dialog[open]`, `VS.active`/`Story.active`, `document.hidden`; `__game.startMatch` dibungkus hanya untuk memulai sesi baru seketika. **Perekam**: kanvas salinan <= 854 px lebar (640 di layar sentuh) diisi dari `#arena` ~30 kali/dtk oleh rAF sendiri (letterbox bila rasio jendela berubah), `captureStream()` -> `MediaRecorder` webm (vp8; vp9/webm cadangan), tanpa audio, 1,5 Mbps (0,9 di layar sentuh), `timeslice` 1 dtk. Potongan WebM tidak bisa dipangkas di depan (hanya potongan pertama memuat header dan keyframe), jadi dipakai **dua perekam bergantian**: perekam baru dimulai tiap 6 dtk waktu rekam, tiap perekam berhenti dan dibuang sesudah 12 dtk, klip = perekam tertua (6 sampai 12 dtk, satu berkas WebM utuh tanpa menyambung kontainer). Jeda/Pengaturan/layar VS/kartu cerita/tab tersembunyi = `pause()` (jam rekam ikut berhenti); menu atau pertandingan dibatalkan = dibuang; latihan tidak direkam. Saat `matchPhase` menjadi `complete` (atau `resultHtml` lebih dulu, karena layar hasil dipasang pada tick yang sama dengan event itu) perekam tertua dihentikan jadi klip dan bingkai terakhir arena dibekukan untuk tombol FOTO di layar hasil; klip dan bingkai dibuang saat pertandingan berikutnya mulai. Layar hasil: SIMPAN KLIP (`konoha-fighting-<karakter>-vs-<lawan>.webm`), TONTON ULANG (`<dialog id=rec-replay>` modal, Esc dan tombol B gamepad menutup), FOTO, BAGIKAN (`navigator.share` dengan berkas, selain itu unduh; PNG dibuat sinkron lewat `toDataURL` agar masih dalam gestur klik). HUD: tombol kamera di `.match-actions` (disembunyikan di lebar <= 620 px) dan penanda REC di bawah barisnya; menu jeda: baris TANGKAP LAYAR dan BAGIKAN; pintasan **F9** (arena, jeda, layar hasil). Pengaturan: dua baris disisipkan setelah slider Suara (Rekam ulang K.O. = saklar utama; Indikator REC = penanda saja). Simpanan `localStorage['konoha.record.v1'] = {on, badge}`. Bila `MediaRecorder`/webm/`captureStream` tidak ada atau terjadi galat, klip dan baris Pengaturan hilang tanpa suara (tangkapan layar tetap jalan). Konsol: `Record.state()`, `Record.set(false)`. Belum ada skenario otomatis (mode ringan, diuji manual); nilai di bagian atas record.js (`PERIOD`, `FPS`, bitrate di `start`) bisa disetel.

View File

@ -2826,6 +2826,7 @@
ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy);
}
drawBackground(shakeX, shakeY);
window.Arena?.back(ctx, { viewW, viewH, scale, offsetX, offsetY, shakeX, shakeY, groundY: CONFIG.groundY, stage: stageId, time: realTime, menu: menuOpen, combo: comboHits, cin: cinematic }); // latar hidup dan cuaca (arena.js, RENCANA 6.1/6.3)
if (bwx.dim > .004) { ctx.fillStyle = `rgba(3,8,5,${bwx.dim})`; ctx.fillRect(-viewW, -viewH, viewW * 3, viewH * 3); }
ctx.save(); ctx.translate(offsetX + shakeX * scale, offsetY + shakeY * scale); ctx.scale(scale, scale);
// Light motes provide life without moving the fixed stage or camera.
@ -2836,6 +2837,7 @@
drawBrowoGiant(true); drawCfFront();
window.Hazard?.draw(ctx, 'front'); // bahaya arena: genting, akar, ombak, petir, serpihan (efek dan angka damage mesin digambar di atasnya)
drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); Feel.ko.drawOverlay(ctx, viewW, viewH); updateHud();
window.Arena?.front(ctx); // penonton, hujan dekat, kilat (arena.js)
}
function fighterInfo(id,form) {
if(id==='fenr'){const wolf=form==='wolf';return {name:'FENR',cls:wolf?'WEREWOLF':'DEMI-HUMAN',deck:wolf?'FENR · FERAL':'FENR',title:'WOLF RANGER',portrait:'assets/fenr/ui/portrait-'+(wolf?'wolf':'human')+'.webp',names:F.kits[wolf?'wolf':'human'].names,icons:['basic','skill1','skill2','ultimate'].map((s,i)=>'assets/fenr/ui/icon-'+(i===3?'ultimate':(wolf?'wolf':'human')+'-'+s)+'.webp')};}

View File

@ -27,6 +27,8 @@
<dialog id="pause-panel" aria-labelledby="pause-title"><div class="pause-main"><div class="dialog-kicker">KONOHA FIGHTING / JEDA</div><h2 id="pause-title">JEDA</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></div><details class="move-list" id="move-list"><summary>Daftar gerakan</summary><div class="move-body" id="move-list-body"></div></details></dialog>
<!-- Statistik pertandingan (RENCANA 5.5): gaya dan logika di berkas sendiri; MatchStats dipanggil lewat window.MatchStats?. dari game.js, meta.js, vs.js -->
<link rel="stylesheet" href="stats.css"><script src="stats.js"></script>
<!-- Latar hidup dan cuaca (RENCANA 6.1 dan 6.3): arena.js dipanggil lewat window.Arena?. dari game.js (draw) dan menu.js (layar arena); memasang kaitnya sendiri saat DOMContentLoaded, jadi urutan muat bebas -->
<link rel="stylesheet" href="arena.css"><script src="arena.js"></script>
<!-- Latihan lanjutan (RENCANA 6.5) dan rival adaptif (RENCANA 6.6): gaya dan logika di berkas sendiri; dipanggil lewat window.Training?. dan window.AIAdapt?. dari game.js -->
<link rel="stylesheet" href="training.css"><script src="ai.js"></script><script src="training.js"></script>
<!-- Rekam ulang K.O. dan tangkapan layar (RENCANA 7.3 dan 7.4): record.js memasang tombol kamera di HUD, baris di jeda dan Pengaturan; kait kecil di vs.js (layar hasil) -->

View File

@ -77,6 +77,7 @@
bind();attachBackground();window.Brand?.decorate(root,screen);window.Mystery?.decorate(root,screen,hover,lockedSlots);
window.Tutorial?.decorate(root,screen); // tombol TUTORIAL kecil di pojok kiri atas layar utama (tutorial.js)
window.Challenge?.decorate(root,screen,{player,enemy,stage,level,mode,p2,runKind,result}); // tombol KODE TANTANGAN, SALIN KODE, catatan About (challenge.js)
window.Arena?.decorate?.(root,screen); // baris Cuaca di layar arena, di sisi kiri tombol mulai (arena.js)
}
function updatePreview(play=true){
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));
@ -93,7 +94,7 @@
lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;if(runKind){const k=runKind;runKind=null;window.Meta?.startRun(k,player);document.querySelector('#arena').focus();return;}step=1;hover=ids.find(x=>x!==player)||player;}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000);
}
function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();}
function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,vs:mode==='versus'?{p2}:false,codeRule:window.Challenge?.pending?.(mode,p2)})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}}
function start(){window.Meta?.cancelRun?.();if(game.startMatch({player,enemy,stage,level,mode,weather:window.Arena?.pick,vs:mode==='versus'?{p2}:false,codeRule:window.Challenge?.pending?.(mode,p2)})){if(p2){game.setP2(true);window.Meta?.event('twoplayer');window.Meta?.toast('P2: ← → jalan, ↑ lompat, N serang, M / , / . = skill 1 / 2 / ultimate');}document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}}
// Opens the shop/locker/collection. If meta.js is missing (stale cached index.html) it is loaded on demand.
function openMeta(tab){
const go=()=>{try{window.Meta.open(tab);}catch(e){console.error(e);alert('Menu gagal dibuka: '+e.message);}};