Android build v1.1.38
This commit is contained in:
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 118
|
versionCode 119
|
||||||
versionName "1.1.37"
|
versionName "1.1.38"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+348
@@ -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 {
|
.combat-touch-lock-status {
|
||||||
background: #101216;
|
background: #101216;
|
||||||
border: 2px solid #d9b55a;
|
border: 2px solid #d9b55a;
|
||||||
|
|||||||
+12
-144
@@ -1,159 +1,27 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AuthScreen } from './components/AuthScreen'
|
import { AuthScreen } from './components/AuthScreen'
|
||||||
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
||||||
import { useGameAction } from './input'
|
import type { Iwt2Save } from './modes/iwt2/save/iwt2Repository'
|
||||||
import {
|
|
||||||
loadAuthSession,
|
|
||||||
type AuthSession,
|
|
||||||
} from './profile'
|
|
||||||
|
|
||||||
type GameVersion = 'iwt1' | 'iwt2'
|
type Iwt2Launch = {
|
||||||
|
onlineBackupsAvailable: boolean
|
||||||
const GAME_OPTIONS: Array<{
|
save: Iwt2Save
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
const [launch, setLaunch] = useState<Iwt2Launch | null>(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
||||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
|
||||||
const [serverMessage, setServerMessage] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (launch) {
|
||||||
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') {
|
|
||||||
return (
|
return (
|
||||||
<IWantToHeal2App
|
<IWantToHeal2App
|
||||||
onlineBackupsAvailable={authSession.account?.id !== -1}
|
initialSave={launch.save}
|
||||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||||
|
onExitToSaveSelect={() => setLaunch(null)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <AuthScreen onContinue={setLaunch} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
+246
-125
@@ -1,99 +1,275 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
loadAuthSession,
|
||||||
loginAccount,
|
loginAccount,
|
||||||
registerAccount,
|
logoutAccount,
|
||||||
type AuthSession,
|
type Account,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
|
||||||
import {
|
import {
|
||||||
createOfflineCharacter,
|
createDefaultIwt2Save,
|
||||||
hasOfflineCharacter,
|
loadExistingIwt2Save,
|
||||||
resumeOfflineCharacter,
|
loadIwt2OnlineSave,
|
||||||
selectOnlineMode,
|
replaceIwt2Save,
|
||||||
} from '../gameRepository'
|
type Iwt2Save,
|
||||||
|
} from '../modes/iwt2/save/iwt2Repository'
|
||||||
|
|
||||||
|
type Iwt2Launch = {
|
||||||
|
onlineBackupsAvailable: boolean
|
||||||
|
save: Iwt2Save
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onAuthenticated: (session: AuthSession) => void
|
onContinue: (launch: Iwt2Launch) => void
|
||||||
serverMessage?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
function formatSaveTimestamp(updatedAt: number) {
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
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 [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [characterName, setCharacterName] = useState('')
|
const [checkingSession, setCheckingSession] = useState(true)
|
||||||
const [offlineName, setOfflineName] = useState('')
|
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [confirmNewSave, setConfirmNewSave] = useState(false)
|
||||||
const offlineCharacterExists = hasOfflineCharacter()
|
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()
|
event.preventDefault()
|
||||||
setBusy(true)
|
setSubmitting(true)
|
||||||
setMessage('')
|
setMessage('Signing in and checking online save...')
|
||||||
|
setMessageIsError(false)
|
||||||
try {
|
try {
|
||||||
selectOnlineMode()
|
selectOnlineMode()
|
||||||
const session = mode === 'login'
|
const session = await loginAccount(username, password)
|
||||||
? await loginAccount(username, password)
|
if (!session.account) throw new Error('Account session was not returned.')
|
||||||
: await registerAccount(username, password, characterName)
|
setAccount(session.account)
|
||||||
onAuthenticated(session)
|
await inspectOnlineSave()
|
||||||
} catch (reason) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginOffline() {
|
function continueOnline() {
|
||||||
setMessage('')
|
if (!account || !onlineSave) return
|
||||||
|
selectOnlineMode()
|
||||||
|
onContinue({ save: onlineSave, onlineBackupsAvailable: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDifferentAccount() {
|
||||||
|
setSubmitting(true)
|
||||||
try {
|
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) {
|
} 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 onlineBusy = checkingSession || checkingOnlineSave || submitting
|
||||||
const session = resumeOfflineCharacter()
|
|
||||||
if (session) onAuthenticated(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell save-gateway-shell">
|
||||||
<section className="auth-panel">
|
<section className="save-gateway-panel">
|
||||||
<div className="auth-brand">
|
<header className="save-gateway-heading">
|
||||||
<p className="eyebrow">Healer RPG</p>
|
<div>
|
||||||
<h1>I want to Heal</h1>
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<p>
|
<h1>Choose Save</h1>
|
||||||
Build your healer, master each dungeon, and compete for the most
|
</div>
|
||||||
efficient clears.
|
<p>Compare last update and character XP before entering the arena.</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>
|
||||||
|
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
<div className="online-save-actions">
|
||||||
<div className="auth-card">
|
|
||||||
<div className="auth-tabs">
|
|
||||||
<button
|
<button
|
||||||
className={mode === 'login' ? 'selected' : ''}
|
className="save-continue-button"
|
||||||
onClick={() => {
|
disabled={onlineBusy || !onlineSave}
|
||||||
setMode('login')
|
onClick={continueOnline}
|
||||||
setMessage('')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Sign In
|
Load Online Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={mode === 'register' ? 'selected' : ''}
|
className="save-secondary-button"
|
||||||
onClick={() => {
|
disabled={onlineBusy}
|
||||||
setMode('register')
|
onClick={() => { void handleDifferentAccount() }}
|
||||||
setMessage('')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Create Account
|
Use Different Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<form onSubmit={submit}>
|
) : (
|
||||||
|
<form className="online-save-login" onSubmit={signIn}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input
|
<input
|
||||||
@@ -106,23 +282,10 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|||||||
value={username}
|
value={username}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{mode === 'register' && (
|
|
||||||
<label>
|
|
||||||
Character Name
|
|
||||||
<input
|
|
||||||
autoComplete="nickname"
|
|
||||||
maxLength={20}
|
|
||||||
minLength={2}
|
|
||||||
onChange={(event) => setCharacterName(event.target.value)}
|
|
||||||
required
|
|
||||||
value={characterName}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input
|
<input
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete="current-password"
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
minLength={10}
|
minLength={10}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
@@ -131,62 +294,20 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|||||||
value={password}
|
value={password}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" disabled={busy} type="submit">
|
<button className="save-continue-button" disabled={onlineBusy} type="submit">
|
||||||
{busy
|
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
|
||||||
? 'Working...'
|
|
||||||
: mode === 'login'
|
|
||||||
? 'Enter Chronicle'
|
|
||||||
: 'Begin Adventure'}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</article>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
{offlineCharacterExists && (
|
|
||||||
<button
|
<p
|
||||||
className="offline-resume-button"
|
aria-live="polite"
|
||||||
onClick={resumeOffline}
|
className={`save-gateway-status ${messageIsError ? 'error' : ''}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
Continue Offline Character
|
{message}
|
||||||
</button>
|
</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}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1841,6 +1841,10 @@ export function selectOnlineMode() {
|
|||||||
writeMode('online')
|
writeMode('online')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectOfflineMode() {
|
||||||
|
writeMode('offline-local')
|
||||||
|
}
|
||||||
|
|
||||||
export function createOfflineCharacter(characterName: string): AuthSession {
|
export function createOfflineCharacter(characterName: string): AuthSession {
|
||||||
const name = characterName.trim() || 'Mira'
|
const name = characterName.trim() || 'Mira'
|
||||||
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
||||||
|
|||||||
+5
-1
@@ -1043,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
<div className="controller-keyboard-heading">
|
<div className="controller-keyboard-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Controller Keyboard</p>
|
<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>
|
</div>
|
||||||
<button onClick={closeKeyboard} type="button">Done</button>
|
<button onClick={closeKeyboard} type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ import {
|
|||||||
Iwt2RoguelikeUpgradeScreen,
|
Iwt2RoguelikeUpgradeScreen,
|
||||||
Iwt2SettingsScreen,
|
Iwt2SettingsScreen,
|
||||||
} from './screens/Iwt2ShellScreens'
|
} from './screens/Iwt2ShellScreens'
|
||||||
import {
|
import { writeIwt2Save, type Iwt2Save } from './save/iwt2Repository'
|
||||||
loadIwt2Save,
|
|
||||||
writeIwt2Save,
|
|
||||||
type Iwt2Save,
|
|
||||||
} from './save/iwt2Repository'
|
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||||
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
||||||
import {
|
import {
|
||||||
@@ -153,15 +149,17 @@ const MENU_ITEMS: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function IWantToHeal2App({
|
export function IWantToHeal2App({
|
||||||
|
initialSave,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
}: {
|
}: {
|
||||||
|
initialSave: Iwt2Save
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
const [save, setSave] = useState<Iwt2Save>(initialSave)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
@@ -205,7 +203,7 @@ export function IWantToHeal2App({
|
|||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (screen !== 'menu' || device !== 'controller') return
|
if (screen !== 'menu' || device !== 'controller') return
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
onBackToGameSelect()
|
onExitToSaveSelect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
@@ -288,7 +286,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeUpgradeScreen
|
<Iwt2RoguelikeUpgradeScreen
|
||||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||||
@@ -312,7 +310,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'dungeons') {
|
if (screen === 'dungeons') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
difficultySlug={selectedDungeonDifficultySlug}
|
difficultySlug={selectedDungeonDifficultySlug}
|
||||||
save={save}
|
save={save}
|
||||||
@@ -333,7 +331,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike') {
|
if (screen === 'roguelike') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeScreen
|
<Iwt2RoguelikeScreen
|
||||||
contentType={roguelikeContentType}
|
contentType={roguelikeContentType}
|
||||||
variant={roguelikeVariant}
|
variant={roguelikeVariant}
|
||||||
@@ -363,7 +361,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'raids') {
|
if (screen === 'raids') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2ModeScreen
|
<Iwt2ModeScreen
|
||||||
difficultySlug={selectedRaidDifficultySlug}
|
difficultySlug={selectedRaidDifficultySlug}
|
||||||
mode="Raids"
|
mode="Raids"
|
||||||
@@ -397,7 +395,7 @@ export function IWantToHeal2App({
|
|||||||
save={save}
|
save={save}
|
||||||
title="Gear Upgrade"
|
title="Gear Upgrade"
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onBackToGameSelect={onBackToGameSelect}
|
onExitToSaveSelect={onExitToSaveSelect}
|
||||||
/>
|
/>
|
||||||
<Iwt2GearUpgradeScreen
|
<Iwt2GearUpgradeScreen
|
||||||
save={save}
|
save={save}
|
||||||
@@ -411,7 +409,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CustomizeCharacterScreen
|
<Iwt2CustomizeCharacterScreen
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
@@ -424,7 +422,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'cloud-save') {
|
if (screen === 'cloud-save') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CloudSaveScreen
|
<Iwt2CloudSaveScreen
|
||||||
save={save}
|
save={save}
|
||||||
onlineBackupsAvailable={onlineBackupsAvailable}
|
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||||
@@ -438,7 +436,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'settings') {
|
if (screen === 'settings') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -446,7 +444,7 @@ export function IWantToHeal2App({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||||
@@ -692,19 +690,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>)
|
|||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
onBack,
|
onBack,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
save,
|
save,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
title?: string
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
<button className="brand-button" onClick={onExitToSaveSelect} type="button">
|
||||||
<strong>Games</strong>
|
<strong>Saves</strong>
|
||||||
</button>
|
</button>
|
||||||
{title && <strong className="iwt2-header-title">{title}</strong>}
|
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||||
<div className="character-summary">
|
<div className="character-summary">
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
||||||
return {
|
return {
|
||||||
version: 2,
|
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: {
|
character: {
|
||||||
name: candidate.character?.name || 'Healer',
|
name: candidate.character?.name || 'Healer',
|
||||||
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
||||||
@@ -150,10 +154,16 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadIwt2Save(): 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 {
|
try {
|
||||||
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
|
return normalizeSave(JSON.parse(serialized))
|
||||||
} catch {
|
} catch {
|
||||||
return createDefaultIwt2Save()
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +186,11 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSav
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function writeIwt2Save(save: Iwt2Save) {
|
export function writeIwt2Save(save: Iwt2Save) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
replaceIwt2Save(save)
|
||||||
...save,
|
}
|
||||||
updatedAt: Date.now(),
|
|
||||||
}))
|
export function replaceIwt2Save(save: Iwt2Save) {
|
||||||
|
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify(normalizeSave(save)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(
|
export function recordIwt2BossKill(
|
||||||
|
|||||||
Reference in New Issue
Block a user