Android build v1.1.15
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
type Iwt2ArenaState,
|
||||
} from '../sim/arenaState'
|
||||
import type { Iwt2EntityId } from '../sim'
|
||||
import { castIwt2HealerAbility } from '../sim'
|
||||
import { PhaserArena } from '../render/PhaserArena'
|
||||
import {
|
||||
recordIwt2BossKill,
|
||||
type Iwt2Save,
|
||||
} from '../save/iwt2Repository'
|
||||
import { AbilityBar } from '../components/AbilityBar'
|
||||
import { BossHud } from '../components/BossHud'
|
||||
import { PartyFrames } from '../components/PartyFrames'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
|
||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||
type OverlayAction = 'primary' | 'menu'
|
||||
|
||||
type BossArenaScreenProps = {
|
||||
bossId: Iwt2BossId
|
||||
save: Iwt2Save
|
||||
onBack: () => void
|
||||
onSaveUpdated: (save: Iwt2Save) => void
|
||||
}
|
||||
|
||||
export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||
const stateRef = useRef(arenaState)
|
||||
const statusRef = useRef(status)
|
||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||
const saveRef = useRef(save)
|
||||
const killRecordedRef = useRef(false)
|
||||
const lastPublishTimeRef = useRef(0)
|
||||
const lastHudSignatureRef = useRef('')
|
||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||
const movementRef = useMovementVectorRef(status === 'playing')
|
||||
const {
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
selectedPartyIdRef.current = selectedPartyId
|
||||
}, [selectedPartyId])
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = save
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId)
|
||||
killRecordedRef.current = false
|
||||
abilityCooldownsRef.current = {}
|
||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||
stateRef.current = next
|
||||
setArenaState(next)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossId])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
const abilities = useMemo(
|
||||
() => abilitiesForHealer('field_medic'),
|
||||
[],
|
||||
)
|
||||
|
||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||
if (statusRef.current !== 'playing') return
|
||||
if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return
|
||||
const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current)
|
||||
if (!result.cast) return
|
||||
|
||||
const nextCooldowns = {
|
||||
...abilityCooldownsRef.current,
|
||||
[ability.id]: ability.cooldownSeconds,
|
||||
}
|
||||
abilityCooldownsRef.current = nextCooldowns
|
||||
stateRef.current = result.state
|
||||
lastHudSignatureRef.current = arenaHudSignature(result.state)
|
||||
setAbilityCooldowns(nextCooldowns)
|
||||
setArenaState(result.state)
|
||||
}, [])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||
if (action.startsWith('navigate')) {
|
||||
setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary')
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
if (selectedOverlayAction === 'menu') onBack()
|
||||
else if (statusRef.current === 'paused') setStatus('playing')
|
||||
else resetArena()
|
||||
return
|
||||
}
|
||||
if (action === 'back') {
|
||||
if (statusRef.current === 'paused') setStatus('playing')
|
||||
else onBack()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (statusRef.current === 'playing' && action.startsWith('ability')) {
|
||||
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
|
||||
if (ability) castAbility(ability)
|
||||
return
|
||||
}
|
||||
if (
|
||||
device === 'controller'
|
||||
&& statusRef.current === 'playing'
|
||||
&& !directPartyTargeting
|
||||
&& (
|
||||
action === 'navigateUp'
|
||||
|| action === 'navigateLeft'
|
||||
|| action === 'previousTarget'
|
||||
|| action === 'navigateDown'
|
||||
|| action === 'navigateRight'
|
||||
|| action === 'nextTarget'
|
||||
)
|
||||
) {
|
||||
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
|
||||
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
|
||||
return
|
||||
}
|
||||
if (action.startsWith('targetParty')) {
|
||||
const index = Number(action.replace('targetParty', '')) - 1
|
||||
const target = stateRef.current.party[index]
|
||||
if (target) setSelectedPartyId(target.id)
|
||||
return
|
||||
}
|
||||
if (device !== 'controller') return
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (statusRef.current === 'playing') showOverlay('paused')
|
||||
else if (statusRef.current === 'paused') setStatus('playing')
|
||||
}
|
||||
})
|
||||
|
||||
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
|
||||
if (statusRef.current !== 'playing') return stateRef.current
|
||||
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
|
||||
const next = tickIwt2Arena(stateRef.current, {
|
||||
moveX: movement.x,
|
||||
moveY: movement.y,
|
||||
}, dtSeconds)
|
||||
stateRef.current = next
|
||||
|
||||
if (next.boss.health <= 0 && !killRecordedRef.current) {
|
||||
killRecordedRef.current = true
|
||||
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
|
||||
saveRef.current = updatedSave
|
||||
onSaveUpdated(updatedSave)
|
||||
showOverlay('victory')
|
||||
} else if (next.party.every((member) => member.health <= 0)) {
|
||||
showOverlay('defeat')
|
||||
}
|
||||
|
||||
const hudSignature = arenaHudSignature(next)
|
||||
if (
|
||||
hudSignature !== lastHudSignatureRef.current
|
||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||
|| next.boss.health <= 0
|
||||
) {
|
||||
lastHudSignatureRef.current = hudSignature
|
||||
lastPublishTimeRef.current = next.time
|
||||
setArenaState(next)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [bossId, onSaveUpdated, showOverlay])
|
||||
|
||||
const targetBindings = directPartyTargeting
|
||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||
: undefined
|
||||
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
||||
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||
const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart'
|
||||
const overlayTitle = status === 'victory'
|
||||
? `${bossMetadata.name} Down`
|
||||
: status === 'defeat'
|
||||
? 'Party Defeated'
|
||||
: 'Arena Paused'
|
||||
const overlayEyebrow = status === 'victory'
|
||||
? 'Hunt Complete'
|
||||
: status === 'defeat'
|
||||
? 'Recovery'
|
||||
: 'Paused'
|
||||
const overlayTone = status === 'victory'
|
||||
? 'is-victory'
|
||||
: status === 'defeat'
|
||||
? 'is-defeat'
|
||||
: 'is-paused'
|
||||
|
||||
return (
|
||||
<main
|
||||
className="game-shell iwt2-arena-shell"
|
||||
data-combat-active={status === 'playing' ? 'true' : 'false'}
|
||||
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
|
||||
>
|
||||
<section className="iwt2-arena-layout">
|
||||
<div className="iwt2-arena-stage">
|
||||
<BossHud boss={arenaState.boss} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
party={arenaState.party}
|
||||
selectedPartyId={selectedPartyId}
|
||||
targetBindings={targetBindings}
|
||||
/>
|
||||
|
||||
<PhaserArena
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
{status !== 'playing' && (
|
||||
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
||||
<div className="iwt2-result-panel">
|
||||
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
|
||||
<span>{bossMetadata.icon}</span>
|
||||
</div>
|
||||
<p className="eyebrow">{overlayEyebrow}</p>
|
||||
<h1>{overlayTitle}</h1>
|
||||
{status !== 'paused' && (
|
||||
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
||||
<span>
|
||||
<strong>{formatArenaTime(arenaState.time)}</strong>
|
||||
Clear
|
||||
</span>
|
||||
<span>
|
||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||
Standing
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+125 XP</span>
|
||||
<span>Common carve</span>
|
||||
<span>Log +1</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="iwt2-overlay-actions">
|
||||
<button
|
||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
|
||||
type="button"
|
||||
>
|
||||
{overlayPrimaryLabel}
|
||||
</button>
|
||||
<button
|
||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
{lastDevice === 'controller' && (
|
||||
<small className="iwt2-result-hint">D-pad selects, A confirms, B exits</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AbilityBar
|
||||
abilities={abilities}
|
||||
cooldowns={abilityCooldowns}
|
||||
mana={playerMana}
|
||||
onCast={castAbility}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function formatArenaTime(seconds: number): string {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds))
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
const remainder = safeSeconds % 60
|
||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||
return [
|
||||
state.boss.id,
|
||||
Math.ceil(state.boss.health),
|
||||
state.boss.attackPhase,
|
||||
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 nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
||||
const living = state.party.filter((member) => member.health > 0)
|
||||
const targets = living.length > 0 ? living : state.party
|
||||
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
|
||||
const nextIndex = (currentIndex + direction + targets.length) % targets.length
|
||||
return targets[nextIndex]?.id ?? currentId
|
||||
}
|
||||
|
||||
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
||||
let changed = false
|
||||
const next: Record<string, number> = {}
|
||||
for (const [id, remaining] of Object.entries(cooldowns)) {
|
||||
const value = Math.max(0, remaining - dt)
|
||||
if (value !== remaining) changed = true
|
||||
if (value > 0) next[id] = value
|
||||
}
|
||||
return changed ? next : cooldowns
|
||||
}
|
||||
Reference in New Issue
Block a user