From ce0848e1d8c5441659c63d9833b426fce795d393 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Thu, 1 Oct 2026 13:32:13 +0700 Subject: [PATCH] Mesin karakter data-driven (window.FIGHTERS): jurus dasar, empat jenis ultimate sinematik, pendaftaran otomatis di menu Karakter baru cukup berupa satu berkas assets//manifest.js: jurus (proyektil, gelombang, kawanan homing, dash, area, pilar, lemparan), ultimate (volley, stampede, hujan, pilar) dengan kamera zoom dan layar gelap, HUD, cut-in, dan roster terdaftar otomatis tanpa mengubah game.js per karakter. --- game.js | 232 +++++++++++++++++++++++++++++++++++++++++++++++++++----- menu.js | 2 + 2 files changed, 216 insertions(+), 18 deletions(-) diff --git a/game.js b/game.js index e6ec273..7042f17 100644 --- a/game.js +++ b/game.js @@ -17,8 +17,9 @@ const cooldownMax = { skill1:BALANCE.skill1.cooldown, skill2:BALANCE.skill2.cooldown, ultimate:BALANCE.ultimate.cooldown }; const skillNames = { skill1: 'AETHER BOLT', skill2: 'SEISMIC DRIVE', ultimate: 'HELIOS SQUADRON' }; const images = {}, F = window.Fenr, M = window.Mira && window.MIRA_MANIFEST ? window.Mira : null, C = window.Cora && window.CORA_MANIFEST ? window.Cora : null, N = window.Naja && window.NAJA_MANIFEST ? window.Naja : null, HD = window.Haldor && window.HALDOR_MANIFEST ? window.Haldor : null, Z = window.Zanni && window.ZANNI_MANIFEST ? window.Zanni : null, IS = window.Isolde && window.ISOLDE_MANIFEST ? window.Isolde : null, RH = window.Rhea && window.RHEA_MANIFEST ? window.Rhea : null, SO = window.Solan && window.SOLAN_MANIFEST ? window.Solan : null, NB = window.Nib && window.NIB_MANIFEST ? window.Nib : null, ED = window.Edda && window.EDDA_MANIFEST ? window.Edda : null, BW = window.Browo && window.BROWO_MANIFEST ? window.Browo : null; + const CF = window.FIGHTERS || {}; // data-driven fighters, see work/WORKFLOW.md // Kits that share one start/move/refund contract (mira.js, cora.js, naja.js, haldor.js, zanni.js, isolde.js, rhea.js, solan.js, nib.js, edda.js). - const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}), ...(BW ? { browo: BW } : {}) }; + const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}), ...(BW ? { browo: BW } : {}), ...Object.fromEntries(Object.entries(CF).map(([id, d]) => [id, cfKit(d)])) }; let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null; let selectedCharacter = 'arco', aiEnabled = !!F, fenrCutin = null, hudIdentity = ''; const Rules = window.MatchRules; @@ -88,7 +89,7 @@ let ultimateVoice = null, voiceActive = false, voiceSerial = 0, voicePlayToken = 0, voiceStarts = 0, voiceError = ''; try { if (typeof Audio !== 'undefined') { - for (const [kind, path] of [['arco', ULTIMATE_VOICE_PATH], ...(F ? [['fenr', FENR_VOICE_PATH]] : []), ...(M ? [['mira', MIRA_VOICE_PATH]] : []), ...(C ? [['cora', CORA_VOICE_PATH]] : []), ...(N ? [['naja', NAJA_VOICE_PATH]] : []), ...(HD ? [['haldor', HALDOR_VOICE_PATH]] : []), ...(Z ? [['zanni', ZANNI_VOICE_PATH]] : []), ...(IS ? [['isolde', ISOLDE_VOICE_PATH]] : []), ...(RH ? [['rhea', RHEA_VOICE_PATH]] : []), ...(SO ? [['solan', SOLAN_VOICE_PATH]] : []), ...(NB ? [['nib', NIB_VOICE_PATH]] : []), ...(ED ? [['edda', EDDA_VOICE_PATH]] : []), ...(BW ? [['browo', BROWO_VOICE_PATH]] : [])]) { + for (const [kind, path] of [['arco', ULTIMATE_VOICE_PATH], ...(F ? [['fenr', FENR_VOICE_PATH]] : []), ...(M ? [['mira', MIRA_VOICE_PATH]] : []), ...(C ? [['cora', CORA_VOICE_PATH]] : []), ...(N ? [['naja', NAJA_VOICE_PATH]] : []), ...(HD ? [['haldor', HALDOR_VOICE_PATH]] : []), ...(Z ? [['zanni', ZANNI_VOICE_PATH]] : []), ...(IS ? [['isolde', ISOLDE_VOICE_PATH]] : []), ...(RH ? [['rhea', RHEA_VOICE_PATH]] : []), ...(SO ? [['solan', SOLAN_VOICE_PATH]] : []), ...(NB ? [['nib', NIB_VOICE_PATH]] : []), ...(ED ? [['edda', EDDA_VOICE_PATH]] : []), ...(BW ? [['browo', BROWO_VOICE_PATH]] : []), ...Object.keys(CF).map(id => [id, 'assets/' + id + '/audio/' + id + '-ultimate.mp3'])]) { const media = new Audio(); media.preload = 'auto'; voiceBank[kind] = media; media.addEventListener('ended', () => { if (ultimateVoice !== media) return; voiceActive = false; applyAudioMix(); }); media.addEventListener('error', () => { if (ultimateVoice !== media) return; voiceError = 'audio-load-failed'; voiceActive = false; applyAudioMix(); }); @@ -366,6 +367,7 @@ if(selectedCharacter==='nib'){nibStrike(hero,NB.move('attack',index));return;} if(selectedCharacter==='edda'){eddaStrike(hero,ED.move('attack',index));return;} if(selectedCharacter==='browo'){browoStrike(hero,BW.move('attack',index));return;} + if(CF[selectedCharacter]){cfStrike(hero,CF[selectedCharacter],KITS[selectedCharacter].move('attack',index));return;} const reach = attackRange(index), dx = (dummy.x - hero.x) * hero.facing, y = emitter(`attack${index}`).y; effects.push({ type: 'slash', x: hero.x + hero.facing * 48, y: y + 5, life: .22, maxLife: .22, facing: hero.facing, index, color: index === 3 ? '#ffe4a5' : '#cffbf0' }); if (dx > -24 && dx < reach + 27 && Math.abs(dummy.y - hero.y) < 113) { @@ -390,6 +392,7 @@ if(selectedCharacter==='nib'){nibStrike(hero,action);return;} if(selectedCharacter==='edda'){eddaStrike(hero,action);return;} if(selectedCharacter==='browo'){browoStrike(hero,action);return;} + if(CF[selectedCharacter]){cfStrike(hero,CF[selectedCharacter],action);return;} const name = action.type, origin = emitter(name); if (name === 'skill1') { projectiles.push({ x: origin.x, y: origin.y, vx: hero.facing * 920, life: 1.6, facing: hero.facing }); effects.push({ type: 'muzzle', x: origin.x, y: origin.y, life: .25, maxLife: .25 }); spawnParticles(origin.x, origin.y, '#a9f8ef', 12, 140); } if (name === 'skill2') { const x = origin.x; effects.push({ type: 'slam', x, y: CONFIG.groundY - 2, life: .8, maxLife: .8 }); spawnParticles(x, CONFIG.groundY - 8, '#c7eafd', 40, 370); trauma = Math.min(1, trauma + .35); sound('heavy'); if (Math.abs(dummy.x - x) < BALANCE.skill2.radius && dummy.y > CONFIG.groundY - 125) hitDummy(BALANCE.skill2.damage, BALANCE.skill2.knockback, '#bfe9ff', hero.x, dummy.y - 65); } @@ -492,7 +495,7 @@ s.phase = s.t < P.cutinDuration ? 'cutin' : s.exploded < s.rockets.length ? 'barrage' : 'clearing'; if (s.t >= P.duration) { if (s.owner === 'enemy') enemyParade = null; else parade = null; } } - function startSummon(actor) { const id = actor === hero ? selectedCharacter : opponentCharacter; if (id === 'cora') startMurmuration(actor); else if (id === 'naja') startSerpent(actor); else if (id === 'haldor') startQuake(actor); else if (id === 'zanni') startFinale(actor); else if (id === 'isolde') startSkyfall(actor); else if (id === 'rhea') startOrrery(actor); else if (id === 'solan' || id === 'browo') startSunroar(actor); else if (id === 'nib') startDelivery(actor); else if (id === 'edda') startTortoise(actor); else startRocketParade(actor); } + function startSummon(actor) { const id = actor === hero ? selectedCharacter : opponentCharacter; if (id === 'cora') startMurmuration(actor); else if (id === 'naja') startSerpent(actor); else if (id === 'haldor') startQuake(actor); else if (id === 'zanni') startFinale(actor); else if (id === 'isolde') startSkyfall(actor); else if (id === 'rhea') startOrrery(actor); else if (id === 'solan' || id === 'browo' || CF[id]) startSunroar(actor); else if (id === 'nib') startDelivery(actor); else if (id === 'edda') startTortoise(actor); else startRocketParade(actor); } // CORA Night Murmuration: three raven passes sweep forward from behind her wings at three heights, // one 16-damage hit each (48 total), no global hit-stop. A pass hits once, when its lead raven crosses the target. function startMurmuration(actor = hero) { @@ -674,13 +677,14 @@ // along the floor (ground projectiles with `tall`, so a jump does not clear them), 16 each, 48 total, fading after // SO.roar.range px. SOLAN is free after the 0.5 s cast; a rival farther away than the range is safe. function startSunroar(actor = hero) { - const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, roars: 0, phase: 'cutin', kind: actor.bwRage ? 'tornado' : 'forest' }; + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, roars: 0, phase: 'cutin', kind: actor.bwRage ? 'tornado' : 'forest', cf: CF[actor === hero ? selectedCharacter : opponentCharacter] ? (actor === hero ? selectedCharacter : opponentCharacter) : null }; if (actor === hero) sunroar = group; else enemySunroar = group; cinematic = .9; announceTimer = 0; - startUltimateVoice((actor === hero ? selectedCharacter : opponentCharacter) === 'browo' ? 'browo' : 'solan', actor); + { const vid = actor === hero ? selectedCharacter : opponentCharacter; startUltimateVoice(CF[vid] || vid === 'browo' ? vid : 'solan', actor); } } function updateSunroar(dt, s) { if (!s) return; + if (s.cf) { updateCfUltimate(dt, s); return; } if ((s.owner === 'enemy' ? opponentCharacter : selectedCharacter) === 'browo') { (s.kind === 'tornado' ? updateTornado : updateForest)(dt, s); return; } const U = SO.roar, a = s.owner === 'enemy' ? dummy : hero; s.t += dt; @@ -694,12 +698,199 @@ s.phase = s.t < U.cutinDuration ? 'cutin' : s.roars < U.roars.length ? 'roaring' : 'fading'; if (s.t >= U.duration && !projectiles.some(p => p.asset === 'solan-roar' && p.owner === s.owner)) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } } + // ======== Data-driven fighters (window.FIGHTERS) ====================================================================== + // One table entry per character (assets//manifest.js). Skills are picked from a small library by `kind`: + // projectile | wave | homing | dash | area | pillar | lob (skill1 / skill2 / basic chain is melee) + // and the ultimate by `ultimate.kind`: volley | stampede | rain | pillars. All ultimates share the cinematic: the camera + // dives onto the caster under a dark screen, the rival is held, the beats land, then an optional finisher. See work/WORKFLOW.md. + const cfx = { ents: [], hazards: [], marks: [] }; + function cfKit(d) { + const move = (name, index = 1) => { + if (name === 'attack') return { name: `attack${index}`, type: 'attack', index, ...d.combo[index - 1] }; + if (name === 'ultimate') return { name: 'ultimate', type: 'ultimate', duration: d.balance.castTime, damage: 0, hitAt: 1 }; + const k = d.skills[name], m = { name, type: name, knockback: 160, duration: .6, ...k, hitAt: k.hitAt ?? .5 }; + if (k.kind === 'projectile' || k.kind === 'wave' || k.kind === 'homing' || k.kind === 'lob') { m.projectile = true; m.range = k.range || 560; m.reach = m.range; } + if (k.kind === 'dash') m.dash = k.dash; + if (k.kind === 'area' || k.kind === 'pillar') { m.area = true; m.reach = k.reach || ((k.ahead || 0) + (k.radius || 0)); } + return m; + }; + const start = (a, name, index = 1) => { + if (a.action || a.state === 'hurt' || a.state === 'down' || a.state === 'recover') return false; + if (name !== 'attack' && a.cooldowns[name] > 0) return false; + if (name !== 'attack') a.cooldowns[name] = d.balance.cooldowns[name]; + a.action = { ...move(name, index), t: 0, fired: false, queued: 0 }; a.state = a.action.name; a.stateTime = 0; return true; + }; + const refund = a => { for (const n of Object.keys(d.balance.cooldowns)) a.cooldowns[n] = Math.max(0, a.cooldowns[n] - d.balance.cooldowns[n] * (d.balance.basicRefund ?? .05)); }; + return { balance: d.balance, names: d.names, combo: d.combo, move, start, refund, def: d }; + } + function cfStrike(actor, def, move) { + const enemy = actor === dummy, target = enemy ? hero : dummy, id = def.id, em = def.metrics?.emitters?.[move.name], col = def.color || '#ffd36a'; + const hy = clamp(actor.y + (em?.y ?? -95), actor.y - 190, actor.y - 40), owner = enemy ? 'enemy' : 'player', key = a => id + '-' + a; + const hit = (dmg, kb, x, y, knockdown = false) => enemy ? window.__game.receiveHit(dmg) : hitDummy(dmg, kb, col, x, y, knockdown, true); + const k = move.kind; + if (k === 'projectile' || k === 'wave' || k === 'homing') { + const n = move.count || 1, ox = actor.x + actor.facing * Math.round((em?.x ?? 100) * .7); + for (let i = 0; i < n; i++) { + const off = (i - (n - 1) / 2) * (move.spread || 0), p = { x: ox, y: actor.y + (move.y ?? -95) + (k === 'homing' ? 0 : off), vx: actor.facing * move.speed, life: move.life || (move.range || 560) / move.speed, facing: actor.facing, owner, damage: move.damage, knockback: move.knockback, asset: key(move.asset), size: move.size || 150, color: col, tall: k === 'wave' ? (move.tall || 170) : move.tall, pass: !!move.pierce }; + if (k === 'homing') { p.vy = off - 60; p.vx = actor.facing * move.speed * .5; p.homing = true; p.speedH = move.speed; p.turn = move.turn || 5; p.tall = move.tall || 90; } + projectiles.push(p); + } + spawnParticles(ox, actor.y + (move.y ?? -95), col, 10, 160); sound('cast'); return; + } + if (k === 'lob') { + const T = .85, vx = clamp((target.x - actor.x) / T, -720, 720); + projectiles.push({ x: actor.x + actor.facing * 30, y: actor.y - 110, vx, vy: -(move.rise || 700), cfG: move.gravity || 1800, cfLand: { asset: move.puddle, radius: move.radius || 110, dps: move.dps || 6, life: move.puddleLife || 3.5, burst: move.damage }, life: 3, facing: vx >= 0 ? 1 : -1, owner, damage: Math.round(move.damage * .5), knockback: move.knockback, asset: key(move.asset), size: move.size || 90, color: col, tall: 80, pass: true }); + sound('cast'); return; + } + let hitX = null, inside; + if (k === 'dash') { + effects.push({ type: 'cf-fx', asset: key(move.asset || 'impact'), x: actor.x + actor.facing * 60, y: actor.y - 80, facing: actor.facing, life: .3, maxLife: .3, size: move.size || 200 }); + const dx = (target.x - actor.x) * actor.facing; inside = dx > -22 && dx < (move.reach || 120) + 27 && Math.abs(target.y - actor.y) <= 115; hitX = actor.x; + } else if (k === 'area' || k === 'pillar') { + hitX = actor.x + actor.facing * (move.ahead || 0); + if (k === 'pillar') effects.push({ type: 'cf-pillar', img: 'fx-' + key(move.asset), x: hitX, y: CONFIG.groundY, life: .9, maxLife: .9, h: move.h || 330 }); + else effects.push({ type: 'cf-fx', asset: key(move.asset || 'impact'), x: hitX, y: actor.y - 90, facing: 1, life: .5, maxLife: .5, size: move.size || (move.radius || 120) * 2.4 }); + effects.push({ type: 'cf-fx', asset: key('impact'), x: hitX, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); + dust(hitX, 14); spawnParticles(hitX, CONFIG.groundY - 10, col, 14, 280, 'dust'); trauma = Math.min(1, trauma + .25); + inside = Math.abs(target.x - hitX) < (move.radius || 100) && Math.abs(target.y - actor.y) <= (move.vy || 150); + } else { // basic chain: a plain melee hit in front + effects.push({ type: 'slash', x: actor.x + actor.facing * 55, y: hy + 5, life: .24, maxLife: .24, facing: actor.facing, index: move.index, color: col }); + if (move.index === 3) { dust(actor.x + actor.facing * (em?.x ?? 95), 12); trauma = Math.min(1, trauma + .15); } + const dx = (target.x - actor.x) * actor.facing; inside = dx > -22 && dx < move.reach + 27 && Math.abs(target.y - actor.y) <= 115; + } + if (!inside) { sound(k && k !== 'dash' ? 'heavy' : 'step'); return; } + const connected = hit(move.damage, move.knockback, hitX ?? actor.x, hy, !!move.knockdown); + if (connected && move.type === 'attack') { + if (enemy) KITS[id].refund(dummy); + else for (const name of Object.keys(cooldownMax)) { if (hero.cooldowns[name] > 0) rechargePulse[name] = .22; hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - cooldownMax[name] * BALANCE.basicCooldownRefund); } + } + if (connected) sound(k || move.index === 3 ? 'heavy' : 'hit'); + } + // Lob landing: a burst on the spot and a puddle that burns anyone standing in it. + function cfLand(p) { + const L = p.cfLand, id = p.asset.split('-')[0], target = p.owner === 'enemy' ? hero : dummy; + cfx.hazards.push({ x: p.x, owner: p.owner, t: 0, life: L.life, radius: L.radius, dps: L.dps, img: 'fx-' + id + '-' + L.asset, acc: 0, shown: 0 }); + effects.push({ type: 'cf-fx', asset: id + '-impact', x: p.x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); + spawnParticles(p.x, CONFIG.groundY - 12, p.color, 14, 260, 'dust'); trauma = Math.min(1, trauma + .2); sound('heavy'); + if (bwAlive(target) && Math.abs(target.x - p.x) < L.radius && target.y > CONFIG.groundY - 120) dotDamage(target, L.burst, p.color); + } + function updateCf(dt) { + // Entities (ultimate volleys and stampedes): move, then land one hit each on the rival they cross. + for (let n = cfx.ents.length - 1; n >= 0; n--) { + const e = cfx.ents[n], target = e.owner === 'enemy' ? hero : dummy; + e.t += dt; e.life -= dt; + if (e.homing && bwAlive(target)) { const want = Math.atan2(target.y - 90 - e.y, target.x - e.x), cur = Math.atan2(e.vy, e.vx); let d = want - cur; d = Math.atan2(Math.sin(d), Math.cos(d)); const ang = cur + clamp(d, -6 * dt, 6 * dt), sp = Math.hypot(e.vx, e.vy); e.vx = Math.cos(ang) * sp; e.vy = Math.sin(ang) * sp; } + e.x += e.vx * dt; e.y += e.vy * dt; + if (!e.hit && bwAlive(target) && Math.abs(e.x - target.x) < (e.rx || 70) && Math.abs(e.y - (target.y - 85)) < (e.ry || 120)) { + dotDamage(target, e.damage, e.color); sound('hit'); spawnParticles(target.x, target.y - 85, e.color, 8, 220); trauma = Math.min(1, trauma + .15); + if (e.single) { cfx.ents.splice(n, 1); continue; } e.hit = true; + } + if (e.life <= 0 || e.x < -260 || e.x > W + 260) cfx.ents.splice(n, 1); + } + // Marked strikes (rain): after a telegraph, damage whoever stands on the mark. + for (let n = cfx.marks.length - 1; n >= 0; n--) { + const m = cfx.marks[n], target = m.owner === 'enemy' ? hero : dummy; m.t += dt; + if (m.t >= m.delay) { + effects.push({ type: 'cf-fx', asset: m.asset, x: m.x, y: CONFIG.groundY - 26, facing: 1, life: .4, maxLife: .4, size: 200 }); + spawnParticles(m.x, CONFIG.groundY - 10, m.color, 8, 200, 'dust'); sound('hit'); trauma = Math.min(1, trauma + .12); + if (bwAlive(target) && Math.abs(target.x - m.x) < m.radius && target.y > CONFIG.groundY - 140) dotDamage(target, m.damage, m.color); + cfx.marks.splice(n, 1); + } + } + // Puddles: one burn per rival, however many puddles they stand in. + const inside = { player: false, enemy: false }, hz = {}; + for (let n = cfx.hazards.length - 1; n >= 0; n--) { + const h = cfx.hazards[n], target = h.owner === 'enemy' ? hero : dummy, tk = h.owner === 'enemy' ? 'player' : 'enemy'; h.t += dt; + if (bwAlive(target) && Math.abs(target.x - h.x) < h.radius && target.y > CONFIG.groundY - 110) { inside[tk] = true; hz[tk] = Math.max(hz[tk] || 0, h.dps); } + if (h.t > h.life) cfx.hazards.splice(n, 1); + } + for (const k of ['player', 'enemy']) { + if (!inside[k]) continue; + const target = k === 'player' ? hero : dummy; bwx.burn[k] += hz[k] * dt; + if (bwx.burn[k] >= 1) { const m = Math.floor(bwx.burn[k]); bwx.burn[k] -= m; bwx.burn.shown[k] += m; dotDamage(target, m, '#9bd05a', false); } + if (bwx.burn.shown[k] >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: bwx.burn.shown[k], life: .9, maxLife: .9, color: '#9bd05a' }); bwx.burn.shown[k] = 0; } + } + } + // The ultimate director. s.t counts from the cast; beats start after `hold` seconds of camera dive. + function updateCfUltimate(dt, s) { + const def = CF[s.cf], U = def.ultimate, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, tk = s.owner === 'enemy' ? 'player' : 'enemy'; + const hold = U.hold ?? 1.1, gap = U.gap ?? .12, count = U.count ?? 10, endAct = hold + (count - 1) * gap + (U.settle ?? .5), col = def.color || '#ffd36a', id = def.id; + s.t += dt; const mx = (a.x + target.x) / 2, dist = Math.abs(target.x - a.x); + if (s.t < hold) { bwx.zoomT = U.zoom ?? 1.7; bwx.dimT = U.dim ?? .8; bwx.cx = a.x; bwx.cy = a.y - 90; if (s.t > .15) trauma = Math.min(.35, trauma + dt * .5); } + else if (s.t < endAct + .3) { bwx.zoomT = 1.25; bwx.dimT = .65; bwx.cx = mx; bwx.cy = a.y - 100; } + else { bwx.zoomT = 1; bwx.dimT = 0; } + if (s.t >= (U.lockAt ?? .3) && s.t < endAct && bwAlive(target)) bwLock(tk, .25); + while (s.roars < count && s.t >= hold + s.roars * gap) { + const i = s.roars++, f = a.facing, owner = s.owner; + if (U.kind === 'volley') { + const sp = U.speed || 900, ang = Math.atan2((target.y - 90) - (a.y - 95) + (random() - .5) * 90, Math.max(60, Math.abs(target.x - a.x))) ; + cfx.ents.push({ x: a.x + f * 50, y: a.y - 95 + (i % 3 - 1) * 24, vx: f * Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1.4, t: 0, owner, damage: U.damage ?? 4, color: col, asset: id + '-' + U.asset, size: U.size || 100, single: true, homing: !!U.homing, facing: f }); + sound('cast'); + } else if (U.kind === 'stampede') { + const fromLeft = f > 0, sp = U.speed || 1000, lane = ((i % 3) - 1) * 12; + cfx.ents.push({ x: fromLeft ? -120 : W + 120, y: CONFIG.groundY - (U.lift ?? 90) + lane, vx: (fromLeft ? 1 : -1) * sp * (.9 + (i % 4) * .06), vy: 0, life: (W + 340) / sp + .3, t: 0, owner, damage: U.damage ?? 8, color: col, asset: id + '-' + U.asset, size: U.size || 260, facing: fromLeft ? 1 : -1, rx: U.rx || 90, ry: U.ry || 130 }); + trauma = Math.min(1, trauma + .12); sound('heavy'); + } else if (U.kind === 'rain') { + const x = clamp(target.x + (i === count - 1 ? 0 : (random() - .5) * (U.spread ?? 360)), 70, W - 70); + cfx.marks.push({ x, t: 0, delay: U.delay ?? .38, owner, damage: U.damage ?? 3, radius: U.radius ?? 100, asset: id + '-impact', color: col, img: 'fx-' + id + '-' + U.asset, drop: U.size || 150 }); + } else if (U.kind === 'pillars') { + const x = clamp(a.x + f * (130 + i * (Math.max(dist, 200) - 30) / Math.max(1, count - 1)), 70, W - 70); + effects.push({ type: 'cf-pillar', img: 'fx-' + id + '-' + U.asset, x, y: CONFIG.groundY, life: .6, maxLife: .6, h: U.h || 320 }); + effects.push({ type: 'cf-fx', asset: id + '-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 }); + spawnParticles(x, CONFIG.groundY - 10, col, 10, 240, 'dust'); trauma = Math.min(1, trauma + .18); sound('hit'); + if (bwAlive(target)) dotDamage(target, U.damage ?? 3, col); + } + } + if (!s.finished && s.t >= endAct && U.finisher) { + s.finished = true; const F2 = U.finisher; + effects.push({ type: 'cf-fx', asset: id + '-impact', x: target.x, y: CONFIG.groundY - 40, facing: 1, life: .6, maxLife: .6, size: 520 }); + effects.push({ type: 'cf-fx', asset: id + '-' + (F2.asset || 'burst'), x: target.x, y: CONFIG.groundY - 130, facing: 1, life: .55, maxLife: .55, size: 560 }); + for (let q = 0; q < 3; q++) spawnParticles(target.x, CONFIG.groundY - 12, q === 1 ? '#fff1c8' : col, 22, 460, 'dust'); + trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy'); + if (bwAlive(target)) { if (target === dummy) { if (!hitDummy(F2.damage, 360, col, a.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, F2.damage, col); } else if (!window.__game.receiveHit(F2.damage, { shake: 2 })) dotDamage(hero, F2.damage, col); } + } + s.phase = s.t < .78 ? 'cutin' : 'roaring'; + if (s.t >= endAct + (U.tail ?? .5)) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } + } + function cfCutin(key, owner) { + const el = document.querySelector('.cutin'); if (!el) return; const f = CF[key]; + if (!f) { el.style.background = ''; el.style.borderColor = ''; el.querySelector('.cutin-kicker').style.color = ''; el.querySelector('.cutin-caption em') && (el.querySelector('.cutin-caption em').style.color = ''); return; } + const c = f.cutin || {}; + el.style.background = `linear-gradient(100deg,${c.a || '#222'},${c.b || '#555'} 58%,${c.a || '#222'})`; el.style.borderColor = f.color || '#fff'; + el.querySelector('.cutin-kicker').textContent = f.name + ' / ' + owner; el.querySelector('.cutin-kicker').style.color = c.kicker || '#fff'; + el.querySelector('strong').innerHTML = c.title || f.names[3]; el.querySelector('.cutin-detail').textContent = c.detail || ''; + el.setAttribute('aria-label', f.name + ' ultimate: ' + f.names[3]); + const em = el.querySelector('.cutin-caption em'); if (em) em.style.color = f.color || ''; + } + function drawCfBack() { + for (const e of effects) { + if (e.type !== 'cf-pillar') continue; const im = images[e.img]; if (!im) continue; + const t = 1 - e.life / e.maxLife, k = Math.min(1, t * 7) * (t > .7 ? 1 - (t - .7) / .3 : 1), h = e.h, w = im.width / im.height * h; + ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.beginPath(); ctx.rect(-w, -h - 10, w * 2, h + 10); ctx.clip(); ctx.drawImage(im, -w / 2, -h * k, w, h); ctx.restore(); + } + for (const h of cfx.hazards) { + const im = images[h.img]; if (!im) continue; const fade = h.t > h.life - .6 ? Math.max(0, (h.life - h.t) / .6) : 1, w = h.radius * 2.4, hh = im.height / im.width * w; + ctx.save(); ctx.globalAlpha = .9 * fade; ctx.drawImage(im, Math.round(h.x - w / 2), CONFIG.groundY + 6 - hh, w, hh); ctx.restore(); + } + } + function drawCfFront() { + for (const m of cfx.marks) { // rain: telegraph ring, then the drop falls in + const im = images[m.img], p = clamp(m.t / m.delay, 0, 1); + ctx.save(); ctx.globalAlpha = .45; ctx.strokeStyle = m.color; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(m.x, CONFIG.groundY + 4, m.radius * p, m.radius * p * .22, 0, 0, 7); ctx.stroke(); ctx.restore(); + if (im) { const sz = m.drop; ctx.drawImage(im, Math.round(m.x - sz / 2), Math.round(-sz + (CONFIG.groundY + sz * .3) * p * p), sz, sz); } + } + for (const e of cfx.ents) { + const im = images['fx-' + e.asset]; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.scale(e.facing || 1, 1); + if (e.vy && e.single) ctx.rotate(Math.atan2(e.vy, Math.abs(e.vx))); if (im) { const sz = e.size; ctx.drawImage(im, -sz / 2, -sz / 2, sz, sz); } else { ctx.fillStyle = e.color; ctx.beginPath(); ctx.arc(0, 0, 14, 0, 7); ctx.fill(); } + ctx.restore(); + } + } // ---- BRO WO status layer: cinematic camera, stun holds, poison drain, rage ------------------------------------------- // `bwx` is shared by the player's and the CPU's BRO WO; 'player' is the hero, 'enemy' is the dummy. const bwx = { zoom: 1, zoomT: 1, dim: 0, dimT: 0, cx: 640, cy: 480, rage: { player: 0, enemy: 0 }, poison: { player: null, enemy: null }, lock: { player: 0, enemy: 0 }, dance: [], giant: null, fires: [], burn: { player: 0, enemy: 0, shown: { player: 0, enemy: 0 } }, tornado: null }; function bwReset() { bwx.zoom = bwx.zoomT = 1; bwx.dim = bwx.dimT = 0; bwx.rage.player = bwx.rage.enemy = 0; bwx.poison.player = bwx.poison.enemy = null; bwx.lock.player = bwx.lock.enemy = 0; bwx.dance.length = 0; bwx.giant = null; - bwx.fires.length = 0; bwx.burn.player = bwx.burn.enemy = 0; bwx.burn.shown.player = bwx.burn.shown.enemy = 0; bwx.tornado = null; + bwx.fires.length = 0; bwx.burn.player = bwx.burn.enemy = 0; bwx.burn.shown.player = bwx.burn.shown.enemy = 0; bwx.tornado = null; cfx.ents.length = cfx.hazards.length = cfx.marks.length = 0; hero.bwRage = dummy.bwRage = false; hero.bwSaved = dummy.bwSaved = null; if (BW && selectedCharacter === 'browo') Object.assign(cooldownMax, BW.balance.cooldowns); } @@ -985,7 +1176,7 @@ if(hero.hp<=0) { hero.koTime=Math.max(0,hero.koTime-dt); state('down'); if(!hero.koTime&&match?.mode!=='versus') {hero.hp=BALANCE.heroMax;hero.invuln=.9;state(hero.grounded?'idle':'jump');} } if (hero.action) { const a = hero.action; a.t += dt; state(a.name); - if((selectedCharacter==='fenr'||selectedCharacter==='mira'||selectedCharacter==='haldor'||selectedCharacter==='isolde'||selectedCharacter==='solan'||selectedCharacter==='nib')&&a.dash&&a.t>a.duration*.2&&a.ta.duration*.2&&a.t= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; if (a.type === 'attack') meleeHit(a.index); else fireSkill(a); } if (a.fired && a.cancelInto && hero.action === a) { const into = a.cancelInto; hero.action = null; state(hero.grounded ? 'idle' : 'jump'); if (into === 'jump') jump(); } if (a.t >= a.duration || (a.type === 'skill1' && a.fired)) { const queued = a.queued && a.index < 3, remaining = Math.max(0, a.queued - 1); const next = (a.index || 0) + 1; hero.action = null; if (queued) { startAttack(next); hero.action.queued = remaining; } else state(hero.grounded ? 'idle' : 'jump'); } @@ -1002,8 +1193,8 @@ if (!hero.grounded) { if (keys.has('s') && hero.vy > 0 && hero.doublePose <= 0) hero.vy = Math.max(hero.vy, CONFIG.fastFall); hero.vy += CONFIG.gravity * dt; hero.y += hero.vy * dt; if (hero.y >= CONFIG.groundY) { hero.y = CONFIG.groundY; hero.vy = 0; hero.grounded = true; hero.jumps = 0; hero.doublePose = 0; hero.airTime = 0; dust(hero.x, 11); sound('step'); } } const stride = metrics?.states?.[hero.state]?.stride_estimate || manifest?.metrics?.[hero.state === 'run' ? 'runStride' : 'walkStride'] || manifest?.locomotion?.[hero.state]?.stride_px || (hero.state === 'run' ? 184 : 148); hero.walkPhase += Math.abs(hero.vx) * dt / stride; - updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); - if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; + updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar);updateBrowo(dt);updateCf(dt); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); + if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'browo' || CF[voiceKind] || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); advanceMatch(dt); } @@ -1037,6 +1228,7 @@ if (p.homing) { const t = p.owner === 'enemy' ? hero : dummy, want = Math.atan2(t.y - 85 - p.y, t.x - p.x), cur = Math.atan2(p.vy, p.vx); let d = want - cur; d = Math.atan2(Math.sin(d), Math.cos(d)); const ang = cur + clamp(d, -p.turn * dt, p.turn * dt); p.vx = Math.cos(ang) * p.speedH; p.vy = Math.sin(ang) * p.speedH; p.facing = p.vx >= 0 ? 1 : -1; } // EDDA Stone Skip: the stone falls under its own gravity and hops off the floor p.bounces more times, then sinks. if (p.skip) { p.vy += p.skipG * dt; if (p.vy > 0 && p.y + p.vy * dt >= CONFIG.groundY - 14) { effects.push({ type: 'edda-fx', asset: 'edda-ripple', x: p.x, y: CONFIG.groundY - 14, facing: p.facing, life: .3, maxLife: .3, size: 110 }); if (p.bounces-- > 0) { p.y = CONFIG.groundY - 14; p.vy = -p.hop; sound('step'); } else { projectiles.splice(i, 1); continue; } } } + if (p.cfLand) { p.vy += p.cfG * dt; if (p.vy > 0 && p.y + p.vy * dt >= CONFIG.groundY - 8) { cfLand(p); projectiles.splice(i, 1); continue; } } if (p.gravity) p.vy += p.gravity * dt; p.x += p.vx * dt; p.y += (p.vy || 0) * dt; p.life -= dt; if (p.gravity && p.y >= CONFIG.groundY - 8) { slagSplash(p); projectiles.splice(i, 1); continue; } if (p.landY && p.y >= p.landY) { lanceShatter(p); projectiles.splice(i, 1); continue; } @@ -1071,6 +1263,7 @@ else if(selectedCharacter==='haldor') {manifest=window.HALDOR_MANIFEST;metrics=window.HALDOR_METRICS;images.hero=images.haldor;Object.assign(cooldownMax,HD.balance.cooldowns);} else if(selectedCharacter==='edda') {manifest=window.EDDA_MANIFEST;metrics=window.EDDA_METRICS;images.hero=images.edda;Object.assign(cooldownMax,ED.balance.cooldowns);} else if(selectedCharacter==='nib') {manifest=window.NIB_MANIFEST;metrics=window.NIB_METRICS;images.hero=images.nib;Object.assign(cooldownMax,NB.balance.cooldowns);} + else if(CF[selectedCharacter]) {manifest=CF[selectedCharacter].manifest;metrics=CF[selectedCharacter].metrics;images.hero=images[selectedCharacter];Object.assign(cooldownMax,CF[selectedCharacter].balance.cooldowns);} else if(selectedCharacter==='browo') {manifest=window.BROWO_MANIFEST;metrics=window.BROWO_METRICS;images.hero=images.browo;Object.assign(cooldownMax,BW.balance.cooldowns);} else if(selectedCharacter==='solan') {manifest=window.SOLAN_MANIFEST;metrics=window.SOLAN_METRICS;images.hero=images.solan;Object.assign(cooldownMax,SO.balance.cooldowns);} else if(selectedCharacter==='rhea') {manifest=window.RHEA_MANIFEST;metrics=window.RHEA_METRICS;images.hero=images.rhea;Object.assign(cooldownMax,RH.balance.cooldowns);} @@ -1388,6 +1581,7 @@ if(opponentCharacter==='nib'){nibStrike(dummy,a);return;} if(opponentCharacter==='edda'){eddaStrike(dummy,a);return;} if(opponentCharacter==='browo'){browoStrike(dummy,a);return;} + if(CF[opponentCharacter]){cfStrike(dummy,CF[opponentCharacter],a);return;} const em=window.MECHA_METRICS.emitters[a.name],x=dummy.x+dummy.facing*em.x,y=dummy.y+em.y,dx=(hero.x-dummy.x)*dummy.facing; if(a.type==='skill1'){projectiles.push({x,y,vx:dummy.facing*920,life:1.6,facing:dummy.facing,owner:'enemy',damage:16});effects.push({type:'muzzle',x,y,life:.25,maxLife:.25});sound('cast');} else if(a.type==='skill2'){effects.push({type:'slam',x,y:CONFIG.groundY-2,life:.8,maxLife:.8});sound('heavy');if(Math.abs(hero.x-x)<220&&Math.abs(hero.y-CONFIG.groundY)<125)window.__game.receiveHit(24);} @@ -1606,7 +1800,7 @@ dummy.aiThink = pick || dist < 260 ? Math.min(P.reaction, 1 / 60) : P.reaction * .5; } function opponentPose() { - const m=opponentCharacter==='arco'?window.MECHA_MANIFEST:opponentCharacter==='mira'?window.MIRA_MANIFEST:opponentCharacter==='cora'?window.CORA_MANIFEST:opponentCharacter==='naja'?window.NAJA_MANIFEST:opponentCharacter==='haldor'?window.HALDOR_MANIFEST:opponentCharacter==='zanni'?window.ZANNI_MANIFEST:opponentCharacter==='isolde'?window.ISOLDE_MANIFEST:opponentCharacter==='rhea'?window.RHEA_MANIFEST:opponentCharacter==='solan'?window.SOLAN_MANIFEST:opponentCharacter==='nib'?window.NIB_MANIFEST:opponentCharacter==='edda'?window.EDDA_MANIFEST:opponentCharacter==='browo'?window.BROWO_MANIFEST:dummy.form==='wolf'?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST; + const m=CF[opponentCharacter]?CF[opponentCharacter].manifest:opponentCharacter==='arco'?window.MECHA_MANIFEST:opponentCharacter==='mira'?window.MIRA_MANIFEST:opponentCharacter==='cora'?window.CORA_MANIFEST:opponentCharacter==='naja'?window.NAJA_MANIFEST:opponentCharacter==='haldor'?window.HALDOR_MANIFEST:opponentCharacter==='zanni'?window.ZANNI_MANIFEST:opponentCharacter==='isolde'?window.ISOLDE_MANIFEST:opponentCharacter==='rhea'?window.RHEA_MANIFEST:opponentCharacter==='solan'?window.SOLAN_MANIFEST:opponentCharacter==='nib'?window.NIB_MANIFEST:opponentCharacter==='edda'?window.EDDA_MANIFEST:opponentCharacter==='browo'?window.BROWO_MANIFEST:dummy.form==='wolf'?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST; const name=m?.frame_layout?.rows?.[dummy.state]?dummy.state:'idle',list=m?.frame_layout?.rows?.[name]||[]; let index=0; // Projectile casts that release at 50% show wind-up -> thrust -> full extension, like the player's pose. @@ -1763,7 +1957,7 @@ } for (const e of effects) { const t = 1 - e.life / e.maxLife; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.globalAlpha = Math.min(1, e.life / e.maxLife * 2); ctx.lineCap = 'square'; - if((e.type==='fenr-fx'||e.type==='mira-fx'||e.type==='cora-fx'||e.type==='naja-fx'||e.type==='haldor-fx'||e.type==='zanni-fx'||e.type==='isolde-fx'||e.type==='rhea-fx'||e.type==='solan-fx'||e.type==='nib-fx'||e.type==='edda-fx'||e.type==='browo-fx') && images['fx-'+e.asset]){ctx.scale(e.facing||1,1);const size=e.size*(.75+t*.4);ctx.drawImage(images['fx-'+e.asset],-size/2,-size/2,size,size);} + if((e.type==='fenr-fx'||e.type==='mira-fx'||e.type==='cora-fx'||e.type==='naja-fx'||e.type==='haldor-fx'||e.type==='zanni-fx'||e.type==='isolde-fx'||e.type==='rhea-fx'||e.type==='solan-fx'||e.type==='nib-fx'||e.type==='edda-fx'||e.type==='browo-fx'||e.type==='cf-fx') && images['fx-'+e.asset]){ctx.scale(e.facing||1,1);const size=e.size*(.75+t*.4);ctx.drawImage(images['fx-'+e.asset],-size/2,-size/2,size,size);} // BRO WO's palm erupts from the floor (clipped at ground level), holds, then sinks back as it fades. // EDDA's tortoise spirit keeps one size; it fades in and out and lifts before each stomp. if(e.type==='edda-spirit'&&images['fx-'+e.asset]){ctx.globalAlpha=Math.min(1,t*6,(1-t)*5)*.92;ctx.scale(e.facing||1,1);ctx.drawImage(images['fx-'+e.asset],-e.size/2,-e.size-e.lift,e.size,e.size);} @@ -1882,9 +2076,9 @@ 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. for (let i = 0; i < 14; i++) { const x = (i * 117 + realTime * (6 + i % 3)) % W, y = 210 + (i * 47) % 330 + Math.sin(realTime + i) * 9; ctx.fillStyle = i % 2 ? '#fff8cb88' : '#f0ffe94d'; ctx.fillRect(Math.round(x), Math.round(y), 2, 2); } - drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); + drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); drawCfBack(); if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } - drawBrowoGiant(true); + drawBrowoGiant(true); drawCfFront(); drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawBrowoExtras(); drawHitboxes(); ctx.restore(); if (camera) ctx.restore(); updateHud(); } function fighterInfo(id,form) { @@ -1892,6 +2086,7 @@ if(id==='cora')return {name:'CORA',cls:'DEMI-HUMAN',deck:'CORA',title:'SKY DANCER',portrait:'assets/cora/ui/portrait.webp',names:C.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/cora/ui/icon-'+s+'.webp')}; if(id==='edda')return {name:'EDDA',cls:'DEMI-HUMAN',deck:'EDDA',title:'SHELL SAGE',portrait:'assets/edda/ui/portrait.webp',names:ED.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/edda/ui/icon-'+s+'.webp')}; if(id==='nib')return {name:'NIB',cls:'DEMI-HUMAN',deck:'NIB',title:'ROOFTOP POST',portrait:'assets/nib/ui/portrait.webp',names:NB.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/nib/ui/icon-'+s+'.webp')}; + if(CF[id]){const f=CF[id];return {name:f.name,cls:f.race,deck:f.name,title:f.title,portrait:'assets/'+id+'/ui/portrait.webp',names:f.names,icons:['basic','skill1','skill2','ultimate'].map(n=>'assets/'+id+'/ui/icon-'+n+'.webp')};} if(id==='browo')return {name:'BRO WO',cls:'HUMAN',deck:'BRO WO',title:'PALM RANGER',portrait:'assets/browo/ui/portrait.webp',names:BW.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/browo/ui/icon-'+s+'.webp')}; if(id==='solan')return {name:'SOLAN',cls:'DEMI-HUMAN',deck:'SOLAN',title:'SUNMANE',portrait:'assets/solan/ui/portrait.webp',names:SO.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/solan/ui/icon-'+s+'.webp')}; if(id==='rhea')return {name:'RHEA',cls:'MECHA',deck:'RHEA',title:'LITTLE ORRERY',portrait:'assets/rhea/ui/portrait.webp',names:RH.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/rhea/ui/icon-'+s+'.webp')}; @@ -1916,7 +2111,7 @@ $('.fighter-two').setAttribute('aria-label','Status lawan '+opponentCharacter.toUpperCase()); document.querySelectorAll('.skill-card').forEach((card,i)=>{card.querySelector('.skill-name').textContent=me.names[i];card.querySelector('img').src=me.icons[i];card.setAttribute('aria-label',me.names[i]+', '+['Spasi','I','O','P'][i]);}); } - $('#player-status').textContent=selectedCharacter==='fenr'?(hero.form==='wolf'?'FERAL ACTIVE':'RANGER READY'):selectedCharacter==='mira'?'PILOT READY':selectedCharacter==='cora'?'WINGS READY':selectedCharacter==='naja'?'SANDS READY':selectedCharacter==='haldor'?'FURNACE HOT':selectedCharacter==='zanni'?'SHOWTIME':selectedCharacter==='isolde'?'LANCE READY':selectedCharacter==='rhea'?'ORBITS SET':selectedCharacter==='solan'?'PRIDE READY':selectedCharacter==='nib'?'ON THE ROUTE':selectedCharacter==='edda'?'STEADY AS STONE':selectedCharacter==='browo'?(bwx.rage.player>0?'MENGAMUK '+Math.ceil(bwx.rage.player)+'s':'SAFARI READY'):'CORE ACTIVE'; + $('#player-status').textContent=selectedCharacter==='fenr'?(hero.form==='wolf'?'FERAL ACTIVE':'RANGER READY'):selectedCharacter==='mira'?'PILOT READY':selectedCharacter==='cora'?'WINGS READY':selectedCharacter==='naja'?'SANDS READY':selectedCharacter==='haldor'?'FURNACE HOT':selectedCharacter==='zanni'?'SHOWTIME':selectedCharacter==='isolde'?'LANCE READY':selectedCharacter==='rhea'?'ORBITS SET':selectedCharacter==='solan'?'PRIDE READY':selectedCharacter==='nib'?'ON THE ROUTE':selectedCharacter==='edda'?'STEADY AS STONE':CF[selectedCharacter]?CF[selectedCharacter].status:selectedCharacter==='browo'?(bwx.rage.player>0?'MENGAMUK '+Math.ceil(bwx.rage.player)+'s':'SAFARI READY'):'CORE ACTIVE'; for(const [actor,id] of [[hero,'player-form-timer'],[dummy,'enemy-form-timer']]){const el=$('#'+id),active=actor.form==='wolf';el.hidden=!active;el.textContent=active?'FERAL '+Math.max(0,actor.formTime).toFixed(1)+' s':'';el.style.setProperty?.('--remaining',String(actor.formTime/F.balance.duration));} } function updateHud() { @@ -1966,11 +2161,11 @@ // One banner at a time: FENR transformation, then ARCO, then a MIRA/CORA/NAJA/HALDOR summon still inside its cut-in window. const cutin=$('#ultimate-cutin'),live=g=>g&&g.t{cutinArt.style.visibility='';};}cutin.querySelector('.cutin-kicker').textContent={fenr:'FENR / '+cutinOwner,mira:'MIRA / '+cutinOwner,cora:'CORA / '+cutinOwner,naja:'NAJA / '+cutinOwner,haldor:'HALDOR / '+cutinOwner,zanni:'ZANNI / '+cutinOwner,isolde:'ISOLDE / '+cutinOwner,rhea:'RHEA / '+cutinOwner,solan:'SOLAN / '+cutinOwner,browo:'BRO WO / '+cutinOwner,browofire:'BRO WO / '+cutinOwner,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL
AWAKENING',mira:'ROCKET
PARADE',cora:'NIGHT
MURMURATION',naja:'DUNE
SERPENT',haldor:'FORGE
QUAKE',zanni:'GRAND
FINALE',isolde:'SKYFALL
LANCES',rhea:'GRAND
ORRERY',solan:'SUNMANE
ROAR',browo:'HUTAN
SAWIT',browofire:'TORNADO
API SAWIT',nib:'SPECIAL
DELIVERY',edda:'ELDER
TORTOISE',arco:'HELIOS
SQUADRON'}[cutinKey];cutin.querySelector('.cutin-detail').textContent={fenr:'THE BEAST WITHIN',mira:'TWELVE-ROCKET SALVO',cora:'THREE-PASS RAVEN STORM',naja:'THREE-STRIKE SAND COBRA',haldor:'THREE-SLAM MOLTEN SHOCKWAVE',zanni:'THREE-RING BLADE BOOMERANG',isolde:'THREE-LANCE ICE DIVE',rhea:'THREE-PLANET ORBIT',solan:'THREE-ROAR SHOCKWAVE',browo:'TEN-PALM RUN + GIANT SLAM',browofire:'FIRE TORNADO FINISHER',nib:'THREE-PARCEL HOMING RUN',edda:'THREE-STOMP SPIRIT WALK',arco:'ORBITAL LASER STRIKE'}[cutinKey];cutin.setAttribute('aria-label',{fenr:'FENR ultimate: Feral Awakening',mira:'MIRA ultimate: Rocket Parade',cora:'CORA ultimate: Night Murmuration',naja:'NAJA ultimate: Dune Serpent',haldor:'HALDOR ultimate: Forge Quake',zanni:'ZANNI ultimate: Grand Finale',isolde:'ISOLDE ultimate: Skyfall Lances',rhea:'RHEA ultimate: Grand Orrery',solan:'SOLAN ultimate: Sunmane Roar',browo:'BRO WO ultimate: Hutan Sawit',browofire:'BRO WO rage ultimate: Tornado Api Sawit',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);} + const cutinArt=cutin.querySelector('.cutin-art'),cutinOwner=cutinKey==='fenr'?fenrCutin.owner:cutinKey==='mira'||cutinKey==='cora'||cutinKey==='naja'||cutinKey==='haldor'||cutinKey==='zanni'||cutinKey==='isolde'||cutinKey==='rhea'||CF[cutinKey]||cutinKey==='solan'||cutinKey==='browo'||cutinKey==='browofire'||cutinKey==='nib'||cutinKey==='edda'?(summon.owner==='enemy'?'RIVAL':'PLAYER'):''; + if(cutin.dataset.identity!==cutinKey+cutinOwner){cutin.dataset.identity=cutinKey+cutinOwner;cutinArt.src=CUTIN_ART[cutinKey];if(cutinArt.complete)cutinArt.style.visibility='';else{cutinArt.style.visibility='hidden';cutinArt.onload=()=>{cutinArt.style.visibility='';};}cutin.querySelector('.cutin-kicker').textContent={fenr:'FENR / '+cutinOwner,mira:'MIRA / '+cutinOwner,cora:'CORA / '+cutinOwner,naja:'NAJA / '+cutinOwner,haldor:'HALDOR / '+cutinOwner,zanni:'ZANNI / '+cutinOwner,isolde:'ISOLDE / '+cutinOwner,rhea:'RHEA / '+cutinOwner,solan:'SOLAN / '+cutinOwner,browo:'BRO WO / '+cutinOwner,browofire:'BRO WO / '+cutinOwner,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL
AWAKENING',mira:'ROCKET
PARADE',cora:'NIGHT
MURMURATION',naja:'DUNE
SERPENT',haldor:'FORGE
QUAKE',zanni:'GRAND
FINALE',isolde:'SKYFALL
LANCES',rhea:'GRAND
ORRERY',solan:'SUNMANE
ROAR',browo:'HUTAN
SAWIT',browofire:'TORNADO
API SAWIT',nib:'SPECIAL
DELIVERY',edda:'ELDER
TORTOISE',arco:'HELIOS
SQUADRON'}[cutinKey];cutin.querySelector('.cutin-detail').textContent={fenr:'THE BEAST WITHIN',mira:'TWELVE-ROCKET SALVO',cora:'THREE-PASS RAVEN STORM',naja:'THREE-STRIKE SAND COBRA',haldor:'THREE-SLAM MOLTEN SHOCKWAVE',zanni:'THREE-RING BLADE BOOMERANG',isolde:'THREE-LANCE ICE DIVE',rhea:'THREE-PLANET ORBIT',solan:'THREE-ROAR SHOCKWAVE',browo:'TEN-PALM RUN + GIANT SLAM',browofire:'FIRE TORNADO FINISHER',nib:'THREE-PARCEL HOMING RUN',edda:'THREE-STOMP SPIRIT WALK',arco:'ORBITAL LASER STRIKE'}[cutinKey];cutin.setAttribute('aria-label',{fenr:'FENR ultimate: Feral Awakening',mira:'MIRA ultimate: Rocket Parade',cora:'CORA ultimate: Night Murmuration',naja:'NAJA ultimate: Dune Serpent',haldor:'HALDOR ultimate: Forge Quake',zanni:'ZANNI ultimate: Grand Finale',isolde:'ISOLDE ultimate: Skyfall Lances',rhea:'RHEA ultimate: Grand Orrery',solan:'SOLAN ultimate: Sunmane Roar',browo:'BRO WO ultimate: Hutan Sawit',browofire:'BRO WO rage ultimate: Tornado Api Sawit',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);cfCutin(cutinKey,cutinOwner);} const visible = ct < SQUAD.cutinDuration; cutin.classList.toggle('visible', visible); cutin.setAttribute('aria-hidden', String(!visible)); const slide = !visible ? -110 : ct < .16 ? -110 * Math.pow(1 - ct / .16,3) : ct > .57 ? 110 * Math.pow((ct-.57)/.21,2) : 0; @@ -1981,6 +2176,7 @@ // Cut-in art per fighter. Only the two fighters of the current fight are warmed (at start/select), so a banner never waits // for its image; until the right image is decoded the art stays hidden (see updateHud) instead of showing the last one. const CUTIN_ART={fenr:'assets/fenr/ui/cutin.webp',mira:'assets/mira/ui/cutin.webp',cora:'assets/cora/ui/cutin.webp',naja:'assets/naja/ui/cutin.webp',haldor:'assets/haldor/ui/cutin.webp',zanni:'assets/zanni/ui/cutin.webp',isolde:'assets/isolde/ui/cutin.webp',rhea:'assets/rhea/ui/cutin.webp',solan:'assets/solan/ui/cutin.webp',browo:'assets/browo/ui/cutin.webp',browofire:'assets/browo/ui/cutin-fire.webp',nib:'assets/nib/ui/cutin.webp',edda:'assets/edda/ui/cutin.webp',arco:'assets/ui/ultimate-cutin.webp'}; + for(const id of Object.keys(CF))CUTIN_ART[id]='assets/'+id+'/ui/cutin.webp'; let cutinWarm=[]; function warmCutins(){cutinWarm=[...new Set([selectedCharacter,opponentCharacter])].flatMap(id=>id==='browo'?[CUTIN_ART.browo,CUTIN_ART.browofire]:[CUTIN_ART[id]]).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});} function setPaused(value) { paused = value; clearInput(); syncUltimateVoice(); $('#pause-btn').textContent = value ? '▶' : 'Ⅱ'; const panel = $('#pause-panel'); if (value && !panel.open) panel.showModal(); if (!value && panel.open) panel.close(); if (!value) canvas.focus({ preventScroll: true }); } @@ -2023,7 +2219,7 @@ if(window.FRONTEND_ENABLED)setMenuOpen(true); resize(); requestAnimationFrame(tick); // Wait for the first-visit download (preload.js) so these requests are served from the cache, not fetched twice. - Promise.resolve(window.AETHER_PRELOAD).then(() => Promise.all([loadImage('hero', 'assets/mecha/run/sprite-sheet-alpha.webp', true), loadImage('stage', 'assets/stage.webp', true), ...(Rules?Object.entries(Rules.stages).filter(([id])=>id!=='bellora').map(([id,s])=>loadImage('stage-'+id,s.image,true)):[]), ...(F?[loadImage('fenrHuman','assets/fenr/human/run/sprite-sheet-alpha.webp',true),loadImage('fenrWolf','assets/fenr/wolf/run/sprite-sheet-alpha.webp',true),...['claw','gale','rush','bite','howl','transform'].map(name=>loadImage('fx-'+name,'assets/fenr/ui/fx-'+name+'.webp',true))]:[]), ...(M?[loadImage('mira','assets/mira/run/sprite-sheet-alpha.webp',true),...['star','rocket','burst','crash'].map(name=>loadImage('fx-mira-'+name,'assets/mira/ui/fx-'+name+'.webp',true))]:[]), ...(C?[loadImage('cora','assets/cora/run/sprite-sheet-alpha.webp',true),...['feather','gust','raven'].map(name=>loadImage('fx-cora-'+name,'assets/cora/ui/fx-'+name+'.webp',true))]:[]), ...(N?[loadImage('naja','assets/naja/run/sprite-sheet-alpha.webp',true),...['sandwave','cyclone','serpent','ripple'].map(name=>loadImage('fx-naja-'+name,'assets/naja/ui/fx-'+name+'.webp',true))]:[]), ...(HD?[loadImage('haldor','assets/haldor/run/sprite-sheet-alpha.webp',true),...['slag','splash','steam','quake'].map(name=>loadImage('fx-haldor-'+name,'assets/haldor/ui/fx-'+name+'.webp',true))]:[]), ...(Z?[loadImage('zanni','assets/zanni/run/sprite-sheet-alpha.webp',true),...['ring','bigring','snatch','confetti'].map(name=>loadImage('fx-zanni-'+name,'assets/zanni/ui/fx-'+name+'.webp',true))]:[]), ...(IS?[loadImage('isolde','assets/isolde/run/sprite-sheet-alpha.webp',true),...['piercer','skyfall','shatter','frost'].map(name=>loadImage('fx-isolde-'+name,'assets/isolde/ui/fx-'+name+'.webp',true))]:[]), ...(RH?[loadImage('rhea','assets/rhea/run/sprite-sheet-alpha.webp',true),...['drift','planet','well','burst'].map(name=>loadImage('fx-rhea-'+name,'assets/rhea/ui/fx-'+name+'.webp',true))]:[]), ...(SO?[loadImage('solan','assets/solan/run/sprite-sheet-alpha.webp',true),...['crescent','roar','impact','sunburst'].map(name=>loadImage('fx-solan-'+name,'assets/solan/ui/fx-'+name+'.webp',true))]:[]), ...(NB?[loadImage('nib','assets/nib/run/sprite-sheet-alpha.webp',true),...['letter','plane','slip','stamp'].map(name=>loadImage('fx-nib-'+name,'assets/nib/ui/fx-'+name+'.webp',true))]:[]), ...(BW?[loadImage('browo','assets/browo/run/sprite-sheet-alpha.webp',true),...['sawit','impact','sunburst','truck','burntree','tornado'].map(name=>loadImage('fx-browo-'+name,'assets/browo/ui/fx-'+name+'.webp',true))]:[]), ...(ED?[loadImage('edda','assets/edda/run/sprite-sheet-alpha.webp',true),...['stone','ripple','shell','tortoise','stomp'].map(name=>loadImage('fx-edda-'+name,'assets/edda/ui/fx-'+name+'.webp',true))]:[]), loadImage('drone', 'assets/ui/drone.png', true), loadImage('cutin', 'assets/ui/ultimate-cutin.webp', true)])).then(results => { + Promise.resolve(window.AETHER_PRELOAD).then(() => Promise.all([loadImage('hero', 'assets/mecha/run/sprite-sheet-alpha.webp', true), loadImage('stage', 'assets/stage.webp', true), ...(Rules?Object.entries(Rules.stages).filter(([id])=>id!=='bellora').map(([id,s])=>loadImage('stage-'+id,s.image,true)):[]), ...(F?[loadImage('fenrHuman','assets/fenr/human/run/sprite-sheet-alpha.webp',true),loadImage('fenrWolf','assets/fenr/wolf/run/sprite-sheet-alpha.webp',true),...['claw','gale','rush','bite','howl','transform'].map(name=>loadImage('fx-'+name,'assets/fenr/ui/fx-'+name+'.webp',true))]:[]), ...(M?[loadImage('mira','assets/mira/run/sprite-sheet-alpha.webp',true),...['star','rocket','burst','crash'].map(name=>loadImage('fx-mira-'+name,'assets/mira/ui/fx-'+name+'.webp',true))]:[]), ...(C?[loadImage('cora','assets/cora/run/sprite-sheet-alpha.webp',true),...['feather','gust','raven'].map(name=>loadImage('fx-cora-'+name,'assets/cora/ui/fx-'+name+'.webp',true))]:[]), ...(N?[loadImage('naja','assets/naja/run/sprite-sheet-alpha.webp',true),...['sandwave','cyclone','serpent','ripple'].map(name=>loadImage('fx-naja-'+name,'assets/naja/ui/fx-'+name+'.webp',true))]:[]), ...(HD?[loadImage('haldor','assets/haldor/run/sprite-sheet-alpha.webp',true),...['slag','splash','steam','quake'].map(name=>loadImage('fx-haldor-'+name,'assets/haldor/ui/fx-'+name+'.webp',true))]:[]), ...(Z?[loadImage('zanni','assets/zanni/run/sprite-sheet-alpha.webp',true),...['ring','bigring','snatch','confetti'].map(name=>loadImage('fx-zanni-'+name,'assets/zanni/ui/fx-'+name+'.webp',true))]:[]), ...(IS?[loadImage('isolde','assets/isolde/run/sprite-sheet-alpha.webp',true),...['piercer','skyfall','shatter','frost'].map(name=>loadImage('fx-isolde-'+name,'assets/isolde/ui/fx-'+name+'.webp',true))]:[]), ...(RH?[loadImage('rhea','assets/rhea/run/sprite-sheet-alpha.webp',true),...['drift','planet','well','burst'].map(name=>loadImage('fx-rhea-'+name,'assets/rhea/ui/fx-'+name+'.webp',true))]:[]), ...(SO?[loadImage('solan','assets/solan/run/sprite-sheet-alpha.webp',true),...['crescent','roar','impact','sunburst'].map(name=>loadImage('fx-solan-'+name,'assets/solan/ui/fx-'+name+'.webp',true))]:[]), ...(NB?[loadImage('nib','assets/nib/run/sprite-sheet-alpha.webp',true),...['letter','plane','slip','stamp'].map(name=>loadImage('fx-nib-'+name,'assets/nib/ui/fx-'+name+'.webp',true))]:[]), ...(BW?[loadImage('browo','assets/browo/run/sprite-sheet-alpha.webp',true),...['sawit','impact','sunburst','truck','burntree','tornado'].map(name=>loadImage('fx-browo-'+name,'assets/browo/ui/fx-'+name+'.webp',true))]:[]), ...Object.values(CF).flatMap(f=>[loadImage(f.id,'assets/'+f.id+'/run/sprite-sheet-alpha.webp',true),...f.fx.map(n=>loadImage('fx-'+f.id+'-'+n,'assets/'+f.id+'/ui/fx-'+n+'.webp',true))]), ...(ED?[loadImage('edda','assets/edda/run/sprite-sheet-alpha.webp',true),...['stone','ripple','shell','tortoise','stomp'].map(name=>loadImage('fx-edda-'+name,'assets/edda/ui/fx-'+name+'.webp',true))]:[]), loadImage('drone', 'assets/ui/drone.png', true), loadImage('cutin', 'assets/ui/ultimate-cutin.webp', true)])).then(results => { images['stage-bellora']=images.stage;images.arco=images.hero;if(F)syncPlayerForm(); ready = results.every(Boolean) && (!F || (!!window.FENR_HUMAN_MANIFEST && !!window.FENR_WOLF_MANIFEST)) && !!manifest && frames('idle').length > 0; if (ready) { $('#load-state').classList.add('hidden'); announce('TRAINING READY', 1.65); } diff --git a/menu.js b/menu.js index f812311..5b48abf 100644 --- a/menu.js +++ b/menu.js @@ -17,6 +17,8 @@ ...(window.Nib&&window.NIB_MANIFEST?{nib:{name:'NIB',tag:'THE ROOFTOP POST',race:'DEMI-HUMAN',portrait:'assets/nib/ui/portrait.webp',art:'assets/menu/nib-select.webp',detail:'Every letter delivered. Every punch, too.',basic:'BATON FLURRY',ultimate:'SPECIAL DELIVERY',style:'Rushdown / twin courier batons',color:'#8fd4ff'}}:{}), ...(window.Edda&&window.EDDA_MANIFEST?{edda:{name:'EDDA',tag:'THE SHELL SAGE',race:'DEMI-HUMAN',portrait:'assets/edda/ui/portrait.webp',art:'assets/menu/edda-select.webp',detail:'Slow to anger, impossible to knock down.',basic:'STAFF FORMS',ultimate:'ELDER TORTOISE',style:'Counter defender / staff and shell guard',color:'#8fe3b4'}}:{}) }; + // Data-driven fighters (window.FIGHTERS, one entry per assets//manifest.js) join the roster automatically. + for(const [id,f] of Object.entries(window.FIGHTERS||{}))fighters[id]={name:f.name,tag:f.tag,race:f.race,portrait:'assets/'+id+'/ui/portrait.webp',art:f.select,detail:f.detail,basic:f.names[0],ultimate:f.names[3],style:f.style,color:f.color}; // Showcase fighters (assets/showcase/showcase.js) fill roster slots with avatar and art; they cannot be confirmed yet. const showcase=Object.fromEntries(Object.entries(window.SHOWCASE_FIGHTERS||{}).filter(([id])=>!fighters[id])); const info=id=>fighters[id]||showcase[id],soon=id=>!!showcase[id];