Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f633e5aaa | ||
|
|
e9ee7bcd70 |
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 120
|
||||
versionName "1.1.39"
|
||||
versionCode 122
|
||||
versionName "1.1.41"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
@@ -1429,6 +1437,36 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) and (max-height: 620px) {
|
||||
.pause-screen.iwt2-arena-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pause-screen.iwt2-arena-overlay > .iwt2-result-panel {
|
||||
gap: 8px;
|
||||
max-height: calc(100dvh - 20px);
|
||||
max-width: 460px;
|
||||
min-width: min(460px, calc(100vw - 20px));
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.iwt2-result-panel .iwt2-result-crest {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.iwt2-result-panel h1 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.iwt2-result-summary {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.iwt2-result-summary span {
|
||||
min-height: 42px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.iwt2-arena-layout {
|
||||
grid-template-columns: 154px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
+24
-8
@@ -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 (
|
||||
<IWantToHeal2App
|
||||
initialSave={launch.save}
|
||||
localSlot={launch.localSlot}
|
||||
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||
onExitToSaveSelect={() => setLaunch(null)}
|
||||
/>
|
||||
<Suspense fallback={<Iwt2LaunchFallback />}>
|
||||
<LazyIWantToHeal2App
|
||||
initialSave={launch.save}
|
||||
localSlot={launch.localSlot}
|
||||
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||
onExitToSaveSelect={() => setLaunch(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
return <AuthScreen onContinue={setLaunch} />
|
||||
}
|
||||
|
||||
function Iwt2LaunchFallback() {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<section className="message-panel" aria-live="polite">
|
||||
<p className="eyebrow">I Want To Heal 2</p>
|
||||
<h1>Loading...</h1>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -84,7 +84,9 @@ export function AuthScreen({ onContinue }: Props) {
|
||||
const result = await loadIwt2OnlineSave()
|
||||
if (cancelled()) return
|
||||
setOnlineSave(result.save)
|
||||
setMessage(result.save ? 'Online save ready. Choose its local destination slot.' : 'Account has no online IWT2 save yet.')
|
||||
setMessage(result.save
|
||||
? 'Online save ready. Load it below, or continue a local slot and upload it from Cloud Backup.'
|
||||
: 'No online save yet. Continue a local slot, then use Cloud Backup to upload it.')
|
||||
setMessageIsError(false)
|
||||
} catch (reason) {
|
||||
if (cancelled()) return
|
||||
@@ -127,7 +129,7 @@ export function AuthScreen({ onContinue }: Props) {
|
||||
if (!save) return
|
||||
selectActiveIwt2SaveSlot(slot)
|
||||
selectOfflineMode()
|
||||
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
|
||||
onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
|
||||
}
|
||||
|
||||
function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
|
||||
@@ -151,7 +153,7 @@ export function AuthScreen({ onContinue }: Props) {
|
||||
replaceIwt2Save(save, slot)
|
||||
selectActiveIwt2SaveSlot(slot)
|
||||
selectOfflineMode()
|
||||
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
|
||||
onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
|
||||
}
|
||||
|
||||
async function signIn(event: React.FormEvent) {
|
||||
|
||||
@@ -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<MovementVector>
|
||||
selectedPartyIdRef: MutableRefObject<string>
|
||||
stateRef: MutableRefObject<Iwt2ArenaState>
|
||||
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<Phaser.Game | null>(null)
|
||||
const hostRef = useRef<HTMLDivElement | null>(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 <div className="iwt2-phaser-host" ref={hostRef} />
|
||||
}
|
||||
|
||||
@@ -52,20 +52,17 @@ const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionPro
|
||||
const WARM_TINT = 0xffc88a
|
||||
const IMPACT_TINT = 0xff9b5f
|
||||
|
||||
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
|
||||
export function bossRenderMotion(
|
||||
entity: Iwt2BossEntityState,
|
||||
timeSeconds: number,
|
||||
motion: BossRenderMotion = createBossRenderMotion(),
|
||||
): BossRenderMotion {
|
||||
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
|
||||
const phase = String(entity.attackPhase)
|
||||
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
||||
const moving = speed > 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>()
|
||||
private liveLabelIds = new Set<string>()
|
||||
private livePartyIds = new Set<string>()
|
||||
private liveProjectileIds = new Set<string>()
|
||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||
@@ -54,11 +61,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
||||
private livePartyEffectIds = new Set<string>()
|
||||
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
||||
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
||||
private currentEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||
private liveEntityAnchorIds = new Set<string>()
|
||||
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<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
|
||||
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
|
||||
const livePartyIds = new Set<string>(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<string, FloatingCombatTextAnchor>,
|
||||
) {
|
||||
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<DrawableEntity['kind'], number>
|
||||
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<string, FloatingCombatTextAnchor> {
|
||||
const anchors = new Map<string, FloatingCombatTextAnchor>()
|
||||
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
|
||||
function updateEntityAnchors(
|
||||
anchors: Map<string, FloatingCombatTextAnchor>,
|
||||
liveIds: Set<string>,
|
||||
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<string, FloatingCombatTextAnchor>,
|
||||
liveIds: Set<string>,
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -56,10 +59,14 @@ type OverlayNavEntry = {
|
||||
column: number
|
||||
}
|
||||
|
||||
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
const PAUSE_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0, column: 0 },
|
||||
{ action: 'menu', row: 1, column: 0 },
|
||||
]
|
||||
const RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0, column: 0 },
|
||||
{ action: 'menu', row: 0, column: 1 },
|
||||
]
|
||||
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0, column: 0 },
|
||||
{ action: 'requeue', row: 0, column: 1 },
|
||||
@@ -71,6 +78,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 +165,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
||||
const saveRef = useRef(save)
|
||||
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||
const lastPublishTimeRef = useRef(0)
|
||||
const lastHudSignatureRef = useRef('')
|
||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||
const movementRef = useMovementVectorRef(status === 'playing')
|
||||
const {
|
||||
@@ -169,14 +176,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 +218,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 +277,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 +443,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 +540,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
||||
targetBindings={targetBindings}
|
||||
/>
|
||||
|
||||
<PhaserArena
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
<Suspense fallback={<div className="iwt2-phaser-host iwt2-arena-loading" role="status">Loading arena...</div>}>
|
||||
<PhaserArena
|
||||
active={status === 'playing'}
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
</Suspense>
|
||||
{status === 'paused' && (
|
||||
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||
<div>
|
||||
@@ -700,28 +697,9 @@ function rewardDifficultySlugForKill(
|
||||
}
|
||||
|
||||
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||
if (status === 'paused') return PAUSE_OVERLAY_NAV_ENTRIES
|
||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||
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('|')
|
||||
return RESULT_OVERLAY_NAV_ENTRIES
|
||||
}
|
||||
|
||||
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
||||
|
||||
@@ -1886,7 +1886,9 @@ export function Iwt2CloudSaveScreen({
|
||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
|
||||
const statusMessage = message || (!onlineBackupsAvailable
|
||||
? 'Sign in at character select to upload or download this local save.'
|
||||
: '')
|
||||
|
||||
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
|
||||
setSyncingOnlineSave(true)
|
||||
@@ -1923,7 +1925,7 @@ export function Iwt2CloudSaveScreen({
|
||||
|
||||
const handleUseLocalSave = useCallback(async () => {
|
||||
if (!onlineBackupsAvailable) {
|
||||
setMessage('Using local save. Sign in online to update the server copy.')
|
||||
setMessage('Using local save. Sign in at character select to update the server copy.')
|
||||
return
|
||||
}
|
||||
setSyncingOnlineSave(true)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user