Compare commits

..
3 Commits
Author SHA1 Message Date
Warren H 90930b71aa Android build v1.1.39 2026-07-09 22:07:35 -04:00
Warren H 4766a34cd3 Android build v1.1.38 2026-07-09 21:57:51 -04:00
Warren H 8ffa6db317 Android build v1.1.37 2026-07-09 19:21:51 -04:00
14 changed files with 1010 additions and 365 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 117
versionName "1.1.36"
versionCode 120
versionName "1.1.39"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+491
View File
@@ -1873,6 +1873,497 @@
}
}
/* 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 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: 10px;
min-width: 0;
outline: 2px solid #3a4250;
padding: 12px;
}
.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-panel .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: 58px;
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%;
}
.slot-save-actions {
display: grid;
gap: 7px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: auto;
}
.slot-save-actions .save-continue-button {
margin-top: 0;
}
.character-create-form {
display: flex;
flex: 1;
flex-direction: column;
gap: 9px;
min-height: 0;
}
.character-create-form label,
.online-save-destination {
color: var(--muted);
display: grid;
font-family: var(--pixel-font);
font-size: 7px;
gap: 5px;
text-transform: uppercase;
}
.character-create-form input,
.online-save-destination select {
background: #0e1016;
border: 2px solid #090a0d;
color: var(--ink);
font: 16px var(--body-font);
min-height: 38px;
outline: 2px solid #3e3d47;
padding: 7px 9px;
width: 100%;
}
.character-create-form > small {
color: var(--muted);
font-size: 12px;
line-height: 1.2;
}
.online-save-panel {
align-items: center;
background: #131821;
border: 2px solid #090a0d;
border-top-color: #5799db;
display: grid;
gap: 12px;
grid-template-columns: 130px minmax(260px, 1fr) 160px minmax(170px, 210px);
min-width: 0;
outline: 2px solid #3a4250;
padding: 10px 12px;
}
.online-save-heading {
min-width: 0;
}
.online-save-summary {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(75px, 0.7fr) minmax(0, 1.8fr);
min-width: 0;
}
.online-save-summary .save-character-name {
font-size: 16px;
}
.online-save-summary .save-metadata {
gap: 5px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.online-save-summary .save-metadata > div {
padding: 5px 7px;
}
.online-save-summary .save-metadata dd {
font-size: 11px;
}
.online-save-summary .save-empty-state {
min-height: 48px;
}
.save-continue-button {
background: var(--gold);
color: #19150e;
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;
grid-column: 2 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(180px, 0.75fr);
margin: 0;
}
.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) {
.auth-shell.save-gateway-shell {
align-items: flex-start;
overflow: auto;
}
.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;
}
.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) {
.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: 6px;
padding: 8px;
}
.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;
}
.slot-save-actions {
gap: 5px;
}
.character-create-form {
gap: 6px;
}
.character-create-form input,
.online-save-destination select {
font-size: 14px;
min-height: 32px;
padding: 5px 7px;
}
.character-create-form > small {
font-size: 10px;
}
.online-save-panel {
gap: 8px;
grid-template-columns: 115px minmax(230px, 1fr) 145px minmax(160px, 190px);
padding: 7px 9px;
}
.online-save-summary {
gap: 5px;
}
.online-save-login {
gap: 6px;
}
.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;
+14 -144
View File
@@ -1,159 +1,29 @@
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 { Iwt2LocalSaveSlot, 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 = {
localSlot: Iwt2LocalSaveSlot
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}
localSlot={launch.localSlot}
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
+379 -153
View File
@@ -1,192 +1,418 @@
import { useState } from 'react'
import { useCallback, useEffect, 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,
IWT2_LOCAL_SAVE_SLOTS,
loadIwt2OnlineSave,
loadIwt2SaveSlots,
replaceIwt2Save,
selectActiveIwt2SaveSlot,
type Iwt2LocalSaveSlot,
type Iwt2LocalSaveSlots,
type Iwt2Save,
} from '../modes/iwt2/save/iwt2Repository'
type Props = {
onAuthenticated: (session: AuthSession) => void
serverMessage?: string
type Iwt2Launch = {
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean
save: Iwt2Save
}
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login')
type Props = {
onContinue: (launch: Iwt2Launch) => void
}
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
function formatSaveTimestamp(updatedAt: number) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
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>
)
}
function firstEmptySlot(saves: Iwt2LocalSaveSlots): Iwt2LocalSaveSlot {
return IWT2_LOCAL_SAVE_SLOTS.find((slot) => !saves[slot]) ?? 1
}
export function AuthScreen({ onContinue }: Props) {
const [localSaves] = useState(loadIwt2SaveSlots)
const [editingSlot, setEditingSlot] = useState<Iwt2LocalSaveSlot | null>(null)
const [characterName, setCharacterName] = useState('')
const [account, setAccount] = useState<Account | null>(null)
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [onlineDestinationSlot, setOnlineDestinationSlot] = useState<Iwt2LocalSaveSlot>(
() => firstEmptySlot(localSaves),
)
const [confirmOnlineDestination, setConfirmOnlineDestination] = useState<Iwt2LocalSaveSlot | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [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 [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
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. Choose its local destination slot.' : '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 slots remain available.`
: 'Online service unavailable. Local slots remain available.',
)
setMessageIsError(true)
})
.finally(() => {
if (!cancelled) setCheckingSession(false)
})
return () => {
cancelled = true
}
}, [inspectOnlineSave])
function continueLocal(slot: Iwt2LocalSaveSlot) {
const save = localSaves[slot]
if (!save) return
selectActiveIwt2SaveSlot(slot)
selectOfflineMode()
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
}
function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
setEditingSlot(slot)
setCharacterName('')
setMessage(localSaves[slot]
? `Creating a new character will replace Slot ${slot}.`
: `Enter a name for the new character in Slot ${slot}.`)
setMessageIsError(false)
}
function createCharacter(event: React.FormEvent, slot: Iwt2LocalSaveSlot) {
event.preventDefault()
setBusy(true)
setMessage('')
const name = characterName.trim()
if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) {
setMessage('Character name must be 218 characters, start with a letter, and use letters, numbers, spaces, or hyphens.')
setMessageIsError(true)
return
}
const save = createDefaultIwt2Save(name)
replaceIwt2Save(save, slot)
selectActiveIwt2SaveSlot(slot)
selectOfflineMode()
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
}
async function signIn(event: React.FormEvent) {
event.preventDefault()
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
const destinationSave = localSaves[onlineDestinationSlot]
if (destinationSave && confirmOnlineDestination !== onlineDestinationSlot) {
setConfirmOnlineDestination(onlineDestinationSlot)
setMessage(`Online save will replace ${destinationSave.character.name} in Slot ${onlineDestinationSlot}. Select again to confirm.`)
setMessageIsError(false)
return
}
replaceIwt2Save(onlineSave, onlineDestinationSlot)
selectActiveIwt2SaveSlot(onlineDestinationSlot)
selectOnlineMode()
onContinue({
localSlot: onlineDestinationSlot,
onlineBackupsAvailable: true,
save: onlineSave,
})
}
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>
<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 Character</h1>
</div>
<p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
</header>
<div className="save-option-grid">
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
const save = localSaves[slot]
const editing = editingSlot === slot
return (
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
<div className="save-option-heading">
<span aria-hidden="true">{slot}</span>
<div>
<p className="eyebrow">Local Save</p>
<h2>Slot {slot}</h2>
</div>
</div>
{editing ? (
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
<label>
Character Name
<input
autoFocus
maxLength={18}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
placeholder="Mira"
required
value={characterName}
/>
</label>
<small>
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
</small>
<div className="slot-save-actions">
<button
className={`save-continue-button ${save ? 'danger' : ''}`}
type="submit"
>
{save ? `Replace Slot ${slot}` : `Create in Slot ${slot}`}
</button>
<button
className="save-secondary-button"
onClick={() => setEditingSlot(null)}
type="button"
>
Cancel
</button>
</div>
</form>
) : save ? (
<>
<strong className="save-character-name">{save.character.name}</strong>
<SaveMetadata save={save} />
<div className="slot-save-actions">
<button
aria-label={`Continue ${save.character.name} in Slot ${slot}`}
className="save-continue-button"
onClick={() => continueLocal(slot)}
type="button"
>
Continue Offline
</button>
<button
aria-label={`Create new character in Slot ${slot}`}
className="save-secondary-button"
onClick={() => openCharacterEditor(slot)}
type="button"
>
New Character
</button>
</div>
</>
) : (
<>
<p className="save-empty-state">Empty slot</p>
<button
aria-label={`Create character in Slot ${slot}`}
className="save-continue-button"
onClick={() => openCharacterEditor(slot)}
type="button"
>
Create Character
</button>
</>
)}
</article>
)
})}
</div>
<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>
<section className="online-save-panel">
<div className="save-option-heading online-save-heading">
<span aria-hidden="true">O</span>
<div>
<p className="eyebrow">Account</p>
<h2>Online Save</h2>
</div>
</div>
<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' && (
{account ? (
<>
<div className="online-save-summary">
<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>
<label className="online-save-destination">
Copy Into
<select
disabled={onlineBusy || !onlineSave}
onChange={(event) => {
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
setConfirmOnlineDestination(null)
}}
value={onlineDestinationSlot}
>
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => (
<option key={slot} value={slot}>
Slot {slot} {localSaves[slot]?.character.name ?? 'Empty'}
</option>
))}
</select>
</label>
<div className="online-save-actions">
<button
className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
disabled={onlineBusy || !onlineSave}
onClick={continueOnline}
type="button"
>
{confirmOnlineDestination === onlineDestinationSlot
? `Confirm Replace Slot ${onlineDestinationSlot}`
: `Load Into Slot ${onlineDestinationSlot}`}
</button>
<button
className="save-secondary-button"
disabled={onlineBusy}
onClick={() => { void handleDifferentAccount() }}
type="button"
>
Switch Account
</button>
</div>
</>
) : (
<form className="online-save-login" onSubmit={signIn}>
<label>
Character Name
Username
<input
autoComplete="nickname"
autoComplete="username"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={characterName}
value={username}
/>
</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>
{offlineCharacterExists && (
<button
className="offline-resume-button"
onClick={resumeOffline}
type="button"
>
Continue Offline Character
<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>
)}
<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>
</form>
)}
</section>
<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>
+26 -26
View File
@@ -22,11 +22,7 @@ import {
Iwt2RoguelikeUpgradeScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
loadIwt2Save,
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 { iwt2BossCoinRewardFor } from './content/bossRewards'
import {
@@ -140,8 +136,8 @@ const MENU_ITEMS: Array<{
},
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Choose local, online, or fresh IWT2 progress.',
title: 'Cloud Backup',
description: 'Compare the active local slot with your online backup.',
glyph: 'C',
},
{
@@ -153,15 +149,19 @@ const MENU_ITEMS: Array<{
]
export function IWantToHeal2App({
initialSave,
localSlot,
onlineBackupsAvailable,
onBackToGameSelect,
onExitToSaveSelect,
}: {
initialSave: Iwt2Save
localSlot: Iwt2LocalSaveSlot
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')
@@ -176,8 +176,8 @@ export function IWantToHeal2App({
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
writeIwt2Save(save, localSlot)
}, [localSlot, save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
@@ -205,7 +205,7 @@ export function IWantToHeal2App({
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
onExitToSaveSelect()
return
}
if (action === 'confirm') {
@@ -288,7 +288,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 +312,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 +333,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 +363,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 +397,7 @@ export function IWantToHeal2App({
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
onExitToSaveSelect={onExitToSaveSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
@@ -411,7 +411,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 +424,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 +438,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 +446,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 +692,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">
+63 -12
View File
@@ -101,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
save: Iwt2Save | null
}
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
export const IWT2_LOCAL_SAVE_SLOTS = [1, 2, 3] as const
export type Iwt2LocalSaveSlot = (typeof IWT2_LOCAL_SAVE_SLOTS)[number]
export type Iwt2LocalSaveSlots = Record<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 {
version: 2,
updatedAt: Date.now(),
character: {
name: 'Healer',
name: normalizeCharacterName(characterName),
level: 1,
experience: 0,
healerStyle: 'dawnweaver',
@@ -130,7 +136,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)),
@@ -149,11 +159,51 @@ function normalizeSave(value: unknown): Iwt2Save {
}
}
export function loadIwt2Save(): Iwt2Save {
function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) {
return `i-want-to-heal-2:save:slot-${slot}:v1`
}
function migrateLegacyIwt2Save() {
if (window.localStorage.getItem(IWT2_SAVE_MIGRATION_KEY)) return
const legacySave = window.localStorage.getItem(IWT2_LEGACY_SAVE_KEY)
const firstSlotKey = iwt2SaveSlotKey(1)
if (legacySave && !window.localStorage.getItem(firstSlotKey)) {
window.localStorage.setItem(firstSlotKey, legacySave)
}
window.localStorage.setItem(IWT2_SAVE_MIGRATION_KEY, 'true')
}
export function activeIwt2SaveSlot(): Iwt2LocalSaveSlot {
const stored = Number(window.localStorage.getItem(IWT2_ACTIVE_SAVE_SLOT_KEY))
return IWT2_LOCAL_SAVE_SLOTS.includes(stored as Iwt2LocalSaveSlot)
? stored as Iwt2LocalSaveSlot
: 1
}
export function selectActiveIwt2SaveSlot(slot: Iwt2LocalSaveSlot) {
window.localStorage.setItem(IWT2_ACTIVE_SAVE_SLOT_KEY, String(slot))
}
export function loadIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save {
return loadExistingIwt2Save(slot) ?? createDefaultIwt2Save()
}
export function loadExistingIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save | null {
migrateLegacyIwt2Save()
const serialized = window.localStorage.getItem(iwt2SaveSlotKey(slot))
if (!serialized) return null
try {
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
return normalizeSave(JSON.parse(serialized))
} catch {
return createDefaultIwt2Save()
return null
}
}
export function loadIwt2SaveSlots(): Iwt2LocalSaveSlots {
return {
1: loadExistingIwt2Save(1),
2: loadExistingIwt2Save(2),
3: loadExistingIwt2Save(3),
}
}
@@ -175,11 +225,12 @@ 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(),
}))
export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
replaceIwt2Save(save, slot)
}
export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
}
export function recordIwt2BossKill(
+25 -12
View File
@@ -48,6 +48,7 @@ import {
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type PvpResultReason = 'opponent-defeated' | null
type OverlayAction = 'primary' | 'requeue' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
@@ -143,6 +144,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [pvpResultReason, setPvpResultReason] = useState<PvpResultReason>(null)
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
@@ -226,16 +228,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setPvpResultReason(null)
setStatus('playing')
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>, nextPvpResultReason: PvpResultReason = null) => {
setSelectedOverlayAction('primary')
setPvpResultReason(nextPvpResultReason)
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
setPvpResultReason(null)
statusRef.current = 'playing'
setStatus('playing')
}, [])
@@ -336,7 +341,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action === 'back') {
if (statusRef.current === 'paused') resumeArena()
if (device === 'pc' && statusRef.current === 'paused') resumeArena()
return
}
}
@@ -421,15 +426,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
if (pvpRoguelike && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
const playerCleared = next.bosses.every((boss) => boss.health <= 0)
const playerDefeated = next.party.every((member) => member.health <= 0)
const opponentCleared = Boolean(nextOpponentState?.bosses.every((boss) => boss.health <= 0))
const opponentDefeated = Boolean(nextOpponentState?.party.every((member) => member.health <= 0))
if (!pvpRoguelike && playerCleared) {
showOverlay('victory')
} else if (pvpRoguelike && playerDefeated) {
showOverlay('defeat')
} else if (pvpRoguelike && playerCleared && opponentDefeated) {
showOverlay('victory', 'opponent-defeated')
} else if (pvpRoguelike && playerCleared && opponentCleared && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else if (!pvpRoguelike && playerDefeated) {
showOverlay('defeat')
}
@@ -465,7 +476,9 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
? 'Rematch'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} Down`
? pvpResultReason === 'opponent-defeated'
? 'CPU Rival Falls'
: `${bossTitle} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
+1 -15
View File
@@ -9,7 +9,6 @@ import {
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
createDefaultIwt2Save,
loadIwt2OnlineSave,
setIwt2InfusionAbility,
setIwt2PassiveInfusion,
@@ -1946,11 +1945,6 @@ export function Iwt2CloudSaveScreen({
setMessage('Local save now uses online progress.')
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'use-local',
@@ -1970,16 +1964,8 @@ export function Iwt2CloudSaveScreen({
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave,
},
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: handleUseNewSave,
},
], onBack), [
handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave,
onlineBackupsAvailable,
onlineSave,
@@ -1989,7 +1975,7 @@ export function Iwt2CloudSaveScreen({
])
return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>