Android build v1.1.39
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 119
|
versionCode 120
|
||||||
versionName "1.1.38"
|
versionName "1.1.39"
|
||||||
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.
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 50 KiB |
+151
-8
@@ -1888,7 +1888,7 @@
|
|||||||
box-shadow: 8px 8px 0 #050609;
|
box-shadow: 8px 8px 0 #050609;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
min-height: min(512px, calc(100dvh - 28px));
|
min-height: min(512px, calc(100dvh - 28px));
|
||||||
@@ -1935,10 +1935,10 @@
|
|||||||
border: 2px solid #090a0d;
|
border: 2px solid #090a0d;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
outline: 2px solid #3a4250;
|
outline: 2px solid #3a4250;
|
||||||
padding: 15px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.local-save-card {
|
.local-save-card {
|
||||||
@@ -1972,7 +1972,7 @@
|
|||||||
outline: 1px solid #4c5667;
|
outline: 1px solid #4c5667;
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-save-card .save-option-heading > span {
|
.online-save-panel .save-option-heading > span {
|
||||||
color: #80bdf2;
|
color: #80bdf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2031,7 +2031,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 72px;
|
min-height: 58px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2047,6 +2047,99 @@
|
|||||||
width: 100%;
|
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 {
|
.save-continue-button {
|
||||||
background: var(--gold);
|
background: var(--gold);
|
||||||
color: #19150e;
|
color: #19150e;
|
||||||
@@ -2076,7 +2169,9 @@
|
|||||||
.online-save-login {
|
.online-save-login {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 9px;
|
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 {
|
.online-save-login label {
|
||||||
@@ -2123,6 +2218,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
.auth-shell.save-gateway-shell {
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.save-gateway-panel {
|
.save-gateway-panel {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
@@ -2140,6 +2240,20 @@
|
|||||||
.save-option-grid {
|
.save-option-grid {
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
@@ -2171,8 +2285,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-option-card {
|
.save-option-card {
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-option-heading > span {
|
.save-option-heading > span {
|
||||||
@@ -2206,6 +2320,35 @@
|
|||||||
padding: 6px 8px;
|
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 {
|
.online-save-login {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AuthScreen } from './components/AuthScreen'
|
import { AuthScreen } from './components/AuthScreen'
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
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 = {
|
type Iwt2Launch = {
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
}
|
}
|
||||||
@@ -15,6 +16,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<IWantToHeal2App
|
<IWantToHeal2App
|
||||||
initialSave={launch.save}
|
initialSave={launch.save}
|
||||||
|
localSlot={launch.localSlot}
|
||||||
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||||
onExitToSaveSelect={() => setLaunch(null)}
|
onExitToSaveSelect={() => setLaunch(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+230
-125
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
loadAuthSession,
|
loadAuthSession,
|
||||||
loginAccount,
|
loginAccount,
|
||||||
@@ -8,13 +8,18 @@ import {
|
|||||||
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
|
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2Save,
|
createDefaultIwt2Save,
|
||||||
loadExistingIwt2Save,
|
IWT2_LOCAL_SAVE_SLOTS,
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
|
loadIwt2SaveSlots,
|
||||||
replaceIwt2Save,
|
replaceIwt2Save,
|
||||||
|
selectActiveIwt2SaveSlot,
|
||||||
|
type Iwt2LocalSaveSlot,
|
||||||
|
type Iwt2LocalSaveSlots,
|
||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from '../modes/iwt2/save/iwt2Repository'
|
} from '../modes/iwt2/save/iwt2Repository'
|
||||||
|
|
||||||
type Iwt2Launch = {
|
type Iwt2Launch = {
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
}
|
}
|
||||||
@@ -23,6 +28,8 @@ type Props = {
|
|||||||
onContinue: (launch: Iwt2Launch) => void
|
onContinue: (launch: Iwt2Launch) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
|
||||||
|
|
||||||
function formatSaveTimestamp(updatedAt: number) {
|
function formatSaveTimestamp(updatedAt: number) {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
dateStyle: 'medium',
|
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) {
|
export function AuthScreen({ onContinue }: Props) {
|
||||||
const [localSave] = useState(loadExistingIwt2Save)
|
const [localSaves] = useState(loadIwt2SaveSlots)
|
||||||
const newSave = useMemo(() => createDefaultIwt2Save(), [])
|
const [editingSlot, setEditingSlot] = useState<Iwt2LocalSaveSlot | null>(null)
|
||||||
|
const [characterName, setCharacterName] = useState('')
|
||||||
const [account, setAccount] = useState<Account | null>(null)
|
const [account, setAccount] = useState<Account | null>(null)
|
||||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | 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 [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [checkingSession, setCheckingSession] = useState(true)
|
const [checkingSession, setCheckingSession] = useState(true)
|
||||||
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
|
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [confirmNewSave, setConfirmNewSave] = useState(false)
|
const [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
|
||||||
const [message, setMessage] = useState('Local play is ready while online saves are checked.')
|
|
||||||
const [messageIsError, setMessageIsError] = useState(false)
|
const [messageIsError, setMessageIsError] = useState(false)
|
||||||
|
|
||||||
const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => {
|
const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => {
|
||||||
@@ -69,7 +84,7 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
const result = await loadIwt2OnlineSave()
|
const result = await loadIwt2OnlineSave()
|
||||||
if (cancelled()) return
|
if (cancelled()) return
|
||||||
setOnlineSave(result.save)
|
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)
|
setMessageIsError(false)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (cancelled()) return
|
if (cancelled()) return
|
||||||
@@ -94,8 +109,8 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setMessage(
|
setMessage(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? `${reason.message} Local saves remain available.`
|
? `${reason.message} Local slots remain available.`
|
||||||
: 'Online service unavailable. Local saves remain available.',
|
: 'Online service unavailable. Local slots remain available.',
|
||||||
)
|
)
|
||||||
setMessageIsError(true)
|
setMessageIsError(true)
|
||||||
})
|
})
|
||||||
@@ -107,22 +122,36 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
}
|
}
|
||||||
}, [inspectOnlineSave])
|
}, [inspectOnlineSave])
|
||||||
|
|
||||||
function continueLocal() {
|
function continueLocal(slot: Iwt2LocalSaveSlot) {
|
||||||
if (!localSave) return
|
const save = localSaves[slot]
|
||||||
|
if (!save) return
|
||||||
|
selectActiveIwt2SaveSlot(slot)
|
||||||
selectOfflineMode()
|
selectOfflineMode()
|
||||||
onContinue({ save: localSave, onlineBackupsAvailable: false })
|
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function continueNew() {
|
function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
|
||||||
if (localSave && !confirmNewSave) {
|
setEditingSlot(slot)
|
||||||
setConfirmNewSave(true)
|
setCharacterName('')
|
||||||
setMessage('New save replaces current local file. Select again to confirm.')
|
setMessage(localSaves[slot]
|
||||||
setMessageIsError(false)
|
? `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
|
return
|
||||||
}
|
}
|
||||||
replaceIwt2Save(newSave)
|
const save = createDefaultIwt2Save(name)
|
||||||
|
replaceIwt2Save(save, slot)
|
||||||
|
selectActiveIwt2SaveSlot(slot)
|
||||||
selectOfflineMode()
|
selectOfflineMode()
|
||||||
onContinue({ save: newSave, onlineBackupsAvailable: false })
|
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signIn(event: React.FormEvent) {
|
async function signIn(event: React.FormEvent) {
|
||||||
@@ -148,8 +177,21 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
|
|
||||||
function continueOnline() {
|
function continueOnline() {
|
||||||
if (!account || !onlineSave) return
|
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()
|
selectOnlineMode()
|
||||||
onContinue({ save: onlineSave, onlineBackupsAvailable: true })
|
onContinue({
|
||||||
|
localSlot: onlineDestinationSlot,
|
||||||
|
onlineBackupsAvailable: true,
|
||||||
|
save: onlineSave,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDifferentAccount() {
|
async function handleDifferentAccount() {
|
||||||
@@ -177,68 +219,112 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
<header className="save-gateway-heading">
|
<header className="save-gateway-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">I Want To Heal 2</p>
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<h1>Choose Save</h1>
|
<h1>Choose Character</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div className="save-option-grid">
|
<div className="save-option-grid">
|
||||||
<article className="save-option-card local-save-card">
|
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
|
||||||
<div className="save-option-heading">
|
const save = localSaves[slot]
|
||||||
<span aria-hidden="true">L</span>
|
const editing = editingSlot === slot
|
||||||
<div>
|
return (
|
||||||
<p className="eyebrow">Offline</p>
|
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
|
||||||
<h2>Local Save</h2>
|
<div className="save-option-heading">
|
||||||
</div>
|
<span aria-hidden="true">{slot}</span>
|
||||||
</div>
|
<div>
|
||||||
{localSave ? (
|
<p className="eyebrow">Local Save</p>
|
||||||
<>
|
<h2>Slot {slot}</h2>
|
||||||
<strong className="save-character-name">{localSave.character.name}</strong>
|
</div>
|
||||||
<SaveMetadata save={localSave} />
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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">
|
{editing ? (
|
||||||
<div className="save-option-heading">
|
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
|
||||||
<span aria-hidden="true">N</span>
|
<label>
|
||||||
<div>
|
Character Name
|
||||||
<p className="eyebrow">Offline</p>
|
<input
|
||||||
<h2>New Save</h2>
|
autoFocus
|
||||||
</div>
|
maxLength={18}
|
||||||
</div>
|
minLength={2}
|
||||||
<strong className="save-character-name">{newSave.character.name}</strong>
|
onChange={(event) => setCharacterName(event.target.value)}
|
||||||
<SaveMetadata save={newSave} />
|
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
|
||||||
<button
|
placeholder="Mira"
|
||||||
className={`save-continue-button ${confirmNewSave ? 'danger' : ''}`}
|
required
|
||||||
onClick={continueNew}
|
value={characterName}
|
||||||
type="button"
|
/>
|
||||||
>
|
</label>
|
||||||
{confirmNewSave ? 'Confirm Replace Local Save' : 'Continue Offline — New'}
|
<small>
|
||||||
</button>
|
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
|
||||||
</article>
|
</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">
|
<section className="online-save-panel">
|
||||||
<div className="save-option-heading">
|
<div className="save-option-heading online-save-heading">
|
||||||
<span aria-hidden="true">O</span>
|
<span aria-hidden="true">O</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Account</p>
|
<p className="eyebrow">Account</p>
|
||||||
<h2>Online Save</h2>
|
<h2>Online Save</h2>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{account ? (
|
{account ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="online-save-summary">
|
||||||
<strong className="save-character-name">
|
<strong className="save-character-name">
|
||||||
{onlineSave?.character.name ?? account.username}
|
{onlineSave?.character.name ?? account.username}
|
||||||
</strong>
|
</strong>
|
||||||
@@ -249,58 +335,77 @@ export function AuthScreen({ onContinue }: Props) {
|
|||||||
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
|
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="online-save-actions">
|
</div>
|
||||||
<button
|
<label className="online-save-destination">
|
||||||
className="save-continue-button"
|
Copy Into
|
||||||
disabled={onlineBusy || !onlineSave}
|
<select
|
||||||
onClick={continueOnline}
|
disabled={onlineBusy || !onlineSave}
|
||||||
type="button"
|
onChange={(event) => {
|
||||||
>
|
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
|
||||||
Load Online Save
|
setConfirmOnlineDestination(null)
|
||||||
</button>
|
}}
|
||||||
<button
|
value={onlineDestinationSlot}
|
||||||
className="save-secondary-button"
|
>
|
||||||
disabled={onlineBusy}
|
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => (
|
||||||
onClick={() => { void handleDifferentAccount() }}
|
<option key={slot} value={slot}>
|
||||||
type="button"
|
Slot {slot} — {localSaves[slot]?.character.name ?? 'Empty'}
|
||||||
>
|
</option>
|
||||||
Use Different Account
|
))}
|
||||||
</button>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
</>
|
<div className="online-save-actions">
|
||||||
) : (
|
<button
|
||||||
<form className="online-save-login" onSubmit={signIn}>
|
className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
|
||||||
<label>
|
disabled={onlineBusy || !onlineSave}
|
||||||
Username
|
onClick={continueOnline}
|
||||||
<input
|
type="button"
|
||||||
autoComplete="username"
|
>
|
||||||
maxLength={20}
|
{confirmOnlineDestination === onlineDestinationSlot
|
||||||
minLength={3}
|
? `Confirm Replace Slot ${onlineDestinationSlot}`
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
: `Load Into Slot ${onlineDestinationSlot}`}
|
||||||
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>
|
</button>
|
||||||
</form>
|
<button
|
||||||
)}
|
className="save-secondary-button"
|
||||||
</article>
|
disabled={onlineBusy}
|
||||||
</div>
|
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
|
<p
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Iwt2RoguelikeUpgradeScreen,
|
Iwt2RoguelikeUpgradeScreen,
|
||||||
Iwt2SettingsScreen,
|
Iwt2SettingsScreen,
|
||||||
} from './screens/Iwt2ShellScreens'
|
} from './screens/Iwt2ShellScreens'
|
||||||
import { writeIwt2Save, type Iwt2Save } from './save/iwt2Repository'
|
import { writeIwt2Save, type Iwt2LocalSaveSlot, 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 {
|
||||||
@@ -136,8 +136,8 @@ const MENU_ITEMS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'cloud-save',
|
screen: 'cloud-save',
|
||||||
title: 'Backup Slot',
|
title: 'Cloud Backup',
|
||||||
description: 'Choose local, online, or fresh IWT2 progress.',
|
description: 'Compare the active local slot with your online backup.',
|
||||||
glyph: 'C',
|
glyph: 'C',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,10 +150,12 @@ const MENU_ITEMS: Array<{
|
|||||||
|
|
||||||
export function IWantToHeal2App({
|
export function IWantToHeal2App({
|
||||||
initialSave,
|
initialSave,
|
||||||
|
localSlot,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onExitToSaveSelect,
|
onExitToSaveSelect,
|
||||||
}: {
|
}: {
|
||||||
initialSave: Iwt2Save
|
initialSave: Iwt2Save
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
onExitToSaveSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -174,8 +176,8 @@ export function IWantToHeal2App({
|
|||||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeIwt2Save(save)
|
writeIwt2Save(save, localSlot)
|
||||||
}, [save])
|
}, [localSlot, save])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancelPvpQueueRef.current?.()
|
cancelPvpQueueRef.current?.()
|
||||||
|
|||||||
@@ -101,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
|
|||||||
save: Iwt2Save | null
|
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<Iwt2LocalSaveSlot, Iwt2Save | null>
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: 'Healer',
|
name: normalizeCharacterName(characterName),
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
healerStyle: 'dawnweaver',
|
healerStyle: 'dawnweaver',
|
||||||
@@ -153,12 +159,38 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadIwt2Save(): Iwt2Save {
|
function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) {
|
||||||
return loadExistingIwt2Save() ?? createDefaultIwt2Save()
|
return `i-want-to-heal-2:save:slot-${slot}:v1`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadExistingIwt2Save(): Iwt2Save | null {
|
function migrateLegacyIwt2Save() {
|
||||||
const serialized = window.localStorage.getItem(IWT2_SAVE_KEY)
|
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
|
if (!serialized) return null
|
||||||
try {
|
try {
|
||||||
return normalizeSave(JSON.parse(serialized))
|
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<Iwt2OnlineSaveResult> {
|
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
|
||||||
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
|
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
|
||||||
return {
|
return {
|
||||||
@@ -185,12 +225,12 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSav
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeIwt2Save(save: Iwt2Save) {
|
export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
replaceIwt2Save(save)
|
replaceIwt2Save(save, slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceIwt2Save(save: Iwt2Save) {
|
export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify(normalizeSave(save)))
|
window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(
|
export function recordIwt2BossKill(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
||||||
import { getGameMode } from '../../../gameRepository'
|
import { getGameMode } from '../../../gameRepository'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2Save,
|
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
setIwt2InfusionAbility,
|
setIwt2InfusionAbility,
|
||||||
setIwt2PassiveInfusion,
|
setIwt2PassiveInfusion,
|
||||||
@@ -1946,11 +1945,6 @@ export function Iwt2CloudSaveScreen({
|
|||||||
setMessage('Local save now uses online progress.')
|
setMessage('Local save now uses online progress.')
|
||||||
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
||||||
|
|
||||||
const handleUseNewSave = useCallback(() => {
|
|
||||||
onSaveUpdated(createDefaultIwt2Save())
|
|
||||||
setMessage('Started a new local IWT2 save.')
|
|
||||||
}, [onSaveUpdated])
|
|
||||||
|
|
||||||
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
||||||
{
|
{
|
||||||
key: 'use-local',
|
key: 'use-local',
|
||||||
@@ -1970,16 +1964,8 @@ export function Iwt2CloudSaveScreen({
|
|||||||
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
||||||
onConfirm: handleUseOnlineSave,
|
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), [
|
], onBack), [
|
||||||
handleUseLocalSave,
|
handleUseLocalSave,
|
||||||
handleUseNewSave,
|
|
||||||
handleUseOnlineSave,
|
handleUseOnlineSave,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onlineSave,
|
onlineSave,
|
||||||
@@ -1989,7 +1975,7 @@ export function Iwt2CloudSaveScreen({
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
|
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
|
||||||
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
||||||
<Iwt2ActionList actions={actions} />
|
<Iwt2ActionList actions={actions} />
|
||||||
</Iwt2ScreenShell>
|
</Iwt2ScreenShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user