Android build v1.1.38

This commit is contained in:
Warren H
2026-07-09 21:57:51 -04:00
parent 8ffa6db317
commit 4766a34cd3
10 changed files with 685 additions and 331 deletions
+348
View File
@@ -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;
+12 -144
View File
@@ -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<GameVersion | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [serverMessage, setServerMessage] = useState('')
const [launch, setLaunch] = useState<Iwt2Launch | null>(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 (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!authSession) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (selectedVersion === 'iwt1') {
return (
<IWantToHeal1App
initialSession={authSession}
onAuthenticationCleared={clearAuthSession}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
}
if (selectedVersion === 'iwt2') {
if (launch) {
return (
<IWantToHeal2App
onlineBackupsAvailable={authSession.account?.id !== -1}
onBackToGameSelect={() => setSelectedVersion(null)}
initialSave={launch.save}
onlineBackupsAvailable={launch.onlineBackupsAvailable}
onExitToSaveSelect={() => setLaunch(null)}
/>
)
}
return (
<main className="game-shell game-version-shell">
<section className="game-version-screen" data-game-nav-active="true">
<div className="game-version-heading">
<p className="eyebrow">Select Game</p>
<h1>I Want To Heal</h1>
</div>
<div className="game-version-grid">
{GAME_OPTIONS.map((option, index) => (
<button
className={`game-version-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={option.version}
onClick={() => setSelectedVersion(option.version)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{option.glyph}</span>
<div>
<strong>{option.title}</strong>
<small>{option.label}</small>
<p>{option.description}</p>
</div>
</button>
))}
</div>
</section>
</main>
)
return <AuthScreen onContinue={setLaunch} />
}
export default App
+276 -155
View File
@@ -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 (
<dl className="save-metadata">
<div>
<dt>Last updated</dt>
<dd>
<time dateTime={new Date(save.updatedAt).toISOString()}>
{formatSaveTimestamp(save.updatedAt)}
</time>
</dd>
</div>
<div>
<dt>Character XP</dt>
<dd>{save.character.experience.toLocaleString()} XP</dd>
</div>
</dl>
)
}
export function AuthScreen({ onContinue }: Props) {
const [localSave] = useState(loadExistingIwt2Save)
const newSave = useMemo(() => createDefaultIwt2Save(), [])
const [account, setAccount] = useState<Account | null>(null)
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(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 (
<main className="auth-shell">
<section className="auth-panel">
<div className="auth-brand">
<p className="eyebrow">Healer RPG</p>
<h1>I want to Heal</h1>
<p>
Build your healer, master each dungeon, and compete for the most
efficient clears.
</p>
</div>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
}}
type="button"
>
Sign In
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
type="button"
>
Create Account
</button>
<main className="auth-shell save-gateway-shell">
<section className="save-gateway-panel">
<header className="save-gateway-heading">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<h1>Choose Save</h1>
</div>
<p>Compare last update and character XP before entering the arena.</p>
</header>
<form onSubmit={submit}>
<label>
Username
<input
autoComplete="username"
maxLength={20}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={username}
/>
</label>
{mode === 'register' && (
<label>
Character Name
<input
autoComplete="nickname"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
required
value={characterName}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter Chronicle'
: 'Begin Adventure'}
</button>
</form>
<p className={`auth-message ${message ? 'error' : ''}`}>
{message || serverMessage || (
mode === 'register'
? 'The first account keeps the current local character and save.'
: 'Sign in to continue your character.'
)}
</p>
<div className="offline-divider"><span>or</span></div>
<section className="offline-entry">
<div>
<p className="eyebrow">Local Save</p>
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device.
</p>
<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>
{offlineCharacterExists && (
<button
className="offline-resume-button"
onClick={resumeOffline}
type="button"
>
Continue Offline Character
</button>
{localSave ? (
<>
<strong className="save-character-name">{localSave.character.name}</strong>
<SaveMetadata save={localSave} />
</>
) : (
<p className="save-empty-state">No local IWT2 save found.</p>
)}
<label>
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
<input
maxLength={20}
minLength={2}
onChange={(event) => setOfflineName(event.target.value)}
placeholder="Mira"
value={offlineName}
/>
</label>
<button
className="text-button offline-new-button"
onClick={beginOffline}
className="save-continue-button"
disabled={!localSave}
onClick={continueLocal}
type="button"
>
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
Continue Offline Local
</button>
</section>
</article>
<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>
<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>
</div>
{account ? (
<>
<strong className="save-character-name">
{onlineSave?.character.name ?? account.username}
</strong>
{onlineSave ? (
<SaveMetadata save={onlineSave} />
) : (
<p className="save-empty-state">
{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'}
</button>
</form>
)}
</article>
</div>
<p
aria-live="polite"
className={`save-gateway-status ${messageIsError ? 'error' : ''}`}
>
{message}
</p>
</section>
</main>
)
+4
View File
@@ -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)) {
+5 -1
View File
@@ -1043,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
<div className="controller-keyboard-heading">
<div>
<p className="eyebrow">Controller Keyboard</p>
<strong>{keyboardInput.value || 'Enter text'}</strong>
<strong>
{keyboardInput.type === 'password'
? '•'.repeat(keyboardInput.value.length) || 'Enter password'
: keyboardInput.value || 'Enter text'}
</strong>
</div>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
+20 -22
View File
@@ -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<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [save, setSave] = useState<Iwt2Save>(initialSave)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('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 (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2RoguelikeUpgradeScreen
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
@@ -312,7 +310,7 @@ export function IWantToHeal2App({
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2DungeonsScreen
difficultySlug={selectedDungeonDifficultySlug}
save={save}
@@ -333,7 +331,7 @@ export function IWantToHeal2App({
if (screen === 'roguelike') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
@@ -363,7 +361,7 @@ export function IWantToHeal2App({
if (screen === 'raids') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2ModeScreen
difficultySlug={selectedRaidDifficultySlug}
mode="Raids"
@@ -397,7 +395,7 @@ export function IWantToHeal2App({
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
onExitToSaveSelect={onExitToSaveSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
@@ -411,7 +409,7 @@ export function IWantToHeal2App({
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2CustomizeCharacterScreen
save={save}
onBack={() => setScreen('menu')}
@@ -424,7 +422,7 @@ export function IWantToHeal2App({
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2CloudSaveScreen
save={save}
onlineBackupsAvailable={onlineBackupsAvailable}
@@ -438,7 +436,7 @@ export function IWantToHeal2App({
if (screen === 'settings') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
</main>
)
@@ -446,7 +444,7 @@ export function IWantToHeal2App({
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
@@ -692,19 +690,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>)
function Iwt2Header({
onBack,
onBackToGameSelect,
onExitToSaveSelect,
save,
title,
}: {
onBack?: () => void
onBackToGameSelect: () => void
onExitToSaveSelect: () => void
save: Iwt2Save
title?: string
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
<button className="brand-button" onClick={onExitToSaveSelect} type="button">
<strong>Saves</strong>
</button>
{title && <strong className="iwt2-header-title">{title}</strong>}
<div className="character-summary">
+18 -7
View File
@@ -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<Iwt2OnlineSav
}
export function writeIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
...save,
updatedAt: Date.now(),
}))
replaceIwt2Save(save)
}
export function replaceIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify(normalizeSave(save)))
}
export function recordIwt2BossKill(