diff --git a/assets/browo/ui/cutin-fire.webp b/assets/browo/ui/cutin-fire.webp new file mode 100644 index 0000000..2c10bd5 Binary files /dev/null and b/assets/browo/ui/cutin-fire.webp differ diff --git a/assets/browo/ui/fx-burntree.webp b/assets/browo/ui/fx-burntree.webp new file mode 100644 index 0000000..471f9f9 Binary files /dev/null and b/assets/browo/ui/fx-burntree.webp differ diff --git a/assets/browo/ui/fx-tornado.webp b/assets/browo/ui/fx-tornado.webp new file mode 100644 index 0000000..64e98b2 Binary files /dev/null and b/assets/browo/ui/fx-tornado.webp differ diff --git a/assets/browo/ui/fx-truck.webp b/assets/browo/ui/fx-truck.webp new file mode 100644 index 0000000..d5c2541 Binary files /dev/null and b/assets/browo/ui/fx-truck.webp differ diff --git a/assets/browo/ui/icon-rage-skill1.webp b/assets/browo/ui/icon-rage-skill1.webp new file mode 100644 index 0000000..730b6d6 Binary files /dev/null and b/assets/browo/ui/icon-rage-skill1.webp differ diff --git a/assets/browo/ui/icon-rage-skill2.webp b/assets/browo/ui/icon-rage-skill2.webp new file mode 100644 index 0000000..ab7f7a7 Binary files /dev/null and b/assets/browo/ui/icon-rage-skill2.webp differ diff --git a/assets/browo/ui/icon-rage-ultimate.webp b/assets/browo/ui/icon-rage-ultimate.webp new file mode 100644 index 0000000..75e57a4 Binary files /dev/null and b/assets/browo/ui/icon-rage-ultimate.webp differ diff --git a/browo.js b/browo.js index bd9c315..24d2714 100644 --- a/browo.js +++ b/browo.js @@ -25,20 +25,40 @@ 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. + // Mengamuk: for `duration` s after the ultimate BRO WO is stronger, longer-reaching and faster, and his three specials are + // replaced (skills 1 and 2 are refreshed with their own cooldowns, the ultimate is usable once and ends the rampage): + // skill1 TRUK KATERING - a delivery truck crosses the whole stage at `speed` px/s, hits once (`damage`, knockdown). + // skill2 HUTAN TERBAKAR - `palms` palms catch fire in a row ahead; each burns an area for `life` s at `dps` HP/s. + // ultimate TORNADO API SAWIT - a fire tornado hunts the rival, holds and burns them, then explodes. const rage = { duration:10, damageMul:1.4, speedMul:1.25, reachAdd:25 }; + const rageNames = ['TINJU API','TRUK KATERING','HUTAN TERBAKAR','TORNADO API SAWIT']; + const rageBalance = { cooldowns:{skill1:5,skill2:8,ultimate:999} }; + const truck = { speed:1250, damage:24, tall:120, size:360 }; + const fire = { palms:5, first:130, gap:105, radius:70, ignite:8, dps:6, life:3.6, delay:.09 }; + // Tornado, seconds from the cast: the camera dives on BRO WO (`spawnAt`), the tornado is born and then chases the rival at + // `chase` px/s for `life` s; inside `radius` the rival is held, pulled to its core and burned for `tickDamage` every `tick` s. + // It ends in an explosion (`blast`, knockdown) and the rampage ends with it. + const tornado = { cutinDuration:.78, spawnAt:.85, life:3.6, chase:330, radius:150, tick:.22, tickDamage:4, blast:30, tail:.6 }; + tornado.duration = tornado.spawnAt + tornado.life + tornado.tail; function move(name,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==='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}; } + // While `a.bwRage` is set (the game keeps it in step with the rampage) the three specials are the rage versions. + function rmove(name) { + if(name==='skill1')return {name,type:name,damage:truck.damage,duration:.6,knockback:320,truck:true,rage:true,reach:900,hitAt:.5}; + if(name==='skill2')return {name,type:name,damage:fire.ignite,duration:.62,knockback:120,fire:true,rage:true,reach:fire.first+fire.gap*fire.palms,hitAt:.55}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1,rage:true}; + } function 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]=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 rg=!!a.bwRage&&name!=='attack'; + if(name!=='attack')a.cooldowns[name]=rg?rageBalance.cooldowns[name]:balance.cooldowns[name]; + a.action={...(rg?rmove(name):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);} - window.Browo={balance,names,combo,tree,dance,forest,rage,move,start,refund}; + window.Browo={balance,names,combo,tree,dance,forest,rage,rageNames,rageBalance,truck,fire,tornado,move,rmove,start,refund}; })(); diff --git a/game.js b/game.js index 212d92c..da45ecc 100644 --- a/game.js +++ b/game.js @@ -674,14 +674,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' }; + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, roars: 0, phase: 'cutin', kind: actor.bwRage ? 'tornado' : 'forest' }; if (actor === hero) sunroar = group; else enemySunroar = group; cinematic = .9; announceTimer = 0; startUltimateVoice((actor === hero ? selectedCharacter : opponentCharacter) === 'browo' ? 'browo' : 'solan', actor); } function updateSunroar(dt, s) { if (!s) return; - if ((s.owner === 'enemy' ? opponentCharacter : selectedCharacter) === 'browo') { updateForest(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; while (s.roars < U.roars.length && s.t >= U.roars[s.roars]) { @@ -696,8 +696,26 @@ } // ---- 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 }; - 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; } + 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; + hero.bwRage = dummy.bwRage = false; hero.bwSaved = dummy.bwSaved = null; + if (BW && selectedCharacter === 'browo') Object.assign(cooldownMax, BW.balance.cooldowns); + } + // Rage swaps the specials: skills 1/2 and the ultimate start fresh (their own cooldowns, see BW.rageBalance); the normal + // cooldowns are put back when the rampage ends. + function bwEnterRage(key) { + const a = key === 'player' ? hero : dummy; bwx.rage[key] = BW.rage.duration; + a.bwSaved = { skill1: a.cooldowns.skill1, skill2: a.cooldowns.skill2, ultimate: a.cooldowns.ultimate }; + a.cooldowns.skill1 = a.cooldowns.skill2 = a.cooldowns.ultimate = 0; a.bwRage = true; + if (key === 'player') Object.assign(cooldownMax, BW.rageBalance.cooldowns); + } + function bwExitRage(key) { + const a = key === 'player' ? hero : dummy; bwx.rage[key] = 0; a.bwRage = false; + if (a.bwSaved) { for (const n of ['skill1', 'skill2', 'ultimate']) a.cooldowns[n] = a.bwSaved[n]; a.bwSaved = null; } + if (key === 'player' && BW) Object.assign(cooldownMax, BW.balance.cooldowns); + } 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) { @@ -717,6 +735,7 @@ 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; + hero.bwRage = selectedCharacter === 'browo' && bwx.rage.player > 0; dummy.bwRage = opponentCharacter === 'browo' && bwx.rage.enemy > 0; // 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'; @@ -750,9 +769,25 @@ 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); + for (const key of ['player', 'enemy']) if (bwx.rage[key] > 0) { bwx.rage[key] = Math.max(0, bwx.rage[key] - dt); if (bwx.rage[key] === 0) bwExitRage(key); } + // Hutan Terbakar: each palm ignites once, then burns anyone standing on the ground inside its radius (one burn per rival). + const F2 = BW.fire, inside = { player: false, enemy: false }; + for (let n = bwx.fires.length - 1; n >= 0; n--) { + const f = bwx.fires[n], target = f.owner === 'enemy' ? hero : dummy, tk = f.owner === 'enemy' ? 'player' : 'enemy'; + f.t += dt; if (f.t < 0) continue; + const near = Math.abs(target.x - f.x) < F2.radius && target.y > CONFIG.groundY - 120 && bwAlive(target); + if (!f.lit) { f.lit = true; effects.push({ type: 'browo-fx', asset: 'browo-impact', x: f.x, y: CONFIG.groundY - 30, facing: 1, life: .4, maxLife: .4, size: 200 }); spawnParticles(f.x, CONFIG.groundY - 40, '#ff8a3a', 10, 220); sound('hit'); if (near) dotDamage(target, F2.ignite, '#ff9a4a'); } + if (near) inside[tk] = true; + if (f.t > F2.life) bwx.fires.splice(n, 1); + } + for (const key of ['player', 'enemy']) { + if (!inside[key]) continue; + const target = key === 'player' ? hero : dummy; bwx.burn[key] += F2.dps * dt; + if (bwx.burn[key] >= 1) { const m = Math.floor(bwx.burn[key]); bwx.burn[key] -= m; bwx.burn.shown[key] += m; dotDamage(target, m, '#ff9a4a', false); } + if (bwx.burn.shown[key] >= 3) { damageNumbers.push({ x: target.x + (random() - .5) * 24, y: target.y - 130, amount: bwx.burn.shown[key], life: .9, maxLife: .9, color: '#ff9a4a' }); bwx.burn.shown[key] = 0; } + } // 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; } + if (!sunroar && !enemySunroar) { bwx.zoomT = 1; bwx.dimT = 0; bwx.giant = null; bwx.tornado = 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; } @@ -798,11 +833,48 @@ } s.phase = s.t < U.cutinDuration ? 'cutin' : s.t < U.treesAt ? 'roaring' : 'fading'; if (s.t >= U.duration) { - bwx.rage[ok] = BW.rage.duration; bwx.giant = null; announce('MENGAMUK!', 1.3); + bwEnterRage(ok); 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; } } + // BRO WO rage ultimate, Tornado Api Sawit: the camera dives on him, a fire tornado is born and hunts the rival; inside its + // radius the rival is held, dragged to the core and burned; it ends in an explosion (and the rampage ends with it). + function updateTornado(dt, s) { + const U = BW.tornado, 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; + if (s.t < U.spawnAt) { bwx.zoomT = 1.65; bwx.dimT = .85; bwx.cx = a.x; bwx.cy = a.y - 90; if (s.t > .12) trauma = Math.min(.4, trauma + dt * .6); } + else if (s.t < U.spawnAt + U.life) { bwx.zoomT = 1.18; bwx.dimT = .6; bwx.cx = s.tw ? s.tw.x : a.x; bwx.cy = a.y - 120; } + else { bwx.zoomT = 1; bwx.dimT = 0; } + if (!s.tw && s.t >= U.spawnAt) { + s.tw = bwx.tornado = { x: clamp(a.x + s.facing * 70, 60, W - 60), age: 0, tick: 0, blasted: false }; + effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: s.tw.x, y: CONFIG.groundY - 100, facing: 1, life: .6, maxLife: .6, size: 460 }); + sound('heavy'); trauma = Math.min(1, trauma + .45); + } + const tw = s.tw; + if (tw) { + tw.age += dt; + if (tw.age < U.life) { + if (bwAlive(target)) tw.x += clamp(target.x - tw.x, -U.chase * dt, U.chase * dt); + tw.x = clamp(tw.x, 60, W - 60); + if (bwAlive(target) && Math.abs(target.x - tw.x) < U.radius) { + bwLock(tk, .25); target.x += (tw.x - target.x) * Math.min(1, dt * 3.2); target.vx = 0; + tw.tick -= dt; if (tw.tick <= 0) { tw.tick += U.tick; dotDamage(target, U.tickDamage, '#ff9a4a'); sound('hit'); } + } + if (Math.sin(realTime * 33) > .5) spawnParticles(tw.x + (random() - .5) * 90, CONFIG.groundY - random() * 260, '#ff7a30', 1, 160); + trauma = Math.min(.5, trauma + dt * .4); + } else if (!tw.blasted) { + tw.blasted = true; + effects.push({ type: 'browo-fx', asset: 'browo-impact', x: tw.x, y: CONFIG.groundY - 40, facing: 1, life: .6, maxLife: .6, size: 560 }); + effects.push({ type: 'browo-fx', asset: 'browo-sunburst', x: tw.x, y: CONFIG.groundY - 150, facing: 1, life: .55, maxLife: .55, size: 620 }); + for (let q = 0; q < 3; q++) spawnParticles(tw.x, CONFIG.groundY - 60, q === 1 ? '#fff1c8' : '#ff7a30', 24, 520); + trauma = 1; hitstop = Math.max(hitstop, .14); sound('heavy'); + if (bwAlive(target) && Math.abs(target.x - tw.x) < U.radius * 1.4) { if (target === dummy) { if (!hitDummy(U.blast, 400, '#ffb060', tw.x, CONFIG.groundY - 100, true, true, 2)) dotDamage(dummy, U.blast, '#ffb060'); } else if (!window.__game.receiveHit(U.blast, { shake: 2 })) dotDamage(hero, U.blast, '#ffb060'); } + } + } + s.phase = s.t < U.cutinDuration ? 'cutin' : 'roaring'; + if (s.t >= U.duration) { bwx.tornado = null; bwExitRage(ok); 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 // projectiles with a limited turn rate, 16 each, 48 total). NIB is free after the 0.5 s cast; a late sidestep or a // jump makes a plane overshoot. @@ -983,11 +1055,11 @@ } if (p.spent) { if (p.life <= 0) projectiles.splice(i, 1); continue; } if(p.owner==='enemy') { - if(Math.min(oldX,p.x)<=hero.x+28&&Math.max(oldX,p.x)>=hero.x-28&&Math.abs(p.y-(hero.y-85))<(p.tall||75)&&window.__game.receiveHit(p.damage,{projectile:true,freeze:p.freeze}))p.boomerang?(p.spent=true,p.returning=true):p.orbit?(p.spent=true):p.life=0; + if(Math.min(oldX,p.x)<=hero.x+28&&Math.max(oldX,p.x)>=hero.x-28&&Math.abs(p.y-(hero.y-85))<(p.tall||75)&&window.__game.receiveHit(p.damage,{projectile:true,freeze:p.freeze}))p.boomerang?(p.spent=true,p.returning=true):(p.orbit||p.pass)?(p.spent=true):p.life=0; if(p.life<=0||p.x<-100||p.x>W+100)projectiles.splice(i,1);continue; } // Swept x interval keeps a high-speed bolt from tunneling through a target. - if (Math.min(oldX, p.x) <= dummy.x + 30 && Math.max(oldX, p.x) >= dummy.x - 30 && Math.abs(p.y - (dummy.y - (F?95:68))) < (p.tall || (F?90:66)) && dummy.state !== 'down' && dummy.state !== 'recover') { if (hitDummy(p.damage||BALANCE.skill1.damage, p.knockback ?? BALANCE.skill1.knockback, p.color || '#94ffde', oldX, p.y, false, false)) { if (p.boomerang) { p.spent = true; p.returning = true; } else if (p.orbit) p.spent = true; else p.life = 0; } } + if (Math.min(oldX, p.x) <= dummy.x + 30 && Math.max(oldX, p.x) >= dummy.x - 30 && Math.abs(p.y - (dummy.y - (F?95:68))) < (p.tall || (F?90:66)) && dummy.state !== 'down' && dummy.state !== 'recover') { if (hitDummy(p.damage||BALANCE.skill1.damage, p.knockback ?? BALANCE.skill1.knockback, p.color || '#94ffde', oldX, p.y, !!p.knockdown, false)) { if (p.boomerang) { p.spent = true; p.returning = true; } else if (p.orbit || p.pass) p.spent = true; else p.life = 0; } } if (p.life <= 0 || p.x < -100 || p.x > W + 100) projectiles.splice(i, 1); } } @@ -1212,6 +1284,17 @@ 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 raging=bwx.rage[enemy?'enemy':'player']>0,mul=raging?BW.rage.damageMul:1,dmg=Math.round(move.damage*mul); + if(move.truck){ + // Truk Katering: a delivery truck crosses the whole stage from behind BRO WO and hits once (it keeps going after the hit). + const dir=actor.facing; + projectiles.push({x:dir>0?-92:W+92,y:CONFIG.groundY-62,vx:dir*BW.truck.speed,life:(W+210)/BW.truck.speed,facing:dir,owner:enemy?'enemy':'player',damage:Math.round(move.damage*mul),knockback:move.knockback,asset:'browo-truck',size:BW.truck.size,color:'#ffb060',tall:BW.truck.tall,pass:true,knockdown:true}); + trauma=Math.min(1,trauma+.35);sound('heavy');return; + } + if(move.fire){ + // Hutan Terbakar: palms catch fire in a row ahead of BRO WO (updateBrowo does the burning). + for(let i=0;i U.life ? Math.max(0, 1 - (tw.age - U.life) / .45) : 1; + for (const [sc, off, al] of [[1, 0, .88], [.78, 3, .5]]) { + const h = 580 * grow * sc, w = ti.width / ti.height * h * (1 + Math.sin(realTime * 13 + off) * .05); + ctx.save(); ctx.globalAlpha = al * fade; ctx.drawImage(ti, Math.round(tw.x - w / 2 + Math.sin(realTime * 21 + off) * 4), CONFIG.groundY + 14 - h, w, h); ctx.restore(); + } + } const im = images['fx-browo-sawit']; for (const key of ['player', 'enemy']) { if (bwx.rage[key] <= 0) continue; @@ -1645,6 +1736,15 @@ if (Math.sin(realTime * 40) > .6) spawnParticles(a.x + (random() - .5) * 50, a.y - 40 - random() * 80, '#ff8a3a', 1, 80); } } + // Burning palms of Hutan Terbakar, behind the fighters. + function drawBrowoFires() { + const im = images['fx-browo-burntree']; if (!im || !bwx.fires.length) return; + for (const f of bwx.fires) { + if (f.t < 0) continue; + const life = BW.fire.life, grow = Math.min(1, f.t / .25), fade = f.t > life - .6 ? Math.max(0, (life - f.t) / .6) : 1, h = 250 * (.35 + .65 * grow) * (1 + Math.sin(realTime * 14 + f.x) * .03), w = im.width / im.height * h; + ctx.save(); ctx.globalAlpha = .95 * fade; ctx.drawImage(im, Math.round(f.x - w / 2), CONFIG.groundY + 6 - h, w, h); ctx.restore(); + } + } // 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; @@ -1782,7 +1882,7 @@ 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(); drawBrowoGiant(false); + drawBrowoTrees(); drawBrowoFires(); drawBrowoGiant(false); if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } 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(); @@ -1804,9 +1904,10 @@ } function updateFighterIdentity() { if(!F)return; - const identity=selectedCharacter+':'+hero.form+':'+opponentCharacter+':'+dummy.form; + const identity=selectedCharacter+(selectedCharacter==='browo'&&bwx.rage.player>0?'*rage':'')+':'+hero.form+':'+opponentCharacter+':'+dummy.form; if(identity!==hudIdentity) { hudIdentity=identity;const me=fighterInfo(selectedCharacter,hero.form),rival=fighterInfo(opponentCharacter,dummy.form); + if(selectedCharacter==='browo'&&bwx.rage.player>0){me.names=BW.rageNames;me.icons=['basic','rage-skill1','rage-skill2','rage-ultimate'].map(n=>'assets/browo/ui/icon-'+n+'.webp');} $('#player-name').textContent=me.name;$('#player-class').textContent=me.cls;$('#deck-character').textContent=me.deck; $('#player-title').textContent=me.title; $('#player-portrait').src=me.portrait;$('#player-portrait').alt='Portrait '+me.name; @@ -1865,11 +1966,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,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',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',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]);} + 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'||cutinKey==='browofire'); + 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==='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]);} 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; @@ -1879,9 +1980,9 @@ } // 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',nib:'assets/nib/ui/cutin.webp',edda:'assets/edda/ui/cutin.webp',arco:'assets/ui/ultimate-cutin.webp'}; + 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'}; let cutinWarm=[]; - function warmCutins(){cutinWarm=[...new Set([selectedCharacter,opponentCharacter])].map(id=>CUTIN_ART[id]).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});} + 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 }); } function openSettings() { settingsOpen = true; clearInput(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); $('#close-settings').innerHTML=(menuOpen?'Kembali ke menu':'Kembali ke arena')+' ↗'; $('#settings-panel').showModal(); } function closeSettings() { settingsOpen = false; $('#settings-panel').close(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); canvas.focus({ preventScroll: true }); } @@ -1922,7 +2023,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'].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))]:[]), ...(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); }