diff --git a/IWantToHeal-Thor-v1.0.60.apk b/IWantToHeal-Thor-v1.0.60.apk new file mode 100644 index 0000000..6023356 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.60.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 7a4c0a9..9996d3b 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 78 - versionName "1.0.59" + versionCode 79 + versionName "1.0.60" 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 9845f6e..55b2023 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -4,6 +4,7 @@ import android.content.Intent; import android.os.Bundle; import android.os.SystemClock; import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.View; import com.getcapacitor.BridgeActivity; import java.io.File; @@ -37,6 +38,18 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { if (hasFocus) enableImmersiveMode(); } + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if ( + event.getActionMasked() == MotionEvent.ACTION_DOWN + && bridge != null + && bridge.getWebView() != null + ) { + bridge.getWebView().requestFocus(); + } + return super.dispatchTouchEvent(event); + } + private void loadIntentUrl() { if (bridge == null || getIntent() == null) return; String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL); @@ -86,7 +99,10 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; bridge.getWebView().post( - () -> bridge.getWebView().evaluateJavascript(script, null) + () -> { + bridge.getWebView().requestFocus(); + bridge.getWebView().evaluateJavascript(script, null); + } ); } return true; diff --git a/server/game-api.mjs b/server/game-api.mjs index f9ec35a..33f01b3 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -2782,6 +2782,19 @@ function updatePvpMatchState(session, matchId, payload) { alive: Boolean(payload.alive), elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)), } + const currentProgress = match.progress[side] + if ( + currentProgress + && ( + progress.stage < currentProgress.stage + || ( + progress.stage === currentProgress.stage + && progress.encounterIndex < currentProgress.encounterIndex + ) + ) + ) { + return pvpSnapshot(match) + } match.states[side] = payload.state ?? null match.statuses[side] = status match.progress[side] = progress diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index 8c4c6ef..ad0bd4d 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -453,6 +453,7 @@ export function PvpStadiumScreen({ const { enabled: dualScreenEnabled } = useDualScreen() const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}` const playerAlive = playerSide.party.some((member) => member.health > 0) + const partyColumns = 3 const setSelectedTargetId = useCallback((id: string) => { selectedIdRef.current = id @@ -841,6 +842,55 @@ export function PvpStadiumScreen({ if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal') }, [addLog, applySpell, playerAlive, status]) + const selectRelativeTarget = useCallback((direction: -1 | 1) => { + const living = playerRef.current.party.filter((member) => member.health > 0) + if (living.length === 0) return + const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current) + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + direction + living.length) % living.length + setSelectedTargetId(living[nextIndex].id) + }, [setSelectedTargetId]) + + const selectDirectionalTarget = useCallback((action: InputAction) => { + const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current) + if (currentIndex < 0) { + const firstLiving = playerRef.current.party.find((member) => member.health > 0) + if (firstLiving) setSelectedTargetId(firstLiving.id) + return + } + const currentRow = Math.floor(currentIndex / partyColumns) + const currentColumn = currentIndex % partyColumns + const candidates = playerRef.current.party + .map((member, index) => ({ + member, + index, + row: Math.floor(index / partyColumns), + column: index % partyColumns, + })) + .filter(({ member, index, row, column }) => { + if (member.health <= 0 || index === currentIndex) return false + if (action === 'navigateLeft') return row === currentRow && column < currentColumn + if (action === 'navigateRight') return row === currentRow && column > currentColumn + if (action === 'navigateUp') return row < currentRow + return row > currentRow + }) + .sort((a, b) => { + const horizontal = action === 'navigateLeft' || action === 'navigateRight' + const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow) + const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow) + const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn) + const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) + return aPrimary - bPrimary || aSecondary - bSecondary + }) + if (candidates[0]) setSelectedTargetId(candidates[0].member.id) + }, [partyColumns, setSelectedTargetId]) + + const selectDirectTarget = useCallback((slot: number) => { + const member = playerRef.current.party[slot] + if (member?.health > 0) setSelectedTargetId(member.id) + }, [setSelectedTargetId]) + const cpuTakeTurn = useCallback(() => { if (!cpuDifficulty || status !== 'playing') return const behavior = CPU_BEHAVIOR[cpuDifficulty] @@ -1032,6 +1082,18 @@ export function PvpStadiumScreen({ setShopReady(false) setShopPoints(0) addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system') + if (liveMatchRef.current) { + publishPvpMatchState(liveMatchRef.current.id, { + state: nextPlayer, + status: 'playing', + stage: nextRound, + encounterIndex: nextRound, + encountersCleared: roundWins.player, + enemyHealth: 0, + alive: true, + elapsedTicks: 0, + }).catch(() => undefined) + } beginRoundCountdown() }, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId]) @@ -1086,7 +1148,7 @@ export function PvpStadiumScreen({ .then((snapshot) => { if (stopped) return const opponentState = snapshot.states[liveMatch.opponentSide] - if (opponentState) { + if (opponentState && opponentState.roundIndex >= roundIndex) { cpuRef.current = opponentState setCpuSide(opponentState) } @@ -1094,6 +1156,7 @@ export function PvpStadiumScreen({ if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost') if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won') if (!opponentState) return + if (opponentState.roundIndex < roundIndex) return if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) { const key = `${roundIndex}-${opponentState.lastRoundOutcome}` if (loggedOpponentRoundRef.current === key) return @@ -1102,7 +1165,14 @@ export function PvpStadiumScreen({ else if (opponentState.lastRoundOutcome === 'win') finishRound('loss') else finishRound('tie') } - if (status === 'shop' && shopReady && opponentState.roundIndex === roundIndex && opponentState.shopReady) { + if ( + status === 'shop' + && shopReady + && ( + (opponentState.roundIndex === roundIndex && opponentState.shopReady) + || opponentState.roundIndex > roundIndex + ) + ) { startNextRound() } }) @@ -1178,15 +1248,31 @@ export function PvpStadiumScreen({ window.requestAnimationFrame(() => focusFirstControl()) }, [status]) + useEffect(() => { + if (!paused) return + window.requestAnimationFrame(() => focusFirstControl()) + }, [paused]) + useGameAction((action) => { - if (action === 'pause') { + if (action === 'pause' || action === 'back') { if (status === 'playing') setPaused((value) => !value) return } + if (paused || status !== 'playing') return + if (action.startsWith('navigate')) { + selectDirectionalTarget(action) + return + } + if (action === 'previousTarget') { + selectRelativeTarget(-1) + return + } + if (action === 'nextTarget') { + selectRelativeTarget(1) + return + } if (action.startsWith('targetParty')) { - const index = Number(action.slice('targetParty'.length)) - 1 - const member = playerRef.current.party[index] - if (member?.health > 0) setSelectedTargetId(member.id) + selectDirectTarget(Number(action.slice('targetParty'.length)) - 1) return } if (action.startsWith('ability')) { diff --git a/src/input.tsx b/src/input.tsx index bee8e94..d42d1ba 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -125,6 +125,9 @@ 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 FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + +let lastControllerFocus: HTMLElement | null = null type CaptureState = { device: InputDevice @@ -234,25 +237,41 @@ function focusableElements() { ).find(isVisible) const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document return Array.from( - scope.querySelectorAll( - 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', - ), + scope.querySelectorAll(FOCUSABLE_SELECTOR), ).filter(isVisible) } +function rememberFocusableControl(element: HTMLElement) { + lastControllerFocus = element +} + +function focusControl(element: HTMLElement) { + rememberFocusableControl(element) + element.focus({ preventScroll: true }) +} + +function currentFocusableControl(candidates = focusableElements()) { + const active = document.activeElement + if (active instanceof HTMLElement && candidates.includes(active)) { + rememberFocusableControl(active) + return active + } + if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) { + return lastControllerFocus + } + return null +} + export function focusFirstControl() { const first = focusableElements()[0] - first?.focus({ preventScroll: true }) + if (first) focusControl(first) return first } function moveFocus(action: InputAction) { const candidates = focusableElements() if (candidates.length === 0) return - const current = document.activeElement instanceof HTMLElement - && candidates.includes(document.activeElement) - ? document.activeElement - : null + const current = currentFocusableControl(candidates) if (!current) { focusFirstControl() return @@ -279,7 +298,7 @@ function moveFocus(action: InputAction) { const next = ranked[0]?.candidate if (!next) return - next.focus({ preventScroll: true }) + focusControl(next) } function hasUiOverlay() { @@ -456,12 +475,12 @@ export function InputProvider({ children }: { children: ReactNode }) { if (action.startsWith('navigate')) { if (uiOverlay || !combatActive) moveFocus(action) } else if (action === 'confirm') { - const active = document.activeElement + const active = currentFocusableControl() if (isTextInput(active)) { setKeyboardInput(active) window.requestAnimationFrame(() => focusFirstControl()) } else if ( - active instanceof HTMLElement + active && active.matches('button:not(:disabled), [role="button"]') && isVisible(active) ) { @@ -581,6 +600,29 @@ export function InputProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('keydown', onKeyDown) }, [assignBinding, dispatchAction]) + useEffect(() => { + const onFocusIn = (event: FocusEvent) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return + rememberFocusableControl(target) + } + const onPointerDown = (event: PointerEvent) => { + document.documentElement.dataset.inputDevice = 'pc' + const target = event.target + if (!(target instanceof Element)) return + const control = target.closest(FOCUSABLE_SELECTOR) + if (!control || !isVisible(control)) return + rememberFocusableControl(control) + } + document.addEventListener('focusin', onFocusIn) + document.addEventListener('pointerdown', onPointerDown, { capture: true }) + return () => { + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('pointerdown', onPointerDown, { capture: true }) + } + }, []) + useEffect(() => { const listener = (event: Event) => { const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail @@ -595,16 +637,17 @@ export function InputProvider({ children }: { children: ReactNode }) { const combatActive = document.querySelector('[data-combat-active="true"]') if (combatActive) return const candidates = focusableElements() - const active = document.activeElement - const activeIsUsable = active instanceof HTMLElement - && candidates.includes(active) - && isVisible(active) + const activeControl = currentFocusableControl(candidates) if ( - (!activeIsUsable || document.activeElement === document.body) + (!activeControl || document.activeElement === document.body) && !keyboardInputRef.current && !captureRef.current ) { - focusFirstControl() + if (activeControl) { + focusControl(activeControl) + } else { + focusFirstControl() + } } } const observer = new MutationObserver(() => {