Android build v1.1.19

This commit is contained in:
Warren H
2026-07-04 20:50:33 -04:00
parent c052b086f8
commit bdae0007a1
16 changed files with 641 additions and 210 deletions
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 97 versionCode 98
versionName "1.1.18" versionName "1.1.19"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+63 -3
View File
@@ -145,6 +145,8 @@
} }
.iwt2-boss-hud { .iwt2-boss-hud {
display: grid;
gap: 6px;
left: 50%; left: 50%;
min-width: 310px; min-width: 310px;
padding: 8px 12px 10px; padding: 8px 12px 10px;
@@ -155,7 +157,7 @@
z-index: 4; z-index: 4;
} }
.iwt2-boss-hud > div:first-child { .iwt2-boss-hud-row > div:first-child {
align-items: baseline; align-items: baseline;
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -2137,7 +2139,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
} }
.pvp-opponent-bottom-display { .pvp-opponent-bottom-display {
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto auto minmax(0, 1fr) auto;
} }
.dual-controls-header, .dual-controls-header,
@@ -2172,6 +2174,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
} }
.dual-opponent-progress, .dual-opponent-progress,
.dual-opponent-arena,
.dual-opponent-effects { .dual-opponent-effects {
background: var(--panel); background: var(--panel);
border: 3px solid #0c0d11; border: 3px solid #0c0d11;
@@ -2195,6 +2198,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
height: 22px; height: 22px;
} }
.dual-opponent-arena {
padding: 7px;
}
.dual-opponent-arena-field {
background:
linear-gradient(rgba(23, 29, 36, 0.96), rgba(23, 29, 36, 0.96)),
repeating-linear-gradient(0deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px),
repeating-linear-gradient(90deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px);
border: 2px solid #3c4a59;
border-radius: 6px;
max-height: 138px;
min-height: 112px;
overflow: hidden;
position: relative;
width: 100%;
}
.dual-opponent-arena-entity {
align-items: center;
border: 2px solid #0a0c10;
border-radius: 999px;
color: #fff7df;
display: flex;
font-family: ui-monospace, Consolas, monospace;
font-size: 10px;
font-weight: 900;
justify-content: center;
line-height: 1;
min-height: 12px;
min-width: 12px;
position: absolute;
transform: translate(-50%, -50%);
}
.dual-opponent-arena-entity.boss {
border-color: #fff0b8;
border-radius: 45%;
font-size: 12px;
min-height: 18px;
min-width: 24px;
}
.dual-opponent-arena-entity.healer {
box-shadow: 0 0 0 3px rgba(255, 244, 168, 0.84);
}
.dual-opponent-party-grid { .dual-opponent-party-grid {
background: var(--panel); background: var(--panel);
border: 3px solid #0c0d11; border: 3px solid #0c0d11;
@@ -2567,7 +2617,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
} }
.pvp-opponent-bottom-display { .pvp-opponent-bottom-display {
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto auto minmax(0, 1fr) auto;
} }
.dual-opponent-progress { .dual-opponent-progress {
@@ -2590,6 +2640,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
height: 16px; height: 16px;
} }
.dual-opponent-arena {
border-width: 2px;
padding: 5px;
}
.dual-opponent-arena-field {
max-height: 112px;
min-height: 92px;
}
.dual-opponent-party-grid { .dual-opponent-party-grid {
border-width: 2px; border-width: 2px;
gap: 6px; gap: 6px;
+71 -3
View File
@@ -1,7 +1,12 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { AuthScreen } from './components/AuthScreen'
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App' import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
import { useGameAction } from './input' import { useGameAction } from './input'
import {
loadAuthSession,
type AuthSession,
} from './profile'
type GameVersion = 'iwt1' | 'iwt2' type GameVersion = 'iwt1' | 'iwt2'
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
function App() { function App() {
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null) const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [serverMessage, setServerMessage] = useState('')
useEffect(() => {
let cancelled = false
loadAuthSession()
.then((session) => {
if (cancelled) return
setAuthSession(session.account && session.profile ? session : null)
})
.catch((reason: unknown) => {
if (cancelled) return
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => {
if (!cancelled) setAuthChecked(true)
})
return () => {
cancelled = true
}
}, [])
const acceptSession = useCallback((session: AuthSession) => {
setAuthSession(session)
setSelectedVersion(null)
setServerMessage('')
}, [])
const clearAuthSession = useCallback(() => {
setAuthSession(null)
setSelectedVersion(null)
}, [])
useGameAction((action, device) => { useGameAction((action, device) => {
if (selectedVersion || device !== 'controller') return if (!authSession || selectedVersion || device !== 'controller') return
if (action === 'navigateLeft' || action === 'navigateUp') { if (action === 'navigateLeft' || action === 'navigateUp') {
setSelectedIndex((current) => Math.max(0, current - 1)) setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateRight' || action === 'navigateDown') { } else if (action === 'navigateRight' || action === 'navigateDown') {
@@ -43,8 +85,34 @@ function App() {
} }
}) })
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!authSession) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (selectedVersion === 'iwt1') { if (selectedVersion === 'iwt1') {
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} /> return (
<IWantToHeal1App
initialSession={authSession}
onAuthenticationCleared={clearAuthSession}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
} }
if (selectedVersion === 'iwt2') { if (selectedVersion === 'iwt2') {
+47
View File
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
opponentClassName?: string opponentClassName?: string
opponentParty?: PartyMember[] opponentParty?: PartyMember[]
opponentEnemyHealth?: number opponentEnemyHealth?: number
opponentArena?: {
bounds: { width: number, height: number }
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
}
opponentResource?: number opponentResource?: number
opponentMaxResource?: number opponentMaxResource?: number
opponentResourceName?: string opponentResourceName?: string
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
</section> </section>
)} )}
{state.opponentArena && (
<section className="dual-opponent-arena" aria-label="Opponent arena">
<div
className="dual-opponent-arena-field"
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
>
{state.opponentArena.bosses.map((boss) => (
<div
className="dual-opponent-arena-entity boss"
key={boss.id}
style={{
backgroundColor: boss.color,
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
>
{boss.icon}
</div>
))}
{state.opponentArena.party.map((member) => (
<div
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
key={member.id}
style={{
backgroundColor: member.color,
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
opacity: member.health > 0 ? 1 : 0.35,
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
>
{member.icon}
</div>
))}
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}> <section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => ( {state.opponentParty.map((member) => (
<PartyMemberFrame <PartyMemberFrame
+25 -20
View File
@@ -1,5 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { AuthScreen } from '../../components/AuthScreen'
import { import {
loadCpuPvpLeaderboard, loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry, type CpuPvpLeaderboardEntry,
@@ -138,11 +137,21 @@ function ScreenLoading() {
type RoguelikeVariant = 'pve' | 'pvp' type RoguelikeVariant = 'pve' | 'pvp'
function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { type IWantToHeal1AppProps = {
initialSession: AuthSession
onAuthenticationCleared: () => void
onBackToGameSelect: () => void
}
function IWantToHeal1App({
initialSession,
onAuthenticationCleared,
onBackToGameSelect,
}: IWantToHeal1AppProps) {
const { enabled: dualScreenEnabled } = useDualScreen() const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Screen>('menu') const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(initialSession.account)
const [profile, setProfile] = useState<CharacterProfile | null>(null) const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(getGameMode()) const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
const [serverMessage, setServerMessage] = useState('') const [serverMessage, setServerMessage] = useState('')
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
.finally(() => setAuthChecked(true)) .finally(() => setAuthChecked(true))
}, []) }, [])
useEffect(() => {
if (authChecked && (!account || !profile)) onAuthenticationCleared()
}, [account, authChecked, onAuthenticationCleared, profile])
useEffect(() => { useEffect(() => {
const handleModeChange = (event: Event) => { const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail const nextMode = (event as CustomEvent<GameMode>).detail
@@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
) )
function acceptSession(session: AuthSession) {
setAccount(session.account)
setProfile(session.profile)
setGameMode(getGameMode())
setScreen('menu')
setError('')
setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
}
async function signOut() { async function signOut() {
try { try {
await logoutAccount() await logoutAccount()
@@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
setScreen('menu') setScreen('menu')
setSyncMessage('') setSyncMessage('')
setSyncComparison(null) setSyncComparison(null)
onAuthenticationCleared()
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.') setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
} }
@@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
if (!account || !profile) { if (!account || !profile) {
return ( return (
<AuthScreen <main className="game-shell">
onAuthenticated={acceptSession} <section className="message-panel">
serverMessage={serverMessage} <p className="eyebrow">Opening Chronicle</p>
/> <h1>Returning to Sign In...</h1>
{serverMessage && <p>{serverMessage}</p>}
</section>
</main>
) )
} }
+77 -22
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useGameAction } from '../../input' import { useGameAction } from '../../input'
import { getGameMode } from '../../gameRepository'
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades' import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
import { import {
useDualScreen, useDualScreen,
@@ -54,6 +56,7 @@ const IWT2_MENU_COLUMNS = 4
const IWT2_ROGUELIKE_CHOICE_COUNT = 3 const IWT2_ROGUELIKE_CHOICE_COUNT = 3
type Iwt2RoguelikeRunState = { type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
buffs: Iwt2RoguelikeSelfBuffId[] buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[] debuffs: Iwt2RoguelikeOpponentDebuffId[]
@@ -129,11 +132,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve') const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon') const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null) const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => { useEffect(() => {
writeIwt2Save(save) writeIwt2Save(save)
}, [save]) }, [save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
}, [])
const setupDualScreenState = useMemo<DualScreenSetupState | null>( const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save), () => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId], [save, screen, selectedBossId],
@@ -182,6 +191,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return ( return (
<BossArenaScreen <BossArenaScreen
bossId={selectedBossId} bossId={selectedBossId}
key={`arena-${selectedBossId}-${arenaModeLabel}`}
modeLabel={arenaModeLabel} modeLabel={arenaModeLabel}
save={save} save={save}
onBack={() => setScreen('menu')} onBack={() => setScreen('menu')}
@@ -193,7 +203,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
if (screen === 'roguelike-arena' && roguelikeRun) { if (screen === 'roguelike-arena' && roguelikeRun) {
return ( return (
<BossArenaScreen <BossArenaScreen
bossIds={roguelikeRun.bossIds}
bossId={selectedBossId} bossId={selectedBossId}
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
roguelikeRun={{ roguelikeRun={{
buffs: roguelikeRun.buffs, buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType, contentType: roguelikeRun.contentType,
@@ -233,7 +245,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
onChoose={(buffId, debuffId) => { onChoose={(buffId, debuffId) => {
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save) const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
setRoguelikeRun(nextRun) setRoguelikeRun(nextRun)
setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage)) setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena') setScreen('roguelike-arena')
}} }}
/> />
@@ -267,17 +279,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
onBack={() => setScreen('menu')} onBack={() => setScreen('menu')}
onContentTypeChange={setRoguelikeContentType} onContentTypeChange={setRoguelikeContentType}
onStart={() => { onStart={() => {
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType) startIwt2RoguelikeRun()
setRoguelikeRun(nextRun)
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
setScreen('roguelike-arena')
}} }}
onVariantChange={(nextVariant) => { onVariantChange={(nextVariant) => {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
setRoguelikeVariant(nextVariant) setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') { if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon') setRoguelikeContentType('dungeon')
} }
}} }}
queueMessage={pvpQueueMessage}
/> />
</main> </main>
) )
@@ -359,6 +372,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<button <button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`} className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`} key={`${item.screen}-${item.title}`}
onClick={() => openMenuItem(item)} onClick={() => openMenuItem(item)}
onPointerDown={() => setSelectedIndex(index)} onPointerDown={() => setSelectedIndex(index)}
@@ -376,24 +390,58 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)} )}
</main> </main>
) )
function startIwt2RoguelikeRun() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
contentType: roguelikeContentType,
startStage,
gameMode: getGameMode(),
liveMatchActive: () => false,
onSearching: setPvpQueueMessage,
onCpuMatch: (_difficulty, message) => {
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
onLiveMatch: (...liveMatchArgs) => {
const message = liveMatchArgs[2]
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
messages: {
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
},
})
}
function beginIwt2RoguelikeArena(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
) {
const nextRun = createRoguelikeRun(save, variant, contentType)
setRoguelikeRun(nextRun)
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}
} }
function bossForRoguelike( function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
variant: Iwt2RoguelikeVariant, if (contentType === 'raid') return 'Raid'
contentType: Iwt2RoguelikeContentType, if (contentType === 'stadium') return 'Stadium'
stage = 1, return 'Dungeon'
): Iwt2BossId {
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
const pool = contentType === 'raid'
? raidBosses
: contentType === 'stadium'
? stadiumBosses
: dungeonBosses
return pool[(stage - 1) % pool.length]
} }
function createRoguelikeRun( function createRoguelikeRun(
@@ -402,6 +450,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState { ): Iwt2RoguelikeRunState {
return { return {
bossIds: createRandomRoguelikeBossPair(),
buffs: [], buffs: [],
contentType, contentType,
debuffs: [], debuffs: [],
@@ -462,11 +511,17 @@ function applyRoguelikeChoice(
return { return {
...run, ...run,
...nextBase, ...nextBase,
bossIds: createRandomRoguelikeBossPair(),
...buildRoguelikeChoices(save, run.variant), ...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1, stage: run.stage + 1,
} }
} }
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
return chooseRunChoices(pool, 2)
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] { function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
const pool = [...items] const pool = [...items]
const choices: T[] = [] const choices: T[] = []
+15 -8
View File
@@ -2,16 +2,23 @@ import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars' import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses' import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) { export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId] const entries = bosses ?? (boss ? [boss] : [])
return ( return (
<div className="iwt2-boss-hud"> <div className="iwt2-boss-hud">
<div> {entries.map((entry) => {
<strong>{metadata.name}</strong> const metadata = IWT2_BOSS_METADATA[entry.bossId]
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small> return (
</div> <div className="iwt2-boss-hud-row" key={entry.id}>
<small className="iwt2-boss-phase">{boss.attackPhase}</small> <div>
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} /> <strong>{metadata.name}</strong>
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
</div>
)
})}
</div> </div>
) )
} }
@@ -27,6 +27,7 @@ export function PartyFrames({
<button <button
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`} className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected ? 'true' : undefined}
key={member.id} key={member.id}
onClick={() => onTarget(member.id)} onClick={() => onTarget(member.id)}
type="button" type="button"
@@ -83,7 +83,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
const graphics = this.entityGraphics! const graphics = this.entityGraphics!
graphics.clear() graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard) for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss] const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
const liveIds = new Set<string>(entities.map((entity) => entity.id)) const liveIds = new Set<string>(entities.map((entity) => entity.id))
for (const entity of entities.sort(entitySort)) { for (const entity of entities.sort(entitySort)) {
+98 -37
View File
@@ -47,6 +47,7 @@ const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
type BossArenaScreenProps = { type BossArenaScreenProps = {
bossId: Iwt2BossId bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
modeLabel?: string modeLabel?: string
save: Iwt2Save save: Iwt2Save
onBack: () => void onBack: () => void
@@ -61,12 +62,12 @@ type BossArenaScreenProps = {
} }
} }
export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId] const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp' const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId)) const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => ( const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
)) ))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({}) const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing') const [status, setStatus] = useState<ArenaStatus>('playing')
@@ -78,7 +79,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction) const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId) const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save) const saveRef = useRef(save)
const killRecordedRef = useRef(false) const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
const lastPublishTimeRef = useRef(0) const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('') const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({}) const abilityCooldownsRef = useRef<Record<string, number>>({})
@@ -100,12 +101,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
opponentStateRef.current = opponentArenaState opponentStateRef.current = opponentArenaState
}, [opponentArenaState]) }, [opponentArenaState])
useEffect(() => {
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
opponentStateRef.current = nextOpponentState
setOpponentArenaState(nextOpponentState)
}, [bossId, pvpRoguelike])
useEffect(() => { useEffect(() => {
statusRef.current = status statusRef.current = status
}, [status]) }, [status])
@@ -123,9 +118,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
}, [save]) }, [save])
const resetArena = useCallback(() => { const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId) const next = createInitialIwt2ArenaState(bossId, bossIds)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
killRecordedRef.current = false recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {} abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next) lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next stateRef.current = next
@@ -135,7 +130,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
setAbilityCooldowns({}) setAbilityCooldowns({})
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
setStatus('playing') setStatus('playing')
}, [bossId, pvpRoguelike]) }, [bossId, bossIds, pvpRoguelike])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => { const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
@@ -272,11 +267,20 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
opponentStateRef.current = nextOpponentState opponentStateRef.current = nextOpponentState
} }
if (next.boss.health <= 0 && !killRecordedRef.current) { const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
killRecordedRef.current = true if (newlyDefeatedBosses.length > 0) {
const updatedSave = recordIwt2BossKill(saveRef.current, bossId) const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave saveRef.current = updatedSave
onSaveUpdated(updatedSave) onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
showOverlay('victory') showOverlay('victory')
} else if (next.party.every((member) => member.health <= 0)) { } else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat') showOverlay('defeat')
@@ -286,7 +290,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
if ( if (
hudSignature !== lastHudSignatureRef.current hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08 || next.time - lastPublishTimeRef.current >= 0.08
|| next.boss.health <= 0 || next.bosses.some((boss) => boss.health <= 0)
) { ) {
lastHudSignatureRef.current = hudSignature lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time lastPublishTimeRef.current = next.time
@@ -295,7 +299,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
setAbilityCooldowns(abilityCooldownsRef.current) setAbilityCooldowns(abilityCooldownsRef.current)
} }
return next return next
}, [bossId, onSaveUpdated, pvpRoguelike, showOverlay]) }, [onSaveUpdated, pvpRoguelike, showOverlay])
const targetBindings = directPartyTargeting const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null) ? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
@@ -303,13 +307,15 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0 const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0) const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused' const overlayPrimaryLabel = status === 'paused'
? 'Resume' ? 'Resume'
: status === 'victory' && roguelikeRun : status === 'victory' && roguelikeRun
? 'Choose Upgrade' ? 'Choose Upgrade'
: 'Restart' : 'Restart'
const overlayTitle = status === 'victory' const overlayTitle = status === 'victory'
? `${bossMetadata.name} Down` ? `${bossTitle} Down`
: status === 'defeat' : status === 'defeat'
? 'Party Defeated' ? 'Party Defeated'
: 'Arena Paused' : 'Arena Paused'
@@ -337,7 +343,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
abilities, abilities,
arenaState, arenaState,
bindings: activeBindings, bindings: activeBindings,
bossMetadata,
cooldowns: abilityCooldowns, cooldowns: abilityCooldowns,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
@@ -353,7 +358,6 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
activeBindings, activeBindings,
arenaState, arenaState,
abilityCooldowns, abilityCooldowns,
bossMetadata,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
modeLabel, modeLabel,
@@ -375,7 +379,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
> >
<section className="iwt2-arena-layout"> <section className="iwt2-arena-layout">
<div className="iwt2-arena-stage"> <div className="iwt2-arena-stage">
<BossHud boss={arenaState.boss} /> <BossHud bosses={arenaState.bosses} />
<PartyFrames <PartyFrames
controllerIconStyle={controllerIconStyle} controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId} onTarget={setSelectedPartyId}
@@ -399,6 +403,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<button <button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''} className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')} onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')} onPointerDown={() => setSelectedOverlayAction('primary')}
type="button" type="button"
@@ -408,6 +413,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<button <button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`} className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')} onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')} onPointerDown={() => setSelectedOverlayAction('menu')}
type="button" type="button"
@@ -422,7 +428,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true"> <div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel"> <div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}> <div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
<span>{bossMetadata.icon}</span> <span>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
</div> </div>
<p className="eyebrow">{overlayEyebrow}</p> <p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1> <h1>{overlayTitle}</h1>
@@ -440,6 +446,10 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
<strong>{alivePartyCount}/{arenaState.party.length}</strong> <strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing Standing
</span> </span>
<span>
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
Bosses
</span>
<span> <span>
<strong>{Math.round(totalPartyDamage)}</strong> <strong>{Math.round(totalPartyDamage)}</strong>
Damage Damage
@@ -447,9 +457,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
</div> </div>
{status === 'victory' && ( {status === 'victory' && (
<div className="iwt2-result-reward"> <div className="iwt2-result-reward">
<span>+125 XP</span> <span>+{arenaState.bosses.length * 125} XP</span>
<span>Common carve</span> <span>{arenaState.bosses.length} carves</span>
<span>Log +1</span> <span>Log +{arenaState.bosses.length}</span>
</div> </div>
)} )}
<div className="iwt2-overlay-actions"> <div className="iwt2-overlay-actions">
@@ -498,9 +508,11 @@ function formatArenaTime(seconds: number): string {
function arenaHudSignature(state: Iwt2ArenaState): string { function arenaHudSignature(state: Iwt2ArenaState): string {
return [ return [
state.boss.id, ...state.bosses.map((boss) => [
Math.ceil(state.boss.health), boss.id,
state.boss.attackPhase, Math.ceil(boss.health),
boss.attackPhase,
].join(':')),
state.hostileAdds.length, state.hostileAdds.length,
state.hazards.length, state.hazards.length,
...state.party.map((member) => [ ...state.party.map((member) => [
@@ -514,6 +526,12 @@ function arenaHudSignature(state: Iwt2ArenaState): string {
].join('|') ].join('|')
} }
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
return bosses
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
.join(' + ')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId { function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0) const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party const targets = living.length > 0 ? living : state.party
@@ -586,7 +604,6 @@ function buildIwt2DualScreenCombatState({
abilities, abilities,
arenaState, arenaState,
bindings, bindings,
bossMetadata,
cooldowns, cooldowns,
controllerIconStyle, controllerIconStyle,
directPartyTargeting, directPartyTargeting,
@@ -600,7 +617,6 @@ function buildIwt2DualScreenCombatState({
abilities: Iwt2HealerAbility[] abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings'] bindings: DualScreenCombatState['bindings']
bossMetadata: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
cooldowns: Record<string, number> cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle'] controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean directPartyTargeting: boolean
@@ -612,12 +628,15 @@ function buildIwt2DualScreenCombatState({
status: ArenaStatus status: ArenaStatus
}): DualScreenCombatState { }): DualScreenCombatState {
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer') const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
const arenaBossHealth = totalBossHealth(arenaState)
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? { ? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`, opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`, opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs), opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentEnemyHealth: opponentArenaState.boss.health, opponentArena: toDualScreenOpponentArena(opponentArenaState),
opponentEnemyHealth: totalBossHealth(opponentArenaState),
opponentMaxResource: opponentHealer?.maxMana ?? 100, opponentMaxResource: opponentHealer?.maxMana ?? 100,
opponentName: 'CPU Rival', opponentName: 'CPU Rival',
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)), opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
@@ -632,14 +651,14 @@ function buildIwt2DualScreenCombatState({
controllerIconStyle, controllerIconStyle,
difficultyName: 'IWT2', difficultyName: 'IWT2',
directPartyTargeting, directPartyTargeting,
dungeonName: `${bossMetadata.name} Arena`, dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
encounterCount: 1, encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase, encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaState.boss.health, encounterHealth: arenaBossHealth,
encounterIndex: 0, encounterIndex: 0,
encounterIsBoss: true, encounterIsBoss: true,
encounterMaxHealth: arenaState.boss.maxHealth, encounterMaxHealth: arenaBossMaxHealth,
encounterName: bossMetadata.name, encounterName: formatBossEncounterTitle(arenaState.bosses),
floatingTexts: [], floatingTexts: [],
maxResource: 100, maxResource: 100,
party: arenaState.party.map((member) => toDualScreenPartyMember(member)), party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
@@ -656,6 +675,48 @@ function buildIwt2DualScreenCombatState({
} }
} }
function totalBossHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
}
function totalBossMaxHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
}
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
return {
bounds: state.bounds,
bosses: state.bosses.map((boss) => {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
name: metadata.name,
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
}
}),
party: state.party.map((member) => {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
isHealer: member.id === 'player-healer',
}
}),
}
}
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember { function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
const metadata = IWT2_CLASS_METADATA[member.classId] const metadata = IWT2_CLASS_METADATA[member.classId]
return { return {
+19 -2
View File
@@ -176,6 +176,7 @@ export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
<button <button
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`} className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
disabled={action.disabled} disabled={action.disabled}
key={action.key} key={action.key}
onClick={action.onConfirm} onClick={action.onConfirm}
@@ -292,6 +293,7 @@ export function Iwt2RoguelikeScreen({
onContentTypeChange, onContentTypeChange,
onStart, onStart,
onVariantChange, onVariantChange,
queueMessage,
variant, variant,
}: { }: {
contentType: Iwt2RoguelikeContentType contentType: Iwt2RoguelikeContentType
@@ -299,6 +301,7 @@ export function Iwt2RoguelikeScreen({
onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void
onStart: () => void onStart: () => void
onVariantChange: (variant: Iwt2RoguelikeVariant) => void onVariantChange: (variant: Iwt2RoguelikeVariant) => void
queueMessage?: string
variant: Iwt2RoguelikeVariant variant: Iwt2RoguelikeVariant
}) { }) {
const gameMode = getGameMode() const gameMode = getGameMode()
@@ -403,6 +406,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`back-button ${selected('back') ? 'game-selected' : ''}`} className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack} onClick={onBack}
onPointerDown={() => select('back')} onPointerDown={() => select('back')}
type="button" type="button"
@@ -414,6 +418,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${variant === 'pve' ? 'active' : ''} ${selected('variant-pve') ? 'game-selected' : ''}`} className={`text-button ${variant === 'pve' ? 'active' : ''} ${selected('variant-pve') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('variant-pve') ? 'true' : undefined}
onClick={() => open('variant-pve')} onClick={() => open('variant-pve')}
onPointerDown={() => select('variant-pve')} onPointerDown={() => select('variant-pve')}
type="button" type="button"
@@ -423,6 +428,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${variant === 'pvp' ? 'active' : ''} ${selected('variant-pvp') ? 'game-selected' : ''}`} className={`text-button ${variant === 'pvp' ? 'active' : ''} ${selected('variant-pvp') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('variant-pvp') ? 'true' : undefined}
onClick={() => open('variant-pvp')} onClick={() => open('variant-pvp')}
onPointerDown={() => select('variant-pvp')} onPointerDown={() => select('variant-pvp')}
type="button" type="button"
@@ -441,6 +447,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pve-dungeon') ? 'game-selected' : ''}`} className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pve-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pve-dungeon') ? 'true' : undefined}
onClick={() => open('pve-dungeon')} onClick={() => open('pve-dungeon')}
onPointerDown={() => select('pve-dungeon')} onPointerDown={() => select('pve-dungeon')}
type="button" type="button"
@@ -450,6 +457,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pve-raid') ? 'game-selected' : ''}`} className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pve-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pve-raid') ? 'true' : undefined}
onClick={() => open('pve-raid')} onClick={() => open('pve-raid')}
onPointerDown={() => select('pve-raid')} onPointerDown={() => select('pve-raid')}
type="button" type="button"
@@ -467,6 +475,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${selected('pve-start') ? 'game-selected' : ''}`} className={`text-button ${selected('pve-start') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pve-start') ? 'true' : undefined}
onClick={() => open('pve-start')} onClick={() => open('pve-start')}
onPointerDown={() => select('pve-start')} onPointerDown={() => select('pve-start')}
type="button" type="button"
@@ -487,6 +496,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pvp-dungeon') ? 'game-selected' : ''}`} className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pvp-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pvp-dungeon') ? 'true' : undefined}
onClick={() => open('pvp-dungeon')} onClick={() => open('pvp-dungeon')}
onPointerDown={() => select('pvp-dungeon')} onPointerDown={() => select('pvp-dungeon')}
type="button" type="button"
@@ -496,6 +506,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pvp-raid') ? 'game-selected' : ''}`} className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pvp-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pvp-raid') ? 'true' : undefined}
onClick={() => open('pvp-raid')} onClick={() => open('pvp-raid')}
onPointerDown={() => select('pvp-raid')} onPointerDown={() => select('pvp-raid')}
type="button" type="button"
@@ -505,6 +516,7 @@ export function Iwt2RoguelikeScreen({
<button <button
className={`text-button ${contentType === 'stadium' ? 'active' : ''} ${selected('pvp-stadium') ? 'game-selected' : ''}`} className={`text-button ${contentType === 'stadium' ? 'active' : ''} ${selected('pvp-stadium') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pvp-stadium') ? 'true' : undefined}
onClick={() => open('pvp-stadium')} onClick={() => open('pvp-stadium')}
onPointerDown={() => select('pvp-stadium')} onPointerDown={() => select('pvp-stadium')}
type="button" type="button"
@@ -517,11 +529,12 @@ export function Iwt2RoguelikeScreen({
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span> <span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div> <div>
<strong>{pvpCardTitle}</strong> <strong>{pvpCardTitle}</strong>
<small>{pvpCardCopy}</small> <small>{queueMessage || pvpCardCopy}</small>
</div> </div>
<button <button
className={`text-button ${selected('pvp-start') ? 'game-selected' : ''}`} className={`text-button ${selected('pvp-start') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={selected('pvp-start') ? 'true' : undefined}
onClick={() => open('pvp-start')} onClick={() => open('pvp-start')}
onPointerDown={() => select('pvp-start')} onPointerDown={() => select('pvp-start')}
type="button" type="button"
@@ -652,7 +665,7 @@ export function Iwt2RoguelikeUpgradeScreen({
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action)) setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return return
} }
if ((action === 'back' || action === 'pause') && !pvpUpgrade) { if (action === 'back' || action === 'pause') {
onBack() onBack()
} }
}) })
@@ -682,6 +695,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button <button
className={`${activeBuffId === choice.id && pvpUpgrade ? 'selected-upgrade' : ''} ${entrySelected('upgradeBuff', index) ? 'game-selected' : ''}`} className={`${activeBuffId === choice.id && pvpUpgrade ? 'selected-upgrade' : ''} ${entrySelected('upgradeBuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={entrySelected('upgradeBuff', index) ? 'true' : undefined}
key={choice.id} key={choice.id}
onClick={() => { onClick={() => {
if (pvpUpgrade) setSelectedBuffId(choice.id) if (pvpUpgrade) setSelectedBuffId(choice.id)
@@ -704,6 +718,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button <button
className={`${activeDebuffId === choice.id ? 'selected-upgrade' : ''} ${entrySelected('upgradeDebuff', index) ? 'game-selected' : ''}`} className={`${activeDebuffId === choice.id ? 'selected-upgrade' : ''} ${entrySelected('upgradeDebuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={entrySelected('upgradeDebuff', index) ? 'true' : undefined}
key={choice.id} key={choice.id}
onClick={() => setSelectedDebuffId(choice.id)} onClick={() => setSelectedDebuffId(choice.id)}
onPointerDown={() => setCursor('upgradeDebuff', index)} onPointerDown={() => setCursor('upgradeDebuff', index)}
@@ -731,6 +746,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button <button
className={`secondary-result-button ${entrySelected('upgradeContinue') ? 'game-selected' : ''}`} className={`secondary-result-button ${entrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={entrySelected('upgradeContinue') ? 'true' : undefined}
disabled={!activeBuffId || !activeDebuffId} disabled={!activeBuffId || !activeDebuffId}
onClick={() => { onClick={() => {
if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId) if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId)
@@ -744,6 +760,7 @@ export function Iwt2RoguelikeUpgradeScreen({
<button <button
className={`secondary-result-button ${entrySelected('upgradeLeave') ? 'game-selected' : ''}`} className={`secondary-result-button ${entrySelected('upgradeLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
data-game-selected={entrySelected('upgradeLeave') ? 'true' : undefined}
onClick={onBack} onClick={onBack}
onPointerDown={() => setCursor('upgradeLeave')} onPointerDown={() => setCursor('upgradeLeave')}
type="button" type="button"
+157 -64
View File
@@ -2,8 +2,10 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes' import { IWT2_CLASS_METADATA } from '../content/classes'
import type { import type {
Iwt2ArenaEvent, Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaInput, Iwt2ArenaInput,
Iwt2ArenaState, Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId, Iwt2EntityId,
Iwt2GroundHazardState, Iwt2GroundHazardState,
Iwt2HostileAddState, Iwt2HostileAddState,
@@ -33,6 +35,7 @@ const MAX_DT = 1 / 15
const MAX_EVENTS = 80 const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3 const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22 const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
type InitialPartyMember = { type InitialPartyMember = {
id: Iwt2PartyEntityId id: Iwt2PartyEntityId
@@ -53,8 +56,9 @@ const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 }, { id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
] ]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState { export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[]): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId] const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index))
return { return {
schemaVersion: 1, schemaVersion: 1,
time: 0, time: 0,
@@ -64,33 +68,8 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
hostileAdds: [], hostileAdds: [],
hazards: [], hazards: [],
indicators: [], indicators: [],
boss: { boss: bosses[0],
id: bossId, bosses,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
},
nextEventId: 1, nextEventId: 1,
nextProjectileId: 1, nextProjectileId: 1,
nextAddId: 1, nextAddId: 1,
@@ -99,6 +78,51 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
} }
} }
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
return {
id: bossId,
kind: 'boss',
bossId,
position,
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { ...position },
chargeEnd: { ...position },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
}
}
function initialBossPosition(index: number) {
return {
x: index === 0 ? 660 : 760,
y: index === 0 ? 190 : 345,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number { function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2 if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4 if (bossId === 'great-jaggi') return 2.4
@@ -128,13 +152,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
} }
const separatedState = { const separatedState = {
...baseState, ...baseState,
party: separatePartyFromBoss(baseState.party, baseState), party: separatePartyFromBosses(baseState.party, baseState),
} }
const bossResult = tickBoss(separatedState, step) const bossResult = tickBosses(separatedState, step)
const projectileResult = advanceProjectiles( const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])], [...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party, bossResult.party,
bossResult.boss, bossResult.bosses,
bossResult.hostileAdds ?? separatedState.hostileAdds, bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards, bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId, bossResult.nextHazardId ?? state.nextHazardId,
@@ -151,7 +175,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
}) })
const damageResult = applyPartyAttacks( const damageResult = applyPartyAttacks(
hazardResult.party, hazardResult.party,
projectileResult.boss, projectileResult.bosses,
projectileResult.hostileAdds, projectileResult.hostileAdds,
separatedState.time, separatedState.time,
projectileResult.nextProjectileId, projectileResult.nextProjectileId,
@@ -164,7 +188,8 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
) )
return { return {
...separatedState, ...separatedState,
boss: damageResult.boss, boss: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)], indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty, party: finalParty,
hostileAdds: damageResult.hostileAdds, hostileAdds: damageResult.hostileAdds,
@@ -274,14 +299,69 @@ function clampInputAxis(value: number): number {
return Math.min(1, Math.max(-1, value)) return Math.min(1, Math.max(-1, value))
} }
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] { function tickBosses(state: Iwt2ArenaState, dt: number) {
let nextParty = state.party
let nextHostileAdds = state.hostileAdds
let nextHazards = state.hazards
let nextProjectileId = state.nextProjectileId
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
const bosses: Iwt2BossEntityState[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
const events: Iwt2ArenaEvent[] = []
const indicators: Iwt2ArenaIndicator[] = []
for (const boss of state.bosses) {
const bossState = {
...state,
boss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextProjectileId,
nextAddId,
nextHazardId,
}
const result = tickBoss(bossState, dt)
bosses.push(result.boss)
nextParty = result.party
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
nextHazards = result.hazards ?? nextHazards
nextProjectileId = result.nextProjectileId ?? nextProjectileId
nextAddId = result.nextAddId ?? nextAddId
nextHazardId = result.nextHazardId ?? nextHazardId
projectiles.push(...(result.projectiles ?? []))
events.push(...result.events)
indicators.push(...result.indicators)
}
return {
bosses,
boss: getPrimaryBoss(bosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
projectiles,
events,
indicators,
nextAddId,
nextHazardId,
nextProjectileId,
}
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => { return party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
const position = separateCircles( let position = member.position
{ position: member.position, radius: member.radius }, for (const boss of state.bosses) {
{ position: state.boss.position, radius: state.boss.radius }, if (boss.health <= 0) continue
state.bounds, position = separateCircles(
) { position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
}
return { ...member, position } return { ...member, position }
}) })
} }
@@ -289,7 +369,7 @@ function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaSt
function advanceProjectiles( function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[], projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[], party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'], bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[], hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[], hazards: Iwt2GroundHazardState[],
nextHazardId: number, nextHazardId: number,
@@ -298,7 +378,7 @@ function advanceProjectiles(
time: number, time: number,
dt: number, dt: number,
): { ): {
boss: Iwt2ArenaState['boss'] bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[] party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[] hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[] hazards: Iwt2GroundHazardState[]
@@ -308,7 +388,7 @@ function advanceProjectiles(
events: Iwt2ArenaEvent[] events: Iwt2ArenaEvent[]
} { } {
const events: Iwt2ArenaEvent[] = [] const events: Iwt2ArenaEvent[] = []
let nextBoss = boss let nextBosses = bosses
let nextParty = party let nextParty = party
let nextHostileAdds = hostileAdds let nextHostileAdds = hostileAdds
let nextHazards = hazards let nextHazards = hazards
@@ -334,7 +414,7 @@ function advanceProjectiles(
continue continue
} }
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue if (nextBosses.every((boss) => boss.health <= 0) && nextHostileAdds.every((add) => add.health <= 0)) continue
const nextPosition = { const nextPosition = {
x: projectile.position.x + projectile.velocity.x * dt, x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt, y: projectile.position.y + projectile.velocity.y * dt,
@@ -371,28 +451,31 @@ function advanceProjectiles(
continue continue
} }
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) { const hitBoss = nextBosses.find((boss) => (
const damage = Math.min(projectile.damage, nextBoss.health) boss.health > 0
nextBoss = { && distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
...nextBoss, ))
health: Math.max(0, nextBoss.health - damage), if (hitBoss) {
} const damage = Math.min(projectile.damage, hitBoss.health)
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
? { ...boss, health: Math.max(0, boss.health - damage) }
: boss)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage) nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({ events.push({
id: 0, id: 0,
time, time,
type: 'bossDamaged', type: 'bossDamaged',
sourceId: projectile.sourceId, sourceId: projectile.sourceId,
targetId: nextBoss.id, targetId: hitBoss.id,
value: damage, value: damage,
}) })
if (nextBoss.health <= 0) { if (hitBoss.health - damage <= 0) {
events.push({ events.push({
id: 0, id: 0,
time, time,
type: 'entityDefeated', type: 'entityDefeated',
sourceId: projectile.sourceId, sourceId: projectile.sourceId,
targetId: nextBoss.id, targetId: hitBoss.id,
}) })
} }
continue continue
@@ -405,7 +488,7 @@ function advanceProjectiles(
} }
return { return {
boss: nextBoss, bosses: nextBosses,
party: nextParty, party: nextParty,
hostileAdds: nextHostileAdds, hostileAdds: nextHostileAdds,
hazards: nextHazards, hazards: nextHazards,
@@ -536,12 +619,12 @@ function advanceBossProjectile({
function applyPartyAttacks( function applyPartyAttacks(
party: Iwt2PartyEntityState[], party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'], bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[], hostileAdds: Iwt2HostileAddState[],
time: number, time: number,
nextProjectileId: number, nextProjectileId: number,
): { ): {
boss: Iwt2ArenaState['boss'] bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[] party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[] hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[] projectiles: Iwt2ProjectileEntityState[]
@@ -550,13 +633,13 @@ function applyPartyAttacks(
} { } {
const events: Iwt2ArenaEvent[] = [] const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = [] const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss let nextBosses = bosses
let nextHostileAdds = hostileAdds let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId let projectileId = nextProjectileId
const nextParty = party.map((member) => { const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds) const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
if (!target) return member if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
const metadata = IWT2_CLASS_METADATA[member.classId] const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) { if (metadata.projectileSpeed > 0) {
if (!member.attackReady) return member if (!member.attackReady) return member
@@ -587,7 +670,9 @@ function applyPartyAttacks(
if (member.attackCooldownRemaining > 0) return member if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health) const damage = Math.min(metadata.attackDamage, target.health)
if (target.kind === 'boss') { if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) } nextBosses = nextBosses.map((boss) => boss.id === target.id
? { ...boss, health: Math.max(0, boss.health - damage) }
: boss)
} else { } else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
? { ...add, health: Math.max(0, add.health - damage) } ? { ...add, health: Math.max(0, add.health - damage) }
@@ -622,7 +707,7 @@ function applyPartyAttacks(
} }
}) })
return { return {
boss: nextBoss, bosses: nextBosses,
party: nextParty, party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0), hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles, projectiles,
@@ -633,9 +718,9 @@ function applyPartyAttacks(
function getPriorityAttackTarget( function getPriorityAttackTarget(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'], bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[], hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined { ): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined if (member.health <= 0) return undefined
const livingAdds = hostileAdds.filter((add) => add.health > 0) const livingAdds = hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) { if (livingAdds.length > 0) {
@@ -643,7 +728,15 @@ function getPriorityAttackTarget(
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0]) ), livingAdds[0])
} }
return boss.health > 0 ? boss : undefined const livingBosses = bosses.filter((boss) => boss.health > 0)
if (livingBosses.length === 0) return undefined
return livingBosses.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), livingBosses[0])
}
function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState {
return bosses.find((boss) => boss.health > 0) ?? bosses[0]
} }
function addDamageDone( function addDamageDone(
+3 -3
View File
@@ -54,8 +54,8 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
telegraphs: Iwt2ArenaTelegraph[] telegraphs: Iwt2ArenaTelegraph[]
} }
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState { export function createInitialIwt2ArenaState(bossId?: Iwt2BossId, bossIds?: Iwt2BossId[]): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId)) return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds))
} }
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
@@ -69,7 +69,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
entities: [ entities: [
...state.party.map(toArenaEntity), ...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity), ...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss), ...state.bosses.map(toBossArenaEntity),
], ],
telegraphs: createTelegraphs(state.indicators), telegraphs: createTelegraphs(state.indicators),
} }
+61 -45
View File
@@ -18,6 +18,7 @@ const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu'])
const CENTER_LEASH_START_DISTANCE = 230 const CENTER_LEASH_START_DISTANCE = 230
const CENTER_LEASH_WALL_MARGIN = 96 const CENTER_LEASH_WALL_MARGIN = 96
const CENTER_LEASH_EXTRA_DISTANCE = 36 const CENTER_LEASH_EXTRA_DISTANCE = 36
const PARTY_ARRIVAL_RADIUS = 3.5
export function tickPartyMember( export function tickPartyMember(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
@@ -68,7 +69,7 @@ export function tickPartyMember(
const canCast = member.aiRole === 'ranged' const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0 && attackCooldownRemaining <= 0
&& !!attackTarget && !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position) && canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) { if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0 const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining ? castSecondsRemaining
@@ -76,7 +77,7 @@ export function tickPartyMember(
return { return {
...member, ...member,
velocity: { x: 0, y: 0 }, velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing), facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, member.position), member.facing),
attackCooldownRemaining, attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining, castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0, attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
@@ -85,15 +86,21 @@ export function tickPartyMember(
} }
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt) const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining) const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * dt const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds) const distanceToDesired = distanceVec2(member.position, desired)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0) const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
: member.position
const velocity = movingToDesired
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
: { x: 0, y: 0 }
return { return {
...member, ...member,
position, position,
velocity, velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing), facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
attackCooldownRemaining, attackCooldownRemaining,
castSecondsRemaining: 0, castSecondsRemaining: 0,
attackReady: false, attackReady: false,
@@ -104,22 +111,21 @@ export function tickPartyMember(
} }
} }
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean { export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId] const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
} }
function getPartyDesiredPosition( function getPartyDesiredPosition(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
state: Iwt2ArenaState, state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 { ): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 } const drift = decisionDrift(member, state.time)
const attackTarget = getPriorityAttackTarget(member, state) const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
const centerLeash = getTankCenterLeashPosition(member, state) const centerLeash = getTankCenterLeashPosition(member, state)
if (centerLeash) return centerLeash if (centerLeash) return centerLeash
return { return {
@@ -132,21 +138,22 @@ function getTankCenterLeashPosition(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
state: Iwt2ArenaState, state: Iwt2ArenaState,
): Iwt2Vec2 | null { ): Iwt2Vec2 | null {
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(state.boss.bossId)) return null const boss = getPrimaryBoss(state)
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return null
const center = arenaCenter(state) const center = arenaCenter(state)
const bossCenterDistance = distanceVec2(state.boss.position, center) const bossCenterDistance = distanceVec2(boss.position, center)
const nearWall = isBossNearWall(state) const nearWall = isBossNearWall(boss, state)
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId] const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), { const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
x: state.boss.position.x < center.x ? 1 : -1, x: boss.position.x < center.x ? 1 : -1,
y: state.boss.position.y < center.y ? 0.35 : -0.35, y: boss.position.y < center.y ? 0.35 : -0.35,
}) })
const leashDistance = bossMetadata.meleeRange + state.boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampVec2ToArena( return clampVec2ToArena(
addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)), addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
member.radius, member.radius,
state.bounds, state.bounds,
) )
@@ -159,39 +166,41 @@ function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
} }
} }
function isBossNearWall(state: Iwt2ArenaState): boolean { function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
const margin = state.boss.radius + CENTER_LEASH_WALL_MARGIN const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
return ( return (
state.boss.position.x <= margin boss.position.x <= margin
|| state.boss.position.x >= state.bounds.width - margin || boss.position.x >= state.bounds.width - margin
|| state.boss.position.y <= margin || boss.position.y <= margin
|| state.boss.position.y >= state.bounds.height - margin || boss.position.y >= state.bounds.height - margin
) )
} }
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null { function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const hazardEscape = getHazardAvoidancePosition(member, state) const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) { if (hazardEscape) {
return hazardEscape return hazardEscape
} }
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) { for (const boss of state.bosses) {
const distance = distanceVec2(member.position, boss.position) if (boss.health <= 0) continue
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34 if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
if (distance < dangerRadius) { const distance = distanceVec2(member.position, boss.position)
const away = normalizeVec2(subtractVec2(member.position, boss.position)) const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds) if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
}
} }
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) { if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state) const danger = chargeDanger(member.position, boss)
if (danger.inside || danger.ahead) { if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x } const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1 const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius))) const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds) return clampVec2ToArena(escape, member.radius, state.bounds)
}
} }
} }
@@ -208,7 +217,7 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const dangerRadius = hazard.radius + member.radius + 46 const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), { const fallback = withFallbackFacing(subtractVec2(member.position, getPrimaryBoss(state).position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1, x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35, y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
}) })
@@ -242,11 +251,14 @@ function getPriorityAttackTarget(
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0]) ), livingAdds[0])
} }
return state.boss.health > 0 ? state.boss : undefined const livingBosses = state.bosses.filter((boss) => boss.health > 0)
if (livingBosses.length === 0) return undefined
return livingBosses.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), livingBosses[0])
} }
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) { function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
const boss = state.boss
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart) const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment)) const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment) const direction = normalizeVec2(segment)
@@ -262,6 +274,10 @@ function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
} }
} }
function getPrimaryBoss(state: Iwt2ArenaState) {
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 { function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17 const seed = member.id.length * 17
return { return {
+1
View File
@@ -256,6 +256,7 @@ export type Iwt2ArenaState = {
hostileAdds: Iwt2HostileAddState[] hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[] hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState boss: Iwt2BossEntityState
bosses: Iwt2BossEntityState[]
indicators: Iwt2ArenaIndicator[] indicators: Iwt2ArenaIndicator[]
nextEventId: number nextEventId: number
nextProjectileId: number nextProjectileId: number