diff --git a/IWantToHeal-Thor-v1.1.39.apk b/IWantToHeal-Thor-v1.1.39.apk new file mode 100644 index 0000000..09c8fff Binary files /dev/null and b/IWantToHeal-Thor-v1.1.39.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 0c9e253..ce2dc38 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 119 - versionName "1.1.38" + versionCode 120 + versionName "1.1.39" 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 index 0b328d0..98431a5 100644 Binary files a/screenshots/iwt2-save-gateway-960x540.png and b/screenshots/iwt2-save-gateway-960x540.png differ diff --git a/src/App.css b/src/App.css index a644c3f..cba6500 100644 --- a/src/App.css +++ b/src/App.css @@ -1888,7 +1888,7 @@ box-shadow: 8px 8px 0 #050609; display: grid; gap: 12px; - grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto auto; margin: auto; max-width: 1080px; min-height: min(512px, calc(100dvh - 28px)); @@ -1935,10 +1935,10 @@ border: 2px solid #090a0d; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; min-width: 0; outline: 2px solid #3a4250; - padding: 15px; + padding: 12px; } .local-save-card { @@ -1972,7 +1972,7 @@ outline: 1px solid #4c5667; } -.online-save-card .save-option-heading > span { +.online-save-panel .save-option-heading > span { color: #80bdf2; } @@ -2031,7 +2031,7 @@ font-size: 14px; line-height: 1.25; margin: 0; - min-height: 72px; + min-height: 58px; padding: 12px; } @@ -2047,6 +2047,99 @@ width: 100%; } +.slot-save-actions { + display: grid; + gap: 7px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: auto; +} + +.slot-save-actions .save-continue-button { + margin-top: 0; +} + +.character-create-form { + display: flex; + flex: 1; + flex-direction: column; + gap: 9px; + min-height: 0; +} + +.character-create-form label, +.online-save-destination { + color: var(--muted); + display: grid; + font-family: var(--pixel-font); + font-size: 7px; + gap: 5px; + text-transform: uppercase; +} + +.character-create-form input, +.online-save-destination select { + 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%; +} + +.character-create-form > small { + color: var(--muted); + font-size: 12px; + line-height: 1.2; +} + +.online-save-panel { + align-items: center; + background: #131821; + border: 2px solid #090a0d; + border-top-color: #5799db; + display: grid; + gap: 12px; + grid-template-columns: 130px minmax(260px, 1fr) 160px minmax(170px, 210px); + min-width: 0; + outline: 2px solid #3a4250; + padding: 10px 12px; +} + +.online-save-heading { + min-width: 0; +} + +.online-save-summary { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: minmax(75px, 0.7fr) minmax(0, 1.8fr); + min-width: 0; +} + +.online-save-summary .save-character-name { + font-size: 16px; +} + +.online-save-summary .save-metadata { + gap: 5px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.online-save-summary .save-metadata > div { + padding: 5px 7px; +} + +.online-save-summary .save-metadata dd { + font-size: 11px; +} + +.online-save-summary .save-empty-state { + min-height: 48px; +} + .save-continue-button { background: var(--gold); color: #19150e; @@ -2076,7 +2169,9 @@ .online-save-login { display: grid; gap: 9px; - margin-top: auto; + grid-column: 2 / -1; + grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(180px, 0.75fr); + margin: 0; } .online-save-login label { @@ -2123,6 +2218,11 @@ } @media (max-width: 800px) { + .auth-shell.save-gateway-shell { + align-items: flex-start; + overflow: auto; + } + .save-gateway-panel { min-height: auto; } @@ -2140,6 +2240,20 @@ .save-option-grid { grid-template-columns: 1fr; } + + .online-save-panel { + align-items: stretch; + grid-template-columns: 1fr; + } + + .online-save-login { + grid-column: auto; + grid-template-columns: 1fr; + } + + .online-save-summary { + grid-template-columns: 1fr; + } } @media (max-width: 1000px) and (max-height: 620px) { @@ -2171,8 +2285,8 @@ } .save-option-card { - gap: 8px; - padding: 10px; + gap: 6px; + padding: 8px; } .save-option-heading > span { @@ -2206,6 +2320,35 @@ padding: 6px 8px; } + .slot-save-actions { + gap: 5px; + } + + .character-create-form { + gap: 6px; + } + + .character-create-form input, + .online-save-destination select { + font-size: 14px; + min-height: 32px; + padding: 5px 7px; + } + + .character-create-form > small { + font-size: 10px; + } + + .online-save-panel { + gap: 8px; + grid-template-columns: 115px minmax(230px, 1fr) 145px minmax(160px, 190px); + padding: 7px 9px; + } + + .online-save-summary { + gap: 5px; + } + .online-save-login { gap: 6px; } diff --git a/src/App.tsx b/src/App.tsx index 5e47ea6..630e184 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' import { AuthScreen } from './components/AuthScreen' import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' -import type { Iwt2Save } from './modes/iwt2/save/iwt2Repository' +import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository' type Iwt2Launch = { + localSlot: Iwt2LocalSaveSlot onlineBackupsAvailable: boolean save: Iwt2Save } @@ -15,6 +16,7 @@ function App() { return ( setLaunch(null)} /> diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index cd6b337..efa6d76 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { loadAuthSession, loginAccount, @@ -8,13 +8,18 @@ import { import { selectOfflineMode, selectOnlineMode } from '../gameRepository' import { createDefaultIwt2Save, - loadExistingIwt2Save, + IWT2_LOCAL_SAVE_SLOTS, loadIwt2OnlineSave, + loadIwt2SaveSlots, replaceIwt2Save, + selectActiveIwt2SaveSlot, + type Iwt2LocalSaveSlot, + type Iwt2LocalSaveSlots, type Iwt2Save, } from '../modes/iwt2/save/iwt2Repository' type Iwt2Launch = { + localSlot: Iwt2LocalSaveSlot onlineBackupsAvailable: boolean save: Iwt2Save } @@ -23,6 +28,8 @@ type Props = { onContinue: (launch: Iwt2Launch) => void } +const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/ + function formatSaveTimestamp(updatedAt: number) { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', @@ -49,18 +56,26 @@ function SaveMetadata({ save }: { save: Iwt2Save }) { ) } +function firstEmptySlot(saves: Iwt2LocalSaveSlots): Iwt2LocalSaveSlot { + return IWT2_LOCAL_SAVE_SLOTS.find((slot) => !saves[slot]) ?? 1 +} + export function AuthScreen({ onContinue }: Props) { - const [localSave] = useState(loadExistingIwt2Save) - const newSave = useMemo(() => createDefaultIwt2Save(), []) + const [localSaves] = useState(loadIwt2SaveSlots) + const [editingSlot, setEditingSlot] = useState(null) + const [characterName, setCharacterName] = useState('') const [account, setAccount] = useState(null) const [onlineSave, setOnlineSave] = useState(null) + const [onlineDestinationSlot, setOnlineDestinationSlot] = useState( + () => firstEmptySlot(localSaves), + ) + const [confirmOnlineDestination, setConfirmOnlineDestination] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') 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 [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.') const [messageIsError, setMessageIsError] = useState(false) const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => { @@ -69,7 +84,7 @@ export function AuthScreen({ onContinue }: Props) { 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.') + setMessage(result.save ? 'Online save ready. Choose its local destination slot.' : 'Account has no online IWT2 save yet.') setMessageIsError(false) } catch (reason) { if (cancelled()) return @@ -94,8 +109,8 @@ export function AuthScreen({ onContinue }: Props) { if (cancelled) return setMessage( reason instanceof Error - ? `${reason.message} Local saves remain available.` - : 'Online service unavailable. Local saves remain available.', + ? `${reason.message} Local slots remain available.` + : 'Online service unavailable. Local slots remain available.', ) setMessageIsError(true) }) @@ -107,22 +122,36 @@ export function AuthScreen({ onContinue }: Props) { } }, [inspectOnlineSave]) - function continueLocal() { - if (!localSave) return + function continueLocal(slot: Iwt2LocalSaveSlot) { + const save = localSaves[slot] + if (!save) return + selectActiveIwt2SaveSlot(slot) selectOfflineMode() - onContinue({ save: localSave, onlineBackupsAvailable: false }) + onContinue({ localSlot: slot, save, onlineBackupsAvailable: false }) } - function continueNew() { - if (localSave && !confirmNewSave) { - setConfirmNewSave(true) - setMessage('New save replaces current local file. Select again to confirm.') - setMessageIsError(false) + function openCharacterEditor(slot: Iwt2LocalSaveSlot) { + setEditingSlot(slot) + setCharacterName('') + setMessage(localSaves[slot] + ? `Creating a new character will replace Slot ${slot}.` + : `Enter a name for the new character in Slot ${slot}.`) + setMessageIsError(false) + } + + function createCharacter(event: React.FormEvent, slot: Iwt2LocalSaveSlot) { + event.preventDefault() + const name = characterName.trim() + if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) { + setMessage('Character name must be 2–18 characters, start with a letter, and use letters, numbers, spaces, or hyphens.') + setMessageIsError(true) return } - replaceIwt2Save(newSave) + const save = createDefaultIwt2Save(name) + replaceIwt2Save(save, slot) + selectActiveIwt2SaveSlot(slot) selectOfflineMode() - onContinue({ save: newSave, onlineBackupsAvailable: false }) + onContinue({ localSlot: slot, save, onlineBackupsAvailable: false }) } async function signIn(event: React.FormEvent) { @@ -148,8 +177,21 @@ export function AuthScreen({ onContinue }: Props) { function continueOnline() { if (!account || !onlineSave) return + const destinationSave = localSaves[onlineDestinationSlot] + if (destinationSave && confirmOnlineDestination !== onlineDestinationSlot) { + setConfirmOnlineDestination(onlineDestinationSlot) + setMessage(`Online save will replace ${destinationSave.character.name} in Slot ${onlineDestinationSlot}. Select again to confirm.`) + setMessageIsError(false) + return + } + replaceIwt2Save(onlineSave, onlineDestinationSlot) + selectActiveIwt2SaveSlot(onlineDestinationSlot) selectOnlineMode() - onContinue({ save: onlineSave, onlineBackupsAvailable: true }) + onContinue({ + localSlot: onlineDestinationSlot, + onlineBackupsAvailable: true, + save: onlineSave, + }) } async function handleDifferentAccount() { @@ -177,68 +219,112 @@ export function AuthScreen({ onContinue }: Props) {

