diff --git a/IWantToHeal-Thor-v1.1.19.apk b/IWantToHeal-Thor-v1.1.19.apk new file mode 100644 index 0000000..d9f734e Binary files /dev/null and b/IWantToHeal-Thor-v1.1.19.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index e11a133..087195f 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 97 - versionName "1.1.18" + versionCode 98 + versionName "1.1.19" 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/src/App.css b/src/App.css index f606e26..b4cfdbb 100644 --- a/src/App.css +++ b/src/App.css @@ -145,6 +145,8 @@ } .iwt2-boss-hud { + display: grid; + gap: 6px; left: 50%; min-width: 310px; padding: 8px 12px 10px; @@ -155,7 +157,7 @@ z-index: 4; } -.iwt2-boss-hud > div:first-child { +.iwt2-boss-hud-row > div:first-child { align-items: baseline; display: flex; gap: 12px; @@ -2137,7 +2139,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo } .pvp-opponent-bottom-display { - grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; } .dual-controls-header, @@ -2172,6 +2174,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo } .dual-opponent-progress, +.dual-opponent-arena, .dual-opponent-effects { background: var(--panel); border: 3px solid #0c0d11; @@ -2195,6 +2198,53 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo height: 22px; } +.dual-opponent-arena { + padding: 7px; +} + +.dual-opponent-arena-field { + background: + linear-gradient(rgba(23, 29, 36, 0.96), rgba(23, 29, 36, 0.96)), + repeating-linear-gradient(0deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px), + repeating-linear-gradient(90deg, transparent 0 39px, rgba(90, 108, 126, 0.28) 40px); + border: 2px solid #3c4a59; + border-radius: 6px; + max-height: 138px; + min-height: 112px; + overflow: hidden; + position: relative; + width: 100%; +} + +.dual-opponent-arena-entity { + align-items: center; + border: 2px solid #0a0c10; + border-radius: 999px; + color: #fff7df; + display: flex; + font-family: ui-monospace, Consolas, monospace; + font-size: 10px; + font-weight: 900; + justify-content: center; + line-height: 1; + min-height: 12px; + min-width: 12px; + position: absolute; + transform: translate(-50%, -50%); +} + +.dual-opponent-arena-entity.boss { + border-color: #fff0b8; + border-radius: 45%; + font-size: 12px; + min-height: 18px; + min-width: 24px; +} + +.dual-opponent-arena-entity.healer { + box-shadow: 0 0 0 3px rgba(255, 244, 168, 0.84); +} + .dual-opponent-party-grid { background: var(--panel); border: 3px solid #0c0d11; @@ -2567,7 +2617,7 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo } .pvp-opponent-bottom-display { - grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; } .dual-opponent-progress { @@ -2590,6 +2640,16 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo height: 16px; } + .dual-opponent-arena { + border-width: 2px; + padding: 5px; + } + + .dual-opponent-arena-field { + max-height: 112px; + min-height: 92px; + } + .dual-opponent-party-grid { border-width: 2px; gap: 6px; diff --git a/src/App.tsx b/src/App.tsx index c7a509f..b0b6b5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,12 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { AuthScreen } from './components/AuthScreen' import IWantToHeal1App from './modes/iwt1/IWantToHeal1App' import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' import { useGameAction } from './input' +import { + loadAuthSession, + type AuthSession, +} from './profile' type GameVersion = 'iwt1' | 'iwt2' @@ -31,9 +36,46 @@ const GAME_OPTIONS: Array<{ function App() { const [selectedVersion, setSelectedVersion] = useState(null) const [selectedIndex, setSelectedIndex] = useState(0) + const [authSession, setAuthSession] = useState(null) + const [authChecked, setAuthChecked] = useState(false) + const [serverMessage, setServerMessage] = useState('') + + useEffect(() => { + let cancelled = false + loadAuthSession() + .then((session) => { + if (cancelled) return + setAuthSession(session.account && session.profile ? session : null) + }) + .catch((reason: unknown) => { + if (cancelled) return + setServerMessage( + reason instanceof Error + ? `${reason.message} Offline play is still available.` + : 'Unable to reach the server. Offline play is still available.', + ) + }) + .finally(() => { + if (!cancelled) setAuthChecked(true) + }) + return () => { + cancelled = true + } + }, []) + + const acceptSession = useCallback((session: AuthSession) => { + setAuthSession(session) + setSelectedVersion(null) + setServerMessage('') + }, []) + + const clearAuthSession = useCallback(() => { + setAuthSession(null) + setSelectedVersion(null) + }, []) useGameAction((action, device) => { - if (selectedVersion || device !== 'controller') return + if (!authSession || selectedVersion || device !== 'controller') return if (action === 'navigateLeft' || action === 'navigateUp') { setSelectedIndex((current) => Math.max(0, current - 1)) } else if (action === 'navigateRight' || action === 'navigateDown') { @@ -43,8 +85,34 @@ function App() { } }) + if (!authChecked) { + return ( +
+
+

Opening Chronicle

+

Loading...

+
+
+ ) + } + + if (!authSession) { + return ( + + ) + } + if (selectedVersion === 'iwt1') { - return setSelectedVersion(null)} /> + return ( + setSelectedVersion(null)} + /> + ) } if (selectedVersion === 'iwt2') { diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index 73152b1..57e3089 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -49,6 +49,11 @@ export type DualScreenCombatState = { opponentClassName?: string opponentParty?: PartyMember[] opponentEnemyHealth?: number + opponentArena?: { + bounds: { width: number, height: number } + bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }> + party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }> + } opponentResource?: number opponentMaxResource?: number opponentResourceName?: string @@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() { )} + {state.opponentArena && ( +
+
+ {state.opponentArena.bosses.map((boss) => ( +
+ {boss.icon} +
+ ))} + {state.opponentArena.party.map((member) => ( +
0 ? 1 : 0.35, + top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`, + width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`, + }} + > + {member.icon} +
+ ))} +
+
+ )} +
6 ? 'raid' : ''}`}> {state.opponentParty.map((member) => ( void }) { +type IWantToHeal1AppProps = { + initialSession: AuthSession + onAuthenticationCleared: () => void + onBackToGameSelect: () => void +} + +function IWantToHeal1App({ + initialSession, + onAuthenticationCleared, + onBackToGameSelect, +}: IWantToHeal1AppProps) { const { enabled: dualScreenEnabled } = useDualScreen() const [screen, setScreen] = useState('menu') - const [account, setAccount] = useState(null) - const [profile, setProfile] = useState(null) + const [account, setAccount] = useState(initialSession.account) + const [profile, setProfile] = useState(initialSession.profile) const [authChecked, setAuthChecked] = useState(false) const [gameMode, setGameMode] = useState(getGameMode()) const [serverMessage, setServerMessage] = useState('') @@ -186,6 +195,10 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi .finally(() => setAuthChecked(true)) }, []) + useEffect(() => { + if (authChecked && (!account || !profile)) onAuthenticationCleared() + }, [account, authChecked, onAuthenticationCleared, profile]) + useEffect(() => { const handleModeChange = (event: Event) => { const nextMode = (event as CustomEvent).detail @@ -350,18 +363,6 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], ) - function acceptSession(session: AuthSession) { - setAccount(session.account) - setProfile(session.profile) - setGameMode(getGameMode()) - setScreen('menu') - setError('') - setServerMessage('') - window.requestAnimationFrame(() => { - focusFirstControl() - }) - } - async function signOut() { try { await logoutAccount() @@ -371,6 +372,7 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi setScreen('menu') setSyncMessage('') setSyncComparison(null) + onAuthenticationCleared() } catch (reason) { setError(reason instanceof Error ? reason.message : 'Unable to sign out.') } @@ -832,10 +834,13 @@ function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => voi if (!account || !profile) { return ( - +
+
+

Opening Chronicle

+

Returning to Sign In...

+ {serverMessage &&

{serverMessage}

} +
+
) } diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index 73c7601..f7cc821 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -1,5 +1,7 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useGameAction } from '../../input' +import { getGameMode } from '../../gameRepository' +import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle' import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades' import { useDualScreen, @@ -54,6 +56,7 @@ const IWT2_MENU_COLUMNS = 4 const IWT2_ROGUELIKE_CHOICE_COUNT = 3 type Iwt2RoguelikeRunState = { + bossIds: Iwt2BossId[] buffs: Iwt2RoguelikeSelfBuffId[] contentType: Iwt2RoguelikeContentType debuffs: Iwt2RoguelikeOpponentDebuffId[] @@ -129,11 +132,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () const [roguelikeVariant, setRoguelikeVariant] = useState('pve') const [roguelikeContentType, setRoguelikeContentType] = useState('dungeon') const [roguelikeRun, setRoguelikeRun] = useState(null) + const [pvpQueueMessage, setPvpQueueMessage] = useState('') + const cancelPvpQueueRef = useRef<(() => void) | null>(null) useEffect(() => { writeIwt2Save(save) }, [save]) + useEffect(() => () => { + cancelPvpQueueRef.current?.() + }, []) + const setupDualScreenState = useMemo( () => buildIwt2SetupDualScreenState(screen, selectedBossId, save), [save, screen, selectedBossId], @@ -182,6 +191,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () return ( setScreen('menu')} @@ -193,7 +203,9 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () if (screen === 'roguelike-arena' && roguelikeRun) { return ( { const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save) setRoguelikeRun(nextRun) - setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage)) + setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome') setScreen('roguelike-arena') }} /> @@ -267,17 +279,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () onBack={() => setScreen('menu')} onContentTypeChange={setRoguelikeContentType} onStart={() => { - const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType) - setRoguelikeRun(nextRun) - setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage)) - setScreen('roguelike-arena') + startIwt2RoguelikeRun() }} onVariantChange={(nextVariant) => { + cancelPvpQueueRef.current?.() + cancelPvpQueueRef.current = null + setPvpQueueMessage('') setRoguelikeVariant(nextVariant) if (nextVariant === 'pve' && roguelikeContentType === 'stadium') { setRoguelikeContentType('dungeon') } }} + queueMessage={pvpQueueMessage} /> ) @@ -359,6 +372,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()