Android build v1.1.6

This commit is contained in:
Warren H
2026-06-30 13:59:36 -04:00
parent f6d408f594
commit df6306fe7a
10 changed files with 147 additions and 37 deletions
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 84 versionCode 85
versionName "1.1.5" versionName "1.1.6"
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.
@@ -18,8 +18,8 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; 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 PREFS_NAME = "com.warren.iwanttoheal.performance";
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version"; private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
private static final long DPAD_THROTTLE_MS = 220; private static final long DPAD_THROTTLE_MS = 55;
private long lastDpadDispatchAt = 0; private final long[] lastDpadDispatchAt = new long[16];
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
@@ -139,7 +139,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true; if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
String script = String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -153,10 +153,11 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
return true; return true;
} }
private boolean shouldThrottleDpad() { private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true; if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now; lastDpadDispatchAt[buttonIndex] = now;
return false; return false;
} }
+3
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './App.css' import './App.css'
import { AdminScreen } from './components/AdminScreen' import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<InputProvider>
<AdminScreen onBack={() => window.close()} /> <AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>, </StrictMode>,
) )
+1
View File
@@ -1509,6 +1509,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive} canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+1
View File
@@ -1289,6 +1289,7 @@ export function PvpStadiumScreen({
bindings={activeBindings} bindings={activeBindings}
canCast={status === 'playing' && playerAlive} canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar" className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle} iconStyle={controllerIconStyle}
onCast={castPlayerSpell} onCast={castPlayerSpell}
resource={playerSide.resource} resource={playerSide.resource}
+7
View File
@@ -40,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle, iconStyle,
resourceName, resourceName,
disabled, disabled,
focusable = true,
onCast, onCast,
emptyKeyPrefix = 'empty', emptyKeyPrefix = 'empty',
}: { }: {
@@ -48,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle iconStyle: ControllerIconStyle
resourceName: string resourceName: string
disabled?: boolean disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
emptyKeyPrefix?: string emptyKeyPrefix?: string
}) { }) {
@@ -61,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return ( return (
<button <button
className="spell" className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled} disabled={disabled}
key={spell.id} key={spell.id}
onClick={() => onCast(spell)} onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description} title={spell.description}
type="button" type="button"
> >
@@ -95,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast, canCast,
onCast, onCast,
className = 'spell-bar six-slots vertical-spell-bar', className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: { }: {
spells: SpellSlot[] spells: SpellSlot[]
bindings: Record<string, string> bindings: Record<string, string>
@@ -104,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean canCast: boolean
onCast: (spell: Spell) => void onCast: (spell: Spell) => void
className?: string className?: string
focusable?: boolean
}) { }) {
return ( return (
<div className={className}> <div className={className}>
@@ -119,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton <SpellButton
binding={bindings[`ability${slotIndex + 1}`]} binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0} disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle} iconStyle={iconStyle}
key={spell.id} key={spell.id}
onCast={onCast} onCast={onCast}
+12 -1
View File
@@ -19,6 +19,7 @@ import {
dispatchExternalGameAction, dispatchExternalGameAction,
type ControllerIconStyle, type ControllerIconStyle,
type InputAction, type InputAction,
useGameAction,
} from './input' } from './input'
import { ControllerBindingLabel } from './components/ControllerIcons' import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames' import { PartyMemberFrame } from './components/PartyFrames'
@@ -402,6 +403,12 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
useGameAction((action) => {
if (!state || state.opponentParty) return
if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return
sendAction(action)
})
if (!state && workshopState) { if (!state && workshopState) {
return ( return (
<main className="dual-bottom-display workshop-bottom-display"> <main className="dual-bottom-display workshop-bottom-display">
@@ -449,7 +456,10 @@ export function DualScreenBottomDisplay() {
} }
return ( return (
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}> <main
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
data-combat-active={!state.opponentParty && state.status === 'playing' && !state.paused ? 'true' : 'false'}
>
<header className="dual-controls-header"> <header className="dual-controls-header">
<div> <div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p> <p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -649,6 +659,7 @@ export function DualScreenTopCombat({
return ( return (
<button <button
className="dual-top-spell" className="dual-top-spell"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
disabled={ disabled={
!state.playerIsAlive !state.playerIsAlive
|| state.resource < spell.cost || state.resource < spell.cost
+110 -26
View File
@@ -130,10 +130,20 @@ 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 FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' 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 = '.menu-screen, .content-screen, .dungeon-run-screen'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30 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_MS = 85
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null let lastControllerFocus: HTMLElement | null = null
@@ -256,6 +266,16 @@ function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0 return element.getClientRects().length > 0
} }
function focusableDescendants(scope: ParentNode) {
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function uniqueElements(elements: HTMLElement[]) {
return elements.filter((element, index) => elements.indexOf(element) === index)
}
function focusableElements() { function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard') const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen') const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
@@ -264,10 +284,22 @@ function focusableElements() {
'.result-screen, .binding-capture, .dual-startup-prompt', '.result-screen, .binding-capture, .dual-startup-prompt',
), ),
).find(isVisible) ).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document const mainContent = Array.from(
return Array.from( document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR), ).find(isVisible)
const overlay = keyboard ?? pauseMenu ?? dialog
if (overlay) return focusableDescendants(overlay)
const visibleHeaders = Array.from(
document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
).filter(isVisible) ).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) { function rememberFocusableControl(element: HTMLElement) {
@@ -291,6 +323,23 @@ function currentFocusableControl(candidates = focusableElements()) {
return null 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() { export function focusFirstControl() {
const first = focusableElements()[0] const first = focusableElements()[0]
if (first) focusControl(first) if (first) focusControl(first)
@@ -311,21 +360,48 @@ function moveFocus(action: InputAction) {
const currentY = currentRect.top + currentRect.height / 2 const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown' const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 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 const ranked = candidates
.filter((candidate) => candidate !== current) .filter((candidate) => candidate !== current)
.map((candidate) => { .map((candidate, index) => {
const rect = candidate.getBoundingClientRect() const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2 const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2 const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX const primary = vertical
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY) ? direction > 0
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 } ? 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) .filter(({ primary }) => primary >= -4)
.sort((a, b) => a.score - b.score) 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 if (!next) return
focusControl(next) focusControl(next)
} }
@@ -524,12 +600,26 @@ export function InputProvider({ children }: { children: ReactNode }) {
setTouchLockMessage('') setTouchLockMessage('')
}, 1400) }, 1400)
} else if (action.startsWith('navigate')) { } 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') { } else if (action === 'confirm') {
const active = currentFocusableControl() const active = currentFocusableControl()
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) 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 ( } else if (
active active
&& active.matches('button:not(:disabled), [role="button"]') && active.matches('button:not(:disabled), [role="button"]')
@@ -566,12 +656,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'), document.querySelector('[data-combat-active="true"]'),
) )
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const uiPriority = [ const uiPriority = [
'navigateUp', 'navigateUp',
'navigateDown', 'navigateDown',
@@ -608,18 +692,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft', 'navigateLeft',
'navigateRight', 'navigateRight',
] satisfies InputAction[] ] satisfies InputAction[]
const action = menuDpadActions[token] && (!combatActive || uiOverlay) const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: uiOverlay : uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token) ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting : combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find( ? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
: combatActive && menuDpadActions[token] : combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: !combatActive && menuDpadActions[token] : !combatActive && DPAD_NAV_ACTIONS[token]
? menuDpadActions[token] ? DPAD_NAV_ACTIONS[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find( : (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
@@ -768,11 +852,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
const action = INPUT_ACTIONS.find( const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token, (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 const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) { if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed) dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125) repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
} }
}) })
+2
View File
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? ( {new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<InputProvider>
<DualScreenBottomDisplay /> <DualScreenBottomDisplay />
</InputProvider>
) : ( ) : (
<DualScreenProvider> <DualScreenProvider>
<DualScreenStartupPrompt /> <DualScreenStartupPrompt />