I Want To Heal 2

-

Choose Save

+

Choose Character

-

Compare last update and character XP before entering the arena.

+

Three local slots. Each character keeps separate progress, timestamp, and XP.

-
-
- -
-

Offline

-

Local Save

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

No local IWT2 save found.

- )} - -
+ {IWT2_LOCAL_SAVE_SLOTS.map((slot) => { + const save = localSaves[slot] + const editing = editingSlot === slot + return ( +
+
+ +
+

Local Save

+

Slot {slot}

+
+
-
-
- -
-

Offline

-

New Save

-
-
- {newSave.character.name} - - -
+ {editing ? ( +
createCharacter(event, slot)}> + + + {save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'} + +
+ + +
+
+ ) : save ? ( + <> + {save.character.name} + +
+ + +
+ + ) : ( + <> +

Empty slot

+ + + )} +
+ ) + })} +
-
-
- -
-

Account

-

Online Save

-
+
+
+ +
+

Account

+

Online Save

+
- {account ? ( - <> + {account ? ( + <> +
{onlineSave?.character.name ?? account.username} @@ -249,58 +335,77 @@ export function AuthScreen({ onContinue }: Props) { {onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}

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

void }) { @@ -174,8 +176,8 @@ export function IWantToHeal2App({ const cancelPvpQueueRef = useRef<(() => void) | null>(null) useEffect(() => { - writeIwt2Save(save) - }, [save]) + writeIwt2Save(save, localSlot) + }, [localSlot, save]) useEffect(() => () => { cancelPvpQueueRef.current?.() diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts index 9c05d47..201cac7 100644 --- a/src/modes/iwt2/save/iwt2Repository.ts +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -101,14 +101,20 @@ export type Iwt2OnlineSaveResult = { save: Iwt2Save | null } -const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1' +export const IWT2_LOCAL_SAVE_SLOTS = [1, 2, 3] as const +export type Iwt2LocalSaveSlot = (typeof IWT2_LOCAL_SAVE_SLOTS)[number] +export type Iwt2LocalSaveSlots = Record -export function createDefaultIwt2Save(): Iwt2Save { +const IWT2_LEGACY_SAVE_KEY = 'i-want-to-heal-2:save:v1' +const IWT2_SAVE_MIGRATION_KEY = 'i-want-to-heal-2:save-slots:migrated:v1' +const IWT2_ACTIVE_SAVE_SLOT_KEY = 'i-want-to-heal-2:active-save-slot:v1' + +export function createDefaultIwt2Save(characterName = 'Healer'): Iwt2Save { return { version: 2, updatedAt: Date.now(), character: { - name: 'Healer', + name: normalizeCharacterName(characterName), level: 1, experience: 0, healerStyle: 'dawnweaver', @@ -153,12 +159,38 @@ function normalizeSave(value: unknown): Iwt2Save { } } -export function loadIwt2Save(): Iwt2Save { - return loadExistingIwt2Save() ?? createDefaultIwt2Save() +function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) { + return `i-want-to-heal-2:save:slot-${slot}:v1` } -export function loadExistingIwt2Save(): Iwt2Save | null { - const serialized = window.localStorage.getItem(IWT2_SAVE_KEY) +function migrateLegacyIwt2Save() { + if (window.localStorage.getItem(IWT2_SAVE_MIGRATION_KEY)) return + const legacySave = window.localStorage.getItem(IWT2_LEGACY_SAVE_KEY) + const firstSlotKey = iwt2SaveSlotKey(1) + if (legacySave && !window.localStorage.getItem(firstSlotKey)) { + window.localStorage.setItem(firstSlotKey, legacySave) + } + window.localStorage.setItem(IWT2_SAVE_MIGRATION_KEY, 'true') +} + +export function activeIwt2SaveSlot(): Iwt2LocalSaveSlot { + const stored = Number(window.localStorage.getItem(IWT2_ACTIVE_SAVE_SLOT_KEY)) + return IWT2_LOCAL_SAVE_SLOTS.includes(stored as Iwt2LocalSaveSlot) + ? stored as Iwt2LocalSaveSlot + : 1 +} + +export function selectActiveIwt2SaveSlot(slot: Iwt2LocalSaveSlot) { + window.localStorage.setItem(IWT2_ACTIVE_SAVE_SLOT_KEY, String(slot)) +} + +export function loadIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save { + return loadExistingIwt2Save(slot) ?? createDefaultIwt2Save() +} + +export function loadExistingIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save | null { + migrateLegacyIwt2Save() + const serialized = window.localStorage.getItem(iwt2SaveSlotKey(slot)) if (!serialized) return null try { return normalizeSave(JSON.parse(serialized)) @@ -167,6 +199,14 @@ export function loadExistingIwt2Save(): Iwt2Save | null { } } +export function loadIwt2SaveSlots(): Iwt2LocalSaveSlots { + return { + 1: loadExistingIwt2Save(1), + 2: loadExistingIwt2Save(2), + 3: loadExistingIwt2Save(3), + } +} + export async function loadIwt2OnlineSave(): Promise { const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save') return { @@ -185,12 +225,12 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise { - onSaveUpdated(createDefaultIwt2Save()) - setMessage('Started a new local IWT2 save.') - }, [onSaveUpdated]) - const actions = useMemo(() => withBackAction([ { key: 'use-local', @@ -1970,16 +1964,8 @@ export function Iwt2CloudSaveScreen({ disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave, onConfirm: handleUseOnlineSave, }, - { - key: 'new-save', - label: 'Use New Save File', - detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.', - value: 'New', - onConfirm: handleUseNewSave, - }, ], onBack), [ handleUseLocalSave, - handleUseNewSave, handleUseOnlineSave, onlineBackupsAvailable, onlineSave, @@ -1989,7 +1975,7 @@ export function Iwt2CloudSaveScreen({ ]) return ( - + {statusMessage &&

{statusMessage}

}