Gabung wf/t6-arena: latar arena bergerak dan cuaca
This commit is contained in:
commit
0f343f0306
|
|
@ -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}}
|
||||
|
|
@ -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 } }; }
|
||||
};
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
2
game.js
2
game.js
|
|
@ -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')};}
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
3
menu.js
3
menu.js
|
|
@ -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);}};
|
||||
|
|
|
|||
Loading…
Reference in New Issue