diff --git a/IWantToHeal-Thor-v1.1.40.apk b/IWantToHeal-Thor-v1.1.40.apk new file mode 100644 index 0000000..64e5e08 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.40.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index ce2dc38..b66650c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 120 - versionName "1.1.39" + versionCode 121 + versionName "1.1.40" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/src/App.css b/src/App.css index cba6500..2ef2685 100644 --- a/src/App.css +++ b/src/App.css @@ -236,6 +236,14 @@ width: 100%; } +.iwt2-arena-loading { + align-items: center; + color: var(--muted); + display: flex; + font-family: var(--pixel-font); + justify-content: center; +} + .iwt2-phaser-host canvas { display: block; } diff --git a/src/App.tsx b/src/App.tsx index 630e184..dbdb98a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,11 @@ -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import { AuthScreen } from './components/AuthScreen' -import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository' +const LazyIWantToHeal2App = lazy(() => import('./modes/iwt2/IWantToHeal2App').then((module) => ({ + default: module.IWantToHeal2App, +}))) + type Iwt2Launch = { localSlot: Iwt2LocalSaveSlot onlineBackupsAvailable: boolean @@ -14,16 +17,29 @@ function App() { if (launch) { return ( - setLaunch(null)} - /> + }> + setLaunch(null)} + /> + ) } return } +function Iwt2LaunchFallback() { + return ( +
+
+

I Want To Heal 2

+

Loading...

