Bro Wo: ultimate sinematik (zoom, latar gelap, 10 pohon + pohon raksasa), mode mengamuk 10 detik, joget gemoy menyetun dan meracuni

Ultimate menukik kamera ke Bro Wo dengan latar menggelap, menahan lawan, menumbuhkan 10 pohon
beruntun lalu mengangkat dan membanting pohon raksasa. Setelahnya Bro Wo mengamuk 10 detik
membawa pohon sawit sebagai senjata (damage, jangkauan, dan kecepatan naik). Joget Gemoy kini
goyang berulang, menyetun lawan, dan menguras darah perlahan lewat racun.
This commit is contained in:
rizkyaz0 2026-10-01 12:05:36 +07:00
parent 1641bd5f4c
commit 41421b7c2a
3 changed files with 205 additions and 44 deletions

View File

@ -1,7 +1,7 @@
/* Shared BRO WO moves (stocky safari ranger, fists and oil-palm trees): player and AI use the same rules. */ /* Shared BRO WO moves (stocky safari ranger, fists and oil-palm trees): player and AI use the same rules. */
(() => { (() => {
'use strict'; 'use strict';
const balance = { cooldowns:{skill1:4,skill2:7,ultimate:18}, castTime:.5, forestDamage:12, basicRefund:.05 }; const balance = { cooldowns:{skill1:4,skill2:9,ultimate:24}, castTime:.5, basicRefund:.05 };
const names = ['TINJU SAFARI','POHON SAWIT','JOGET GEMOY','HUTAN SAWIT']; const names = ['TINJU SAFARI','POHON SAWIT','JOGET GEMOY','HUTAN SAWIT'];
// Safari chain: a quick jab, a wide hook, then a heavy shoulder smash with big knockback. Reach sits inside the measured // Safari chain: a quick jab, a wide hook, then a heavy shoulder smash with big knockback. Reach sits inside the measured
// fist tip (82/82/97 px in BROWO_METRICS.emitters). // fist tip (82/82/97 px in BROWO_METRICS.emitters).
@ -13,15 +13,24 @@
// Pohon Sawit: BRO WO slams a palm on the floor and an oil-palm erupts `ahead` px in front; everything inside `radius` // Pohon Sawit: BRO WO slams a palm on the floor and an oil-palm erupts `ahead` px in front; everything inside `radius`
// px of that spot on the floor is hit. He does not need to touch the rival; a jump above 150 px clears it. // px of that spot on the floor is hit. He does not need to touch the rival; a jump above 150 px clears it.
const tree = { ahead:190, radius:90 }; const tree = { ahead:190, radius:90 };
// Joget Gemoy: a cheerful shoulder-swaying dance; leaves burst around him and everything within `radius` is hit. // Joget Gemoy: a long cheerful dance. `pulses` leaf bursts, `gap` s apart from `first`; every burst that reaches a rival
const dance = { radius:125 }; // inside `radius` stuns them (for gap + stunExtra s), deals `pulseDamage`, and refreshes a poison that drains `poisonDps`
// Hutan Sawit: four palms erupt one after another in front of him (spacing `trees`, every `gap` s from `at`); a rival // HP per second for `poisonTime` s without interrupting them.
// standing inside `radius` of any one takes 12 (48 total if all four land). He is free after the 0.5 s cast. const dance = { radius:150, pulses:5, first:.12, gap:.3, pulseDamage:3, stunExtra:.5, poisonDps:2.5, poisonTime:6, duration:1.7, loops:3 };
const forest = { cutinDuration:.78, trees:[150,280,410,540], at:.8, gap:.34, radius:85, duration:2.6 }; // Hutan Sawit (ultimate), seconds from the cast. The camera dives onto BRO WO under a dark screen (zoom to `zoomIn`),
// holds, then `treeCount` palms erupt in a rapid run toward the rival (`treeDamage` each) while the rival is held in place.
// At `giantAt` one giant palm rises (`giantRise`), leans back (`giantHold`) and is brought down on the rival (`slam`),
// dealing `giantDamage` and a knockdown. Afterwards BRO WO rampages (see `rage`).
const forest = { cutinDuration:.78, zoomIn:.45, lockAt:.3, treesAt:1.45, treeCount:10, treeGap:.11, treeDamage:3,
giantAt:2.85, giantRise:.55, giantHold:.3, slam:.16, giantDamage:40, tail:.55 };
forest.release = forest.giantAt + forest.giantRise + forest.giantHold + forest.slam;
forest.duration = forest.release + forest.tail;
// Mengamuk: for `duration` s after the ultimate BRO WO carries a palm tree as a club: stronger hits, longer reach, faster.
const rage = { duration:10, damageMul:1.4, speedMul:1.25, reachAdd:25 };
function move(name,index=1) { function move(name,index=1) {
if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]};
if(name==='skill1')return {name,type:name,damage:18,duration:.62,knockback:210,tree:true,reach:tree.ahead+tree.radius,hitAt:.55}; if(name==='skill1')return {name,type:name,damage:18,duration:.62,knockback:210,tree:true,reach:tree.ahead+tree.radius,hitAt:.55};
if(name==='skill2')return {name,type:name,damage:14,duration:.62,knockback:260,dance:true,reach:dance.radius,hitAt:.5}; if(name==='skill2')return {name,type:name,damage:0,duration:dance.duration,knockback:0,dance:true,loops:dance.loops,reach:dance.radius,hitAt:.04};
return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1};
} }
function start(a,name,index=1) { function start(a,name,index=1) {
@ -31,5 +40,5 @@
a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true;
} }
function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);}
window.Browo={balance,names,combo,tree,dance,forest,move,start,refund}; window.Browo={balance,names,combo,tree,dance,forest,rage,move,start,refund};
})(); })();

