Compare commits

...
3 Commits
Author SHA1 Message Date
Warren H bdae0007a1 Android build v1.1.19 2026-07-04 20:50:33 -04:00
Warren H c052b086f8 Android build v1.1.18 2026-07-04 19:31:58 -04:00
Warren H c2a14bac7d Android build v1.1.17 2026-07-04 18:07:50 -04:00
44 changed files with 1348 additions and 201 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 95 versionCode 98
versionName "1.1.16" 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.
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
} }
Set<String> currentTokens = new HashSet<>(); Set<String> currentTokens = new HashSet<>();
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X), event.getAxisValue(MotionEvent.AXIS_HAT_X),
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
); );
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_X), leftStickX,
"Axis0-", "Axis0-",
"Axis0+" "Axis0+"
); );
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y), leftStickY,
"Axis1-", "Axis1-",
"Axis1+" "Axis1+"
); );
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
); );
} }
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) { private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length())); int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
+396 -3
View File
@@ -145,6 +145,8 @@
} }
.iwt2-boss-hud { .iwt2-boss-hud {
display: grid;
gap: 6px;
left: 50%; left: 50%;
min-width: 310px; min-width: 310px;
padding: 8px 12px 10px; padding: 8px 12px 10px;
@@ -155,7 +157,7 @@
z-index: 4; z-index: 4;
} }
.iwt2-boss-hud > div:first-child { .iwt2-boss-hud-row > div:first-child {
align-items: baseline; align-items: baseline;
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -837,6 +839,285 @@
} }
} }
@media (max-width: 1000px) and (max-height: 620px) {
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
}
.iwt2-shell .app-header {
min-height: 44px;
padding: 6px 10px;
}
.iwt2-shell .brand-button strong {
font-size: 10px;
}
.iwt2-shell .character-summary {
gap: 7px;
}
.iwt2-shell .character-summary strong {
font-size: 14px;
}
.iwt2-shell .character-summary small {
font-size: 6px;
}
.iwt2-menu-screen {
flex: 1;
gap: 10px;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-menu-heading {
padding-bottom: 8px;
}
.iwt2-menu-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-menu-heading h1 {
font-size: 1.55rem;
}
.iwt2-menu-grid {
gap: 10px;
}
.iwt2-menu-card {
gap: 8px;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 92px;
padding: 8px;
}
.iwt2-menu-card > span {
font-size: 0.95rem;
height: 42px;
width: 42px;
}
.iwt2-menu-card strong {
font-size: 0.8rem;
margin-bottom: 3px;
}
.iwt2-menu-card small {
font-size: 0.64rem;
line-height: 1.25;
}
.iwt2-screen-shell {
flex: 1;
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .back-button {
min-height: 30px;
padding: 5px 9px;
}
.iwt2-action-list,
.iwt2-info-list {
gap: 7px;
min-height: 0;
overflow-y: auto;
padding: 3px;
}
.iwt2-action-row,
.iwt2-info-row {
gap: 8px;
min-height: 54px;
padding: 8px 10px;
}
.iwt2-action-row strong,
.iwt2-info-row strong {
font-size: 0.78rem;
}
.iwt2-action-row small,
.iwt2-info-row small {
font-size: 0.62rem;
line-height: 1.2;
margin-top: 2px;
}
.iwt2-profile-grid,
.iwt2-settings-panel,
.iwt2-customize-layout {
min-height: 0;
overflow: hidden;
}
.iwt2-settings-panel,
.iwt2-customize-layout {
gap: 10px;
}
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
overflow: hidden;
}
.iwt2-loadout-preview {
gap: 8px;
}
.iwt2-loadout-preview h2 {
font-size: 12px;
line-height: 1.1;
}
.iwt2-loadout-preview span {
font-size: 0.62rem;
}
.iwt2-controller-preview {
min-height: 112px;
padding: 10px;
}
.iwt2-boss-hud {
min-width: 280px;
padding: 6px 10px 8px;
top: 6px;
}
.iwt2-boss-hud strong {
font-size: 0.82rem;
}
.iwt2-boss-phase {
font-size: 0.5rem;
height: 10px;
line-height: 10px;
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
overflow: hidden;
padding: 4px;
top: 10px;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 20px minmax(0, 1fr);
height: 62px;
min-height: 62px;
padding: 4px;
}
.iwt2-party-row.has-target-binding {
grid-template-columns: 24px 20px minmax(0, 1fr);
}
.iwt2-party-row > span,
.iwt2-party-row .iwt2-party-target-key {
height: 20px;
min-width: 20px;
width: 20px;
}
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
height: 16px;
min-width: 16px;
}
.iwt2-party-row-title {
gap: 4px;
}
.iwt2-party-row strong {
font-size: 0.62rem;
}
.iwt2-party-row small {
font-size: 0.52rem;
line-height: 1.15;
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
}
.iwt2-party-effects {
height: 10px;
margin-top: 2px;
}
.iwt2-effect-badge {
font-size: 0.44rem;
line-height: 9px;
padding: 0 2px;
}
.iwt2-party-damage {
display: none;
}
.iwt2-ability-bar {
bottom: 8px;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
width: 256px;
}
.iwt2-ability-button {
min-height: 44px;
padding: 3px;
}
.iwt2-ability-button > span {
font-size: 0.9rem;
height: 24px;
width: 24px;
}
.iwt2-ability-button strong {
font-size: 0.5rem;
}
.iwt2-ability-button i {
font-size: 0.46rem;
}
}
.combat-touch-lock-status { .combat-touch-lock-status {
background: #101216; background: #101216;
border: 2px solid #d9b55a; border: 2px solid #d9b55a;
@@ -1858,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,
@@ -1893,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;
@@ -1916,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;
@@ -2288,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 {
@@ -2311,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;
@@ -11576,3 +11915,57 @@ h2 {
height: 20px; height: 20px;
} }
} }
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-screen-shell.content-screen {
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell.content-screen > .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell.content-screen > .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell.content-screen > .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .iwt2-action-list,
.iwt2-screen-shell .iwt2-info-list,
.iwt2-settings-panel .iwt2-action-list,
.iwt2-customize-layout .iwt2-action-list,
.iwt2-loadout-preview .iwt2-info-list {
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.iwt2-screen-shell.content-screen > .iwt2-action-list,
.iwt2-screen-shell.content-screen > .iwt2-info-list {
flex: 1 1 auto;
}
.iwt2-action-row:not(:has(small)),
.iwt2-info-row:not(:has(small)) {
min-height: 32px;
padding: 5px 10px;
}
.iwt2-settings-panel,
.iwt2-customize-layout,
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
}
}
+71 -3
View File
@@ -1,7 +1,12 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { AuthScreen } from './components/AuthScreen'
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App' import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
import { useGameAction } from './input' import { useGameAction } from './input'
import {
loadAuthSession,
type AuthSession,
} from './profile'
type GameVersion = 'iwt1' | 'iwt2' type GameVersion = 'iwt1' | 'iwt2'
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
function App() { function App() {
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null) const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [serverMessage, setServerMessage] = useState('')
useEffect(() => {
let cancelled = false
loadAuthSession()
.then((session) => {
if (cancelled) return
setAuthSession(session.account && session.profile ? session : null)
})
.catch((reason: unknown) => {
if (cancelled) return
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => {
if (!cancelled) setAuthChecked(true)
})
return () => {
cancelled = true
}
}, [])
const acceptSession = useCallback((session: AuthSession) => {
setAuthSession(session)
setSelectedVersion(null)
setServerMessage('')
}, [])
const clearAuthSession = useCallback(() => {
setAuthSession(null)
setSelectedVersion(null)
}, [])
useGameAction((action, device) => { useGameAction((action, device) => {
if (selectedVersion || device !== 'controller') return if (!authSession || selectedVersion || device !== 'controller') return
if (action === 'navigateLeft' || action === 'navigateUp') { if (action === 'navigateLeft' || action === 'navigateUp') {
setSelectedIndex((current) => Math.max(0, current - 1)) setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateRight' || action === 'navigateDown') { } else if (action === 'navigateRight' || action === 'navigateDown') {
@@ -43,8 +85,34 @@ function App() {
} }
}) })
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!authSession) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (selectedVersion === 'iwt1') { if (selectedVersion === 'iwt1') {
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} /> return (
<IWantToHeal1App
initialSession={authSession}
onAuthenticationCleared={clearAuthSession}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
} }
if (selectedVersion === 'iwt2') { if (selectedVersion === 'iwt2') {
+47
View File
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
opponentClassName?: string opponentClassName?: string
opponentParty?: PartyMember[] opponentParty?: PartyMember[]
opponentEnemyHealth?: number opponentEnemyHealth?: number
opponentArena?: {
bounds: { width: number, height: number }
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
}
opponentResource?: number opponentResource?: number
opponentMaxResource?: number opponentMaxResource?: number
opponentResourceName?: string opponentResourceName?: string
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
</section> </section>
)} )}
{state.opponentArena && (
<section className="dual-opponent-arena" aria-label="Opponent arena">
<div
className="dual-opponent-arena-field"
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
>
{state.opponentArena.bosses.map((boss) => (
<div
className="dual-opponent-arena-entity boss"
key={boss.id}
style={{
backgroundColor: boss.color,
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
>
{boss.icon}
</div>
))}
{state.opponentArena.party.map((member) => (
<div
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
key={member.id}
style={{
backgroundColor: member.color,
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
opacity: member.health > 0 ? 1 : 0.35,
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
>
{member.icon}
</div>
))}
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}> <section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => ( {state.opponentParty.map((member) => (
<PartyMemberFrame <PartyMemberFrame
+21 -1
View File
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display' const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header' const HEADER_CONTENT_SELECTOR = '.app-header'
@@ -142,6 +143,7 @@ const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260 const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85 const CONTROLLER_REPEAT_MS = 85
const NATIVE_STICK_TIMEOUT_MS = 140
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = { const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp', Button12: 'navigateUp',
Button13: 'navigateDown', Button13: 'navigateDown',
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
let frame = 0 let frame = 0
const pressedKeys = new Set<string>() const pressedKeys = new Set<string>()
let nativeStick: MovementVector = { x: 0, y: 0 }
let nativeStickUpdatedAt = 0
const keyboardVector = () => normalizedVector( const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0), (pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0), (pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
const updateMovement = () => { const updateMovement = () => {
const gamepad = firstConnectedGamepad() const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 } const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
? applyStickDeadzone(nativeStick.x, nativeStick.y)
: { x: 0, y: 0 }
const keyboard = keyboardVector() const keyboard = keyboardVector()
movementRef.current = Math.hypot(stick.x, stick.y) > 0 movementRef.current = Math.hypot(native.x, native.y) > 0
? native
: Math.hypot(stick.x, stick.y) > 0
? stick ? stick
: keyboard : keyboard
frame = window.requestAnimationFrame(updateMovement) frame = window.requestAnimationFrame(updateMovement)
} }
const onNativeMotion = (event: Event) => {
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
nativeStick = {
x: Number(detail.x) || 0,
y: Number(detail.y) || 0,
}
nativeStickUpdatedAt = performance.now()
document.documentElement.dataset.inputDevice = 'controller'
}
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
event.preventDefault() event.preventDefault()
} }
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement) frame = window.requestAnimationFrame(updateMovement)
return () => { return () => {
window.cancelAnimationFrame(frame) window.cancelAnimationFrame(frame)
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 } movementRef.current = { x: 0, y: 0 }
+25 -20
View File
@@ -1,5 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { AuthScreen } from '../../components/AuthScreen'
import { import {
loadCpuPvpLeaderboard, loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry, type CpuPvpLeaderboardEntry,
@@ -138,11 +137,21 @@ function ScreenLoading() {
type RoguelikeVariant = 'pve' | 'pvp' type RoguelikeVariant = 'pve' | 'pvp'
function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { type IWantToHeal1AppProps = {
initialSession: AuthSession
onAuthenticationCleared: () => void
onBackToGameSelect: () => void
}
function IWantToHeal1App({
initialSession,
onAuthenticationCleared,
onBackToGameSelect,
}: IWantToHeal1AppProps) {
const { enabled: dualScreenEnabled } = useDualScreen() const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Screen>('menu') const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(initialSession.account)
const [profile, setProfile] = useState<CharacterProfile | null>(null) const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(getGameMode()) const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
const [serverMessage, setServerMessage] = useState('') const [serverMessage, setServerMessage] = useState('')
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
.finally(() => setAuthChecked(true)) .finally(() => setAuthChecked(true))
}, []) }, [])
useEffect(() => {
if (authChecked && (!account || !profile)) onAuthenticationCleared()
}, [account, authChecked, onAuthenticationCleared, profile])
useEffect(() => { useEffect(() => {
const handleModeChange = (event: Event) => { const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail const nextMode = (event as CustomEvent<GameMode>).detail
@@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
[leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id],
) )
function acceptSession(session: AuthSession) {
setAccount(session.account)
setProfile(session.profile)
setGameMode(getGameMode())
setScreen('menu')
setError('')
setServerMessage('')
window.requestAnimationFrame(() => {
focusFirstControl()
})
}
async function signOut() { async function signOut() {
try { try {
await logoutAccount() await logoutAccount()
@@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
setScreen('menu') setScreen('menu')
setSyncMessage('') setSyncMessage('')
setSyncComparison(null) setSyncComparison(null)
onAuthenticationCleared()
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.') setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
} }
@@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
if (!account || !profile) { if (!account || !profile) {
return ( return (
<AuthScreen <main className="game-shell">
onAuthenticated={acceptSession} <section className="message-panel">
serverMessage={serverMessage} <p className="eyebrow">Opening Chronicle</p>
/> <h1>Returning to Sign In...</h1>
{serverMessage && <p>{serverMessage}</p>}
</section>
</main>
) )
} }
+184 -22
View File
@@ -1,6 +1,15 @@
import { useEffect, 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 {
useDualScreen,
useDualScreenSetupPublisher,
useDualScreenWorkshopPublisher,
type DualScreenSetupState,
type DualScreenWorkshopState,
} from '../../dualScreen'
import { BossArenaScreen } from './screens/BossArenaScreen' import { BossArenaScreen } from './screens/BossArenaScreen'
import { import {
Iwt2CloudSaveScreen, Iwt2CloudSaveScreen,
@@ -17,8 +26,8 @@ import {
writeIwt2Save, writeIwt2Save,
type Iwt2Save, type Iwt2Save,
} from './save/iwt2Repository' } from './save/iwt2Repository'
import type { Iwt2BossId } from './content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { abilitiesForHealer } from './content/healerAbilities' import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import { import {
buildIwt2OpponentDebuffChoices, buildIwt2OpponentDebuffChoices,
buildIwt2SelfBuffChoices, buildIwt2SelfBuffChoices,
@@ -47,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[]
@@ -113,6 +123,7 @@ const MENU_ITEMS: Array<{
] ]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu') const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save) const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
@@ -121,11 +132,29 @@ 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>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
[save, screen],
)
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
useGameAction((action, device) => { useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') { if (action === 'back') {
@@ -162,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')}
@@ -173,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,
@@ -213,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')
}} }}
/> />
@@ -247,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>
) )
@@ -339,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)}
@@ -356,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(
@@ -382,6 +450,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState { ): Iwt2RoguelikeRunState {
return { return {
bossIds: createRandomRoguelikeBossPair(),
buffs: [], buffs: [],
contentType, contentType,
debuffs: [], debuffs: [],
@@ -442,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[] = []
@@ -478,3 +553,90 @@ function Iwt2Header({
</header> </header>
) )
} }
function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
initials: boss.icon,
itemLevel: save.character.level,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
): DualScreenWorkshopState | null {
if (screen === 'hunter-profile') {
return {
items: [
{
glyph: 'H',
meta: `${save.character.experience} XP`,
status: `Level ${save.character.level}`,
title: save.character.name,
},
{
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
status: 'Class',
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
},
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
glyph: IWT2_BOSS_METADATA[bossId].icon,
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
title: IWT2_BOSS_METADATA[bossId].name,
})),
],
mode: 'collection',
subtitle: 'IWT2 inventory and collection log',
summary: `${save.inventory.length} inventory slots`,
title: 'Hunter Profile',
}
}
if (screen === 'customize-character') {
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
return {
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
glyph: ability.icon,
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
status: `Slot ${ability.slot}`,
title: ability.name,
})),
mode: 'class',
subtitle: healer.description,
summary: healer.name,
title: 'Customize Character',
}
}
return null
}
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
}
+12 -5
View File
@@ -2,16 +2,23 @@ import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars' import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses' import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) { export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId] const entries = bosses ?? (boss ? [boss] : [])
return ( return (
<div className="iwt2-boss-hud"> <div className="iwt2-boss-hud">
{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)) {
+310 -20
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input' import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input' import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../../../dualScreen'
import type { PartyMember, Role, Spell } from '../../../game'
import { import {
createInitialIwt2ArenaState, createInitialIwt2ArenaState,
tickIwt2Arena, tickIwt2Arena,
@@ -16,6 +22,7 @@ import {
import { AbilityBar } from '../components/AbilityBar' import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud' import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames' import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls' import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities' import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
@@ -40,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
@@ -54,19 +62,24 @@ 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 [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId)) const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | 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')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary') const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer') const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState) const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status) const statusRef = useRef(status)
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>>({})
@@ -77,12 +90,17 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
directPartyTargeting, directPartyTargeting,
lastDevice, lastDevice,
} = useInput() } = useInput()
const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice] const activeBindings = bindings[lastDevice]
useEffect(() => { useEffect(() => {
stateRef.current = arenaState stateRef.current = arenaState
}, [arenaState]) }, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => { useEffect(() => {
statusRef.current = status statusRef.current = status
}, [status]) }, [status])
@@ -100,16 +118,19 @@ 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)
killRecordedRef.current = false const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {} abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next) lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next stateRef.current = next
opponentStateRef.current = nextOpponentState
setArenaState(next) setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({}) setAbilityCooldowns({})
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
setStatus('playing') setStatus('playing')
}, [bossId]) }, [bossId, bossIds, pvpRoguelike])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => { const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
@@ -232,12 +253,34 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
moveY: movement.y, moveY: movement.y,
}, dtSeconds) }, dtSeconds)
stateRef.current = next stateRef.current = next
let nextOpponentState = opponentStateRef.current
if (pvpRoguelike && nextOpponentState) {
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
nextOpponentState = tickIwt2Arena(
nextOpponentState,
{
moveX: opponentMovement.x,
moveY: opponentMovement.y,
},
dtSeconds,
)
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')
@@ -247,15 +290,16 @@ 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
setArenaState(next) setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
setAbilityCooldowns(abilityCooldownsRef.current) setAbilityCooldowns(abilityCooldownsRef.current)
} }
return next return next
}, [bossId, onSaveUpdated, 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)
@@ -263,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'
@@ -292,6 +338,38 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
: roguelikeRun : roguelikeRun
? 'Leave Roguelike' ? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}` : `Leave ${modeLabel ?? 'Arena'}`
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings: activeBindings,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel: modeLabel ?? 'Arena',
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}),
[
abilities,
activeBindings,
arenaState,
abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
],
)
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return ( return (
<main <main
@@ -301,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}
@@ -325,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"
@@ -334,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"
@@ -348,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>
@@ -366,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
@@ -373,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">
@@ -424,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) => [
@@ -440,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
@@ -507,3 +599,201 @@ function arenaPauseTitle(
} }
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike' return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
} }
function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
cooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}: {
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean
modeLabel: string
opponentArenaState: Iwt2ArenaState | null
playerMana: number
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
selectedPartyId: Iwt2EntityId
status: ArenaStatus
}): DualScreenCombatState {
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
const arenaBossHealth = totalBossHealth(arenaState)
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentArena: toDualScreenOpponentArena(opponentArenaState),
opponentEnemyHealth: totalBossHealth(opponentArenaState),
opponentMaxResource: opponentHealer?.maxMana ?? 100,
opponentName: 'CPU Rival',
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
opponentResource: opponentHealer?.mana ?? 100,
opponentResourceName: 'Mana',
}
: {}
return {
...pvpOpponentState,
bindings,
contentName: modeLabel,
controllerIconStyle,
difficultyName: 'IWT2',
directPartyTargeting,
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaBossHealth,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaBossMaxHealth,
encounterName: formatBossEncounterTitle(arenaState.bosses),
floatingTexts: [],
maxResource: 100,
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
partySize: arenaState.party.length,
paused: status === 'paused',
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
resource: playerMana,
resourceName: 'Mana',
selectedId: selectedPartyId,
speedMultiplier: 1,
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
targetGroup: 0,
}
}
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 {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
bounceHeals: [],
health: member.health,
hotEffects: member.hotEffects.map((effect) => ({
id: effect.id,
label: effect.label,
power: effect.power,
spellId: effect.id,
ticks: Math.ceil(effect.remainingSeconds),
})),
hotTicks: member.hotEffects.length,
id: opponent ? `opponent-${member.id}` : member.id,
maxHealth: member.maxHealth,
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
role: toDualScreenRole(metadata.role),
shield: member.shield,
}
}
function opponentPartyName(name: string): string {
if (name === 'Player Healer') return 'CPU Healer'
return `CPU ${name.replace(' Tank', '')}`
}
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
if (role === 'tank') return 'Tank'
if (role === 'healer') return 'Healer'
return 'Damage'
}
function toDualScreenSpell(
ability: Iwt2HealerAbility,
slotIndex: number,
remaining: number,
): Spell & { slotIndex: number; remaining: number } {
return {
cooldown: ability.cooldownSeconds,
cost: ability.manaCost,
description: 'IWT2 arena ability',
effectType: ability.effectType,
glyph: ability.icon,
id: ability.id,
key: String(ability.slot),
kind: ability.kind,
name: ability.name,
power: ability.power,
remaining,
slotIndex,
}
}
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
const healer = state.party.find((member) => member.id === 'player-healer')
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
const orbitSeconds = Math.max(1, state.time)
return normalizedArenaMovement({
x: Math.cos(orbitSeconds * 0.75),
y: Math.sin(orbitSeconds * 0.75) * 0.55,
})
}
function normalizedArenaMovement(vector: MovementVector): MovementVector {
const magnitude = Math.hypot(vector.x, vector.y)
if (magnitude <= 1) return vector
return {
x: vector.x / magnitude,
y: vector.y / magnitude,
}
}
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
if (debuffs.length === 0) return 'none'
return debuffs
.map((debuff) => debuff
.replace('opp-', '')
.replaceAll('-', ' '))
.join(', ')
}
+31 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons' import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input' import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import { getGameMode } from '../../../gameRepository' import { getGameMode } from '../../../gameRepository'
@@ -161,6 +161,14 @@ export function Iwt2ScreenShell({
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) { export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions) const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
const rowRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
rowRefs.current[selectedIndex]?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}, [selectedIndex])
return ( return (
<div className="iwt2-action-list"> <div className="iwt2-action-list">
@@ -168,12 +176,16 @@ 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}
onPointerDown={() => { onPointerDown={() => {
if (!action.disabled) setSelectedIndex(index) if (!action.disabled) setSelectedIndex(index)
}} }}
ref={(element) => {
rowRefs.current[index] = element
}}
type="button" type="button"
> >
<span> <span>
@@ -281,6 +293,7 @@ export function Iwt2RoguelikeScreen({
onContentTypeChange, onContentTypeChange,
onStart, onStart,
onVariantChange, onVariantChange,
queueMessage,
variant, variant,
}: { }: {
contentType: Iwt2RoguelikeContentType contentType: Iwt2RoguelikeContentType
@@ -288,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()
@@ -392,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"
@@ -403,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"
@@ -412,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"
@@ -430,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"
@@ -439,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"
@@ -456,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"
@@ -476,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"
@@ -485,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"
@@ -494,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"
@@ -506,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"
@@ -641,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()
} }
}) })
@@ -671,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)
@@ -693,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)}
@@ -720,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)
@@ -733,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
View File
@@ -2,8 +2,10 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes' import { IWT2_CLASS_METADATA } from '../content/classes'
import type { import type {
Iwt2ArenaEvent, Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaInput, Iwt2ArenaInput,
Iwt2ArenaState, Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId, Iwt2EntityId,
Iwt2GroundHazardState, Iwt2GroundHazardState,
Iwt2HostileAddState, Iwt2HostileAddState,
@@ -33,6 +35,7 @@ const MAX_DT = 1 / 15
const MAX_EVENTS = 80 const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3 const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22 const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
type InitialPartyMember = { type InitialPartyMember = {
id: Iwt2PartyEntityId id: Iwt2PartyEntityId
@@ -53,8 +56,9 @@ const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 }, { id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
] ]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState { export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[]): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId] const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index))
return { return {
schemaVersion: 1, schemaVersion: 1,
time: 0, time: 0,
@@ -64,33 +68,8 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
hostileAdds: [], hostileAdds: [],
hazards: [], hazards: [],
indicators: [], indicators: [],
boss: { boss: bosses[0],
id: bossId, bosses,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
},
nextEventId: 1, nextEventId: 1,
nextProjectileId: 1, nextProjectileId: 1,
nextAddId: 1, nextAddId: 1,
@@ -99,6 +78,51 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
} }
} }
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
return {
id: bossId,
kind: 'boss',
bossId,
position,
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { ...position },
chargeEnd: { ...position },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
}
}
function initialBossPosition(index: number) {
return {
x: index === 0 ? 660 : 760,
y: index === 0 ? 190 : 345,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number { function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2 if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4 if (bossId === 'great-jaggi') return 2.4
@@ -128,13 +152,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
} }
const separatedState = { const separatedState = {
...baseState, ...baseState,
party: separatePartyFromBoss(baseState.party, baseState), party: separatePartyFromBosses(baseState.party, baseState),
} }
const bossResult = tickBoss(separatedState, step) const bossResult = tickBosses(separatedState, step)
const projectileResult = advanceProjectiles( const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])], [...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party, bossResult.party,
bossResult.boss, bossResult.bosses,
bossResult.hostileAdds ?? separatedState.hostileAdds, bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards, bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId, bossResult.nextHazardId ?? state.nextHazardId,
@@ -151,7 +175,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
}) })
const damageResult = applyPartyAttacks( const damageResult = applyPartyAttacks(
hazardResult.party, hazardResult.party,
projectileResult.boss, projectileResult.bosses,
projectileResult.hostileAdds, projectileResult.hostileAdds,
separatedState.time, separatedState.time,
projectileResult.nextProjectileId, projectileResult.nextProjectileId,
@@ -164,7 +188,8 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
) )
return { return {
...separatedState, ...separatedState,
boss: damageResult.boss, boss: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)], indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty, party: finalParty,
hostileAdds: damageResult.hostileAdds, hostileAdds: damageResult.hostileAdds,
@@ -274,14 +299,69 @@ function clampInputAxis(value: number): number {
return Math.min(1, Math.max(-1, value)) return Math.min(1, Math.max(-1, value))
} }
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] { function tickBosses(state: Iwt2ArenaState, dt: number) {
let nextParty = state.party
let nextHostileAdds = state.hostileAdds
let nextHazards = state.hazards
let nextProjectileId = state.nextProjectileId
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
const bosses: Iwt2BossEntityState[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
const events: Iwt2ArenaEvent[] = []
const indicators: Iwt2ArenaIndicator[] = []
for (const boss of state.bosses) {
const bossState = {
...state,
boss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextProjectileId,
nextAddId,
nextHazardId,
}
const result = tickBoss(bossState, dt)
bosses.push(result.boss)
nextParty = result.party
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
nextHazards = result.hazards ?? nextHazards
nextProjectileId = result.nextProjectileId ?? nextProjectileId
nextAddId = result.nextAddId ?? nextAddId
nextHazardId = result.nextHazardId ?? nextHazardId
projectiles.push(...(result.projectiles ?? []))
events.push(...result.events)
indicators.push(...result.indicators)
}
return {
bosses,
boss: getPrimaryBoss(bosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
projectiles,
events,
indicators,
nextAddId,
nextHazardId,
nextProjectileId,
}
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => { return party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
const position = separateCircles( let position = member.position
{ position: member.position, radius: member.radius }, for (const boss of state.bosses) {
{ position: state.boss.position, radius: state.boss.radius }, if (boss.health <= 0) continue
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(
+3 -3
View File
@@ -54,8 +54,8 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
telegraphs: Iwt2ArenaTelegraph[] telegraphs: Iwt2ArenaTelegraph[]
} }
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState { export function createInitialIwt2ArenaState(bossId?: Iwt2BossId, bossIds?: Iwt2BossId[]): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId)) return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds))
} }
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
@@ -69,7 +69,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
entities: [ entities: [
...state.party.map(toArenaEntity), ...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity), ...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss), ...state.bosses.map(toBossArenaEntity),
], ],
telegraphs: createTelegraphs(state.indicators), telegraphs: createTelegraphs(state.indicators),
} }
+48 -32
View File
@@ -18,6 +18,7 @@ const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu'])
const CENTER_LEASH_START_DISTANCE = 230 const CENTER_LEASH_START_DISTANCE = 230
const CENTER_LEASH_WALL_MARGIN = 96 const CENTER_LEASH_WALL_MARGIN = 96
const CENTER_LEASH_EXTRA_DISTANCE = 36 const CENTER_LEASH_EXTRA_DISTANCE = 36
const PARTY_ARRIVAL_RADIUS = 3.5
export function tickPartyMember( export function tickPartyMember(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
@@ -68,7 +69,7 @@ export function tickPartyMember(
const canCast = member.aiRole === 'ranged' const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0 && attackCooldownRemaining <= 0
&& !!attackTarget && !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position) && canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) { if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0 const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining ? castSecondsRemaining
@@ -76,7 +77,7 @@ export function tickPartyMember(
return { return {
...member, ...member,
velocity: { x: 0, y: 0 }, velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing), facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, member.position), member.facing),
attackCooldownRemaining, attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining, castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0, attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
@@ -85,15 +86,21 @@ export function tickPartyMember(
} }
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt) const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining) const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * dt const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds) const distanceToDesired = distanceVec2(member.position, desired)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0) const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
: member.position
const velocity = movingToDesired
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
: { x: 0, y: 0 }
return { return {
...member, ...member,
position, position,
velocity, velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing), facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
attackCooldownRemaining, attackCooldownRemaining,
castSecondsRemaining: 0, castSecondsRemaining: 0,
attackReady: false, attackReady: false,
@@ -104,22 +111,21 @@ export function tickPartyMember(
} }
} }
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean { export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId] const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
} }
function getPartyDesiredPosition( function getPartyDesiredPosition(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
state: Iwt2ArenaState, state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 { ): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 } const drift = decisionDrift(member, state.time)
const attackTarget = getPriorityAttackTarget(member, state) const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
const centerLeash = getTankCenterLeashPosition(member, state) const centerLeash = getTankCenterLeashPosition(member, state)
if (centerLeash) return centerLeash if (centerLeash) return centerLeash
return { return {
@@ -132,21 +138,22 @@ function getTankCenterLeashPosition(
member: Iwt2PartyEntityState, member: Iwt2PartyEntityState,
state: Iwt2ArenaState, state: Iwt2ArenaState,
): Iwt2Vec2 | null { ): Iwt2Vec2 | null {
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(state.boss.bossId)) return null const boss = getPrimaryBoss(state)
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return null
const center = arenaCenter(state) const center = arenaCenter(state)
const bossCenterDistance = distanceVec2(state.boss.position, center) const bossCenterDistance = distanceVec2(boss.position, center)
const nearWall = isBossNearWall(state) const nearWall = isBossNearWall(boss, state)
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId] const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), { const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
x: state.boss.position.x < center.x ? 1 : -1, x: boss.position.x < center.x ? 1 : -1,
y: state.boss.position.y < center.y ? 0.35 : -0.35, y: boss.position.y < center.y ? 0.35 : -0.35,
}) })
const leashDistance = bossMetadata.meleeRange + state.boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampVec2ToArena( return clampVec2ToArena(
addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)), addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
member.radius, member.radius,
state.bounds, state.bounds,
) )
@@ -159,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 {
+1
View File
@@ -256,6 +256,7 @@ export type Iwt2ArenaState = {
hostileAdds: Iwt2HostileAddState[] hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[] hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState boss: Iwt2BossEntityState
bosses: Iwt2BossEntityState[]
indicators: Iwt2ArenaIndicator[] indicators: Iwt2ArenaIndicator[]
nextEventId: number nextEventId: number
nextProjectileId: number nextProjectileId: number