diff --git a/browo.js b/browo.js index 6f3cb4d..bd9c315 100644 --- a/browo.js +++ b/browo.js @@ -1,7 +1,7 @@ /* Shared BRO WO moves (stocky safari ranger, fists and oil-palm trees): player and AI use the same rules. */ (() => { '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']; // 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). @@ -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` // 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 }; - // Joget Gemoy: a cheerful shoulder-swaying dance; leaves burst around him and everything within `radius` is hit. - const dance = { radius:125 }; - // Hutan Sawit: four palms erupt one after another in front of him (spacing `trees`, every `gap` s from `at`); a rival - // standing inside `radius` of any one takes 12 (48 total if all four land). He is free after the 0.5 s cast. - const forest = { cutinDuration:.78, trees:[150,280,410,540], at:.8, gap:.34, radius:85, duration:2.6 }; + // Joget Gemoy: a long cheerful dance. `pulses` leaf bursts, `gap` s apart from `first`; every burst that reaches a rival + // inside `radius` stuns them (for gap + stunExtra s), deals `pulseDamage`, and refreshes a poison that drains `poisonDps` + // HP per second for `poisonTime` s without interrupting them. + const dance = { radius:150, pulses:5, first:.12, gap:.3, pulseDamage:3, stunExtra:.5, poisonDps:2.5, poisonTime:6, duration:1.7, loops:3 }; + // 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) { 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: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}; } 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; } 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}; })(); diff --git a/game.js b/game.js index 1997ed4..2f8d0e1 100644 --- a/game.js +++ b/game.js @@ -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++;} } 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; window.FrontEnd?.syncBackground(); } @@ -198,7 +198,7 @@ for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) { if(event.type==='cue')emitRoundCue(event.cue,event.round); 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;} 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'; @@ -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();} 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); - 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; } function reset() { @@ -694,23 +694,114 @@ 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; } } - // BRO WO Hutan Sawit: four oil-palms erupt one after another in front of him (BW.forest.trees). The group object is the - // shared `sunroar` slot, so the pause/reset/cut-in plumbing is the same as SOLAN's; `roars` counts the trees raised. + // ---- 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; } + 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) { - 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; - while (s.roars < U.trees.length && s.t >= U.at + s.roars * U.gap) { - const i = s.roars++, x = clamp(a.x + s.facing * U.trees[i], 70, W - 70); - effects.push({ type: 'browo-tree', x, y: CONFIG.groundY, life: .9, maxLife: .9 }); - effects.push({ type: 'browo-fx', asset: 'browo-impact', x, y: CONFIG.groundY - 24, facing: 1, life: .45, maxLife: .45, size: 240 }); - spawnParticles(x, CONFIG.groundY - 10, '#a47a3a', 14, 260, 'dust'); trauma = Math.min(1, trauma + .25); sound('heavy'); - if (Math.abs(target.x - x) < U.radius && target.y > CONFIG.groundY - 150) { - if (s.owner === 'enemy') window.__game.receiveHit(BW.balance.forestDamage); - else hitDummy(BW.balance.forestDamage, i === U.trees.length - 1 ? 190 : 50, '#9bd05a', x, CONFIG.groundY - 90, false, true); + const mx = (a.x + target.x) / 2; + // Camera script. + if (s.t < U.treesAt) { bwx.zoomT = 1.75; bwx.dimT = .82; bwx.cx = a.x; bwx.cy = a.y - 90; } + else if (s.t < U.giantAt) { bwx.zoomT = 1.2; bwx.dimT = .78; bwx.cx = mx; bwx.cy = a.y - 90; } + else if (s.t < U.release + .35) { bwx.zoomT = 1.12; bwx.dimT = .7; bwx.cx = mx; bwx.cy = a.y - 130; } + else { bwx.zoomT = 1; bwx.dimT = 0; } + if (s.t >= .15 && s.t < U.treesAt) trauma = Math.min(.35, trauma + dt * .5); + 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'; - if (s.t >= U.duration) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } + 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); + 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 // 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 (dir && hero.doublePose <= 0) hero.facing = dir; 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)); if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle'); 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'); } } const stride = metrics?.states?.[hero.state]?.stride_estimate || manifest?.metrics?.[hero.state === 'run' ? 'runStride' : 'walkStride'] || manifest?.locomotion?.[hero.state]?.stride_px || (hero.state === 'run' ? 184 : 148); hero.walkPhase += Math.abs(hero.vx) * dt / stride; - updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar); 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 !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); advanceMatch(dt); @@ -1120,6 +1211,11 @@ function browoStrike(actor,move) { 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.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; if(move.tree){ // 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-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); - inside=Math.abs(target.x-hitX)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)CONFIG.groundY-150; } 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);} const dx=(target.x-actor.x)*actor.facing; - inside=dx>-22&&dx-22&&dx0)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) { const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NIB_METRICS?.emitters?.[move.name]; @@ -1438,6 +1528,7 @@ let index=0; // 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)); + 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(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); @@ -1492,6 +1583,7 @@ result.phase = 'somersault'; } 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?.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 (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; @@ -1522,6 +1614,55 @@ 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() { 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;} @@ -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'; 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. - 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. 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(); } @@ -1638,11 +1778,23 @@ function draw() { 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; - 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. 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(); } - 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) { 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()); 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));} } function updateHud() { @@ -1726,7 +1878,7 @@ 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'); 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
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:'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
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]);} 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; diff --git a/menu.js b/menu.js index 49db330..f812311 100644 --- a/menu.js +++ b/menu.js @@ -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.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.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.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'}}:{})