diff --git a/IWantToHeal-Thor-v1.1.38.apk b/IWantToHeal-Thor-v1.1.38.apk new file mode 100644 index 0000000..061557e Binary files /dev/null and b/IWantToHeal-Thor-v1.1.38.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 7664bfa..0c9e253 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 118 - versionName "1.1.37" + versionCode 119 + versionName "1.1.38" 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/screenshots/iwt2-save-gateway-960x540.png b/screenshots/iwt2-save-gateway-960x540.png new file mode 100644 index 0000000..0b328d0 Binary files /dev/null and b/screenshots/iwt2-save-gateway-960x540.png differ diff --git a/src/App.css b/src/App.css index 4234b8b..a644c3f 100644 --- a/src/App.css +++ b/src/App.css @@ -1873,6 +1873,354 @@ } } +/* IWT2 startup save gateway */ +.save-gateway-shell { + align-items: stretch; + height: 100dvh; + min-height: 0; + overflow: auto; + padding: 14px; +} + +.save-gateway-panel { + background: rgba(17, 19, 25, 0.96); + border: 3px solid #08090c; + box-shadow: 8px 8px 0 #050609; + display: grid; + gap: 12px; + grid-template-rows: auto minmax(0, 1fr) auto; + margin: auto; + max-width: 1080px; + min-height: min(512px, calc(100dvh - 28px)); + outline: 2px solid #4a4653; + padding: 16px; + width: 100%; +} + +.save-gateway-heading { + align-items: end; + border-bottom: 2px solid #34343d; + display: flex; + gap: 22px; + justify-content: space-between; + padding: 0 2px 10px; +} + +.save-gateway-heading h1 { + color: var(--ink); + font-family: var(--pixel-font); + font-size: clamp(24px, 4vw, 38px); + line-height: 1; + margin: 4px 0 0; +} + +.save-gateway-heading > p { + color: var(--muted); + font-size: 14px; + line-height: 1.25; + margin: 0; + max-width: 440px; + text-align: right; +} + +.save-option-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + min-height: 0; +} + +.save-option-card { + background: #151921; + border: 2px solid #090a0d; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + outline: 2px solid #3a4250; + padding: 15px; +} + +.local-save-card { + border-top-color: #5eaf7e; +} + +.new-save-card { + border-top-color: #d8af58; +} + +.online-save-card { + border-top-color: #5799db; +} + +.save-option-heading { + align-items: center; + display: flex; + gap: 10px; +} + +.save-option-heading > span { + align-items: center; + background: #202938; + border: 2px solid #090a0d; + color: var(--gold); + display: inline-flex; + flex: 0 0 42px; + font-family: var(--pixel-font); + height: 42px; + justify-content: center; + outline: 1px solid #4c5667; +} + +.online-save-card .save-option-heading > span { + color: #80bdf2; +} + +.save-option-heading h2 { + color: var(--ink); + font-family: var(--pixel-font); + font-size: 15px; + line-height: 1.15; + margin: 3px 0 0; +} + +.save-character-name { + color: var(--ink); + display: block; + font-size: 19px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.save-metadata { + display: grid; + gap: 7px; + margin: 0; +} + +.save-metadata > div { + background: #101219; + border-left: 3px solid #4b5363; + display: grid; + gap: 3px; + padding: 7px 9px; +} + +.save-metadata dt { + color: var(--muted); + font-family: var(--pixel-font); + font-size: 7px; + text-transform: uppercase; +} + +.save-metadata dd { + color: var(--ink); + font-size: 14px; + line-height: 1.15; + margin: 0; +} + +.save-empty-state { + align-items: center; + background: #101219; + border-left: 3px solid #4b5363; + color: var(--muted); + display: flex; + flex: 1; + font-size: 14px; + line-height: 1.25; + margin: 0; + min-height: 72px; + padding: 12px; +} + +.save-continue-button, +.save-secondary-button { + border: 2px solid #090a0d; + cursor: pointer; + font-family: var(--pixel-font); + font-size: 8px; + line-height: 1.2; + min-height: 42px; + padding: 8px 10px; + width: 100%; +} + +.save-continue-button { + background: var(--gold); + color: #19150e; + margin-top: auto; + outline: 2px solid #816630; +} + +.save-continue-button.danger { + background: var(--red-bright); + color: #fff6ee; + outline-color: #8d2b38; +} + +.save-continue-button:disabled, +.save-secondary-button:disabled { + cursor: not-allowed; + filter: grayscale(0.8); + opacity: 0.45; +} + +.save-secondary-button { + background: #242630; + color: var(--muted); + outline: 2px solid #4b4855; +} + +.online-save-login { + display: grid; + gap: 9px; + margin-top: auto; +} + +.online-save-login label { + color: var(--muted); + display: grid; + font-family: var(--pixel-font); + font-size: 7px; + gap: 5px; + text-transform: uppercase; +} + +.online-save-login input { + background: #0e1016; + border: 2px solid #090a0d; + color: var(--ink); + font: 16px var(--body-font); + min-height: 38px; + outline: 2px solid #3e3d47; + padding: 7px 9px; + width: 100%; +} + +.online-save-actions { + display: grid; + gap: 7px; + margin-top: auto; +} + +.online-save-actions .save-continue-button { + margin-top: 0; +} + +.save-gateway-status { + color: #a9d7b8; + font-size: 13px; + line-height: 1.2; + margin: 0; + min-height: 16px; + padding: 0 2px; +} + +.save-gateway-status.error { + color: #ff8190; +} + +@media (max-width: 800px) { + .save-gateway-panel { + min-height: auto; + } + + .save-gateway-heading { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .save-gateway-heading > p { + text-align: left; + } + + .save-option-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1000px) and (max-height: 620px) { + .save-gateway-shell { + overflow: hidden; + padding: 10px; + } + + .save-gateway-panel { + gap: 9px; + min-height: calc(100dvh - 20px); + padding: 11px; + } + + .save-gateway-heading { + padding-bottom: 7px; + } + + .save-gateway-heading h1 { + font-size: 25px; + } + + .save-gateway-heading > p { + font-size: 12px; + } + + .save-option-grid { + gap: 9px; + } + + .save-option-card { + gap: 8px; + padding: 10px; + } + + .save-option-heading > span { + flex-basis: 36px; + height: 36px; + } + + .save-option-heading h2 { + font-size: 13px; + } + + .save-character-name { + font-size: 16px; + } + + .save-metadata { + gap: 5px; + } + + .save-metadata > div { + padding: 5px 7px; + } + + .save-metadata dd { + font-size: 12px; + } + + .save-continue-button, + .save-secondary-button { + min-height: 34px; + padding: 6px 8px; + } + + .online-save-login { + gap: 6px; + } + + .online-save-login input { + font-size: 14px; + min-height: 32px; + padding: 5px 7px; + } + + .save-gateway-status { + font-size: 11px; + } +} + .combat-touch-lock-status { background: #101216; border: 2px solid #d9b55a; diff --git a/src/App.tsx b/src/App.tsx index 19707ba..5e47ea6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,159 +1,27 @@ -import { useCallback, useEffect, useState } from 'react' +import { 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' +import type { Iwt2Save } from './modes/iwt2/save/iwt2Repository' -type GameVersion = 'iwt1' | 'iwt2' - -const GAME_OPTIONS: Array<{ - version: GameVersion - title: string - label: string - description: string - glyph: string -}> = [ - { - version: 'iwt1', - title: 'I Want To Heal 1', - label: 'Classic Healer Runs', - description: 'Original dungeon, raid, roguelike, PvP, gear, talents, and collection progression.', - glyph: 'I', - }, - { - version: 'iwt2', - title: 'I Want To Heal 2', - label: '2D Boss Arena', - description: 'Move through boss arenas with a visible party, analog movement, projectiles, and separate progression.', - glyph: 'II', - }, -] +type Iwt2Launch = { + onlineBackupsAvailable: boolean + save: Iwt2Save +} 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('') + const [launch, setLaunch] = useState(null) - 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 (!authSession || selectedVersion || device !== 'controller') return - if (action === 'navigateLeft' || action === 'navigateUp') { - setSelectedIndex((current) => Math.max(0, current - 1)) - } else if (action === 'navigateRight' || action === 'navigateDown') { - setSelectedIndex((current) => Math.min(GAME_OPTIONS.length - 1, current + 1)) - } else if (action === 'confirm') { - setSelectedVersion(GAME_OPTIONS[selectedIndex].version) - } - }) - - if (!authChecked) { - return ( -
-
-

Opening Chronicle

-

Loading...

-
-
- ) - } - - if (!authSession) { - return ( - - ) - } - - if (selectedVersion === 'iwt1') { - return ( - setSelectedVersion(null)} - /> - ) - } - - if (selectedVersion === 'iwt2') { + if (launch) { return ( setSelectedVersion(null)} + initialSave={launch.save} + onlineBackupsAvailable={launch.onlineBackupsAvailable} + onExitToSaveSelect={() => setLaunch(null)} /> ) } - return ( -
-
-
-

Select Game

-

I Want To Heal

-
-
- {GAME_OPTIONS.map((option, index) => ( - - ))} -
-
-
- ) + return } export default App diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index 2b15af3..cd6b337 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -1,192 +1,313 @@ -import { useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { + loadAuthSession, loginAccount, - registerAccount, - type AuthSession, + logoutAccount, + type Account, } from '../profile' +import { selectOfflineMode, selectOnlineMode } from '../gameRepository' import { - createOfflineCharacter, - hasOfflineCharacter, - resumeOfflineCharacter, - selectOnlineMode, -} from '../gameRepository' + createDefaultIwt2Save, + loadExistingIwt2Save, + loadIwt2OnlineSave, + replaceIwt2Save, + type Iwt2Save, +} from '../modes/iwt2/save/iwt2Repository' -type Props = { - onAuthenticated: (session: AuthSession) => void - serverMessage?: string +type Iwt2Launch = { + onlineBackupsAvailable: boolean + save: Iwt2Save } -export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) { - const [mode, setMode] = useState<'login' | 'register'>('login') +type Props = { + onContinue: (launch: Iwt2Launch) => void +} + +function formatSaveTimestamp(updatedAt: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(updatedAt)) +} + +function SaveMetadata({ save }: { save: Iwt2Save }) { + return ( +
+
+
Last updated
+
+ +
+
+
+
Character XP
+
{save.character.experience.toLocaleString()} XP
+
+
+ ) +} + +export function AuthScreen({ onContinue }: Props) { + const [localSave] = useState(loadExistingIwt2Save) + const newSave = useMemo(() => createDefaultIwt2Save(), []) + const [account, setAccount] = useState(null) + const [onlineSave, setOnlineSave] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [characterName, setCharacterName] = useState('') - const [offlineName, setOfflineName] = useState('') - const [busy, setBusy] = useState(false) - const [message, setMessage] = useState('') - const offlineCharacterExists = hasOfflineCharacter() + const [checkingSession, setCheckingSession] = useState(true) + const [checkingOnlineSave, setCheckingOnlineSave] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [confirmNewSave, setConfirmNewSave] = useState(false) + const [message, setMessage] = useState('Local play is ready while online saves are checked.') + const [messageIsError, setMessageIsError] = useState(false) - async function submit(event: React.FormEvent) { + const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => { + setCheckingOnlineSave(true) + try { + const result = await loadIwt2OnlineSave() + if (cancelled()) return + setOnlineSave(result.save) + setMessage(result.save ? 'Online save ready to load.' : 'Account has no online IWT2 save yet.') + setMessageIsError(false) + } catch (reason) { + if (cancelled()) return + setOnlineSave(null) + setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.') + setMessageIsError(true) + } finally { + if (!cancelled()) setCheckingOnlineSave(false) + } + }, []) + + useEffect(() => { + let cancelled = false + selectOnlineMode() + loadAuthSession() + .then((session) => { + if (cancelled || !session.account) return + setAccount(session.account) + return inspectOnlineSave(() => cancelled) + }) + .catch((reason: unknown) => { + if (cancelled) return + setMessage( + reason instanceof Error + ? `${reason.message} Local saves remain available.` + : 'Online service unavailable. Local saves remain available.', + ) + setMessageIsError(true) + }) + .finally(() => { + if (!cancelled) setCheckingSession(false) + }) + return () => { + cancelled = true + } + }, [inspectOnlineSave]) + + function continueLocal() { + if (!localSave) return + selectOfflineMode() + onContinue({ save: localSave, onlineBackupsAvailable: false }) + } + + function continueNew() { + if (localSave && !confirmNewSave) { + setConfirmNewSave(true) + setMessage('New save replaces current local file. Select again to confirm.') + setMessageIsError(false) + return + } + replaceIwt2Save(newSave) + selectOfflineMode() + onContinue({ save: newSave, onlineBackupsAvailable: false }) + } + + async function signIn(event: React.FormEvent) { event.preventDefault() - setBusy(true) - setMessage('') + setSubmitting(true) + setMessage('Signing in and checking online save...') + setMessageIsError(false) try { selectOnlineMode() - const session = mode === 'login' - ? await loginAccount(username, password) - : await registerAccount(username, password, characterName) - onAuthenticated(session) + const session = await loginAccount(username, password) + if (!session.account) throw new Error('Account session was not returned.') + setAccount(session.account) + await inspectOnlineSave() } catch (reason) { - setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.') + setAccount(null) + setOnlineSave(null) + setMessage(reason instanceof Error ? reason.message : 'Unable to sign in.') + setMessageIsError(true) } finally { - setBusy(false) + setSubmitting(false) } } - function beginOffline() { - setMessage('') + function continueOnline() { + if (!account || !onlineSave) return + selectOnlineMode() + onContinue({ save: onlineSave, onlineBackupsAvailable: true }) + } + + async function handleDifferentAccount() { + setSubmitting(true) try { - onAuthenticated(createOfflineCharacter(offlineName)) + await logoutAccount() + setAccount(null) + setOnlineSave(null) + setPassword('') + setMessage('Signed out. Enter account credentials to load another online save.') + setMessageIsError(false) } catch (reason) { - setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.') + setMessage(reason instanceof Error ? reason.message : 'Unable to sign out.') + setMessageIsError(true) + } finally { + setSubmitting(false) } } - function resumeOffline() { - const session = resumeOfflineCharacter() - if (session) onAuthenticated(session) - } + const onlineBusy = checkingSession || checkingOnlineSave || submitting return ( -
-
-
-

Healer RPG

-

I want to Heal

-

- Build your healer, master each dungeon, and compete for the most - efficient clears. -

-
- -
-
- - +
+
+
+
+

I Want To Heal 2

+

Choose Save

+

Compare last update and character XP before entering the arena.

+
-
- - {mode === 'register' && ( - - )} - - -
- -

- {message || serverMessage || ( - mode === 'register' - ? 'The first account keeps the current local character and save.' - : 'Sign in to continue your character.' - )} -

- -
or
- -
-
-

Local Save

-

Play Offline

-

- No account or connection required. Offline progress stays on - this device. -

+
+
+
+ +
+

Offline

+

Local Save

+
- {offlineCharacterExists && ( - + {localSave ? ( + <> + {localSave.character.name} + + + ) : ( +

No local IWT2 save found.

)} - -
+ + +
+
+ +
+

Offline

+

New Save

+
+
+ {newSave.character.name} + + +
+ +
+
+ +
+

Account

+

Online Save

+
+
+ + {account ? ( + <> + + {onlineSave?.character.name ?? account.username} + + {onlineSave ? ( + + ) : ( +

+ {onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'} +

+ )} +
+ + +
+ + ) : ( +
+ + + +
+ )} +
+ +

+ {message} +

) diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 686ef8e..ae246a6 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -1841,6 +1841,10 @@ export function selectOnlineMode() { writeMode('online') } +export function selectOfflineMode() { + writeMode('offline-local') +} + export function createOfflineCharacter(characterName: string): AuthSession { const name = characterName.trim() || 'Mira' if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { diff --git a/src/input.tsx b/src/input.tsx index d233e5a..10ea074 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -1043,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {

Controller Keyboard

- {keyboardInput.value || 'Enter text'} + + {keyboardInput.type === 'password' + ? '•'.repeat(keyboardInput.value.length) || 'Enter password' + : keyboardInput.value || 'Enter text'} +
diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index 98c9283..2c7093d 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -22,11 +22,7 @@ import { Iwt2RoguelikeUpgradeScreen, Iwt2SettingsScreen, } from './screens/Iwt2ShellScreens' -import { - loadIwt2Save, - writeIwt2Save, - type Iwt2Save, -} from './save/iwt2Repository' +import { writeIwt2Save, type Iwt2Save } from './save/iwt2Repository' import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses' import { iwt2BossCoinRewardFor } from './content/bossRewards' import { @@ -153,15 +149,17 @@ const MENU_ITEMS: Array<{ ] export function IWantToHeal2App({ + initialSave, onlineBackupsAvailable, - onBackToGameSelect, + onExitToSaveSelect, }: { + initialSave: Iwt2Save onlineBackupsAvailable: boolean - onBackToGameSelect: () => void + onExitToSaveSelect: () => void }) { const { enabled: dualScreenEnabled } = useDualScreen() const [screen, setScreen] = useState('menu') - const [save, setSave] = useState(loadIwt2Save) + const [save, setSave] = useState(initialSave) const [selectedIndex, setSelectedIndex] = useState(0) const [selectedBossId, setSelectedBossId] = useState('bulldrome') const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon') @@ -205,7 +203,7 @@ export function IWantToHeal2App({ useGameAction((action, device) => { if (screen !== 'menu' || device !== 'controller') return if (action === 'back') { - onBackToGameSelect() + onExitToSaveSelect() return } if (action === 'confirm') { @@ -288,7 +286,7 @@ export function IWantToHeal2App({ if (screen === 'roguelike-upgrade' && roguelikeRun) { return (
- + - + - + - + setScreen('menu')} - onBackToGameSelect={onBackToGameSelect} + onExitToSaveSelect={onExitToSaveSelect} /> - + setScreen('menu')} @@ -424,7 +422,7 @@ export function IWantToHeal2App({ if (screen === 'cloud-save') { return (
- + - + setScreen('menu')} />
) @@ -446,7 +444,7 @@ export function IWantToHeal2App({ return (
- + {screen === 'menu' && (
@@ -692,19 +690,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice) function Iwt2Header({ onBack, - onBackToGameSelect, + onExitToSaveSelect, save, title, }: { onBack?: () => void - onBackToGameSelect: () => void + onExitToSaveSelect: () => void save: Iwt2Save title?: string }) { return (
- {title && {title}}
diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts index 09a7b1b..9c05d47 100644 --- a/src/modes/iwt2/save/iwt2Repository.ts +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -130,7 +130,11 @@ function normalizeSave(value: unknown): Iwt2Save { if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save() return { version: 2, - updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(), + updatedAt: typeof candidate.updatedAt === 'number' + && Number.isFinite(candidate.updatedAt) + && candidate.updatedAt > 0 + ? candidate.updatedAt + : Date.now(), character: { name: candidate.character?.name || 'Healer', level: Math.max(1, Math.floor(candidate.character?.level ?? 1)), @@ -150,10 +154,16 @@ function normalizeSave(value: unknown): Iwt2Save { } export function loadIwt2Save(): Iwt2Save { + return loadExistingIwt2Save() ?? createDefaultIwt2Save() +} + +export function loadExistingIwt2Save(): Iwt2Save | null { + const serialized = window.localStorage.getItem(IWT2_SAVE_KEY) + if (!serialized) return null try { - return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null')) + return normalizeSave(JSON.parse(serialized)) } catch { - return createDefaultIwt2Save() + return null } } @@ -176,10 +186,11 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise