Compare commits

...
1 Commits
Author SHA1 Message Date
Warren H c052b086f8 Android build v1.1.18 2026-07-04 19:31:58 -04:00
5 changed files with 137 additions and 13 deletions
Binary file not shown.
+2 -2
View File
@@ -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.
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
}
Set<String> 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();
+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 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<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
@@ -1087,6 +1089,8 @@ export function useMovementVectorRef(enabled = true) {
let frame = 0
const pressedKeys = new Set<string>()
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
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<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) => {
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 }
+94 -6
View File
@@ -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<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId) : null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(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<ArenaStatus, 'playing'>) => {
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(', ')
}