Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90930b71aa | ||
|
|
4766a34cd3 | ||
|
|
8ffa6db317 | ||
|
|
1fa1c8c070 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 116
|
||||
versionName "1.1.35"
|
||||
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
@@ -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
@@ -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
@@ -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 2–18 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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -76,9 +72,11 @@ type Iwt2Screen =
|
||||
const IWT2_MENU_COLUMNS = 2
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
bossesDefeated: number
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
@@ -138,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',
|
||||
},
|
||||
{
|
||||
@@ -151,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')
|
||||
@@ -174,8 +176,8 @@ export function IWantToHeal2App({
|
||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
writeIwt2Save(save)
|
||||
}, [save])
|
||||
writeIwt2Save(save, localSlot)
|
||||
}, [localSlot, save])
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
@@ -203,7 +205,7 @@ export function IWantToHeal2App({
|
||||
useGameAction((action, device) => {
|
||||
if (screen !== 'menu' || device !== 'controller') return
|
||||
if (action === 'back') {
|
||||
onBackToGameSelect()
|
||||
onExitToSaveSelect()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
@@ -256,10 +258,13 @@ export function IWantToHeal2App({
|
||||
buffs: roguelikeRun.buffs,
|
||||
contentType: roguelikeRun.contentType,
|
||||
debuffs: roguelikeRun.debuffs,
|
||||
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
|
||||
bossesDefeated: roguelikeRun.bossesDefeated,
|
||||
onVictory: () => {
|
||||
setRoguelikeRun((current) => current
|
||||
? {
|
||||
...current,
|
||||
bossesDefeated: current.bossesDefeated + current.bossIds.length,
|
||||
...buildRoguelikeChoices(save, current.variant),
|
||||
}
|
||||
: current)
|
||||
@@ -283,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)}
|
||||
@@ -307,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}
|
||||
@@ -328,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}
|
||||
@@ -358,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"
|
||||
@@ -392,7 +397,7 @@ export function IWantToHeal2App({
|
||||
save={save}
|
||||
title="Gear Upgrade"
|
||||
onBack={() => setScreen('menu')}
|
||||
onBackToGameSelect={onBackToGameSelect}
|
||||
onExitToSaveSelect={onExitToSaveSelect}
|
||||
/>
|
||||
<Iwt2GearUpgradeScreen
|
||||
save={save}
|
||||
@@ -406,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')}
|
||||
@@ -419,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}
|
||||
@@ -433,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>
|
||||
)
|
||||
@@ -441,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">
|
||||
@@ -534,7 +539,8 @@ function createRoguelikeRun(
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRoguelikeBossPair(variant, contentType, 1),
|
||||
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
|
||||
bossesDefeated: 0,
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
@@ -602,7 +608,7 @@ function applyRoguelikeChoice(
|
||||
return {
|
||||
...run,
|
||||
...nextBase,
|
||||
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
|
||||
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
stage: run.stage + 1,
|
||||
}
|
||||
@@ -612,6 +618,7 @@ function createRoguelikeBossPair(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
stage: number,
|
||||
bossesDefeated: number,
|
||||
): Iwt2BossId[] {
|
||||
const weightedProgressionEnabled = variant === 'pve'
|
||||
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||
@@ -619,11 +626,12 @@ function createRoguelikeBossPair(
|
||||
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
|
||||
|
||||
if (weightedProgressionEnabled) {
|
||||
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool })
|
||||
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
|
||||
}
|
||||
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool })
|
||||
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
|
||||
}
|
||||
|
||||
function roguelikeBossPoolFor(
|
||||
@@ -684,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">
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
|
||||
import type { Iwt2PlayerClassId } from './classes'
|
||||
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
|
||||
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||
|
||||
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
||||
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||
export type Iwt2PassiveInfusionId = Exclude<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
|
||||
export type Iwt2GearStatId =
|
||||
| 'maxHealth'
|
||||
| 'moveSpeed'
|
||||
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
|
||||
export type Iwt2ClassGearProgress = {
|
||||
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||
passiveInfusionId: Iwt2PassiveInfusionId | null
|
||||
}
|
||||
|
||||
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
|
||||
}
|
||||
|
||||
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||
export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||
export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||
export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||
|
||||
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||
weapon: 'Weapon',
|
||||
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
||||
}
|
||||
|
||||
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
||||
return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||
}
|
||||
|
||||
export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean {
|
||||
return Object.values(progress).some((classProgress) => (
|
||||
Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||
))
|
||||
}
|
||||
|
||||
export function iwt2GearUpgradeCosts(
|
||||
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
|
||||
slotId: Iwt2GearSlotId,
|
||||
currentLevel: Iwt2GearLevel,
|
||||
): Iwt2GearUpgradeCost[] {
|
||||
if (currentLevel >= 5) return []
|
||||
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
|
||||
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||
const slug = upgradeDifficultySlug(nextLevel)
|
||||
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
|
||||
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||
]
|
||||
return [
|
||||
if (nextLevel === 5) return [
|
||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||
]
|
||||
const overcap = nextLevel - 5
|
||||
return [
|
||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 + overcap },
|
||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap },
|
||||
]
|
||||
}
|
||||
|
||||
export function iwt2InfusionCosts(
|
||||
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
||||
feet: { level: 0 },
|
||||
},
|
||||
infusionAbilityId: null,
|
||||
passiveInfusionId: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +211,7 @@ function slotRecipe(
|
||||
|
||||
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||
if (level <= 2) return 'initiate'
|
||||
if (level >= 6) return 'veteran'
|
||||
if (level === 3) return 'veteran'
|
||||
if (level === 4) return 'champion'
|
||||
return 'mythic'
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createIwt2PvpNormalizedGearProgress(
|
||||
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
||||
),
|
||||
infusionAbilityId: null,
|
||||
passiveInfusionId: null,
|
||||
},
|
||||
]),
|
||||
) as Iwt2GearProgress
|
||||
|
||||
@@ -19,6 +19,7 @@ type TierWeight = {
|
||||
|
||||
type Iwt2RoguelikeBossPoolOptions = {
|
||||
bossPool?: readonly Iwt2BossId[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||
@@ -67,16 +68,17 @@ export function createIwt2WeightedRoguelikeBossPair(
|
||||
const choices: Iwt2BossId[] = []
|
||||
const maxThreat = maxThreatForStage(stage)
|
||||
const bossPool = normalizeBossPool(options.bossPool)
|
||||
const count = normalizeBossCount(options.count)
|
||||
|
||||
while (choices.length < 2) {
|
||||
while (choices.length < count) {
|
||||
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||
if (!next) break
|
||||
choices.push(next)
|
||||
}
|
||||
|
||||
return choices.length === 2
|
||||
return choices.length === count
|
||||
? choices
|
||||
: createUniformIwt2RoguelikeBossPair(random, { bossPool })
|
||||
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
|
||||
}
|
||||
|
||||
export function createUniformIwt2RoguelikeBossPair(
|
||||
@@ -84,8 +86,9 @@ export function createUniformIwt2RoguelikeBossPair(
|
||||
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||
): Iwt2BossId[] {
|
||||
const pool = normalizeBossPool(options.bossPool)
|
||||
const count = normalizeBossCount(options.count)
|
||||
const choices: Iwt2BossId[] = []
|
||||
while (pool.length > 0 && choices.length < 2) {
|
||||
while (pool.length > 0 && choices.length < count) {
|
||||
const index = randomIndex(pool.length, random)
|
||||
const [choice] = pool.splice(index, 1)
|
||||
if (choice) choices.push(choice)
|
||||
@@ -192,7 +195,7 @@ function maxThreatForStage(stage: number): number {
|
||||
if (safeStage <= 2) return 3
|
||||
if (safeStage === 3) return 4
|
||||
if (safeStage <= 5) return 5
|
||||
return 6
|
||||
return 8
|
||||
}
|
||||
|
||||
function threatForBoss(bossId: Iwt2BossId): number {
|
||||
@@ -203,6 +206,10 @@ function randomIndex(length: number, random: () => number): number {
|
||||
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
||||
}
|
||||
|
||||
function normalizeBossCount(count: number | undefined): number {
|
||||
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
|
||||
}
|
||||
|
||||
function safeRandom(random: () => number): number {
|
||||
const value = random()
|
||||
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
||||
|
||||
@@ -13,6 +13,11 @@ type PhaserArenaProps = {
|
||||
|
||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const onStepRef = useRef(onStep)
|
||||
|
||||
useEffect(() => {
|
||||
onStepRef.current = onStep
|
||||
}, [onStep])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return undefined
|
||||
@@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
||||
getMovement: () => movementRef.current,
|
||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||
getState: () => stateRef.current,
|
||||
step: onStep,
|
||||
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
|
||||
})
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
@@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
||||
return () => {
|
||||
game.destroy(true)
|
||||
}
|
||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
||||
}, [movementRef, selectedPartyIdRef, stateRef])
|
||||
|
||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||
}
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
import type { Iwt2BossId } from '../content/bosses'
|
||||
import {
|
||||
createDefaultIwt2GearProgress,
|
||||
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||
IWT2_GEAR_SLOTS,
|
||||
IWT2_MAX_GEAR_LEVEL,
|
||||
iwt2GearUpgradeCosts,
|
||||
iwt2InfusionCosts,
|
||||
isIwt2InfusionUnlocked,
|
||||
isIwt2PassiveInfusionUnlocked,
|
||||
type Iwt2GearLevel,
|
||||
type Iwt2GearProgress,
|
||||
type Iwt2GearSlotId,
|
||||
type Iwt2PassiveInfusionId,
|
||||
} from '../content/gear'
|
||||
import {
|
||||
IWT2_INFUSION_ABILITIES,
|
||||
@@ -97,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',
|
||||
@@ -126,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)),
|
||||
@@ -145,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,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(
|
||||
@@ -263,7 +318,7 @@ export function upgradeIwt2GearSlot(
|
||||
): Iwt2Save {
|
||||
const classProgress = save.gearProgress[classId]
|
||||
const slot = classProgress.slots[slotId]
|
||||
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
|
||||
if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
|
||||
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||
return {
|
||||
@@ -294,8 +349,10 @@ export function setIwt2InfusionAbility(
|
||||
const classProgress = save.gearProgress[classId]
|
||||
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
||||
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
|
||||
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
||||
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
|
||||
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
|
||||
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`)
|
||||
}
|
||||
if (classProgress.infusionAbilityId === abilityId) return save
|
||||
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||
return {
|
||||
@@ -312,6 +369,27 @@ export function setIwt2InfusionAbility(
|
||||
}
|
||||
}
|
||||
|
||||
export function setIwt2PassiveInfusion(
|
||||
save: Iwt2Save,
|
||||
passiveInfusionId: Iwt2PassiveInfusionId,
|
||||
): Iwt2Save {
|
||||
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
|
||||
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
|
||||
}
|
||||
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
|
||||
return {
|
||||
...save,
|
||||
updatedAt: Date.now(),
|
||||
gearProgress: {
|
||||
...save.gearProgress,
|
||||
healer: {
|
||||
...save.gearProgress.healer,
|
||||
passiveInfusionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||
}
|
||||
@@ -450,6 +528,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
||||
const classProgress = rawClassProgress as {
|
||||
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||
infusionAbilityId?: unknown
|
||||
passiveInfusionId?: unknown
|
||||
}
|
||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||
next[classId].slots[slotId] = {
|
||||
@@ -461,6 +540,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
||||
&& isIwt2InfusionUnlocked(next[classId])
|
||||
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||
: null
|
||||
next[classId].passiveInfusionId = classId === 'healer'
|
||||
&& isIwt2PassiveInfusionUnlocked(next)
|
||||
? asPassiveInfusionId(classProgress.passiveInfusionId)
|
||||
: null
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -470,10 +553,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
||||
}
|
||||
|
||||
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
||||
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
|
||||
return level as Iwt2GearLevel
|
||||
}
|
||||
|
||||
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
|
||||
if (typeof value !== 'string' || value === 'revive-party-members') return null
|
||||
return value as Iwt2PassiveInfusionId
|
||||
}
|
||||
|
||||
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const byId = new Map<string, Iwt2InventoryItem>()
|
||||
|
||||
@@ -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
|
||||
@@ -82,9 +83,11 @@ type BossArenaScreenProps = {
|
||||
onPvpRequeue?: () => void
|
||||
onSaveUpdated: (save: Iwt2Save) => void
|
||||
roguelikeRun?: {
|
||||
bossesDefeated: number
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
greenCoinThreshold: number
|
||||
onVictory: () => void
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
@@ -141,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[]>([])
|
||||
@@ -224,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')
|
||||
}, [])
|
||||
@@ -247,9 +254,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
||||
const gearAbilities = activeGearProgress
|
||||
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
||||
: baseAbilities
|
||||
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
|
||||
? [activeGearProgress.healer.passiveInfusionId]
|
||||
: []
|
||||
return applyRoguelikeModifiers(
|
||||
gearAbilities,
|
||||
roguelikeRun?.buffs ?? [],
|
||||
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
|
||||
roguelikeRun?.debuffs ?? [],
|
||||
)
|
||||
},
|
||||
@@ -331,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
|
||||
}
|
||||
}
|
||||
@@ -402,7 +412,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||
nextRecordedIds.add(defeatedBoss.bossId)
|
||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||
difficultySlug,
|
||||
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
|
||||
experienceMultiplier,
|
||||
})
|
||||
updatedSave = reward.save
|
||||
@@ -416,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')
|
||||
}
|
||||
|
||||
@@ -460,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'
|
||||
@@ -670,6 +688,17 @@ function roguelikeBossHealthScale(stage: number): number {
|
||||
return 1 + Math.max(0, stage - 1) * 0.1
|
||||
}
|
||||
|
||||
function rewardDifficultySlugForKill(
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||
defeatedEarlierThisArena: number,
|
||||
fallbackSlug: string,
|
||||
): string {
|
||||
if (!roguelikeRun) return fallbackSlug
|
||||
return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold
|
||||
? 'veteran'
|
||||
: fallbackSlug
|
||||
}
|
||||
|
||||
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
||||
import { getGameMode } from '../../../gameRepository'
|
||||
import {
|
||||
createDefaultIwt2Save,
|
||||
loadIwt2OnlineSave,
|
||||
setIwt2InfusionAbility,
|
||||
setIwt2PassiveInfusion,
|
||||
updateIwt2CharacterSettings,
|
||||
upgradeIwt2GearSlot,
|
||||
canAffordIwt2Costs,
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
IWT2_HEALER_ORDER,
|
||||
} from '../content/healerAbilities'
|
||||
import {
|
||||
buildIwt2SelfBuffChoices,
|
||||
IWT2_REVIVE_PARTY_CHOICE,
|
||||
type Iwt2RoguelikeChoice,
|
||||
type Iwt2RoguelikeContentType,
|
||||
type Iwt2RoguelikeOpponentDebuffId,
|
||||
@@ -51,9 +53,13 @@ import {
|
||||
IWT2_GEAR_SLOT_RECIPES,
|
||||
IWT2_GEAR_SLOTS,
|
||||
IWT2_GEAR_STAT_LABELS,
|
||||
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||
IWT2_MAX_GEAR_LEVEL,
|
||||
iwt2GearUpgradeCosts,
|
||||
iwt2InfusionCosts,
|
||||
isIwt2InfusionUnlocked,
|
||||
isIwt2PassiveInfusionUnlocked,
|
||||
type Iwt2PassiveInfusionId,
|
||||
type Iwt2GearStatId,
|
||||
type Iwt2GearSlotId,
|
||||
} from '../content/gear'
|
||||
@@ -144,6 +150,7 @@ type Iwt2GearNavEntry =
|
||||
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
||||
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
||||
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
||||
| { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean }
|
||||
|
||||
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||
const IWT2_NAME_MAX_LENGTH = 18
|
||||
@@ -1938,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',
|
||||
@@ -1962,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,
|
||||
@@ -1981,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>
|
||||
@@ -2005,16 +1999,22 @@ export function Iwt2GearUpgradeScreen({
|
||||
const selectedSlot = classProgress.slots[selectedSlotId]
|
||||
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
||||
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
||||
const nextLevel = Math.min(5, selectedSlot.level + 1)
|
||||
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
|
||||
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
||||
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
||||
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
||||
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
|
||||
const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts)
|
||||
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
||||
const infusionAnchorSlot = selectedSlot.level >= 5
|
||||
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
|
||||
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
|
||||
? selectedSlotId
|
||||
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
|
||||
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId
|
||||
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
||||
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
|
||||
if (selectedClassId !== 'healer') return []
|
||||
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
|
||||
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
|
||||
}, [save.character.healerStyle, selectedClassId])
|
||||
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
||||
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
||||
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
||||
@@ -2036,8 +2036,19 @@ export function Iwt2GearUpgradeScreen({
|
||||
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
||||
})
|
||||
})
|
||||
passiveInfusionChoices.forEach((choice, index) => {
|
||||
const selected = classProgress.passiveInfusionId === choice.id
|
||||
entries.push({
|
||||
kind: 'passiveInfusion',
|
||||
key: `passive:${choice.id}`,
|
||||
row: infusionAbilities.length + index + 1,
|
||||
column: 2,
|
||||
passiveId: choice.id,
|
||||
disabled: selected || !passiveInfusionUnlocked,
|
||||
})
|
||||
})
|
||||
return entries
|
||||
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
|
||||
}, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId])
|
||||
|
||||
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
||||
|
||||
@@ -2089,6 +2100,18 @@ export function Iwt2GearUpgradeScreen({
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (entry.kind === 'passiveInfusion') {
|
||||
if (entry.disabled) return
|
||||
try {
|
||||
const nextSave = setIwt2PassiveInfusion(save, entry.passiveId)
|
||||
onSaveUpdated(nextSave)
|
||||
const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive'
|
||||
setMessage(`${passiveName} set as passive infusion.`)
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Passive infusion failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2112,6 +2135,7 @@ export function Iwt2GearUpgradeScreen({
|
||||
const selected = selectedClassId === classId
|
||||
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
||||
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
||||
const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null
|
||||
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
||||
const className = gearClassDisplayName(classId, save)
|
||||
const classSubtitle = gearClassSubtitle(classId, save)
|
||||
@@ -2129,7 +2153,7 @@ export function Iwt2GearUpgradeScreen({
|
||||
<div>
|
||||
<strong>{className}</strong>
|
||||
<small>{classSubtitle}</small>
|
||||
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
|
||||
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -2172,7 +2196,7 @@ export function Iwt2GearUpgradeScreen({
|
||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
||||
type="button"
|
||||
>
|
||||
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
|
||||
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -2188,8 +2212,8 @@ export function Iwt2GearUpgradeScreen({
|
||||
<small>{selectedBonus.text}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
||||
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
||||
<strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
||||
<small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="iwt2-gear-cost-list">
|
||||
@@ -2227,12 +2251,40 @@ export function Iwt2GearUpgradeScreen({
|
||||
<div>
|
||||
<strong>{ability.name}</strong>
|
||||
<small>{ability.description}</small>
|
||||
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
|
||||
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedClassId === 'healer' && (
|
||||
<div className="iwt2-infusion-list">
|
||||
{passiveInfusionChoices.map((choice) => {
|
||||
const selected = classProgress.passiveInfusionId === choice.id
|
||||
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
|
||||
const disabled = selected || !passiveInfusionUnlocked
|
||||
return (
|
||||
<button
|
||||
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={focused ? 'true' : undefined}
|
||||
disabled={disabled}
|
||||
key={choice.id}
|
||||
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
|
||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>P</span>
|
||||
<div>
|
||||
<strong>{choice.name}</strong>
|
||||
<small>{choice.description}</small>
|
||||
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<footer className="iwt2-gear-message">{message}</footer>
|
||||
</section>
|
||||
</div>
|
||||
@@ -2276,7 +2328,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
|
||||
}
|
||||
|
||||
function infusionAnchorText(level: number): string {
|
||||
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
|
||||
if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.'
|
||||
if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.'
|
||||
return 'No infusion anchor.'
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
|
||||
Reference in New Issue
Block a user