diff --git a/IWantToHeal-Thor-v1.1.6.apk b/IWantToHeal-Thor-v1.1.6.apk new file mode 100644 index 0000000..f97238b Binary files /dev/null and b/IWantToHeal-Thor-v1.1.6.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 4206613..6902e40 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 84 - versionName "1.1.5" + versionCode 85 + versionName "1.1.6" 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 3949d12..727e9e5 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -18,8 +18,8 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; private static final String PREFS_NAME = "com.warren.iwanttoheal.performance"; private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version"; - private static final long DPAD_THROTTLE_MS = 220; - private long lastDpadDispatchAt = 0; + private static final long DPAD_THROTTLE_MS = 55; + private final long[] lastDpadDispatchAt = new long[16]; @Override public void onCreate(Bundle savedInstanceState) { @@ -139,7 +139,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { if (event.getAction() == KeyEvent.ACTION_DOWN) { boolean repeat = event.getRepeatCount() > 0; - if (isDpadToken(token) && shouldThrottleDpad()) return true; + if (isDpadToken(token) && shouldThrottleDpad(token)) return true; String script = "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; @@ -153,10 +153,11 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { return true; } - private boolean shouldThrottleDpad() { + private boolean shouldThrottleDpad(String token) { + int buttonIndex = Integer.parseInt(token.substring("Button".length())); long now = SystemClock.uptimeMillis(); - if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; - lastDpadDispatchAt = now; + if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true; + lastDpadDispatchAt[buttonIndex] = now; return false; } diff --git a/src/admin.tsx b/src/admin.tsx index d314694..ee35ec2 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client' import './index.css' import './App.css' import { AdminScreen } from './components/AdminScreen' +import { InputProvider } from './input' createRoot(document.getElementById('root')!).render( - window.close()} /> + + window.close()} /> + , ) diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index 93bdb02..fd6ac34 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -1509,6 +1509,7 @@ export function PvPRoguelikeScreen({ bindings={activeBindings} canCast={status === 'playing' && !playerDone && playerAlive} className="pvp-bottom-spell-bar" + focusable={false} iconStyle={controllerIconStyle} onCast={castPlayerSpell} resource={playerSide.resource} diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index ab6125b..c908160 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -1289,6 +1289,7 @@ export function PvpStadiumScreen({ bindings={activeBindings} canCast={status === 'playing' && playerAlive} className="pvp-bottom-spell-bar" + focusable={false} iconStyle={controllerIconStyle} onCast={castPlayerSpell} resource={playerSide.resource} diff --git a/src/components/SpellBars.tsx b/src/components/SpellBars.tsx index de7cef7..a97cd9b 100644 --- a/src/components/SpellBars.tsx +++ b/src/components/SpellBars.tsx @@ -40,6 +40,7 @@ export const SpellButton = memo(function SpellButton({ iconStyle, resourceName, disabled, + focusable = true, onCast, emptyKeyPrefix = 'empty', }: { @@ -48,6 +49,7 @@ export const SpellButton = memo(function SpellButton({ iconStyle: ControllerIconStyle resourceName: string disabled?: boolean + focusable?: boolean onCast: (spell: Spell) => void emptyKeyPrefix?: string }) { @@ -61,9 +63,11 @@ export const SpellButton = memo(function SpellButton({ return ( onCast(spell)} + tabIndex={focusable ? undefined : -1} title={spell.description} type="button" > @@ -95,6 +99,7 @@ export const SpellBar = memo(function SpellBar({ canCast, onCast, className = 'spell-bar six-slots vertical-spell-bar', + focusable = true, }: { spells: SpellSlot[] bindings: Record @@ -104,6 +109,7 @@ export const SpellBar = memo(function SpellBar({ canCast: boolean onCast: (spell: Spell) => void className?: string + focusable?: boolean }) { return ( @@ -119,6 +125,7 @@ export const SpellBar = memo(function SpellBar({ 0} + focusable={focusable} iconStyle={iconStyle} key={spell.id} onCast={onCast} diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index 024df2f..01d058a 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -19,6 +19,7 @@ import { dispatchExternalGameAction, type ControllerIconStyle, type InputAction, + useGameAction, } from './input' import { ControllerBindingLabel } from './components/ControllerIcons' import { PartyMemberFrame } from './components/PartyFrames' @@ -402,6 +403,12 @@ export function DualScreenBottomDisplay() { channel?.close() } + useGameAction((action) => { + if (!state || state.opponentParty) return + if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return + sendAction(action) + }) + if (!state && workshopState) { return ( @@ -449,7 +456,10 @@ export function DualScreenBottomDisplay() { } return ( - + {state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`} @@ -649,6 +659,7 @@ export function DualScreenTopCombat({ return ( > = { + Button12: 'navigateUp', + Button13: 'navigateDown', + Button14: 'navigateLeft', + Button15: 'navigateRight', +} let lastControllerFocus: HTMLElement | null = null @@ -256,6 +266,16 @@ function isVisible(element: HTMLElement) { return element.getClientRects().length > 0 } +function focusableDescendants(scope: ParentNode) { + return Array.from( + scope.querySelectorAll(FOCUSABLE_SELECTOR), + ).filter(isVisible) +} + +function uniqueElements(elements: HTMLElement[]) { + return elements.filter((element, index) => elements.indexOf(element) === index) +} + function focusableElements() { const keyboard = document.querySelector('.controller-keyboard') const pauseMenu = document.querySelector('.pause-screen') @@ -264,10 +284,22 @@ function focusableElements() { '.result-screen, .binding-capture, .dual-startup-prompt', ), ).find(isVisible) - const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document - return Array.from( - scope.querySelectorAll(FOCUSABLE_SELECTOR), + const mainContent = Array.from( + document.querySelectorAll(MAIN_CONTENT_SELECTOR), + ).find(isVisible) + const overlay = keyboard ?? pauseMenu ?? dialog + if (overlay) return focusableDescendants(overlay) + const visibleHeaders = Array.from( + document.querySelectorAll(HEADER_CONTENT_SELECTOR), ).filter(isVisible) + if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document) + const headerControls = visibleHeaders.flatMap( + (header) => focusableDescendants(header), + ) + return uniqueElements([ + ...focusableDescendants(mainContent), + ...headerControls, + ]) } function rememberFocusableControl(element: HTMLElement) { @@ -291,6 +323,23 @@ function currentFocusableControl(candidates = focusableElements()) { return null } +function rangeDistance(startA: number, endA: number, startB: number, endB: number) { + if (endA < startB) return startB - endA + if (endB < startA) return startA - endB + return 0 +} + +function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) { + const options = Array.from(select.options).filter((option) => !option.disabled) + const currentIndex = options.findIndex((option) => option.index === select.selectedIndex) + const nextOption = options[currentIndex + direction] + if (!nextOption) return false + select.selectedIndex = nextOption.index + select.dispatchEvent(new Event('input', { bubbles: true })) + select.dispatchEvent(new Event('change', { bubbles: true })) + return true +} + export function focusFirstControl() { const first = focusableElements()[0] if (first) focusControl(first) @@ -311,21 +360,48 @@ function moveFocus(action: InputAction) { const currentY = currentRect.top + currentRect.height / 2 const vertical = action === 'navigateUp' || action === 'navigateDown' const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 + const currentIndex = candidates.indexOf(current) + + const adjacent = candidates[currentIndex + direction] + const fallbackToAdjacent = () => { + if (adjacent) focusControl(adjacent) + } const ranked = candidates .filter((candidate) => candidate !== current) - .map((candidate) => { + .map((candidate, index) => { const rect = candidate.getBoundingClientRect() const x = rect.left + rect.width / 2 const y = rect.top + rect.height / 2 - const primary = vertical ? y - currentY : x - currentX - const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY) - return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 } + const primary = vertical + ? direction > 0 + ? rect.top - currentRect.bottom + : currentRect.top - rect.bottom + : direction > 0 + ? rect.left - currentRect.right + : currentRect.left - rect.right + const secondary = vertical + ? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right) + : rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom) + return { + candidate, + index, + primary, + secondary, + score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1, + } }) - .filter(({ primary }) => Math.sign(primary) === direction) - .sort((a, b) => a.score - b.score) + .filter(({ primary }) => primary >= -4) + const sameRowHorizontal = !vertical + ? ranked.filter(({ secondary }) => secondary === 0) + : ranked + if (sameRowHorizontal.length === 0) { + fallbackToAdjacent() + return + } + sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index) - const next = ranked[0]?.candidate + const next = sameRowHorizontal[0]?.candidate if (!next) return focusControl(next) } @@ -524,12 +600,26 @@ export function InputProvider({ children }: { children: ReactNode }) { setTouchLockMessage('') }, 1400) } else if (action.startsWith('navigate')) { - if (uiOverlay || !combatActive) moveFocus(action) + if (uiOverlay || !combatActive) { + const active = currentFocusableControl() + if ( + active instanceof HTMLSelectElement + && (action === 'navigateUp' || action === 'navigateDown') + && changeSelectOption(active, action === 'navigateUp' ? -1 : 1) + ) { + return + } + moveFocus(action) + } } else if (action === 'confirm') { const active = currentFocusableControl() if (isTextInput(active)) { setKeyboardInput(active) window.requestAnimationFrame(() => focusFirstControl()) + } else if (active instanceof HTMLSelectElement) { + const select = active as HTMLSelectElement & { showPicker?: () => void } + if (select.showPicker) select.showPicker() + else active.click() } else if ( active && active.matches('button:not(:disabled), [role="button"]') @@ -566,12 +656,6 @@ export function InputProvider({ children }: { children: ReactNode }) { document.querySelector('[data-combat-active="true"]'), ) const uiOverlay = hasUiOverlay() - const menuDpadActions: Partial> = { - Button12: 'navigateUp', - Button13: 'navigateDown', - Button14: 'navigateLeft', - Button15: 'navigateRight', - } const uiPriority = [ 'navigateUp', 'navigateDown', @@ -608,18 +692,18 @@ export function InputProvider({ children }: { children: ReactNode }) { 'navigateLeft', 'navigateRight', ] satisfies InputAction[] - const action = menuDpadActions[token] && (!combatActive || uiOverlay) - ? menuDpadActions[token] + const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay) + ? DPAD_NAV_ACTIONS[token] : uiOverlay ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) : combatActive && preferencesRef.current.directPartyTargeting ? [...directTargetActions, ...combatPriority].find( (candidate) => bindingsRef.current.controller[candidate] === token, ) - : combatActive && menuDpadActions[token] - ? menuDpadActions[token] - : !combatActive && menuDpadActions[token] - ? menuDpadActions[token] + : combatActive && DPAD_NAV_ACTIONS[token] + ? DPAD_NAV_ACTIONS[token] + : !combatActive && DPAD_NAV_ACTIONS[token] + ? DPAD_NAV_ACTIONS[token] : (combatActive ? combatPriority : INPUT_ACTIONS).find( (candidate) => bindingsRef.current.controller[candidate] === token, ) @@ -768,11 +852,11 @@ export function InputProvider({ children }: { children: ReactNode }) { const action = INPUT_ACTIONS.find( (candidate) => bindingsRef.current.controller[candidate] === token, ) - const canRepeat = action?.startsWith('navigate') ?? false + const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token]) const nextRepeat = repeatRef.current[token] ?? 0 if (pressed || (canRepeat && time >= nextRepeat)) { dispatchControllerToken(token, !pressed) - repeatRef.current[token] = time + (pressed ? 360 : 125) + repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS) } }) diff --git a/src/main.tsx b/src/main.tsx index 7f74c96..9924c37 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } createRoot(document.getElementById('root')!).render( {new URLSearchParams(window.location.search).get('display') === 'bottom' ? ( - + + + ) : (
{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}