Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdae0007a1 | ||
|
|
c052b086f8 | ||
|
|
c2a14bac7d |
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 95
|
||||
versionName "1.1.16"
|
||||
versionCode 98
|
||||
versionName "1.1.19"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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<>();
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_X),
|
||||
leftStickX,
|
||||
"Axis0-",
|
||||
"Axis0+"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
||||
leftStickY,
|
||||
"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) {
|
||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
|
||||
+396
-3
@@ -145,6 +145,8 @@
|
||||
}
|
||||
|
||||
.iwt2-boss-hud {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
left: 50%;
|
||||
min-width: 310px;
|
||||
padding: 8px 12px 10px;
|
||||
@@ -155,7 +157,7 @@
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.iwt2-boss-hud > div:first-child {
|
||||
.iwt2-boss-hud-row > div:first-child {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
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 {
|
||||
background: #101216;
|
||||
border: 2px solid #d9b55a;
|
||||
@@ -1858,7 +2139,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -1893,6 +2174,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
}
|
||||
|
||||
.dual-opponent-progress,
|
||||
.dual-opponent-arena,
|
||||
.dual-opponent-effects {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
@@ -1916,6 +2198,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
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 {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
@@ -2288,7 +2617,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -2311,6 +2640,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dual-opponent-arena {
|
||||
border-width: 2px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.dual-opponent-arena-field {
|
||||
max-height: 112px;
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.dual-opponent-party-grid {
|
||||
border-width: 2px;
|
||||
gap: 6px;
|
||||
@@ -11576,3 +11915,57 @@ h2 {
|
||||
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
@@ -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 { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
||||
import { useGameAction } from './input'
|
||||
import {
|
||||
loadAuthSession,
|
||||
type AuthSession,
|
||||
} from './profile'
|
||||
|
||||
type GameVersion = 'iwt1' | 'iwt2'
|
||||
|
||||
@@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{
|
||||
function App() {
|
||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
||||
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) => {
|
||||
if (selectedVersion || device !== 'controller') return
|
||||
if (!authSession || selectedVersion || device !== 'controller') return
|
||||
if (action === 'navigateLeft' || action === 'navigateUp') {
|
||||
setSelectedIndex((current) => Math.max(0, current - 1))
|
||||
} 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') {
|
||||
return <IWantToHeal1App onBackToGameSelect={() => setSelectedVersion(null)} />
|
||||
return (
|
||||
<IWantToHeal1App
|
||||
initialSession={authSession}
|
||||
onAuthenticationCleared={clearAuthSession}
|
||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedVersion === 'iwt2') {
|
||||
|
||||
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
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
|
||||
opponentMaxResource?: number
|
||||
opponentResourceName?: string
|
||||
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
|
||||
</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' : ''}`}>
|
||||
{state.opponentParty.map((member) => (
|
||||
<PartyMemberFrame
|
||||
|
||||
+21
-1
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
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 MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||
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 CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||
const CONTROLLER_REPEAT_MS = 85
|
||||
const NATIVE_STICK_TIMEOUT_MS = 140
|
||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
|
||||
|
||||
let frame = 0
|
||||
const pressedKeys = new Set<string>()
|
||||
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||
let nativeStickUpdatedAt = 0
|
||||
const keyboardVector = () => normalizedVector(
|
||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
|
||||
const updateMovement = () => {
|
||||
const gamepad = firstConnectedGamepad()
|
||||
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()
|
||||
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
|
||||
: keyboard
|
||||
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) => {
|
||||
if (isTextInput(document.activeElement)) return
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { AuthScreen } from '../../components/AuthScreen'
|
||||
import {
|
||||
loadCpuPvpLeaderboard,
|
||||
type CpuPvpLeaderboardEntry,
|
||||
@@ -138,11 +137,21 @@ function ScreenLoading() {
|
||||
|
||||
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 [screen, setScreen] = useState<Screen>('menu')
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
const [profile, setProfile] = useState<CharacterProfile | null>(null)
|
||||
const [account, setAccount] = useState<Account | null>(initialSession.account)
|
||||
const [profile, setProfile] = useState<CharacterProfile | null>(initialSession.profile)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
||||
const [serverMessage, setServerMessage] = useState('')
|
||||
@@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
||||
.finally(() => setAuthChecked(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (authChecked && (!account || !profile)) onAuthenticationCleared()
|
||||
}, [account, authChecked, onAuthenticationCleared, profile])
|
||||
|
||||
useEffect(() => {
|
||||
const handleModeChange = (event: Event) => {
|
||||
const nextMode = (event as CustomEvent<GameMode>).detail
|
||||
@@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
||||
[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() {
|
||||
try {
|
||||
await logoutAccount()
|
||||
@@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
||||
setScreen('menu')
|
||||
setSyncMessage('')
|
||||
setSyncComparison(null)
|
||||
onAuthenticationCleared()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||
}
|
||||
@@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi
|
||||
|
||||
if (!account || !profile) {
|
||||
return (
|
||||
<AuthScreen
|
||||
onAuthenticated={acceptSession}
|
||||
serverMessage={serverMessage}
|
||||
/>
|
||||
<main className="game-shell">
|
||||
<section className="message-panel">
|
||||
<p className="eyebrow">Opening Chronicle</p>
|
||||
<h1>Returning to Sign In...</h1>
|
||||
{serverMessage && <p>{serverMessage}</p>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGameAction } from '../../input'
|
||||
import { getGameMode } from '../../gameRepository'
|
||||
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
|
||||
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenSetupPublisher,
|
||||
useDualScreenWorkshopPublisher,
|
||||
type DualScreenSetupState,
|
||||
type DualScreenWorkshopState,
|
||||
} from '../../dualScreen'
|
||||
import { BossArenaScreen } from './screens/BossArenaScreen'
|
||||
import {
|
||||
Iwt2CloudSaveScreen,
|
||||
@@ -17,8 +26,8 @@ import {
|
||||
writeIwt2Save,
|
||||
type Iwt2Save,
|
||||
} from './save/iwt2Repository'
|
||||
import type { Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer } from './content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import {
|
||||
buildIwt2OpponentDebuffChoices,
|
||||
buildIwt2SelfBuffChoices,
|
||||
@@ -47,6 +56,7 @@ const IWT2_MENU_COLUMNS = 4
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
@@ -113,6 +123,7 @@ const MENU_ITEMS: Array<{
|
||||
]
|
||||
|
||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -121,11 +132,29 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
writeIwt2Save(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) => {
|
||||
if (screen !== 'menu' || device !== 'controller') return
|
||||
if (action === 'back') {
|
||||
@@ -162,6 +191,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
@@ -173,7 +203,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossIds={roguelikeRun.bossIds}
|
||||
bossId={selectedBossId}
|
||||
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
|
||||
roguelikeRun={{
|
||||
buffs: roguelikeRun.buffs,
|
||||
contentType: roguelikeRun.contentType,
|
||||
@@ -213,7 +245,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
onChoose={(buffId, debuffId) => {
|
||||
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage))
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}}
|
||||
/>
|
||||
@@ -247,17 +279,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
onBack={() => setScreen('menu')}
|
||||
onContentTypeChange={setRoguelikeContentType}
|
||||
onStart={() => {
|
||||
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
|
||||
setScreen('roguelike-arena')
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
queueMessage={pvpQueueMessage}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
@@ -339,6 +372,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<button
|
||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
key={`${item.screen}-${item.title}`}
|
||||
onClick={() => openMenuItem(item)}
|
||||
onPointerDown={() => setSelectedIndex(index)}
|
||||
@@ -356,24 +390,58 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function bossForRoguelike(
|
||||
function startIwt2RoguelikeRun() {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
if (roguelikeVariant !== 'pvp') {
|
||||
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||
return
|
||||
}
|
||||
const startStage = 1
|
||||
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||
contentType: roguelikeContentType,
|
||||
startStage,
|
||||
gameMode: getGameMode(),
|
||||
liveMatchActive: () => false,
|
||||
onSearching: setPvpQueueMessage,
|
||||
onCpuMatch: (_difficulty, message) => {
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
onLiveMatch: (...liveMatchArgs) => {
|
||||
const message = liveMatchArgs[2]
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
messages: {
|
||||
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
|
||||
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
|
||||
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
|
||||
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
||||
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function beginIwt2RoguelikeArena(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
stage = 1,
|
||||
): Iwt2BossId {
|
||||
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
|
||||
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
|
||||
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
|
||||
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
|
||||
const pool = contentType === 'raid'
|
||||
? raidBosses
|
||||
: contentType === 'stadium'
|
||||
? stadiumBosses
|
||||
: dungeonBosses
|
||||
return pool[(stage - 1) % pool.length]
|
||||
) {
|
||||
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function createRoguelikeRun(
|
||||
@@ -382,6 +450,7 @@ function createRoguelikeRun(
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
@@ -442,11 +511,17 @@ function applyRoguelikeChoice(
|
||||
return {
|
||||
...run,
|
||||
...nextBase,
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
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[] {
|
||||
const pool = [...items]
|
||||
const choices: T[] = []
|
||||
@@ -478,3 +553,90 @@ function Iwt2Header({
|
||||
</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'
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@ import type { Iwt2BossEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
|
||||
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
|
||||
const entries = bosses ?? (boss ? [boss] : [])
|
||||
return (
|
||||
<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>
|
||||
<strong>{metadata.name}</strong>
|
||||
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
|
||||
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||
</div>
|
||||
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
|
||||
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function PartyFrames({
|
||||
<button
|
||||
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected ? 'true' : undefined}
|
||||
key={member.id}
|
||||
onClick={() => onTarget(member.id)}
|
||||
type="button"
|
||||
|
||||
@@ -83,7 +83,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
||||
const graphics = this.entityGraphics!
|
||||
graphics.clear()
|
||||
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))
|
||||
|
||||
for (const entity of entities.sort(entitySort)) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../../../dualScreen'
|
||||
import type { PartyMember, Role, Spell } from '../../../game'
|
||||
import {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
@@ -16,6 +22,7 @@ import {
|
||||
import { AbilityBar } from '../components/AbilityBar'
|
||||
import { BossHud } from '../components/BossHud'
|
||||
import { PartyFrames } from '../components/PartyFrames'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
@@ -40,6 +47,7 @@ const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
|
||||
type BossArenaScreenProps = {
|
||||
bossId: Iwt2BossId
|
||||
bossIds?: Iwt2BossId[]
|
||||
modeLabel?: string
|
||||
save: Iwt2Save
|
||||
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 [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 [status, setStatus] = useState<ArenaStatus>('playing')
|
||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||
const stateRef = useRef(arenaState)
|
||||
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||
const statusRef = useRef(status)
|
||||
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||
const saveRef = useRef(save)
|
||||
const killRecordedRef = useRef(false)
|
||||
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||
const lastPublishTimeRef = useRef(0)
|
||||
const lastHudSignatureRef = useRef('')
|
||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||
@@ -77,12 +90,17 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
opponentStateRef.current = opponentArenaState
|
||||
}, [opponentArenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
@@ -100,16 +118,19 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId)
|
||||
killRecordedRef.current = false
|
||||
const next = createInitialIwt2ArenaState(bossId, bossIds)
|
||||
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
|
||||
recordedKillIdsRef.current = new Set()
|
||||
abilityCooldownsRef.current = {}
|
||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||
stateRef.current = next
|
||||
opponentStateRef.current = nextOpponentState
|
||||
setArenaState(next)
|
||||
setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossId])
|
||||
}, [bossId, bossIds, pvpRoguelike])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
@@ -232,12 +253,34 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
moveY: movement.y,
|
||||
}, dtSeconds)
|
||||
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) {
|
||||
killRecordedRef.current = true
|
||||
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
|
||||
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
|
||||
if (newlyDefeatedBosses.length > 0) {
|
||||
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
|
||||
onSaveUpdated(updatedSave)
|
||||
}
|
||||
|
||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
||||
showOverlay('victory')
|
||||
} else if (next.party.every((member) => member.health <= 0)) {
|
||||
showOverlay('defeat')
|
||||
@@ -247,15 +290,16 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
if (
|
||||
hudSignature !== lastHudSignatureRef.current
|
||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||
|| next.boss.health <= 0
|
||||
|| next.bosses.some((boss) => boss.health <= 0)
|
||||
) {
|
||||
lastHudSignatureRef.current = hudSignature
|
||||
lastPublishTimeRef.current = next.time
|
||||
setArenaState(next)
|
||||
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [bossId, onSaveUpdated, showOverlay])
|
||||
}, [onSaveUpdated, pvpRoguelike, showOverlay])
|
||||
|
||||
const targetBindings = directPartyTargeting
|
||||
? 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 alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||
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'
|
||||
? 'Resume'
|
||||
: status === 'victory' && roguelikeRun
|
||||
? 'Choose Upgrade'
|
||||
: 'Restart'
|
||||
const overlayTitle = status === 'victory'
|
||||
? `${bossMetadata.name} Down`
|
||||
? `${bossTitle} Down`
|
||||
: status === 'defeat'
|
||||
? 'Party Defeated'
|
||||
: 'Arena Paused'
|
||||
@@ -292,6 +338,38 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
: roguelikeRun
|
||||
? 'Leave Roguelike'
|
||||
: `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 (
|
||||
<main
|
||||
@@ -301,7 +379,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
>
|
||||
<section className="iwt2-arena-layout">
|
||||
<div className="iwt2-arena-stage">
|
||||
<BossHud boss={arenaState.boss} />
|
||||
<BossHud bosses={arenaState.bosses} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
@@ -325,6 +403,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
<button
|
||||
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||
type="button"
|
||||
@@ -334,6 +413,7 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
<button
|
||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||
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="iwt2-result-panel">
|
||||
<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>
|
||||
<p className="eyebrow">{overlayEyebrow}</p>
|
||||
<h1>{overlayTitle}</h1>
|
||||
@@ -366,6 +446,10 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||
Standing
|
||||
</span>
|
||||
<span>
|
||||
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||
Bosses
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
@@ -373,9 +457,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
|
||||
</div>
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+125 XP</span>
|
||||
<span>Common carve</span>
|
||||
<span>Log +1</span>
|
||||
<span>+{arenaState.bosses.length * 125} XP</span>
|
||||
<span>{arenaState.bosses.length} carves</span>
|
||||
<span>Log +{arenaState.bosses.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="iwt2-overlay-actions">
|
||||
@@ -424,9 +508,11 @@ function formatArenaTime(seconds: number): string {
|
||||
|
||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||
return [
|
||||
state.boss.id,
|
||||
Math.ceil(state.boss.health),
|
||||
state.boss.attackPhase,
|
||||
...state.bosses.map((boss) => [
|
||||
boss.id,
|
||||
Math.ceil(boss.health),
|
||||
boss.attackPhase,
|
||||
].join(':')),
|
||||
state.hostileAdds.length,
|
||||
state.hazards.length,
|
||||
...state.party.map((member) => [
|
||||
@@ -440,6 +526,12 @@ function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||
].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 {
|
||||
const living = state.party.filter((member) => member.health > 0)
|
||||
const targets = living.length > 0 ? living : state.party
|
||||
@@ -507,3 +599,201 @@ function arenaPauseTitle(
|
||||
}
|
||||
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(', ')
|
||||
}
|
||||
|
||||
@@ -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 { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
|
||||
import { getGameMode } from '../../../gameRepository'
|
||||
@@ -161,6 +161,14 @@ export function Iwt2ScreenShell({
|
||||
|
||||
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
|
||||
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
|
||||
const rowRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
|
||||
useEffect(() => {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<div className="iwt2-action-list">
|
||||
@@ -168,12 +176,16 @@ export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
|
||||
<button
|
||||
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
disabled={action.disabled}
|
||||
key={action.key}
|
||||
onClick={action.onConfirm}
|
||||
onPointerDown={() => {
|
||||
if (!action.disabled) setSelectedIndex(index)
|
||||
}}
|
||||
ref={(element) => {
|
||||
rowRefs.current[index] = element
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
@@ -281,6 +293,7 @@ export function Iwt2RoguelikeScreen({
|
||||
onContentTypeChange,
|
||||
onStart,
|
||||
onVariantChange,
|
||||
queueMessage,
|
||||
variant,
|
||||
}: {
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
@@ -288,6 +301,7 @@ export function Iwt2RoguelikeScreen({
|
||||
onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void
|
||||
onStart: () => void
|
||||
onVariantChange: (variant: Iwt2RoguelikeVariant) => void
|
||||
queueMessage?: string
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}) {
|
||||
const gameMode = getGameMode()
|
||||
@@ -392,6 +406,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('back') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => select('back')}
|
||||
type="button"
|
||||
@@ -403,6 +418,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${variant === 'pve' ? 'active' : ''} ${selected('variant-pve') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('variant-pve') ? 'true' : undefined}
|
||||
onClick={() => open('variant-pve')}
|
||||
onPointerDown={() => select('variant-pve')}
|
||||
type="button"
|
||||
@@ -412,6 +428,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${variant === 'pvp' ? 'active' : ''} ${selected('variant-pvp') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('variant-pvp') ? 'true' : undefined}
|
||||
onClick={() => open('variant-pvp')}
|
||||
onPointerDown={() => select('variant-pvp')}
|
||||
type="button"
|
||||
@@ -430,6 +447,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pve-dungeon') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pve-dungeon') ? 'true' : undefined}
|
||||
onClick={() => open('pve-dungeon')}
|
||||
onPointerDown={() => select('pve-dungeon')}
|
||||
type="button"
|
||||
@@ -439,6 +457,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pve-raid') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pve-raid') ? 'true' : undefined}
|
||||
onClick={() => open('pve-raid')}
|
||||
onPointerDown={() => select('pve-raid')}
|
||||
type="button"
|
||||
@@ -456,6 +475,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${selected('pve-start') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pve-start') ? 'true' : undefined}
|
||||
onClick={() => open('pve-start')}
|
||||
onPointerDown={() => select('pve-start')}
|
||||
type="button"
|
||||
@@ -476,6 +496,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pvp-dungeon') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pvp-dungeon') ? 'true' : undefined}
|
||||
onClick={() => open('pvp-dungeon')}
|
||||
onPointerDown={() => select('pvp-dungeon')}
|
||||
type="button"
|
||||
@@ -485,6 +506,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pvp-raid') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pvp-raid') ? 'true' : undefined}
|
||||
onClick={() => open('pvp-raid')}
|
||||
onPointerDown={() => select('pvp-raid')}
|
||||
type="button"
|
||||
@@ -494,6 +516,7 @@ export function Iwt2RoguelikeScreen({
|
||||
<button
|
||||
className={`text-button ${contentType === 'stadium' ? 'active' : ''} ${selected('pvp-stadium') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pvp-stadium') ? 'true' : undefined}
|
||||
onClick={() => open('pvp-stadium')}
|
||||
onPointerDown={() => select('pvp-stadium')}
|
||||
type="button"
|
||||
@@ -506,11 +529,12 @@ export function Iwt2RoguelikeScreen({
|
||||
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
|
||||
<div>
|
||||
<strong>{pvpCardTitle}</strong>
|
||||
<small>{pvpCardCopy}</small>
|
||||
<small>{queueMessage || pvpCardCopy}</small>
|
||||
</div>
|
||||
<button
|
||||
className={`text-button ${selected('pvp-start') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('pvp-start') ? 'true' : undefined}
|
||||
onClick={() => open('pvp-start')}
|
||||
onPointerDown={() => select('pvp-start')}
|
||||
type="button"
|
||||
@@ -641,7 +665,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
||||
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
|
||||
return
|
||||
}
|
||||
if ((action === 'back' || action === 'pause') && !pvpUpgrade) {
|
||||
if (action === 'back' || action === 'pause') {
|
||||
onBack()
|
||||
}
|
||||
})
|
||||
@@ -671,6 +695,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
||||
<button
|
||||
className={`${activeBuffId === choice.id && pvpUpgrade ? 'selected-upgrade' : ''} ${entrySelected('upgradeBuff', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={entrySelected('upgradeBuff', index) ? 'true' : undefined}
|
||||
key={choice.id}
|
||||
onClick={() => {
|
||||
if (pvpUpgrade) setSelectedBuffId(choice.id)
|
||||
@@ -693,6 +718,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
||||
<button
|
||||
className={`${activeDebuffId === choice.id ? 'selected-upgrade' : ''} ${entrySelected('upgradeDebuff', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={entrySelected('upgradeDebuff', index) ? 'true' : undefined}
|
||||
key={choice.id}
|
||||
onClick={() => setSelectedDebuffId(choice.id)}
|
||||
onPointerDown={() => setCursor('upgradeDebuff', index)}
|
||||
@@ -720,6 +746,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
||||
<button
|
||||
className={`secondary-result-button ${entrySelected('upgradeContinue') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={entrySelected('upgradeContinue') ? 'true' : undefined}
|
||||
disabled={!activeBuffId || !activeDebuffId}
|
||||
onClick={() => {
|
||||
if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId)
|
||||
@@ -733,6 +760,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
||||
<button
|
||||
className={`secondary-result-button ${entrySelected('upgradeLeave') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={entrySelected('upgradeLeave') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => setCursor('upgradeLeave')}
|
||||
type="button"
|
||||
|
||||
+155
-62
@@ -2,8 +2,10 @@ import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaInput,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HostileAddState,
|
||||
@@ -33,6 +35,7 @@ const MAX_DT = 1 / 15
|
||||
const MAX_EVENTS = 80
|
||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||
|
||||
type InitialPartyMember = {
|
||||
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 },
|
||||
]
|
||||
|
||||
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[]): Iwt2ArenaState {
|
||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index))
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
time: 0,
|
||||
@@ -64,33 +68,8 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I
|
||||
hostileAdds: [],
|
||||
hazards: [],
|
||||
indicators: [],
|
||||
boss: {
|
||||
id: bossId,
|
||||
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: [],
|
||||
},
|
||||
boss: bosses[0],
|
||||
bosses,
|
||||
nextEventId: 1,
|
||||
nextProjectileId: 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 {
|
||||
if (bossId === 'bulldrome') return 2
|
||||
if (bossId === 'great-jaggi') return 2.4
|
||||
@@ -128,13 +152,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
||||
}
|
||||
const separatedState = {
|
||||
...baseState,
|
||||
party: separatePartyFromBoss(baseState.party, baseState),
|
||||
party: separatePartyFromBosses(baseState.party, baseState),
|
||||
}
|
||||
const bossResult = tickBoss(separatedState, step)
|
||||
const bossResult = tickBosses(separatedState, step)
|
||||
const projectileResult = advanceProjectiles(
|
||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||
bossResult.party,
|
||||
bossResult.boss,
|
||||
bossResult.bosses,
|
||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||
bossResult.hazards ?? separatedState.hazards,
|
||||
bossResult.nextHazardId ?? state.nextHazardId,
|
||||
@@ -151,7 +175,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
||||
})
|
||||
const damageResult = applyPartyAttacks(
|
||||
hazardResult.party,
|
||||
projectileResult.boss,
|
||||
projectileResult.bosses,
|
||||
projectileResult.hostileAdds,
|
||||
separatedState.time,
|
||||
projectileResult.nextProjectileId,
|
||||
@@ -164,7 +188,8 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
||||
)
|
||||
return {
|
||||
...separatedState,
|
||||
boss: damageResult.boss,
|
||||
boss: getPrimaryBoss(damageResult.bosses),
|
||||
bosses: damageResult.bosses,
|
||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||
party: finalParty,
|
||||
hostileAdds: damageResult.hostileAdds,
|
||||
@@ -274,14 +299,69 @@ function clampInputAxis(value: number): number {
|
||||
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) => {
|
||||
if (member.health <= 0) return member
|
||||
const position = separateCircles(
|
||||
{ position: member.position, radius: member.radius },
|
||||
{ position: state.boss.position, radius: state.boss.radius },
|
||||
let position = member.position
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
position = separateCircles(
|
||||
{ position, radius: member.radius },
|
||||
{ position: boss.position, radius: boss.radius },
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
return { ...member, position }
|
||||
})
|
||||
}
|
||||
@@ -289,7 +369,7 @@ function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaSt
|
||||
function advanceProjectiles(
|
||||
projectiles: Iwt2ProjectileEntityState[],
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
hazards: Iwt2GroundHazardState[],
|
||||
nextHazardId: number,
|
||||
@@ -298,7 +378,7 @@ function advanceProjectiles(
|
||||
time: number,
|
||||
dt: number,
|
||||
): {
|
||||
boss: Iwt2ArenaState['boss']
|
||||
bosses: Iwt2BossEntityState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
@@ -308,7 +388,7 @@ function advanceProjectiles(
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let nextBoss = boss
|
||||
let nextBosses = bosses
|
||||
let nextParty = party
|
||||
let nextHostileAdds = hostileAdds
|
||||
let nextHazards = hazards
|
||||
@@ -334,7 +414,7 @@ function advanceProjectiles(
|
||||
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 = {
|
||||
x: projectile.position.x + projectile.velocity.x * dt,
|
||||
y: projectile.position.y + projectile.velocity.y * dt,
|
||||
@@ -371,28 +451,31 @@ function advanceProjectiles(
|
||||
continue
|
||||
}
|
||||
|
||||
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
|
||||
const damage = Math.min(projectile.damage, nextBoss.health)
|
||||
nextBoss = {
|
||||
...nextBoss,
|
||||
health: Math.max(0, nextBoss.health - damage),
|
||||
}
|
||||
const hitBoss = nextBosses.find((boss) => (
|
||||
boss.health > 0
|
||||
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
|
||||
))
|
||||
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)
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: nextBoss.id,
|
||||
targetId: hitBoss.id,
|
||||
value: damage,
|
||||
})
|
||||
if (nextBoss.health <= 0) {
|
||||
if (hitBoss.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: nextBoss.id,
|
||||
targetId: hitBoss.id,
|
||||
})
|
||||
}
|
||||
continue
|
||||
@@ -405,7 +488,7 @@ function advanceProjectiles(
|
||||
}
|
||||
|
||||
return {
|
||||
boss: nextBoss,
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
@@ -536,12 +619,12 @@ function advanceBossProjectile({
|
||||
|
||||
function applyPartyAttacks(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
time: number,
|
||||
nextProjectileId: number,
|
||||
): {
|
||||
boss: Iwt2ArenaState['boss']
|
||||
bosses: Iwt2BossEntityState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
@@ -550,13 +633,13 @@ function applyPartyAttacks(
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||
let nextBoss = boss
|
||||
let nextBosses = bosses
|
||||
let nextHostileAdds = hostileAdds
|
||||
let projectileId = nextProjectileId
|
||||
const nextParty = party.map((member) => {
|
||||
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
|
||||
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
|
||||
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]
|
||||
if (metadata.projectileSpeed > 0) {
|
||||
if (!member.attackReady) return member
|
||||
@@ -587,7 +670,9 @@ function applyPartyAttacks(
|
||||
if (member.attackCooldownRemaining > 0) return member
|
||||
const damage = Math.min(metadata.attackDamage, target.health)
|
||||
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 {
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||
? { ...add, health: Math.max(0, add.health - damage) }
|
||||
@@ -622,7 +707,7 @@ function applyPartyAttacks(
|
||||
}
|
||||
})
|
||||
return {
|
||||
boss: nextBoss,
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||
projectiles,
|
||||
@@ -633,9 +718,9 @@ function applyPartyAttacks(
|
||||
|
||||
function getPriorityAttackTarget(
|
||||
member: Iwt2PartyEntityState,
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
||||
): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
|
||||
if (member.health <= 0) return undefined
|
||||
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
||||
if (livingAdds.length > 0) {
|
||||
@@ -643,7 +728,15 @@ function getPriorityAttackTarget(
|
||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||
), 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(
|
||||
|
||||
@@ -54,8 +54,8 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & {
|
||||
telegraphs: Iwt2ArenaTelegraph[]
|
||||
}
|
||||
|
||||
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
|
||||
return decorateArenaState(createCoreIwt2ArenaState(bossId))
|
||||
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId, bossIds?: Iwt2BossId[]): Iwt2ArenaState {
|
||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds))
|
||||
}
|
||||
|
||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||
@@ -69,7 +69,7 @@ export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
||||
entities: [
|
||||
...state.party.map(toArenaEntity),
|
||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
||||
toBossArenaEntity(state.boss),
|
||||
...state.bosses.map(toBossArenaEntity),
|
||||
],
|
||||
telegraphs: createTelegraphs(state.indicators),
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu'])
|
||||
const CENTER_LEASH_START_DISTANCE = 230
|
||||
const CENTER_LEASH_WALL_MARGIN = 96
|
||||
const CENTER_LEASH_EXTRA_DISTANCE = 36
|
||||
const PARTY_ARRIVAL_RADIUS = 3.5
|
||||
|
||||
export function tickPartyMember(
|
||||
member: Iwt2PartyEntityState,
|
||||
@@ -68,7 +69,7 @@ export function tickPartyMember(
|
||||
const canCast = member.aiRole === 'ranged'
|
||||
&& attackCooldownRemaining <= 0
|
||||
&& !!attackTarget
|
||||
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
|
||||
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
|
||||
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
||||
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
||||
? castSecondsRemaining
|
||||
@@ -76,7 +77,7 @@ export function tickPartyMember(
|
||||
return {
|
||||
...member,
|
||||
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,
|
||||
castSecondsRemaining: nextCastSecondsRemaining,
|
||||
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
|
||||
@@ -85,15 +86,21 @@ export function tickPartyMember(
|
||||
}
|
||||
|
||||
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 position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
||||
const distanceToDesired = distanceVec2(member.position, desired)
|
||||
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 {
|
||||
...member,
|
||||
position,
|
||||
velocity,
|
||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
|
||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
|
||||
attackCooldownRemaining,
|
||||
castSecondsRemaining: 0,
|
||||
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.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
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(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
decisionSecondsRemaining: number,
|
||||
): 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 anchor = attackTarget?.position ?? state.boss.position
|
||||
const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
|
||||
const centerLeash = getTankCenterLeashPosition(member, state)
|
||||
if (centerLeash) return centerLeash
|
||||
return {
|
||||
@@ -132,21 +138,22 @@ function getTankCenterLeashPosition(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): 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 bossCenterDistance = distanceVec2(state.boss.position, center)
|
||||
const nearWall = isBossNearWall(state)
|
||||
const bossCenterDistance = distanceVec2(boss.position, center)
|
||||
const nearWall = isBossNearWall(boss, state)
|
||||
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
|
||||
|
||||
const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId]
|
||||
const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), {
|
||||
x: state.boss.position.x < center.x ? 1 : -1,
|
||||
y: state.boss.position.y < center.y ? 0.35 : -0.35,
|
||||
const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
|
||||
x: boss.position.x < center.x ? 1 : -1,
|
||||
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(
|
||||
addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)),
|
||||
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
)
|
||||
@@ -159,23 +166,24 @@ function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
}
|
||||
}
|
||||
|
||||
function isBossNearWall(state: Iwt2ArenaState): boolean {
|
||||
const margin = state.boss.radius + CENTER_LEASH_WALL_MARGIN
|
||||
function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
|
||||
return (
|
||||
state.boss.position.x <= margin
|
||||
|| state.boss.position.x >= state.bounds.width - margin
|
||||
|| state.boss.position.y <= margin
|
||||
|| state.boss.position.y >= state.bounds.height - margin
|
||||
boss.position.x <= margin
|
||||
|| boss.position.x >= state.bounds.width - margin
|
||||
|| boss.position.y <= margin
|
||||
|| boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
const boss = state.boss
|
||||
const hazardEscape = getHazardAvoidancePosition(member, state)
|
||||
if (hazardEscape) {
|
||||
return hazardEscape
|
||||
}
|
||||
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
||||
const distance = distanceVec2(member.position, boss.position)
|
||||
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')) {
|
||||
const danger = chargeDanger(member.position, state)
|
||||
const danger = chargeDanger(member.position, boss)
|
||||
if (danger.inside || danger.ahead) {
|
||||
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
||||
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 null
|
||||
}
|
||||
@@ -208,7 +217,7 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
||||
const dangerRadius = hazard.radius + member.radius + 46
|
||||
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,
|
||||
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
|
||||
), 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) {
|
||||
const boss = state.boss
|
||||
function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
|
||||
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
|
||||
const lengthSq = Math.max(1, lengthSqVec2(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 {
|
||||
const seed = member.id.length * 17
|
||||
return {
|
||||
|
||||
@@ -256,6 +256,7 @@ export type Iwt2ArenaState = {
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
boss: Iwt2BossEntityState
|
||||
bosses: Iwt2BossEntityState[]
|
||||
indicators: Iwt2ArenaIndicator[]
|
||||
nextEventId: number
|
||||
nextProjectileId: number
|
||||
|
||||
Reference in New Issue
Block a user