Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdae0007a1 |
Binary file not shown.
@@ -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
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 bossForRoguelike(
|
function beginIwt2RoguelikeArena(
|
||||||
variant: Iwt2RoguelikeVariant,
|
variant: Iwt2RoguelikeVariant,
|
||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
stage = 1,
|
) {
|
||||||
): Iwt2BossId {
|
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||||
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
|
setRoguelikeRun(nextRun)
|
||||||
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
|
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||||
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
setScreen('roguelike-arena')
|
||||||
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
|
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||||
: contentType === 'stadium'
|
if (contentType === 'raid') return 'Raid'
|
||||||
? stadiumBosses
|
if (contentType === 'stadium') return 'Stadium'
|
||||||
: dungeonBosses
|
return 'Dungeon'
|
||||||
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[] = []
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const metadata = IWT2_BOSS_METADATA[entry.bossId]
|
||||||
|
return (
|
||||||
|
<div className="iwt2-boss-hud-row" key={entry.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{metadata.name}</strong>
|
<strong>{metadata.name}</strong>
|
||||||
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
|
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||||
</div>
|
</div>
|
||||||
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
|
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||||
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
|
<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)) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+155
-62
@@ -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
|
||||||
|
position = separateCircles(
|
||||||
|
{ position, radius: member.radius },
|
||||||
|
{ position: boss.position, radius: boss.radius },
|
||||||
state.bounds,
|
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(
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +166,24 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const boss of state.bosses) {
|
||||||
|
if (boss.health <= 0) continue
|
||||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
||||||
const distance = distanceVec2(member.position, boss.position)
|
const distance = distanceVec2(member.position, boss.position)
|
||||||
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
||||||
@@ -186,7 +194,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -194,6 +202,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
|||||||
return clampVec2ToArena(escape, member.radius, state.bounds)
|
return clampVec2ToArena(escape, member.radius, state.bounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user