222
game.js
View File

@ -172,7 +172,7 @@
for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;} for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;}
} }
function setMenuOpen(value) { function setMenuOpen(value) {
menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;setPaused(false);} menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;setPaused(false);}
document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen;
window.FrontEnd?.syncBackground(); window.FrontEnd?.syncBackground();
} }
@ -198,7 +198,7 @@
for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) { for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) {
if(event.type==='cue')emitRoundCue(event.cue,event.round); if(event.type==='cue')emitRoundCue(event.cue,event.round);
if(event.type==='round-end') { if(event.type==='round-end') {
clearInput();stopUltimateVoice();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0; clearInput();stopUltimateVoice();bwReset();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0;
if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;} if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;}
match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy'); match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy');
const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko'; const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko';
@ -311,7 +311,7 @@
if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();} if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();}
fenrCutin=null;hudIdentity=''; fenrCutin=null;hudIdentity='';
particles.length = effects.length = projectiles.length = damageNumbers.length = 0; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25); particles.length = effects.length = projectiles.length = damageNumbers.length = 0; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25);
squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null; bwReset(); squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null;
for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0; for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0;
} }
function reset() { function reset() {
@ -694,23 +694,114 @@
s.phase = s.t < U.cutinDuration ? 'cutin' : s.roars < U.roars.length ? 'roaring' : 'fading'; 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; } 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; }
} }
// BRO WO Hutan Sawit: four oil-palms erupt one after another in front of him (BW.forest.trees). The group object is the // ---- BRO WO status layer: cinematic camera, stun holds, poison drain, rage -------------------------------------------
// shared `sunroar` slot, so the pause/reset/cut-in plumbing is the same as SOLAN's; `roars` counts the trees raised. // `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 };
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; }
function bwAlive(target) { return target === dummy ? !dummy.ko && dummy.state !== 'down' && dummy.state !== 'recover' : hero.hp > 0; }
// Damage over time / scripted damage: no hurt animation, no knockback, no cooldown refunds; K.O. still applies.
function dotDamage(target, amount, color = '#9bd05a', numbers = true) {
if (amount <= 0 || menuOpen || (match && match.phase !== 'fight') || !bwAlive(target)) return false;
if (target === dummy) {
dummy.damage += amount; totalDamage += amount; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += amount; comboTimer = 2.3;
if (numbers) damageNumbers.push({ x: dummy.x + (random() - .5) * 30, y: dummy.y - 120, amount, life: .9, maxLife: .9, color });
if (dummy.damage >= BALANCE.dummyMax) { dummy.ko = true; dummy.state = 'down'; dummy.vy = -330; dummy.vx = 0; dummy.fallDirection = hero.x <= dummy.x ? 1 : -1; announce('K.O.', .9); }
return true;
}
hero.hp = Math.max(0, hero.hp - amount);
if (numbers) damageNumbers.push({ x: hero.x + (random() - .5) * 30, y: hero.y - 120, amount, life: .9, maxLife: .9, color });
if (!hero.hp) { hero.action = null; hero.hurtTime = 0; state('down'); hero.koTime = 1.7; clearInput(); if (match?.mode !== 'versus') announce('CORE RESTART', 1.7); }
return true;
}
function bwLock(key, seconds) { bwx.lock[key] = Math.max(bwx.lock[key], seconds); }
function bwPoison(key) { const D = BW.dance, p = bwx.poison[key]; if (p) p.t = D.poisonTime; else bwx.poison[key] = { t: D.poisonTime, acc: 0, shown: 0 }; }
function updateBrowo(dt) {
const D = BW ? BW.dance : null; if (!D) return;
// Dance bursts: each burst that reaches the rival stuns them, deals damage and refreshes the poison.
for (let n = bwx.dance.length - 1; n >= 0; n--) {
const d = bwx.dance[n], caster = d.owner === 'enemy' ? dummy : hero, target = d.owner === 'enemy' ? hero : dummy, tk = d.owner === 'enemy' ? 'player' : 'enemy';
d.t += dt;
if (caster === hero ? hero.hp <= 0 || hero.state === 'hurt' || hero.state === 'down' : dummy.ko || dummy.state === 'hurt' || dummy.state === 'down') { bwx.dance.splice(n, 1); continue; }
while (d.pulses < D.pulses && d.t >= D.first + d.pulses * D.gap) {
d.pulses++;
effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: caster.x, y: caster.y - 95, facing: 1, life: .5, maxLife: .5, size: 320 });
spawnParticles(caster.x, caster.y - 95, '#8fd14f', 12, 260); trauma = Math.min(1, trauma + .12); sound('heavy');
if (Math.abs(target.x - caster.x) < D.radius && Math.abs(target.y - caster.y) < 130 && bwAlive(target)) {
dotDamage(target, D.pulseDamage, '#cfe9a0'); bwLock(tk, D.gap + D.stunExtra); bwPoison(tk);
spawnParticles(target.x, target.y - 85, '#7be04a', 10, 200);
}
}
if (d.pulses >= D.pulses) bwx.dance.splice(n, 1);
}
// Holds: the rival stays in a hurt pose, without moving or acting, while a lock is active.
for (const key of ['player', 'enemy']) {
if (bwx.lock[key] <= 0) continue;
bwx.lock[key] = Math.max(0, bwx.lock[key] - dt);
if (key === 'enemy') { if (bwAlive(dummy)) { dummy.vx = 0; dummy.action = null; dummy.state = 'hurt'; dummy.stateTime = Math.min(dummy.stateTime, .2); } }
else if (hero.hp > 0) { hero.vx = 0; hero.action = null; hero.hurtTime = .25; if (hero.state !== 'hurt') state('hurt'); }
}
// Poison drain.
for (const key of ['player', 'enemy']) {
const p = bwx.poison[key]; if (!p) continue;
const target = key === 'player' ? hero : dummy;
p.t -= dt; p.acc += D.poisonDps * dt;
if (p.acc >= 1) { const n = Math.floor(p.acc); p.acc -= n; p.shown += n; dotDamage(target, n, '#7be04a', false); }
if (p.shown >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: p.shown, life: .9, maxLife: .9, color: '#7be04a' }); p.shown = 0; }
if (p.t <= 0 || !bwAlive(target)) bwx.poison[key] = null;
}
// Rage countdown.
for (const key of ['player', 'enemy']) if (bwx.rage[key] > 0) bwx.rage[key] = Math.max(0, bwx.rage[key] - dt);
// Camera eases toward the targets set by the ultimate; with no ultimate running they are neutral.
if (!sunroar && !enemySunroar) { bwx.zoomT = 1; bwx.dimT = 0; bwx.giant = null; }
const k = Math.min(1, dt * 7); bwx.zoom += (bwx.zoomT - bwx.zoom) * k; bwx.dim += (bwx.dimT - bwx.dim) * k;
if (Math.abs(bwx.zoom - 1) < .002 && bwx.zoomT === 1) bwx.zoom = 1; if (bwx.dim < .004 && bwx.dimT === 0) bwx.dim = 0;
}
// BRO WO Hutan Sawit: the group object is the shared `sunroar` slot, so pause/reset/cut-in plumbing is SOLAN's; `roars`
// counts the small palms raised and `giant` is the final palm.
function updateForest(dt, s) { function updateForest(dt, s) {
const U = BW.forest, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy; const U = BW.forest, a = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, tk = s.owner === 'enemy' ? 'player' : 'enemy', ok = s.owner === 'enemy' ? 'enemy' : 'player';
s.t += dt; s.t += dt;
while (s.roars < U.trees.length && s.t >= U.at + s.roars * U.gap) { const mx = (a.x + target.x) / 2;
const i = s.roars++, x = clamp(a.x + s.facing * U.trees[i], 70, W - 70); // Camera script.
effects.push({ type: 'browo-tree', x, y: CONFIG.groundY, life: .9, maxLife: .9 }); if (s.t < U.treesAt) { bwx.zoomT = 1.75; bwx.dimT = .82; bwx.cx = a.x; bwx.cy = a.y - 90; }
effects.push({ type: 'browo-fx', asset: 'browo-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .45, maxLife: .45, size: 240 }); else if (s.t < U.giantAt) { bwx.zoomT = 1.2; bwx.dimT = .78; bwx.cx = mx; bwx.cy = a.y - 90; }
spawnParticles(x, CONFIG.groundY - 10, '#a47a3a', 14, 260, 'dust'); trauma = Math.min(1, trauma + .25); sound('heavy'); else if (s.t < U.release + .35) { bwx.zoomT = 1.12; bwx.dimT = .7; bwx.cx = mx; bwx.cy = a.y - 130; }
if (Math.abs(target.x - x) < U.radius && target.y > CONFIG.groundY - 150) { else { bwx.zoomT = 1; bwx.dimT = 0; }
if (s.owner === 'enemy') window.__game.receiveHit(BW.balance.forestDamage); if (s.t >= .15 && s.t < U.treesAt) trauma = Math.min(.35, trauma + dt * .5);
else hitDummy(BW.balance.forestDamage, i === U.trees.length - 1 ? 190 : 50, '#9bd05a', x, CONFIG.groundY - 90, false, true); if (s.t >= U.lockAt && s.t < U.release && bwAlive(target)) bwLock(tk, .25);
// Ten palms in a rapid run from BRO WO toward (and a little past) the rival.
const dist = Math.abs(target.x - a.x);
while (s.roars < U.treeCount && s.t >= U.treesAt + s.roars * U.treeGap) {
const i = s.roars++, x = clamp(a.x + s.facing * (150 + i * (dist - 50) / (U.treeCount - 1)), 70, W - 70);
effects.push({ type: 'browo-tree', x, y: CONFIG.groundY, life: .6, maxLife: .6 });
effects.push({ type: 'browo-fx', asset: 'browo-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .4, maxLife: .4, size: 220 });
spawnParticles(x, CONFIG.groundY - 10, '#a47a3a', 10, 240, 'dust'); trauma = Math.min(1, trauma + .18); sound('hit');
if (bwAlive(target)) dotDamage(target, U.treeDamage, '#9bd05a');
}
// The giant palm: rises, leans back, then comes down on the rival.
if (!s.giant && s.t >= U.giantAt) {
s.giant = bwx.giant = { t: 0, x: a.x + s.facing * 26, dir: s.facing, L: clamp(dist * 1.7 + 300, 760, 1250), hit: false };
sound('heavy'); trauma = Math.min(1, trauma + .3);
}
if (s.giant) {
const g = s.giant; g.t += dt;
const slamAt = U.giantRise + U.giantHold;
if (!g.hit && g.t >= slamAt + U.slam) {
g.hit = true;
const tx = clamp(a.x + s.facing * dist, 70, W - 70);
effects.push({ type: 'browo-fx', asset: 'browo-impact', x: tx, y: CONFIG.groundY - 30, facing: 1, life: .6, maxLife: .6, size: 520 });
effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: tx, y: CONFIG.groundY - 120, facing: 1, life: .5, maxLife: .5, size: 520 });
for (let q = 0; q < 3; q++) spawnParticles(tx, CONFIG.groundY - 12, q === 1 ? '#fff1c8' : '#a47a3a', 22, 460, 'dust');
trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy');
if (bwAlive(target)) { if (target === dummy) { if (!hitDummy(U.giantDamage, 360, '#ffe08a', a.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, U.giantDamage, '#ffe08a'); } else if (!window.__game.receiveHit(U.giantDamage, { shake: 2 })) dotDamage(hero, U.giantDamage, '#ffe08a'); }
} }
} }
s.phase = s.t < U.cutinDuration ? 'cutin' : s.roars < U.trees.length ? 'roaring' : 'fading'; s.phase = s.t < U.cutinDuration ? 'cutin' : s.t < U.treesAt ? 'roaring' : 'fading';
if (s.t >= U.duration) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } if (s.t >= U.duration) {
bwx.rage[ok] = BW.rage.duration; bwx.giant = null; announce('MENGAMUK!', 1.3);
effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: a.x, y: a.y - 95, facing: 1, life: .6, maxLife: .6, size: 380 });
if (s.owner === 'enemy') enemySunroar = null; else sunroar = null;
}
} }
// NIB Special Delivery: three paper-plane parcels leave the satchel 0.38 s apart and home in on the rival (homing // NIB Special Delivery: three paper-plane parcels leave the satchel 0.38 s apart and home in on the rival (homing
// projectiles with a limited turn rate, 16 each, 48 total). NIB is free after the 0.5 s cast; a late sidestep or a // projectiles with a limited turn rate, 16 each, 48 total). NIB is free after the 0.5 s cast; a late sidestep or a
@ -813,7 +904,7 @@
if (!hero.action && hero.hurtTime <= 0 && hero.hp > 0) { if (!hero.action && hero.hurtTime <= 0 && hero.hp > 0) {
if (dir && hero.doublePose <= 0) hero.facing = dir; if (dir && hero.doublePose <= 0) hero.facing = dir;
if (hero.runDirection && dir !== hero.runDirection) hero.runDirection = 0; if (hero.runDirection && dir !== hero.runDirection) hero.runDirection = 0;
const speed = (hero.runDirection === dir && dir ? CONFIG.runSpeed : CONFIG.walkSpeed)*(selectedCharacter==='fenr'&&hero.form==='wolf'?1.08:1); const speed = (hero.runDirection === dir && dir ? CONFIG.runSpeed : CONFIG.walkSpeed)*(selectedCharacter==='fenr'&&hero.form==='wolf'?1.08:1)*(selectedCharacter==='browo'&&bwx.rage.player>0?BW.rage.speedMul:1);
hero.vx = approach(hero.vx, crouching ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600)); hero.vx = approach(hero.vx, crouching ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600));
if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle'); if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle');
else state(hero.doublePose > 0 ? 'doublejump' : 'jump'); else state(hero.doublePose > 0 ? 'doublejump' : 'jump');
@ -839,7 +930,7 @@
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'); } } 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); 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; 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); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); 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; 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;
if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice();
advanceMatch(dt); advanceMatch(dt);
@ -1120,6 +1211,11 @@
function browoStrike(actor,move) { function browoStrike(actor,move) {
const enemy=actor===dummy,target=enemy?hero:dummy,em=window.BROWO_METRICS?.emitters?.[move.name]; const enemy=actor===dummy,target=enemy?hero:dummy,em=window.BROWO_METRICS?.emitters?.[move.name];
const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40);
const raging=bwx.rage[enemy?'enemy':'player']>0,mul=raging?BW.rage.damageMul:1,dmg=Math.round(move.damage*mul);
if(move.dance){
// Joget Gemoy: the bursts are driven by updateBrowo (stun + poison); nothing lands at the cast itself.
bwx.dance.push({owner:enemy?'enemy':'player',t:0,pulses:0});sound('cast');return;
}
let hitX=null,inside; let hitX=null,inside;
if(move.tree){ if(move.tree){
// Pohon Sawit: a palm erupts BW.tree.ahead px in front; everything within the radius on the floor is hit. // Pohon Sawit: a palm erupts BW.tree.ahead px in front; everything within the radius on the floor is hit.
@ -1127,26 +1223,20 @@
effects.push({type:'browo-tree',x:hitX,y:CONFIG.groundY,life:.9,maxLife:.9}); effects.push({type:'browo-tree',x:hitX,y:CONFIG.groundY,life:.9,maxLife:.9});
effects.push({type:'browo-fx',asset:'browo-impact',x:hitX,y:CONFIG.groundY-24,facing:1,life:.45,maxLife:.45,size:240}); effects.push({type:'browo-fx',asset:'browo-impact',x:hitX,y:CONFIG.groundY-24,facing:1,life:.45,maxLife:.45,size:240});
dust(hitX,18);spawnParticles(hitX,CONFIG.groundY-10,'#a47a3a',18,300,'dust');trauma=Math.min(1,trauma+.3); dust(hitX,18);spawnParticles(hitX,CONFIG.groundY-10,'#a47a3a',18,300,'dust');trauma=Math.min(1,trauma+.3);
inside=Math.abs(target.x-hitX)<BW.tree.radius&&target.y>CONFIG.groundY-150; inside=Math.abs(target.x-hitX)<BW.tree.radius+(raging?20:0)&&target.y>CONFIG.groundY-150;
} else if(move.dance){
// Joget Gemoy: a leaf burst around BRO WO; everything within BW.dance.radius is hit.
hitX=actor.x;
effects.push({type:'browo-fx',asset:'browo-sunburst',x:actor.x,y:actor.y-95,facing:1,life:.5,maxLife:.5,size:300});
spawnParticles(actor.x,actor.y-95,'#8fd14f',16,260);trauma=Math.min(1,trauma+.2);
inside=Math.abs(target.x-actor.x)<BW.dance.radius&&Math.abs(target.y-actor.y)<130;
} else { } else {
effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:move.index===3?'#ffe7a0':'#f0dba5'}); effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:raging?'#ff9a5a':move.index===3?'#ffe7a0':'#f0dba5'});
if(move.type==='attack'&&move.index===3){dust(actor.x+actor.facing*(em?.x??95),12);trauma=Math.min(1,trauma+.15);} if(move.type==='attack'&&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; const dx=(target.x-actor.x)*actor.facing;
inside=dx>-22&&dx<move.reach+27&&Math.abs(target.y-actor.y)<=115; inside=dx>-22&&dx<move.reach+27+(raging?BW.rage.reachAdd:0)&&Math.abs(target.y-actor.y)<=115;
} }
if(!inside){sound(move.tree||move.dance?'heavy':'step');return;} if(!inside){sound(move.tree?'heavy':'step');return;}
const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.tree||move.dance?'#cfe9a0':'#f0dba5',hitX??actor.x,hy,false,true); const connected=enemy?window.__game.receiveHit(dmg):hitDummy(dmg,move.knockback*(raging?1.3:1),move.tree?'#cfe9a0':raging?'#ffb070':'#f0dba5',hitX??actor.x,hy,false,true);
if(connected&&move.type==='attack') { if(connected&&move.type==='attack') {
if(enemy)BW.refund(dummy); if(enemy)BW.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);} 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(move.tree||move.dance||move.index===3?'heavy':'hit'); if(connected)sound(move.tree||move.index===3?'heavy':'hit');
} }
function nibStrike(actor,move) { function nibStrike(actor,move) {
const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NIB_METRICS?.emitters?.[move.name]; const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NIB_METRICS?.emitters?.[move.name];
@ -1438,6 +1528,7 @@
let index=0; let index=0;
// Projectile casts that release at 50% show wind-up -> thrust -> full extension, like the player's pose. // Projectile casts that release at 50% show wind-up -> thrust -> full extension, like the player's pose.
if(dummy.action?.type==='skill1'&&opponentCharacter!=='fenr')index=Math.min(2,list.length-1,Math.floor(dummy.action.t/(dummy.action.duration*.5)*3)); if(dummy.action?.type==='skill1'&&opponentCharacter!=='fenr')index=Math.min(2,list.length-1,Math.floor(dummy.action.t/(dummy.action.duration*.5)*3));
else if(dummy.action?.loops)index=Math.floor(dummy.action.t/dummy.action.duration*list.length*dummy.action.loops)%list.length;
else if(dummy.action)index=Math.floor(dummy.action.t/dummy.action.duration*list.length); else if(dummy.action)index=Math.floor(dummy.action.t/dummy.action.duration*list.length);
else if(name==='walk'||name==='run')index=Math.floor(dummy.walkPhase*list.length)%list.length; else if(name==='walk'||name==='run')index=Math.floor(dummy.walkPhase*list.length)%list.length;
else if(name==='down')index=Math.floor(dummy.stateTime/.55*list.length); else if(name==='down')index=Math.floor(dummy.stateTime/.55*list.length);
@ -1492,6 +1583,7 @@
result.phase = 'somersault'; result.phase = 'somersault';
} else if (hero.state === 'walk' || hero.state === 'run') index = Math.floor(hero.walkPhase * list.length) % list.length; } else if (hero.state === 'walk' || hero.state === 'run') index = Math.floor(hero.walkPhase * list.length) % list.length;
else if (hero.action?.type === 'skill1') index = Math.min(2,list.length-1,Math.floor(hero.action.t/(hero.action.duration*.5)*3)); else if (hero.action?.type === 'skill1') index = Math.min(2,list.length-1,Math.floor(hero.action.t/(hero.action.duration*.5)*3));
else if (hero.action?.loops) index = Math.floor(hero.action.t / hero.action.duration * list.length * hero.action.loops) % list.length;
else if (hero.action) index = Math.min(list.length - 1, Math.floor(hero.action.t / hero.action.duration * list.length)); else if (hero.action) index = Math.min(list.length - 1, Math.floor(hero.action.t / hero.action.duration * list.length));
else if (row.loop === false) index = Math.min(list.length - 1, Math.floor(hero.animTime * (row.fps || 8))); else if (row.loop === false) index = Math.min(list.length - 1, Math.floor(hero.animTime * (row.fps || 8)));
else index = Math.floor(hero.animTime * (row.fps || 8)) % list.length; else index = Math.floor(hero.animTime * (row.fps || 8)) % list.length;
@ -1522,6 +1614,55 @@
ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore(); ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore();
} }
} }
// BRO WO extras: poison aura on a drained rival, rage aura + palm-tree club on BRO WO, and the giant palm.
// The giant palm of the ultimate: behind the fighters while it rises and leans back, in front of them as it comes down.
function drawBrowoGiant(front) {
const gnt = bwx.giant, im = images['fx-browo-sawit']; if (!gnt || !im) return;
const U = BW.forest, t = gnt.t, slamning = t >= U.giantRise + U.giantHold; if (slamning !== front) return;
const riseK = clamp(t / U.giantRise, 0, 1), eRise = 1 - Math.pow(1 - riseK, 3), holdT = clamp((t - U.giantRise) / U.giantHold, 0, 1), slamT = clamp((t - U.giantRise - U.giantHold) / U.slam, 0, 1);
let ang = -.12 * eRise - .22 * holdT; if (slamT > 0) ang = -.34 + (Math.PI / 2 + .34) * slamT * slamT;
const after = t - (U.giantRise + U.giantHold + U.slam), alpha = after > 0 ? clamp(1 - after / .5, 0, 1) : 1;
if (alpha <= 0) return;
const L = gnt.L * eRise, w = im.width / im.height * gnt.L * .9, shake = t > U.giantRise && slamT === 0 ? Math.sin(realTime * 70) * 2 : 0;
ctx.save(); ctx.globalAlpha = alpha; ctx.translate(gnt.x + shake, CONFIG.groundY + 8); ctx.rotate(gnt.dir * ang); ctx.drawImage(im, -w / 2, -L, w, L); ctx.restore();
}
function drawBrowoExtras() {
if (!BW) return;
const bubbles = (a, color) => { for (let i = 0; i < 6; i++) { const ph = (realTime * .9 + i * .17) % 1, bx = a.x + Math.sin(realTime * 3 + i * 2.1) * 26 + (i - 3) * 6, by = a.y - 15 - ph * 130; ctx.globalAlpha = (1 - ph) * .85; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(bx, by, 4 + (i % 3) * 2, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; };
for (const key of ['player', 'enemy']) {
const a = key === 'player' ? hero : dummy;
if (bwx.poison[key] && bwAlive(a)) {
const g = ctx.createRadialGradient(a.x, a.y - 75, 10, a.x, a.y - 75, 95); g.addColorStop(0, 'rgba(120,230,60,.36)'); g.addColorStop(1, 'rgba(120,230,60,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y - 75, 95, 0, 7); ctx.fill(); bubbles(a, '#9be84f');
}
}
const im = images['fx-browo-sawit'];
for (const key of ['player', 'enemy']) {
if (bwx.rage[key] <= 0) continue;
const a = key === 'player' ? hero : dummy; if (!bwAlive(a)) continue;
const pulse = .75 + Math.sin(realTime * 9) * .25, g = ctx.createRadialGradient(a.x, a.y - 80, 8, a.x, a.y - 80, 125);
g.addColorStop(0, `rgba(255,110,40,${.42 * pulse})`); g.addColorStop(1, 'rgba(255,60,20,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y - 80, 125, 0, 7); ctx.fill();
if (im) {
// The club: a palm tree held in the front hand, leaning back at rest and swinging through every attack.
let ang = -.38; const act = a.action;
if (act && act.type === 'attack') { const p = clamp(act.t / act.duration, 0, 1), e = 1 - Math.pow(1 - p, 2); ang = -1.15 + (act.index === 2 ? 2.7 : 2.3) * e; }
else if (act && act.type === 'skill1') { const p = clamp(act.t / act.duration, 0, 1); ang = -.9 + 2.2 * p * p; }
else if (act) ang = -.2;
const h = 190, w = im.width / im.height * h;
ctx.save(); ctx.translate(a.x + a.facing * 34, a.y - 72); ctx.rotate(a.facing * ang); ctx.drawImage(im, -w / 2, -h + 14, w, h); ctx.restore();
}
if (Math.sin(realTime * 40) > .6) spawnParticles(a.x + (random() - .5) * 50, a.y - 40 - random() * 80, '#ff8a3a', 1, 80);
}
}
// Small palms erupt behind both fighters so BRO WO and the rival stay readable.
function drawBrowoTrees() {
const im = images['fx-browo-sawit']; if (!im) return;
for (const e of effects) {
if (e.type !== 'browo-tree') continue;
const t = 1 - e.life / e.maxLife, k = Math.min(1, t * 7) * (t > .7 ? 1 - (t - .7) / .3 : 1), h = im.height * .62, w = im.width * .62;
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();
}
}
function drawEffects() { function drawEffects() {
for (const p of projectiles) { for (const p of projectiles) {
if(p.asset && images['fx-'+p.asset]){const w=p.size||84,h=p.size||76;ctx.save();ctx.translate(p.x,p.y);ctx.scale(p.facing,1);if(p.vy)ctx.rotate(Math.atan2(p.vy,Math.abs(p.vx)));if(p.spin)ctx.rotate(realTime*p.spin);ctx.drawImage(images['fx-'+p.asset],-w/2,-h/2,w,h);ctx.restore();continue;} if(p.asset && images['fx-'+p.asset]){const w=p.size||84,h=p.size||76;ctx.save();ctx.translate(p.x,p.y);ctx.scale(p.facing,1);if(p.vy)ctx.rotate(Math.atan2(p.vy,Math.abs(p.vx)));if(p.spin)ctx.rotate(realTime*p.spin);ctx.drawImage(images['fx-'+p.asset],-w/2,-h/2,w,h);ctx.restore();continue;}
@ -1533,7 +1674,6 @@
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'; 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') && 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. // BRO WO's palm erupts from the floor (clipped at ground level), holds, then sinks back as it fades.
if(e.type==='browo-tree'&&images['fx-browo-sawit']){const im=images['fx-browo-sawit'],k=Math.min(1,t*7)*(t>.7?1-(t-.7)/.3:1),h=im.height*.62,w=im.width*.62;ctx.globalAlpha=1;ctx.beginPath();ctx.rect(-w,-h-10,w*2,h+10);ctx.clip();ctx.drawImage(im,-w/2,-h*k+ (1-k)*0,w,h);}
// EDDA's tortoise spirit keeps one size; it fades in and out and lifts before each stomp. // 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);} 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);}
if (e.type === 'slash') { ctx.scale(e.facing, 1); ctx.rotate(e.index === 2 ? -.5 : .15); ctx.strokeStyle = e.color; ctx.lineWidth = 9 * (1 - t) + 2; ctx.beginPath(); ctx.arc(10, 4, 55 + e.index * 10, -1.2 + t * .3, 1.0 + t * .4); ctx.stroke(); ctx.strokeStyle = '#ffffffaa'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(4, 4, 46 + e.index * 10, -.85, .7); ctx.stroke(); } if (e.type === 'slash') { ctx.scale(e.facing, 1); ctx.rotate(e.index === 2 ? -.5 : .15); ctx.strokeStyle = e.color; ctx.lineWidth = 9 * (1 - t) + 2; ctx.beginPath(); ctx.arc(10, 4, 55 + e.index * 10, -1.2 + t * .3, 1.0 + t * .4); ctx.stroke(); ctx.strokeStyle = '#ffffffaa'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(4, 4, 46 + e.index * 10, -.85, .7); ctx.stroke(); }
@ -1638,11 +1778,23 @@
function draw() { function draw() {
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH);
const shakeX = Math.sin(realTime * 93) * trauma * trauma * 13, shakeY = Math.cos(realTime * 111) * trauma * trauma * 9; const shakeX = Math.sin(realTime * 93) * trauma * trauma * 13, shakeY = Math.cos(realTime * 111) * trauma * trauma * 9;
drawBackground(shakeX, shakeY); ctx.save(); ctx.translate(offsetX + shakeX * scale, offsetY + shakeY * scale); ctx.scale(scale, scale); // BRO WO's ultimate dives the camera onto him: the whole picture (stage and fighters) is zoomed around a point that eases
// toward the screen centre, clamped so the zoomed stage never leaves an empty edge; the stage is then darkened.
const camera = bwx.zoom > 1.002 || bwx.dim > .004;
if (camera) {
const sx = offsetX + bwx.cx * scale, sy = offsetY + bwx.cy * scale, z = bwx.zoom, w = clamp((z - 1) / .75, 0, 1);
const ax = clamp(sx + (viewW / 2 - sx) * w, viewW - z * (viewW - sx), z * sx), ay = clamp(sy + (viewH * .55 - sy) * w, viewH - z * (viewH - sy), z * sy);
ctx.save(); ctx.translate(ax, ay); ctx.scale(z, z); ctx.translate(-sx, -sy);
}
drawBackground(shakeX, shakeY);
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. // 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); } 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(); drawBrowoGiant(false);
if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); }
drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawHitboxes(); ctx.restore(); updateHud(); drawBrowoGiant(true);
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) { 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')};} 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')};}
@ -1672,7 +1824,7 @@
$('.fighter-two').setAttribute('aria-label','Status lawan '+opponentCharacter.toUpperCase()); $('.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]);}); 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'?'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':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));} 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() { function updateHud() {
@ -1726,7 +1878,7 @@
const ct=fenrCutin?.t ?? (arcoGroup||summon)?.t ?? 100; const ct=fenrCutin?.t ?? (arcoGroup||summon)?.t ?? 100;
cutin.classList.toggle('fenr-cutin',cutinKey==='fenr');cutin.classList.toggle('mira-cutin',cutinKey==='mira');cutin.classList.toggle('cora-cutin',cutinKey==='cora');cutin.classList.toggle('naja-cutin',cutinKey==='naja');cutin.classList.toggle('haldor-cutin',cutinKey==='haldor');cutin.classList.toggle('zanni-cutin',cutinKey==='zanni');cutin.classList.toggle('isolde-cutin',cutinKey==='isolde');cutin.classList.toggle('rhea-cutin',cutinKey==='rhea');cutin.classList.toggle('solan-cutin',cutinKey==='solan');cutin.classList.toggle('nib-cutin',cutinKey==='nib');cutin.classList.toggle('edda-cutin',cutinKey==='edda');cutin.classList.toggle('browo-cutin',cutinKey==='browo'); cutin.classList.toggle('fenr-cutin',cutinKey==='fenr');cutin.classList.toggle('mira-cutin',cutinKey==='mira');cutin.classList.toggle('cora-cutin',cutinKey==='cora');cutin.classList.toggle('naja-cutin',cutinKey==='naja');cutin.classList.toggle('haldor-cutin',cutinKey==='haldor');cutin.classList.toggle('zanni-cutin',cutinKey==='zanni');cutin.classList.toggle('isolde-cutin',cutinKey==='isolde');cutin.classList.toggle('rhea-cutin',cutinKey==='rhea');cutin.classList.toggle('solan-cutin',cutinKey==='solan');cutin.classList.toggle('nib-cutin',cutinKey==='nib');cutin.classList.toggle('edda-cutin',cutinKey==='edda');cutin.classList.toggle('browo-cutin',cutinKey==='browo');
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'||cutinKey==='solan'||cutinKey==='browo'||cutinKey==='nib'||cutinKey==='edda'?(summon.owner==='enemy'?'RIVAL':'PLAYER'):''; 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'||cutinKey==='solan'||cutinKey==='browo'||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,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL<br><em>AWAKENING</em>',mira:'ROCKET<br><em>PARADE</em>',cora:'NIGHT<br><em>MURMURATION</em>',naja:'DUNE<br><em>SERPENT</em>',haldor:'FORGE<br><em>QUAKE</em>',zanni:'GRAND<br><em>FINALE</em>',isolde:'SKYFALL<br><em>LANCES</em>',rhea:'GRAND<br><em>ORRERY</em>',solan:'SUNMANE<br><em>ROAR</em>',browo:'HUTAN<br><em>SAWIT</em>',nib:'SPECIAL<br><em>DELIVERY</em>',edda:'ELDER<br><em>TORTOISE</em>',arco:'HELIOS<br><em>SQUADRON</em>'}[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:'FOUR-PALM FOREST',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',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);} 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,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL<br><em>AWAKENING</em>',mira:'ROCKET<br><em>PARADE</em>',cora:'NIGHT<br><em>MURMURATION</em>',naja:'DUNE<br><em>SERPENT</em>',haldor:'FORGE<br><em>QUAKE</em>',zanni:'GRAND<br><em>FINALE</em>',isolde:'SKYFALL<br><em>LANCES</em>',rhea:'GRAND<br><em>ORRERY</em>',solan:'SUNMANE<br><em>ROAR</em>',browo:'HUTAN<br><em>SAWIT</em>',nib:'SPECIAL<br><em>DELIVERY</em>',edda:'ELDER<br><em>TORTOISE</em>',arco:'HELIOS<br><em>SQUADRON</em>'}[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',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',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);}
const visible = ct < SQUAD.cutinDuration; const visible = ct < SQUAD.cutinDuration;
cutin.classList.toggle('visible', visible); cutin.setAttribute('aria-hidden', String(!visible)); 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; const slide = !visible ? -110 : ct < .16 ? -110 * Math.pow(1 - ct / .16,3) : ct > .57 ? 110 * Math.pow((ct-.57)/.21,2) : 0;

View File

@ -12,7 +12,7 @@
...(window.Zanni&&window.ZANNI_MANIFEST?{zanni:{name:'ZANNI',tag:'THE CLOCKWORK JESTER',race:'MECHA',portrait:'assets/zanni/ui/portrait.webp',art:'assets/menu/zanni-select.webp',detail:'Arms that stretch. Jokes that sting.',basic:'SCISSOR JAB',ultimate:'GRAND FINALE',style:'Trickster / extending scissor-arm strikes and bladed rings',color:'#c9d64a'}}:{}), ...(window.Zanni&&window.ZANNI_MANIFEST?{zanni:{name:'ZANNI',tag:'THE CLOCKWORK JESTER',race:'MECHA',portrait:'assets/zanni/ui/portrait.webp',art:'assets/menu/zanni-select.webp',detail:'Arms that stretch. Jokes that sting.',basic:'SCISSOR JAB',ultimate:'GRAND FINALE',style:'Trickster / extending scissor-arm strikes and bladed rings',color:'#c9d64a'}}:{}),
...(window.Isolde&&window.ISOLDE_MANIFEST?{isolde:{name:'ISOLDE',tag:'THE WHITE STRIDER',race:'MECHA',portrait:'assets/isolde/ui/portrait.webp',art:'assets/menu/isolde-select.webp',detail:'Long legs, longer lance, zero mercy.',basic:'LANCE LINE',ultimate:'SKYFALL LANCES',style:'Long-range lancer / stilt-leg charges',color:'#9fd4ff'}}:{}), ...(window.Isolde&&window.ISOLDE_MANIFEST?{isolde:{name:'ISOLDE',tag:'THE WHITE STRIDER',race:'MECHA',portrait:'assets/isolde/ui/portrait.webp',art:'assets/menu/isolde-select.webp',detail:'Long legs, longer lance, zero mercy.',basic:'LANCE LINE',ultimate:'SKYFALL LANCES',style:'Long-range lancer / stilt-leg charges',color:'#9fd4ff'}}:{}),
...(window.Rhea&&window.RHEA_MANIFEST?{rhea:{name:'RHEA',tag:'THE LITTLE ORRERY',race:'MECHA',portrait:'assets/rhea/ui/portrait.webp',art:'assets/menu/rhea-select.webp',detail:'Homework done. Planets armed. Class dismissed.',basic:'ORBIT STRIKE',ultimate:'GRAND ORRERY',style:'Zoner / orbiting orrery spheres',color:'#c7485e'}}:{}), ...(window.Rhea&&window.RHEA_MANIFEST?{rhea:{name:'RHEA',tag:'THE LITTLE ORRERY',race:'MECHA',portrait:'assets/rhea/ui/portrait.webp',art:'assets/menu/rhea-select.webp',detail:'Homework done. Planets armed. Class dismissed.',basic:'ORBIT STRIKE',ultimate:'GRAND ORRERY',style:'Zoner / orbiting orrery spheres',color:'#c7485e'}}:{}),
...(window.Browo&&window.BROWO_MANIFEST?{browo:{name:'BRO WO',tag:'THE PALM RANGER',race:'HUMAN',portrait:'assets/browo/ui/portrait.webp',art:'assets/menu/browo-select.webp',detail:'Steady boots. Wide hat. Whole forests.',basic:'TINJU SAFARI',ultimate:'HUTAN SAWIT',style:'Bruiser / palm trees and area slam',color:'#c9b87a'}}:{}), ...(window.Browo&&window.BROWO_MANIFEST?{browo:{name:'BRO WO',tag:'THE PALM RANGER',race:'HUMAN',portrait:'assets/browo/ui/portrait.webp',art:'assets/menu/browo-select.webp',detail:'Steady boots. Wide hat. Whole forests, then a rampage.',basic:'TINJU SAFARI',ultimate:'HUTAN SAWIT',style:'Bruiser / palm trees and area slam',color:'#c9b87a'}}:{}),
...(window.Solan&&window.SOLAN_MANIFEST?{solan:{name:'SOLAN',tag:'THE SUNMANE',race:'DEMI-HUMAN',portrait:'assets/solan/ui/portrait.webp',art:'assets/menu/solan-select.webp',detail:'Golden mane, heavy blade, louder roar.',basic:'SUNBLADE',ultimate:'SUNMANE ROAR',style:'Powerhouse / greatsword and roar',color:'#f2b632'}}:{}), ...(window.Solan&&window.SOLAN_MANIFEST?{solan:{name:'SOLAN',tag:'THE SUNMANE',race:'DEMI-HUMAN',portrait:'assets/solan/ui/portrait.webp',art:'assets/menu/solan-select.webp',detail:'Golden mane, heavy blade, louder roar.',basic:'SUNBLADE',ultimate:'SUNMANE ROAR',style:'Powerhouse / greatsword and roar',color:'#f2b632'}}:{}),
...(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.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'}}:{}) ...(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'}}:{})