+
+
+ ) +} + export default App diff --git a/src/modes/iwt2/render/PhaserArena.tsx b/src/modes/iwt2/render/PhaserArena.tsx index c97a345..f4995d3 100644 --- a/src/modes/iwt2/render/PhaserArena.tsx +++ b/src/modes/iwt2/render/PhaserArena.tsx @@ -4,14 +4,18 @@ import type { MovementVector } from '../../../input' import { BulldromeArenaScene } from './scenes/BulldromeArenaScene' import type { Iwt2ArenaState } from '../sim/arenaState' +const IWT2_ARENA_FPS = 60 + type PhaserArenaProps = { + active: boolean movementRef: MutableRefObject selectedPartyIdRef: MutableRefObject stateRef: MutableRefObject onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState } -export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { +export function PhaserArena({ active, movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { + const gameRef = useRef(null) const hostRef = useRef(null) const onStepRef = useRef(onStep) @@ -35,17 +39,36 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef height: initialState.bounds.height, backgroundColor: '#10141b', pixelArt: false, + fps: { + target: IWT2_ARENA_FPS, + limit: IWT2_ARENA_FPS, + }, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, }, scene, }) + gameRef.current = game return () => { + if (gameRef.current === game) gameRef.current = null game.destroy(true) } }, [movementRef, selectedPartyIdRef, stateRef]) + useEffect(() => { + const game = gameRef.current + if (!game?.loop.started) return + if (active) { + if (!game.loop.running) { + game.loop.resetDelta() + game.loop.wake() + } + return + } + game.loop.sleep() + }, [active]) + return
} diff --git a/src/modes/iwt2/render/bossAnimation.ts b/src/modes/iwt2/render/bossAnimation.ts index 2f41b73..7659684 100644 --- a/src/modes/iwt2/render/bossAnimation.ts +++ b/src/modes/iwt2/render/bossAnimation.ts @@ -52,20 +52,17 @@ const BOSS_PROFILES: Partial 8 || phase === 'relocating' const facingX = entity.facing.x < -0.05 ? -1 : 1 - - const motion: BossRenderMotion = { - x: 0, - y: 0, - scaleX: 1, - scaleY: 1, - rotation: 0, - } + resetBossRenderMotion(motion) if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX) if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX) @@ -76,12 +73,10 @@ export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: numbe function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion { const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2) - return { - ...motion, - y: -Math.max(0, wave) * profile.idleBob, - scaleY: 1 + Math.max(0, wave) * 0.025, - scaleX: 1 - Math.max(0, wave) * 0.01, - } + motion.y = -Math.max(0, wave) * profile.idleBob + motion.scaleY = 1 + Math.max(0, wave) * 0.025 + motion.scaleX = 1 - Math.max(0, wave) * 0.01 + return motion } function movingMotion( @@ -94,13 +89,11 @@ function movingMotion( const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180) const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2) const lift = Math.abs(stride) * profile.moveBob - return { - ...motion, - y: -lift, - scaleX: 1 + 0.012 * speedFactor, - scaleY: 1 - 0.009 * Math.abs(stride), - rotation: facingX * 0.025 * speedFactor, - } + motion.y = -lift + motion.scaleX = 1 + 0.012 * speedFactor + motion.scaleY = 1 - 0.009 * Math.abs(stride) + motion.rotation = facingX * 0.025 * speedFactor + return motion } function windupMotion( @@ -112,28 +105,24 @@ function windupMotion( ): BossRenderMotion { const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5 const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter') - return { - ...motion, - x: -facingX * (isSlam ? 1.5 : 3), - y: isSlam ? -2 : 2, - scaleX: 1.035, - scaleY: 0.965, - rotation: -facingX * 0.02, - tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined, - } + motion.x = -facingX * (isSlam ? 1.5 : 3) + motion.y = isSlam ? -2 : 2 + motion.scaleX = 1.035 + motion.scaleY = 0.965 + motion.rotation = -facingX * 0.02 + motion.tint = pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined + return motion } function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion { const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8 - return { - ...motion, - x: facingX * 3 + shake, - y: Math.cos(timeSeconds * Math.PI * 42) * 1.2, - scaleX: 1.045, - scaleY: 0.96, - rotation: facingX * 0.018, - tint: WARM_TINT, - } + motion.x = facingX * 3 + shake + motion.y = Math.cos(timeSeconds * Math.PI * 42) * 1.2 + motion.scaleX = 1.045 + motion.scaleY = 0.96 + motion.rotation = facingX * 0.018 + motion.tint = WARM_TINT + return motion } function recoverMotion( @@ -145,13 +134,24 @@ function recoverMotion( const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45)) const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle const pop = profile.impactScale * impactPulse - return { - ...motion, - y: -2 * impactPulse, - scaleX: 1 + pop, - scaleY: 1 + pop * 0.45, - tint: impactPulse > 0.45 ? IMPACT_TINT : undefined, - } + motion.y = -2 * impactPulse + motion.scaleX = 1 + pop + motion.scaleY = 1 + pop * 0.45 + motion.tint = impactPulse > 0.45 ? IMPACT_TINT : undefined + return motion +} + +function createBossRenderMotion(): BossRenderMotion { + return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 } +} + +function resetBossRenderMotion(motion: BossRenderMotion) { + motion.x = 0 + motion.y = 0 + motion.scaleX = 1 + motion.scaleY = 1 + motion.rotation = 0 + motion.tint = undefined } function isActionPhase(phase: string, suffix: string): boolean { diff --git a/src/modes/iwt2/render/partyAnimation.ts b/src/modes/iwt2/render/partyAnimation.ts index 66dc57f..c2a2305 100644 --- a/src/modes/iwt2/render/partyAnimation.ts +++ b/src/modes/iwt2/render/partyAnimation.ts @@ -27,65 +27,59 @@ export function partyRenderMotion( entity: Iwt2PartyEntityState, timeSeconds: number, attackPulse?: PartyAttackPulse, + motion: PartyRenderMotion = createPartyRenderMotion(), ): PartyRenderMotion { const speed = Math.hypot(entity.velocity.x, entity.velocity.y) const facingX = entity.facing.x < -0.05 ? -1 : 1 - const motion: PartyRenderMotion = { - x: 0, - y: 0, - rotation: 0, - scaleX: 1, - scaleY: 1, - } + resetPartyRenderMotion(motion) - if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 } + if (entity.health <= 0) { + motion.rotation = facingX * 0.08 + motion.scaleY = 0.92 + return motion + } if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) { - return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 } + motion.y = 3 + motion.rotation = -facingX * 0.12 + motion.scaleY = 0.9 + return motion } const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1 if (attackProgress < 1) { const strike = Math.sin(attackProgress * Math.PI) const ranged = entity.projectileSpeed > 0 - return { - ...motion, - x: facingX * (ranged ? -3 : 4) * strike, - y: -1.5 * strike, - rotation: facingX * (ranged ? -0.05 : 0.09) * strike, - scaleX: 1 + (ranged ? 0.012 : 0.035) * strike, - scaleY: 1 - (ranged ? 0.006 : 0.018) * strike, - tint: ranged ? 0xdff3ff : 0xffe1a6, - } + motion.x = facingX * (ranged ? -3 : 4) * strike + motion.y = -1.5 * strike + motion.rotation = facingX * (ranged ? -0.05 : 0.09) * strike + motion.scaleX = 1 + (ranged ? 0.012 : 0.035) * strike + motion.scaleY = 1 - (ranged ? 0.006 : 0.018) * strike + motion.tint = ranged ? 0xdff3ff : 0xffe1a6 + return motion } if (entity.castSecondsRemaining > 0) { const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5 - return { - ...motion, - y: -1 - pulse * 1.5, - scaleX: 1 + pulse * 0.012, - scaleY: 1 + pulse * 0.012, - tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff, - } + motion.y = -1 - pulse * 1.5 + motion.scaleX = 1 + pulse * 0.012 + motion.scaleY = 1 + pulse * 0.012 + motion.tint = entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff + return motion } if (speed > 10) { const stride = Math.sin(timeSeconds * Math.PI * 9) const lift = Math.abs(stride) * Math.min(2.8, speed / 90) - return { - ...motion, - y: -lift, - rotation: facingX * 0.025 * Math.min(1, speed / 190), - scaleY: 1 - Math.abs(stride) * 0.01, - } + motion.y = -lift + motion.rotation = facingX * 0.025 * Math.min(1, speed / 190) + motion.scaleY = 1 - Math.abs(stride) * 0.01 + return motion } const idle = Math.sin(timeSeconds * Math.PI * 2.2) - return { - ...motion, - y: -Math.max(0, idle) * 0.9, - scaleY: 1 + Math.max(0, idle) * 0.008, - } + motion.y = -Math.max(0, idle) * 0.9 + motion.scaleY = 1 + Math.max(0, idle) * 0.008 + return motion } export function isPartyAttackPulseActive( @@ -101,17 +95,41 @@ export function partyWeaponLayerMotion( layer: Iwt2ClassWeaponLayer, timeSeconds: number, attackPulse?: PartyAttackPulse, + motion: PartyWeaponLayerMotion = createPartyWeaponLayerMotion(), ): PartyWeaponLayerMotion { - if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 } + resetPartyWeaponLayerMotion(motion) + if (!attackPulse || entity.health <= 0) return motion const progress = attackProgressFor(entity, timeSeconds, attackPulse) - if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 } + if (progress >= 1) return motion const strike = Math.sin(progress * Math.PI) const attackMotion = layer.attackMotion - return { - offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike, - offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike, - rotation: (attackMotion?.rotation ?? 0) * strike, - } + motion.offsetXScale = (attackMotion?.offsetXScale ?? 0) * strike + motion.offsetYScale = (attackMotion?.offsetYScale ?? 0) * strike + motion.rotation = (attackMotion?.rotation ?? 0) * strike + return motion +} + +function createPartyRenderMotion(): PartyRenderMotion { + return { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 } +} + +function resetPartyRenderMotion(motion: PartyRenderMotion) { + motion.x = 0 + motion.y = 0 + motion.rotation = 0 + motion.scaleX = 1 + motion.scaleY = 1 + motion.tint = undefined +} + +function createPartyWeaponLayerMotion(): PartyWeaponLayerMotion { + return { offsetXScale: 0, offsetYScale: 0, rotation: 0 } +} + +function resetPartyWeaponLayerMotion(motion: PartyWeaponLayerMotion) { + motion.offsetXScale = 0 + motion.offsetYScale = 0 + motion.rotation = 0 } function attackProgressFor( diff --git a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts index 8377c1d..e9bd265 100644 --- a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts +++ b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts @@ -2,12 +2,14 @@ import Phaser from 'phaser' import type { MovementVector } from '../../../../input' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses' import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes' -import { bossRenderMotion } from '../bossAnimation' +import { bossRenderMotion, type BossRenderMotion } from '../bossAnimation' import { isPartyAttackPulseActive, partyRenderMotion, partyWeaponLayerMotion, type PartyAttackPulse, + type PartyRenderMotion, + type PartyWeaponLayerMotion, } from '../partyAnimation' import type { Iwt2ArenaEvent, @@ -41,10 +43,15 @@ type PartyLayerSprites = { export class BulldromeArenaScene extends Phaser.Scene { private deps: SceneDeps + private drawableEntities: DrawableEntity[] = [] private arenaGraphics?: Phaser.GameObjects.Graphics private bossUnderlayGraphics?: Phaser.GameObjects.Graphics private entityGraphics?: Phaser.GameObjects.Graphics private telegraphGraphics?: Phaser.GameObjects.Graphics + private liveBossIds = new Set() + private liveLabelIds = new Set() + private livePartyIds = new Set() + private liveProjectileIds = new Set() private labels = new Map() private bossSprites = new Map() private partySprites = new Map() @@ -54,11 +61,15 @@ export class BulldromeArenaScene extends Phaser.Scene { private livePartyEffectIds = new Set() private floatingCombatTexts = new Map() private castGlowEffects = new Set() + private currentEntityAnchors = new Map() private lastEntityAnchors = new Map() + private liveEntityAnchorIds = new Set() + private bossMotionScratch: BossRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 } + private partyMotionScratch: PartyRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 } + private weaponMotionScratch: PartyWeaponLayerMotion = { offsetXScale: 0, offsetYScale: 0, rotation: 0 } private lastPartyAnimationEventId = 0 private lastFloatingEventId = 0 private lastStateTime = 0 - private lastHudPublish = 0 constructor(deps: SceneDeps) { super('bulldrome-arena') @@ -66,7 +77,9 @@ export class BulldromeArenaScene extends Phaser.Scene { } preload() { - for (const metadata of Object.values(IWT2_BOSS_METADATA)) { + const encounterBossIds = new Set(this.deps.getState().bosses.map((boss) => boss.bossId)) + for (const bossId of encounterBossIds) { + const metadata = IWT2_BOSS_METADATA[bossId] if (metadata.spriteUrl.endsWith('.svg')) { this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 }) } else { @@ -92,7 +105,9 @@ export class BulldromeArenaScene extends Phaser.Scene { this.telegraphGraphics = this.add.graphics().setDepth(10) this.bossUnderlayGraphics = this.add.graphics().setDepth(20) this.entityGraphics = this.add.graphics().setDepth(35) - this.drawState(this.deps.getState()) + const state = this.deps.getState() + this.drawArena(state) + this.drawState(state) } update(_: number, delta: number) { @@ -102,13 +117,11 @@ export class BulldromeArenaScene extends Phaser.Scene { private drawState(state: Iwt2ArenaState) { if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return - this.drawArena(state) this.drawTelegraphs(state) this.updatePartyAnimations(state) this.drawEntities(state) this.drawProjectiles(state) this.drawFloatingCombatTexts(state) - this.lastHudPublish += 1 } private drawArena(state: Iwt2ArenaState) { @@ -140,12 +153,30 @@ export class BulldromeArenaScene extends Phaser.Scene { bossUnderlay.clear() graphics.clear() for (const hazard of state.hazards) drawHazard(graphics, hazard) - const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses] - const liveLabelIds = new Set(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id)) - const liveBossIds = new Set(state.bosses.map((boss) => boss.id)) - const livePartyIds = new Set(state.party.map((member) => member.id)) + const entities = this.drawableEntities + const liveBossIds = this.liveBossIds + const liveLabelIds = this.liveLabelIds + const livePartyIds = this.livePartyIds + entities.length = 0 + liveBossIds.clear() + liveLabelIds.clear() + livePartyIds.clear() + for (const boss of state.bosses) { + entities.push(boss) + liveBossIds.add(boss.id) + if (shouldDrawLabel(boss)) liveLabelIds.add(boss.id) + } + for (const add of state.hostileAdds) { + entities.push(add) + if (shouldDrawLabel(add)) liveLabelIds.add(add.id) + } + for (const member of state.party) { + entities.push(member) + livePartyIds.add(member.id) + if (shouldDrawLabel(member)) liveLabelIds.add(member.id) + } - for (const entity of entities.sort(entitySort)) { + for (const entity of entities) { const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1 const color = entityColor(entity) @@ -232,8 +263,10 @@ export class BulldromeArenaScene extends Phaser.Scene { private drawProjectiles(state: Iwt2ArenaState) { const graphics = this.entityGraphics! - const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id)) + const liveProjectileIds = this.liveProjectileIds + liveProjectileIds.clear() for (const projectile of state.projectiles) { + liveProjectileIds.add(projectile.id) const spriteKey = projectileSpriteKey(projectile.projectileKind) if (this.textures.exists(spriteKey)) { let sprite = this.projectileSprites.get(projectile.id) @@ -244,7 +277,6 @@ export class BulldromeArenaScene extends Phaser.Scene { const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x) const size = projectileDisplaySize(projectile.projectileKind, projectile.radius) sprite - .setTexture(spriteKey) .setPosition(projectile.position.x, projectile.position.y) .setRotation(velocityAngle) .setDisplaySize(size.width, size.height) @@ -280,14 +312,13 @@ export class BulldromeArenaScene extends Phaser.Scene { const width = entity.radius * (metadata.spriteWidthScale ?? 3.35) const height = entity.radius * (metadata.spriteHeightScale ?? 3.35) const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0) - const motion = bossRenderMotion(entity, timeSeconds) + const motion = bossRenderMotion(entity, timeSeconds, this.bossMotionScratch) if (motion.tint) { sprite.setTint(motion.tint) } else { sprite.clearTint() } sprite - .setTexture(key) .setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y) .setDisplaySize(width * motion.scaleX, height * motion.scaleY) .setRotation(rotation + motion.rotation) @@ -314,10 +345,9 @@ export class BulldromeArenaScene extends Phaser.Scene { } const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55) - const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id)) + const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch) applyMotionTint(sprites.body, motion.tint) sprites.body - .setTexture(classArenaBodySpriteKey(entity.classId)) .setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y) .setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY) .setRotation(motion.rotation) @@ -335,7 +365,13 @@ export class BulldromeArenaScene extends Phaser.Scene { } const flipX = entity.facing.x < -0.05 const facingSign = flipX ? -1 : 1 - const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id)) + const layerMotion = partyWeaponLayerMotion( + entity, + layer, + timeSeconds, + this.partyAttackPulses.get(entity.id), + this.weaponMotionScratch, + ) const layerHeight = entity.radius * layer.heightScale const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale) const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale) @@ -358,7 +394,6 @@ export class BulldromeArenaScene extends Phaser.Scene { }) applyMotionTint(sprite, motion.tint) sprite - .setTexture(key) .setDepth(layer.drawOrder === 'behindBody' ? 38 : 42) .setPosition(positionX, positionY) .setDisplaySize(displayWidth, displayHeight) @@ -420,7 +455,6 @@ export class BulldromeArenaScene extends Phaser.Scene { } accent .setVisible(true) - .setTexture(key) .setDepth(layer.drawOrder === 'behindBody' ? 37 : 41) .setPosition(positionX, positionY) .setDisplaySize(displayWidth * 1.08, displayHeight * 1.12) @@ -446,14 +480,13 @@ export class BulldromeArenaScene extends Phaser.Scene { } const displayHeight = entity.radius * 4.55 - const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id)) + const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch) if (motion.tint) { sprite.setTint(motion.tint) } else { sprite.clearTint() } sprite - .setTexture(key) .setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y) .setDisplaySize( displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX, @@ -541,6 +574,8 @@ export class BulldromeArenaScene extends Phaser.Scene { } private drawFloatingCombatTexts(state: Iwt2ArenaState) { + const currentEntityAnchors = this.currentEntityAnchors + updateEntityAnchors(currentEntityAnchors, this.liveEntityAnchorIds, state) if (state.time < this.lastStateTime) { this.lastFloatingEventId = 0 for (const text of this.floatingCombatTexts.values()) text.destroy() @@ -557,17 +592,21 @@ export class BulldromeArenaScene extends Phaser.Scene { this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id) continue } - this.spawnFloatingCombatText(event, state) + this.spawnFloatingCombatText(event, currentEntityAnchors) this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id) } this.lastStateTime = state.time - this.lastEntityAnchors = entityAnchors(state) + this.currentEntityAnchors = this.lastEntityAnchors + this.lastEntityAnchors = currentEntityAnchors } - private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) { + private spawnFloatingCombatText( + event: Iwt2ArenaEvent, + currentEntityAnchors: Map, + ) { const anchor = event.targetId - ? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId) + ? currentEntityAnchors.get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId) : undefined if (!anchor || !event.value || event.value <= 0) return @@ -630,11 +669,6 @@ export class BulldromeArenaScene extends Phaser.Scene { } } -function entitySort(a: DrawableEntity, b: DrawableEntity): number { - const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record - return rank[a.kind] - rank[b.kind] -} - function entityColor(entity: DrawableEntity): string { if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color if (entity.kind === 'hostileAdd') return '#f0b84f' @@ -710,16 +744,38 @@ function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean { && event.targetId !== undefined } -function entityAnchors(state: Iwt2ArenaState): Map { - const anchors = new Map() - for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) { +function updateEntityAnchors( + anchors: Map, + liveIds: Set, + state: Iwt2ArenaState, +) { + liveIds.clear() + for (const entity of state.party) updateEntityAnchor(anchors, liveIds, entity) + for (const entity of state.hostileAdds) updateEntityAnchor(anchors, liveIds, entity) + for (const entity of state.bosses) updateEntityAnchor(anchors, liveIds, entity) + for (const id of anchors.keys()) { + if (!liveIds.has(id)) anchors.delete(id) + } +} + +function updateEntityAnchor( + anchors: Map, + liveIds: Set, + entity: DrawableEntity, +) { + liveIds.add(entity.id) + const anchor = anchors.get(entity.id) + if (!anchor) { anchors.set(entity.id, { kind: entity.kind, - position: { ...entity.position }, + position: entity.position, radius: entity.radius, }) + return } - return anchors + anchor.kind = entity.kind + anchor.position = entity.position + anchor.radius = entity.radius } function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number { @@ -792,9 +848,7 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09)) if (indicator.kind === 'lane') { - const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width) - graphics.fillPoints(points, true) - graphics.strokePoints(points, true) + drawLaneDangerPath(graphics, indicator.start, indicator.end, indicator.width) return } @@ -805,27 +859,25 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are } if (indicator.kind === 'cone') { - const points = coneDangerPolygon( + drawConeDangerPath( + graphics, indicator.origin, indicator.direction, indicator.range, indicator.angleRadians, ) - graphics.fillPoints(points, true) - graphics.strokePoints(points, true) return } if (indicator.kind === 'arc') { - const points = arcDangerPolygon( + drawArcDangerPath( + graphics, indicator.position, indicator.direction, indicator.innerRadius, indicator.outerRadius, indicator.angleRadians, ) - graphics.fillPoints(points, true) - graphics.strokePoints(points, true) return } @@ -835,7 +887,8 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius) } -function arcDangerPolygon( +function drawArcDangerPath( + graphics: Phaser.GameObjects.Graphics, position: Iwt2Vec2, direction: Iwt2Vec2, innerRadius: number, @@ -845,43 +898,50 @@ function arcDangerPolygon( const baseAngle = Math.atan2(direction.y, direction.x) const halfAngle = angleRadians / 2 const segmentCount = 18 - const points: Phaser.Math.Vector2[] = [] + graphics.beginPath() for (let index = 0; index <= segmentCount; index += 1) { const t = index / segmentCount const angle = baseAngle - halfAngle + angleRadians * t - points.push(new Phaser.Math.Vector2( - position.x + Math.cos(angle) * outerRadius, - position.y + Math.sin(angle) * outerRadius, - )) + const x = position.x + Math.cos(angle) * outerRadius + const y = position.y + Math.sin(angle) * outerRadius + if (index === 0) graphics.moveTo(x, y) + else graphics.lineTo(x, y) } for (let index = segmentCount; index >= 0; index -= 1) { const t = index / segmentCount const angle = baseAngle - halfAngle + angleRadians * t - points.push(new Phaser.Math.Vector2( + graphics.lineTo( position.x + Math.cos(angle) * innerRadius, position.y + Math.sin(angle) * innerRadius, - )) + ) } - return points + graphics.closePath().fillPath().strokePath() } -function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) { +function drawLaneDangerPath( + graphics: Phaser.GameObjects.Graphics, + start: Iwt2Vec2, + end: Iwt2Vec2, + halfWidth: number, +) { const dx = end.x - start.x const dy = end.y - start.y const length = Math.max(1, Math.hypot(dx, dy)) - const normal = { - x: (-dy / length) * halfWidth, - y: (dx / length) * halfWidth, - } - return [ - new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y), - new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y), - new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y), - new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y), - ] + const normalX = (-dy / length) * halfWidth + const normalY = (dx / length) * halfWidth + graphics + .beginPath() + .moveTo(start.x + normalX, start.y + normalY) + .lineTo(end.x + normalX, end.y + normalY) + .lineTo(end.x - normalX, end.y - normalY) + .lineTo(start.x - normalX, start.y - normalY) + .closePath() + .fillPath() + .strokePath() } -function coneDangerPolygon( +function drawConeDangerPath( + graphics: Phaser.GameObjects.Graphics, origin: Iwt2Vec2, direction: Iwt2Vec2, range: number, @@ -889,15 +949,15 @@ function coneDangerPolygon( ) { const baseAngle = Math.atan2(direction.y, direction.x) const halfAngle = angleRadians / 2 - const points = [new Phaser.Math.Vector2(origin.x, origin.y)] const segmentCount = 14 + graphics.beginPath().moveTo(origin.x, origin.y) for (let index = 0; index <= segmentCount; index += 1) { const t = index / segmentCount const angle = baseAngle - halfAngle + angleRadians * t - points.push(new Phaser.Math.Vector2( + graphics.lineTo( origin.x + Math.cos(angle) * range, origin.y + Math.sin(angle) * range, - )) + ) } - return points + graphics.closePath().fillPath().strokePath() } diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index 4bfd642..af5178e 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import type { MovementVector } from '../../../input' import { useGameAction, useInput, useMovementVectorRef } from '../../../input' import { @@ -14,7 +14,6 @@ import { } from '../sim/arenaState' import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim' import { castIwt2HealerAbility } from '../sim' -import { PhaserArena } from '../render/PhaserArena' import { recordIwt2BossKillReward, type Iwt2BossDropAward, @@ -47,6 +46,10 @@ import { } from '../content/roguelike' import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization' +const PhaserArena = lazy(() => import('../render/PhaserArena').then((module) => ({ + default: module.PhaserArena, +}))) + type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat' type PvpResultReason = 'opponent-defeated' | null type OverlayAction = 'primary' | 'requeue' | 'menu' @@ -71,6 +74,7 @@ const IWT2_TOP_PARTY_RAIL_WIDTH = 172 const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154 const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000 const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620 +const IWT2_HUD_PUBLISH_INTERVAL_SECONDS = 0.1 type BossArenaScreenProps = { bossId: Iwt2BossId @@ -157,7 +161,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, const saveRef = useRef(save) const recordedKillIdsRef = useRef>(new Set()) const lastPublishTimeRef = useRef(0) - const lastHudSignatureRef = useRef('') const abilityCooldownsRef = useRef>({}) const movementRef = useMovementVectorRef(status === 'playing') const { @@ -169,14 +172,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, const { enabled: dualScreenEnabled } = useDualScreen() const activeBindings = bindings[lastDevice] - useEffect(() => { - stateRef.current = arenaState - }, [arenaState]) - - useEffect(() => { - opponentStateRef.current = opponentArenaState - }, [opponentArenaState]) - useEffect(() => { statusRef.current = status }, [status]) @@ -219,7 +214,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, : null recordedKillIdsRef.current = new Set() abilityCooldownsRef.current = {} - lastHudSignatureRef.current = arenaHudSignature(next) + lastPublishTimeRef.current = next.time stateRef.current = next opponentStateRef.current = nextOpponentState setArenaState(next) @@ -278,7 +273,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, } abilityCooldownsRef.current = nextCooldowns stateRef.current = result.state - lastHudSignatureRef.current = arenaHudSignature(result.state) + lastPublishTimeRef.current = result.state.time setAbilityCooldowns(nextCooldowns) setArenaState(result.state) }, []) @@ -444,13 +439,8 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, showOverlay('defeat') } - const hudSignature = arenaHudSignature(next) - if ( - hudSignature !== lastHudSignatureRef.current - || next.time - lastPublishTimeRef.current >= 0.08 - || next.bosses.some((boss) => boss.health <= 0) - ) { - lastHudSignatureRef.current = hudSignature + const fightEnded = statusRef.current !== 'playing' + if (fightEnded || next.time - lastPublishTimeRef.current >= IWT2_HUD_PUBLISH_INTERVAL_SECONDS) { lastPublishTimeRef.current = next.time setArenaState(next) if (pvpRoguelike) setOpponentArenaState(nextOpponentState) @@ -546,12 +536,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, targetBindings={targetBindings} /> - + Loading arena...
}> + + {status === 'paused' && (
@@ -704,26 +697,6 @@ function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): Overl return DEFAULT_OVERLAY_NAV_ENTRIES } -function arenaHudSignature(state: Iwt2ArenaState): string { - return [ - ...state.bosses.map((boss) => [ - boss.id, - Math.ceil(boss.health), - boss.attackPhase, - ].join(':')), - state.hostileAdds.length, - state.hazards.length, - ...state.party.map((member) => [ - member.id, - Math.ceil(member.health), - Math.ceil(member.shield), - Math.ceil(member.mana), - Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10), - member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','), - ].join(':')), - ].join('|') -} - function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string { return bosses .map((boss) => IWT2_BOSS_METADATA[boss.bossId].name) diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index 1834ed0..ebc644c 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -197,7 +197,7 @@ function initialBossSecondaryCooldown(bossId: Iwt2BossId): number { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { const step = Math.max(0, Math.min(dt, MAX_DT)) - if (step <= 0) return { ...state, events: [...state.events] } + if (step <= 0) return state const inputMove = { x: clampInputAxis(input.moveX), @@ -283,10 +283,18 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: nextAddId: bossResult.nextAddId ?? state.nextAddId, nextHazardId: projectileResult.nextHazardId, nextEventId: state.nextEventId + nextEvents.length, - events: [...state.events, ...nextEvents].slice(-MAX_EVENTS), + events: appendEventHistory(state.events, nextEvents), } } +function appendEventHistory(previous: Iwt2ArenaEvent[], next: Iwt2ArenaEvent[]): Iwt2ArenaEvent[] { + if (next.length === 0) return previous + const overflow = Math.max(0, previous.length + next.length - MAX_EVENTS) + return overflow > 0 + ? [...previous.slice(overflow), ...next] + : [...previous, ...next] +} + function tickPartyHotEffects( party: Iwt2PartyEntityState[], dt: number, diff --git a/src/modes/iwt2/sim/arenaState.ts b/src/modes/iwt2/sim/arenaState.ts index cd903c7..1f08657 100644 --- a/src/modes/iwt2/sim/arenaState.ts +++ b/src/modes/iwt2/sim/arenaState.ts @@ -1,60 +1,16 @@ -import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' -import { IWT2_CLASS_METADATA } from '../content/classes' +import type { Iwt2BossId } from '../content/bosses' import { createInitialIwt2ArenaState as createCoreIwt2ArenaState, tickIwt2Arena as tickCoreIwt2Arena, } from './arena' import type { - Iwt2ArenaIndicator, Iwt2ArenaInput, Iwt2ArenaBounds, Iwt2ArenaState as Iwt2CoreArenaState, - Iwt2BossEntityState, - Iwt2HostileAddState, - Iwt2PartyEntityState, Iwt2RoguelikePressureState, } from './types' -export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd' - -export type Iwt2ArenaEntity = { - id: string - kind: Iwt2ArenaEntityKind - icon: string - color: string - x: number - y: number - radius: number - health: number - maxHealth: number - stunnedFor: number -} - -export type Iwt2ArenaTelegraph = - | { - kind: 'charge' - x: number - y: number - width: number - height: number - active: boolean - } - | { - kind: 'slam' - x: number - y: number - radius: number - active: boolean - } - -export type Iwt2ArenaState = Iwt2CoreArenaState & { - arena: { - width: number - height: number - } - entities: Iwt2ArenaEntity[] - telegraphs: Iwt2ArenaTelegraph[] -} +export type Iwt2ArenaState = Iwt2CoreArenaState export function createInitialIwt2ArenaState( bossId?: Iwt2BossId, @@ -64,94 +20,9 @@ export function createInitialIwt2ArenaState( roguelikePressure?: Iwt2RoguelikePressureState, bounds?: Iwt2ArenaBounds, ): Iwt2ArenaState { - return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds)) + return createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds) } export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { - return decorateArenaState(tickCoreIwt2Arena(state, input, dt)) -} - -export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState { - return { - ...state, - arena: { ...state.bounds }, - entities: [ - ...state.party.map(toArenaEntity), - ...state.hostileAdds.map(toHostileAddArenaEntity), - ...state.bosses.map(toBossArenaEntity), - ], - telegraphs: createTelegraphs(state.indicators), - } -} - -function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity { - return { - id: add.id, - kind: 'hostileAdd', - icon: 'v', - color: '#f0b84f', - x: add.position.x, - y: add.position.y, - radius: add.radius, - health: add.health, - maxHealth: add.maxHealth, - stunnedFor: 0, - } -} - -function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity { - const metadata = IWT2_CLASS_METADATA[member.classId] - return { - id: member.id, - kind: member.aiRole === 'player' ? 'player' : 'party', - icon: metadata.icon, - color: metadata.color, - x: member.position.x, - y: member.position.y, - radius: member.radius, - health: member.health, - maxHealth: member.maxHealth, - stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds), - } -} - -function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity { - const metadata = IWT2_BOSS_METADATA[boss.bossId] - return { - id: boss.id, - kind: 'boss', - icon: metadata.icon, - color: metadata.color, - x: boss.position.x, - y: boss.position.y, - radius: boss.radius, - health: boss.health, - maxHealth: boss.maxHealth, - stunnedFor: 0, - } -} - -function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] { - return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => { - if (indicator.kind === 'lane') { - return [{ - active: indicator.phase === 'active', - height: indicator.width * 2, - kind: 'charge' as const, - width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)), - x: Math.min(indicator.start.x, indicator.end.x), - y: Math.min(indicator.start.y, indicator.end.y) - indicator.width, - }] - } - if (indicator.kind === 'circle') { - return [{ - active: indicator.phase === 'active', - kind: 'slam' as const, - radius: indicator.radius, - x: indicator.position.x, - y: indicator.position.y, - }] - } - return [] - }) + return tickCoreIwt2Arena(state, input, dt) }