Compare commits

...
2 Commits
Author SHA1 Message Date
Warren H c052b086f8 Android build v1.1.18 2026-07-04 19:31:58 -04:00
Warren H c2a14bac7d Android build v1.1.17 2026-07-04 18:07:50 -04:00
34 changed files with 729 additions and 13 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 95 versionCode 97
versionName "1.1.16" versionName "1.1.18"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
} }
Set<String> currentTokens = new HashSet<>(); Set<String> currentTokens = new HashSet<>();
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X), event.getAxisValue(MotionEvent.AXIS_HAT_X),
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
); );
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_X), leftStickX,
"Axis0-", "Axis0-",
"Axis0+" "Axis0+"
); );
addAxisTokens( addAxisTokens(
currentTokens, currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y), leftStickY,
"Axis1-", "Axis1-",
"Axis1+" "Axis1+"
); );
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
); );
} }
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) { private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length())); int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
+333
View File
@@ -837,6 +837,285 @@
} }
} }
@media (max-width: 1000px) and (max-height: 620px) {
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
}
.iwt2-shell .app-header {
min-height: 44px;
padding: 6px 10px;
}
.iwt2-shell .brand-button strong {
font-size: 10px;
}
.iwt2-shell .character-summary {
gap: 7px;
}
.iwt2-shell .character-summary strong {
font-size: 14px;
}
.iwt2-shell .character-summary small {
font-size: 6px;
}
.iwt2-menu-screen {
flex: 1;
gap: 10px;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-menu-heading {
padding-bottom: 8px;
}
.iwt2-menu-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-menu-heading h1 {
font-size: 1.55rem;
}
.iwt2-menu-grid {
gap: 10px;
}
.iwt2-menu-card {
gap: 8px;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 92px;
padding: 8px;
}
.iwt2-menu-card > span {
font-size: 0.95rem;
height: 42px;
width: 42px;
}
.iwt2-menu-card strong {
font-size: 0.8rem;
margin-bottom: 3px;
}
.iwt2-menu-card small {
font-size: 0.64rem;
line-height: 1.25;
}
.iwt2-screen-shell {
flex: 1;
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .back-button {
min-height: 30px;
padding: 5px 9px;
}
.iwt2-action-list,
.iwt2-info-list {
gap: 7px;
min-height: 0;
overflow-y: auto;
padding: 3px;
}
.iwt2-action-row,
.iwt2-info-row {
gap: 8px;
min-height: 54px;
padding: 8px 10px;
}
.iwt2-action-row strong,
.iwt2-info-row strong {
font-size: 0.78rem;
}
.iwt2-action-row small,
.iwt2-info-row small {
font-size: 0.62rem;
line-height: 1.2;
margin-top: 2px;
}
.iwt2-profile-grid,
.iwt2-settings-panel,
.iwt2-customize-layout {
min-height: 0;
overflow: hidden;
}
.iwt2-settings-panel,
.iwt2-customize-layout {
gap: 10px;
}
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
overflow: hidden;
}
.iwt2-loadout-preview {
gap: 8px;
}
.iwt2-loadout-preview h2 {
font-size: 12px;
line-height: 1.1;
}
.iwt2-loadout-preview span {
font-size: 0.62rem;
}
.iwt2-controller-preview {
min-height: 112px;
padding: 10px;
}
.iwt2-boss-hud {
min-width: 280px;
padding: 6px 10px 8px;
top: 6px;
}
.iwt2-boss-hud strong {
font-size: 0.82rem;
}
.iwt2-boss-phase {
font-size: 0.5rem;
height: 10px;
line-height: 10px;
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
overflow: hidden;
padding: 4px;
top: 10px;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 20px minmax(0, 1fr);
height: 62px;
min-height: 62px;
padding: 4px;
}
.iwt2-party-row.has-target-binding {
grid-template-columns: 24px 20px minmax(0, 1fr);
}
.iwt2-party-row > span,
.iwt2-party-row .iwt2-party-target-key {
height: 20px;
min-width: 20px;
width: 20px;
}
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
height: 16px;
min-width: 16px;
}
.iwt2-party-row-title {
gap: 4px;
}
.iwt2-party-row strong {
font-size: 0.62rem;
}
.iwt2-party-row small {
font-size: 0.52rem;
line-height: 1.15;
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
}
.iwt2-party-effects {
height: 10px;
margin-top: 2px;
}
.iwt2-effect-badge {
font-size: 0.44rem;
line-height: 9px;
padding: 0 2px;
}
.iwt2-party-damage {
display: none;
}
.iwt2-ability-bar {
bottom: 8px;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
width: 256px;
}
.iwt2-ability-button {
min-height: 44px;
padding: 3px;
}
.iwt2-ability-button > span {
font-size: 0.9rem;
height: 24px;
width: 24px;
}
.iwt2-ability-button strong {
font-size: 0.5rem;
}
.iwt2-ability-button i {
font-size: 0.46rem;
}
}
.combat-touch-lock-status { .combat-touch-lock-status {
background: #101216; background: #101216;
border: 2px solid #d9b55a; border: 2px solid #d9b55a;
@@ -11576,3 +11855,57 @@ h2 {
height: 20px; height: 20px;
} }
} }
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-screen-shell.content-screen {
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell.content-screen > .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell.content-screen > .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell.content-screen > .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .iwt2-action-list,
.iwt2-screen-shell .iwt2-info-list,
.iwt2-settings-panel .iwt2-action-list,
.iwt2-customize-layout .iwt2-action-list,
.iwt2-loadout-preview .iwt2-info-list {
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.iwt2-screen-shell.content-screen > .iwt2-action-list,
.iwt2-screen-shell.content-screen > .iwt2-info-list {
flex: 1 1 auto;
}
.iwt2-action-row:not(:has(small)),
.iwt2-info-row:not(:has(small)) {
min-height: 32px;
padding: 5px 10px;
}
.iwt2-settings-panel,
.iwt2-customize-layout,
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
}
}
+21 -1
View File
@@ -134,6 +134,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display' const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header' const HEADER_CONTENT_SELECTOR = '.app-header'
@@ -142,6 +143,7 @@ const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250 const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260 const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85 const CONTROLLER_REPEAT_MS = 85
const NATIVE_STICK_TIMEOUT_MS = 140
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = { const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp', Button12: 'navigateUp',
Button13: 'navigateDown', Button13: 'navigateDown',
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
let frame = 0 let frame = 0
const pressedKeys = new Set<string>() const pressedKeys = new Set<string>()
let nativeStick: MovementVector = { x: 0, y: 0 }
let nativeStickUpdatedAt = 0
const keyboardVector = () => normalizedVector( const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0), (pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0), (pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
@@ -1094,12 +1098,26 @@ export function useMovementVectorRef(enabled = true) {
const updateMovement = () => { const updateMovement = () => {
const gamepad = firstConnectedGamepad() const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 } const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
? applyStickDeadzone(nativeStick.x, nativeStick.y)
: { x: 0, y: 0 }
const keyboard = keyboardVector() const keyboard = keyboardVector()
movementRef.current = Math.hypot(stick.x, stick.y) > 0 movementRef.current = Math.hypot(native.x, native.y) > 0
? native
: Math.hypot(stick.x, stick.y) > 0
? stick ? stick
: keyboard : keyboard
frame = window.requestAnimationFrame(updateMovement) frame = window.requestAnimationFrame(updateMovement)
} }
const onNativeMotion = (event: Event) => {
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
nativeStick = {
x: Number(detail.x) || 0,
y: Number(detail.y) || 0,
}
nativeStickUpdatedAt = performance.now()
document.documentElement.dataset.inputDevice = 'controller'
}
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
@@ -1113,12 +1131,14 @@ export function useMovementVectorRef(enabled = true) {
event.preventDefault() event.preventDefault()
} }
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement) frame = window.requestAnimationFrame(updateMovement)
return () => { return () => {
window.cancelAnimationFrame(frame) window.cancelAnimationFrame(frame)
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 } movementRef.current = { x: 0, y: 0 }
+110 -3
View File
@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useGameAction } from '../../input' import { useGameAction } from '../../input'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades' import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
import {
useDualScreen,
useDualScreenSetupPublisher,
useDualScreenWorkshopPublisher,
type DualScreenSetupState,
type DualScreenWorkshopState,
} from '../../dualScreen'
import { BossArenaScreen } from './screens/BossArenaScreen' import { BossArenaScreen } from './screens/BossArenaScreen'
import { import {
Iwt2CloudSaveScreen, Iwt2CloudSaveScreen,
@@ -17,8 +24,8 @@ import {
writeIwt2Save, writeIwt2Save,
type Iwt2Save, type Iwt2Save,
} from './save/iwt2Repository' } from './save/iwt2Repository'
import type { Iwt2BossId } from './content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { abilitiesForHealer } from './content/healerAbilities' import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import { import {
buildIwt2OpponentDebuffChoices, buildIwt2OpponentDebuffChoices,
buildIwt2SelfBuffChoices, buildIwt2SelfBuffChoices,
@@ -113,6 +120,7 @@ const MENU_ITEMS: Array<{
] ]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu') const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save) const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
@@ -126,6 +134,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
writeIwt2Save(save) writeIwt2Save(save)
}, [save]) }, [save])
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
[save, screen],
)
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
useGameAction((action, device) => { useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') { if (action === 'back') {
@@ -478,3 +498,90 @@ function Iwt2Header({
</header> </header>
) )
} }
function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
initials: boss.icon,
itemLevel: save.character.level,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
): DualScreenWorkshopState | null {
if (screen === 'hunter-profile') {
return {
items: [
{
glyph: 'H',
meta: `${save.character.experience} XP`,
status: `Level ${save.character.level}`,
title: save.character.name,
},
{
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
status: 'Class',
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
},
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
glyph: IWT2_BOSS_METADATA[bossId].icon,
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
title: IWT2_BOSS_METADATA[bossId].name,
})),
],
mode: 'collection',
subtitle: 'IWT2 inventory and collection log',
summary: `${save.inventory.length} inventory slots`,
title: 'Hunter Profile',
}
}
if (screen === 'customize-character') {
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
return {
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
glyph: ability.icon,
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
status: `Slot ${ability.slot}`,
title: ability.name,
})),
mode: 'class',
subtitle: healer.description,
summary: healer.name,
title: 'Customize Character',
}
}
return null
}
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
}
+231 -2
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input' import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input' import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../../../dualScreen'
import type { PartyMember, Role, Spell } from '../../../game'
import { import {
createInitialIwt2ArenaState, createInitialIwt2ArenaState,
tickIwt2Arena, tickIwt2Arena,
@@ -16,6 +22,7 @@ import {
import { AbilityBar } from '../components/AbilityBar' import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud' import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames' import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls' import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities' import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
@@ -56,12 +63,17 @@ type BossArenaScreenProps = {
export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId] const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId)) const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({}) const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing') const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary') const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer') const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState) const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status) const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction) const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId) const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
@@ -77,12 +89,23 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
directPartyTargeting, directPartyTargeting,
lastDevice, lastDevice,
} = useInput() } = useInput()
const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice] const activeBindings = bindings[lastDevice]
useEffect(() => { useEffect(() => {
stateRef.current = arenaState stateRef.current = arenaState
}, [arenaState]) }, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => {
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
opponentStateRef.current = nextOpponentState
setOpponentArenaState(nextOpponentState)
}, [bossId, pvpRoguelike])
useEffect(() => { useEffect(() => {
statusRef.current = status statusRef.current = status
}, [status]) }, [status])
@@ -101,15 +124,18 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
const resetArena = useCallback(() => { const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId) const next = createInitialIwt2ArenaState(bossId)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
killRecordedRef.current = false killRecordedRef.current = false
abilityCooldownsRef.current = {} abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next) lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next stateRef.current = next
opponentStateRef.current = nextOpponentState
setArenaState(next) setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({}) setAbilityCooldowns({})
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
setStatus('playing') setStatus('playing')
}, [bossId]) }, [bossId, pvpRoguelike])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => { const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
@@ -232,6 +258,19 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
moveY: movement.y, moveY: movement.y,
}, dtSeconds) }, dtSeconds)
stateRef.current = next stateRef.current = next
let nextOpponentState = opponentStateRef.current
if (pvpRoguelike && nextOpponentState) {
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
nextOpponentState = tickIwt2Arena(
nextOpponentState,
{
moveX: opponentMovement.x,
moveY: opponentMovement.y,
},
dtSeconds,
)
opponentStateRef.current = nextOpponentState
}
if (next.boss.health <= 0 && !killRecordedRef.current) { if (next.boss.health <= 0 && !killRecordedRef.current) {
killRecordedRef.current = true killRecordedRef.current = true
@@ -252,10 +291,11 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
lastHudSignatureRef.current = hudSignature lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time lastPublishTimeRef.current = next.time
setArenaState(next) setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
setAbilityCooldowns(abilityCooldownsRef.current) setAbilityCooldowns(abilityCooldownsRef.current)
} }
return next return next
}, [bossId, onSaveUpdated, showOverlay]) }, [bossId, onSaveUpdated, pvpRoguelike, showOverlay])
const targetBindings = directPartyTargeting const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null) ? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
@@ -292,6 +332,40 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated
: roguelikeRun : roguelikeRun
? 'Leave Roguelike' ? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}` : `Leave ${modeLabel ?? 'Arena'}`
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings: activeBindings,
bossMetadata,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel: modeLabel ?? 'Arena',
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}),
[
abilities,
activeBindings,
arenaState,
abilityCooldowns,
bossMetadata,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
],
)
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return ( return (
<main <main
@@ -507,3 +581,158 @@ function arenaPauseTitle(
} }
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike' return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
} }
function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
bossMetadata,
cooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}: {
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
bossMetadata: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
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 pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentEnemyHealth: opponentArenaState.boss.health,
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: `${bossMetadata.name} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaState.boss.health,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaState.boss.maxHealth,
encounterName: bossMetadata.name,
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 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(', ')
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons' import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input' import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import { getGameMode } from '../../../gameRepository' import { getGameMode } from '../../../gameRepository'
@@ -161,6 +161,14 @@ export function Iwt2ScreenShell({
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) { export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions) const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
const rowRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
rowRefs.current[selectedIndex]?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}, [selectedIndex])
return ( return (
<div className="iwt2-action-list"> <div className="iwt2-action-list">
@@ -174,6 +182,9 @@ export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
onPointerDown={() => { onPointerDown={() => {
if (!action.disabled) setSelectedIndex(index) if (!action.disabled) setSelectedIndex(index)
}} }}
ref={(element) => {
rowRefs.current[index] = element
}}
type="button" type="button"
> >
<span> <span>