Android build v1.1.39
This commit is contained in:
+230
-125
@@ -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<Iwt2LocalSaveSlot | null>(null)
|
||||
const [characterName, setCharacterName] = useState('')
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||
const [onlineDestinationSlot, setOnlineDestinationSlot] = useState<Iwt2LocalSaveSlot>(
|
||||
() => firstEmptySlot(localSaves),
|
||||
)
|
||||
const [confirmOnlineDestination, setConfirmOnlineDestination] = useState<Iwt2LocalSaveSlot | null>(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) {
|
||||
<header className="save-gateway-heading">
|
||||
<div>
|
||||
<p className="eyebrow">I Want To Heal 2</p>
|
||||
<h1>Choose Save</h1>
|
||||
<h1>Choose Character</h1>
|
||||
</div>
|
||||
<p>Compare last update and character XP before entering the arena.</p>
|
||||
<p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
|
||||
</header>
|
||||
|
||||
<div className="save-option-grid">
|
||||
<article className="save-option-card local-save-card">
|
||||
<div className="save-option-heading">
|
||||
<span aria-hidden="true">L</span>
|
||||
<div>
|
||||
<p className="eyebrow">Offline</p>
|
||||
<h2>Local Save</h2>
|
||||
</div>
|
||||
</div>
|
||||
{localSave ? (
|
||||
<>
|
||||
<strong className="save-character-name">{localSave.character.name}</strong>
|
||||
<SaveMetadata save={localSave} />
|
||||
</>
|
||||
) : (
|
||||
<p className="save-empty-state">No local IWT2 save found.</p>
|
||||
)}
|
||||
<button
|
||||
className="save-continue-button"
|
||||
disabled={!localSave}
|
||||
onClick={continueLocal}
|
||||
type="button"
|
||||
>
|
||||
Continue Offline — Local
|
||||
</button>
|
||||
</article>
|
||||
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
|
||||
const save = localSaves[slot]
|
||||
const editing = editingSlot === slot
|
||||
return (
|
||||
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
|
||||
<div className="save-option-heading">
|
||||
<span aria-hidden="true">{slot}</span>
|
||||
<div>
|
||||
<p className="eyebrow">Local Save</p>
|
||||
<h2>Slot {slot}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="save-option-card new-save-card">
|
||||
<div className="save-option-heading">
|
||||
<span aria-hidden="true">N</span>
|
||||
<div>
|
||||
<p className="eyebrow">Offline</p>
|
||||
<h2>New Save</h2>
|
||||
</div>
|
||||
</div>
|
||||
<strong className="save-character-name">{newSave.character.name}</strong>
|
||||
<SaveMetadata save={newSave} />
|
||||
<button
|
||||
className={`save-continue-button ${confirmNewSave ? 'danger' : ''}`}
|
||||
onClick={continueNew}
|
||||
type="button"
|
||||
>
|
||||
{confirmNewSave ? 'Confirm Replace Local Save' : 'Continue Offline — New'}
|
||||
</button>
|
||||
</article>
|
||||
{editing ? (
|
||||
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
|
||||
<label>
|
||||
Character Name
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={18}
|
||||
minLength={2}
|
||||
onChange={(event) => setCharacterName(event.target.value)}
|
||||
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
|
||||
placeholder="Mira"
|
||||
required
|
||||
value={characterName}
|
||||
/>
|
||||
</label>
|
||||
<small>
|
||||
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
|
||||
</small>
|
||||
<div className="slot-save-actions">
|
||||
<button
|
||||
className={`save-continue-button ${save ? 'danger' : ''}`}
|
||||
type="submit"
|
||||
>
|
||||
{save ? `Replace Slot ${slot}` : `Create in Slot ${slot}`}
|
||||
</button>
|
||||
<button
|
||||
className="save-secondary-button"
|
||||
onClick={() => setEditingSlot(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : save ? (
|
||||
<>
|
||||
<strong className="save-character-name">{save.character.name}</strong>
|
||||
<SaveMetadata save={save} />
|
||||
<div className="slot-save-actions">
|
||||
<button
|
||||
aria-label={`Continue ${save.character.name} in Slot ${slot}`}
|
||||
className="save-continue-button"
|
||||
onClick={() => continueLocal(slot)}
|
||||
type="button"
|
||||
>
|
||||
Continue Offline
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Create new character in Slot ${slot}`}
|
||||
className="save-secondary-button"
|
||||
onClick={() => openCharacterEditor(slot)}
|
||||
type="button"
|
||||
>
|
||||
New Character
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="save-empty-state">Empty slot</p>
|
||||
<button
|
||||
aria-label={`Create character in Slot ${slot}`}
|
||||
className="save-continue-button"
|
||||
onClick={() => openCharacterEditor(slot)}
|
||||
type="button"
|
||||
>
|
||||
Create Character
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<article className="save-option-card online-save-card">
|
||||
<div className="save-option-heading">
|
||||
<span aria-hidden="true">O</span>
|
||||
<div>
|
||||
<p className="eyebrow">Account</p>
|
||||
<h2>Online Save</h2>
|
||||
</div>
|
||||
<section className="online-save-panel">
|
||||
<div className="save-option-heading online-save-heading">
|
||||
<span aria-hidden="true">O</span>
|
||||
<div>
|
||||
<p className="eyebrow">Account</p>
|
||||
<h2>Online Save</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{account ? (
|
||||
<>
|
||||
{account ? (
|
||||
<>
|
||||
<div className="online-save-summary">
|
||||
<strong className="save-character-name">
|
||||
{onlineSave?.character.name ?? account.username}
|
||||
</strong>
|
||||
@@ -249,58 +335,77 @@ export function AuthScreen({ onContinue }: Props) {
|
||||
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="online-save-actions">
|
||||
<button
|
||||
className="save-continue-button"
|
||||
disabled={onlineBusy || !onlineSave}
|
||||
onClick={continueOnline}
|
||||
type="button"
|
||||
>
|
||||
Load Online Save
|
||||
</button>
|
||||
<button
|
||||
className="save-secondary-button"
|
||||
disabled={onlineBusy}
|
||||
onClick={() => { void handleDifferentAccount() }}
|
||||
type="button"
|
||||
>
|
||||
Use Different Account
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form className="online-save-login" onSubmit={signIn}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
autoComplete="username"
|
||||
maxLength={20}
|
||||
minLength={3}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
pattern="[A-Za-z0-9_]+"
|
||||
required
|
||||
value={username}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
maxLength={128}
|
||||
minLength={10}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
<button className="save-continue-button" disabled={onlineBusy} type="submit">
|
||||
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
|
||||
</div>
|
||||
<label className="online-save-destination">
|
||||
Copy Into
|
||||
<select
|
||||
disabled={onlineBusy || !onlineSave}
|
||||
onChange={(event) => {
|
||||
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
|
||||
setConfirmOnlineDestination(null)
|
||||
}}
|
||||
value={onlineDestinationSlot}
|
||||
>
|
||||
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => (
|
||||
<option key={slot} value={slot}>
|
||||
Slot {slot} — {localSaves[slot]?.character.name ?? 'Empty'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="online-save-actions">
|
||||
<button
|
||||
className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
|
||||
disabled={onlineBusy || !onlineSave}
|
||||
onClick={continueOnline}
|
||||
type="button"
|
||||
>
|
||||
{confirmOnlineDestination === onlineDestinationSlot
|
||||
? `Confirm Replace Slot ${onlineDestinationSlot}`
|
||||
: `Load Into Slot ${onlineDestinationSlot}`}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
className="save-secondary-button"
|
||||
disabled={onlineBusy}
|
||||
onClick={() => { void handleDifferentAccount() }}
|
||||
type="button"
|
||||
>
|
||||
Switch Account
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form className="online-save-login" onSubmit={signIn}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
autoComplete="username"
|
||||
maxLength={20}
|
||||
minLength={3}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
pattern="[A-Za-z0-9_]+"
|
||||
required
|
||||
value={username}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
maxLength={128}
|
||||
minLength={10}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
<button className="save-continue-button" disabled={onlineBusy} type="submit">
|
||||
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p
|
||||
aria-live="polite"
|
||||
|
||||
Reference in New Issue
Block a user