diff --git a/IWantToHeal-Thor-v1.1.18.apk b/IWantToHeal-Thor-v1.1.18.apk new file mode 100644 index 0000000..cec8d3f Binary files /dev/null and b/IWantToHeal-Thor-v1.1.18.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index e03ecee..e11a133 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 96 - versionName "1.1.17" + versionCode 97 + versionName "1.1.18" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java index 2c440fb..e4c1335 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { } Set 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(); diff --git a/src/input.tsx b/src/input.tsx index cc18776..5d3b7c7 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -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> = { Button12: 'navigateUp', Button13: 'navigateDown', @@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) { let frame = 0 const pressedKeys = new Set() + 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 - ? stick - : keyboard + 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>).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 } diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index 0279c97..77c8d0d 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -63,12 +63,17 @@ type BossArenaScreenProps = { export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { const bossMetadata = IWT2_BOSS_METADATA[bossId] + const pvpRoguelike = roguelikeRun?.variant === 'pvp' const [arenaState, setArenaState] = useState(() => createInitialIwt2ArenaState(bossId)) + const [opponentArenaState, setOpponentArenaState] = useState(() => ( + pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null + )) const [abilityCooldowns, setAbilityCooldowns] = useState>({}) const [status, setStatus] = useState('playing') const [selectedOverlayAction, setSelectedOverlayAction] = useState('primary') const [selectedPartyId, setSelectedPartyId] = useState('player-healer') const stateRef = useRef(arenaState) + const opponentStateRef = useRef(opponentArenaState) const statusRef = useRef(status) const selectedOverlayActionRef = useRef(selectedOverlayAction) const selectedPartyIdRef = useRef(selectedPartyId) @@ -91,6 +96,16 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated stateRef.current = arenaState }, [arenaState]) + useEffect(() => { + opponentStateRef.current = opponentArenaState + }, [opponentArenaState]) + + useEffect(() => { + const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null + opponentStateRef.current = nextOpponentState + setOpponentArenaState(nextOpponentState) + }, [bossId, pvpRoguelike]) + useEffect(() => { statusRef.current = status }, [status]) @@ -109,15 +124,18 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated const resetArena = useCallback(() => { const next = createInitialIwt2ArenaState(bossId) + const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null killRecordedRef.current = false abilityCooldownsRef.current = {} lastHudSignatureRef.current = arenaHudSignature(next) stateRef.current = next + opponentStateRef.current = nextOpponentState setArenaState(next) + setOpponentArenaState(nextOpponentState) setAbilityCooldowns({}) setSelectedOverlayAction('primary') setStatus('playing') - }, [bossId]) + }, [bossId, pvpRoguelike]) const showOverlay = useCallback((nextStatus: Exclude) => { setSelectedOverlayAction('primary') @@ -240,6 +258,19 @@ 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 @@ -260,10 +291,11 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated lastHudSignatureRef.current = hudSignature lastPublishTimeRef.current = next.time setArenaState(next) + if (pvpRoguelike) setOpponentArenaState(nextOpponentState) setAbilityCooldowns(abilityCooldownsRef.current) } return next - }, [bossId, onSaveUpdated, showOverlay]) + }, [bossId, onSaveUpdated, pvpRoguelike, showOverlay]) const targetBindings = directPartyTargeting ? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null) @@ -310,7 +342,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated controllerIconStyle, directPartyTargeting, modeLabel: modeLabel ?? 'Arena', + opponentArenaState, playerMana, + roguelikeRun, selectedPartyId, status, }), @@ -323,7 +357,9 @@ export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated controllerIconStyle, directPartyTargeting, modeLabel, + opponentArenaState, playerMana, + roguelikeRun, selectedPartyId, status, ], @@ -555,7 +591,9 @@ function buildIwt2DualScreenCombatState({ controllerIconStyle, directPartyTargeting, modeLabel, + opponentArenaState, playerMana, + roguelikeRun, selectedPartyId, status, }: { @@ -567,11 +605,28 @@ function buildIwt2DualScreenCombatState({ 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, @@ -587,7 +642,7 @@ function buildIwt2DualScreenCombatState({ encounterName: bossMetadata.name, floatingTexts: [], maxResource: 100, - party: arenaState.party.map(toDualScreenPartyMember), + 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, @@ -601,7 +656,7 @@ function buildIwt2DualScreenCombatState({ } } -function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number]): PartyMember { +function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember { const metadata = IWT2_CLASS_METADATA[member.classId] return { bounceHeals: [], @@ -614,14 +669,19 @@ function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number]): Party ticks: Math.ceil(effect.remainingSeconds), })), hotTicks: member.hotEffects.length, - id: member.id, + id: opponent ? `opponent-${member.id}` : member.id, maxHealth: member.maxHealth, - name: metadata.name, + 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' @@ -648,3 +708,31 @@ function toDualScreenSpell( 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(', ') +}