Compare commits

..
3 Commits
Author SHA1 Message Date
Warren H 1f633e5aaa Android build v1.1.41 2026-07-10 00:01:17 -04:00
Warren H e9ee7bcd70 Android build v1.1.40 2026-07-09 23:37:31 -04:00
Warren H 90930b71aa Android build v1.1.39 2026-07-09 22:07:35 -04:00
18 changed files with 814 additions and 520 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 119 versionCode 122
versionName "1.1.38" versionName "1.1.41"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+189 -8
View File
@@ -236,6 +236,14 @@
width: 100%; width: 100%;
} }
.iwt2-arena-loading {
align-items: center;
color: var(--muted);
display: flex;
font-family: var(--pixel-font);
justify-content: center;
}
.iwt2-phaser-host canvas { .iwt2-phaser-host canvas {
display: block; display: block;
} }
@@ -1429,6 +1437,36 @@
} }
@media (max-width: 1000px) and (max-height: 620px) { @media (max-width: 1000px) and (max-height: 620px) {
.pause-screen.iwt2-arena-overlay {
padding: 10px;
}
.pause-screen.iwt2-arena-overlay > .iwt2-result-panel {
gap: 8px;
max-height: calc(100dvh - 20px);
max-width: 460px;
min-width: min(460px, calc(100vw - 20px));
padding: 12px 14px;
}
.iwt2-result-panel .iwt2-result-crest {
display: none;
}
.iwt2-result-panel h1 {
font-size: 1.35rem;
}
.iwt2-result-summary {
gap: 6px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.iwt2-result-summary span {
min-height: 42px;
padding: 6px;
}
.iwt2-arena-layout { .iwt2-arena-layout {
grid-template-columns: 154px minmax(0, 1fr); grid-template-columns: 154px minmax(0, 1fr);
} }
@@ -1888,7 +1926,7 @@
box-shadow: 8px 8px 0 #050609; box-shadow: 8px 8px 0 #050609;
display: grid; display: grid;
gap: 12px; gap: 12px;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto auto;
margin: auto; margin: auto;
max-width: 1080px; max-width: 1080px;
min-height: min(512px, calc(100dvh - 28px)); min-height: min(512px, calc(100dvh - 28px));
@@ -1935,10 +1973,10 @@
border: 2px solid #090a0d; border: 2px solid #090a0d;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
min-width: 0; min-width: 0;
outline: 2px solid #3a4250; outline: 2px solid #3a4250;
padding: 15px; padding: 12px;
} }
.local-save-card { .local-save-card {
@@ -1972,7 +2010,7 @@
outline: 1px solid #4c5667; outline: 1px solid #4c5667;
} }
.online-save-card .save-option-heading > span { .online-save-panel .save-option-heading > span {
color: #80bdf2; color: #80bdf2;
} }
@@ -2031,7 +2069,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.25; line-height: 1.25;
margin: 0; margin: 0;
min-height: 72px; min-height: 58px;
padding: 12px; padding: 12px;
} }
@@ -2047,6 +2085,99 @@
width: 100%; width: 100%;
} }
.slot-save-actions {
display: grid;
gap: 7px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: auto;
}
.slot-save-actions .save-continue-button {
margin-top: 0;
}
.character-create-form {
display: flex;
flex: 1;
flex-direction: column;
gap: 9px;
min-height: 0;
}
.character-create-form label,
.online-save-destination {
color: var(--muted);
display: grid;
font-family: var(--pixel-font);
font-size: 7px;
gap: 5px;
text-transform: uppercase;
}
.character-create-form input,
.online-save-destination select {
background: #0e1016;
border: 2px solid #090a0d;
color: var(--ink);
font: 16px var(--body-font);
min-height: 38px;
outline: 2px solid #3e3d47;
padding: 7px 9px;
width: 100%;
}
.character-create-form > small {
color: var(--muted);
font-size: 12px;
line-height: 1.2;
}
.online-save-panel {
align-items: center;
background: #131821;
border: 2px solid #090a0d;
border-top-color: #5799db;
display: grid;
gap: 12px;
grid-template-columns: 130px minmax(260px, 1fr) 160px minmax(170px, 210px);
min-width: 0;
outline: 2px solid #3a4250;
padding: 10px 12px;
}
.online-save-heading {
min-width: 0;
}
.online-save-summary {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(75px, 0.7fr) minmax(0, 1.8fr);
min-width: 0;
}
.online-save-summary .save-character-name {
font-size: 16px;
}
.online-save-summary .save-metadata {
gap: 5px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.online-save-summary .save-metadata > div {
padding: 5px 7px;
}
.online-save-summary .save-metadata dd {
font-size: 11px;
}
.online-save-summary .save-empty-state {
min-height: 48px;
}
.save-continue-button { .save-continue-button {
background: var(--gold); background: var(--gold);
color: #19150e; color: #19150e;
@@ -2076,7 +2207,9 @@
.online-save-login { .online-save-login {
display: grid; display: grid;
gap: 9px; gap: 9px;
margin-top: auto; grid-column: 2 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(180px, 0.75fr);
margin: 0;
} }
.online-save-login label { .online-save-login label {
@@ -2123,6 +2256,11 @@
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.auth-shell.save-gateway-shell {
align-items: flex-start;
overflow: auto;
}
.save-gateway-panel { .save-gateway-panel {
min-height: auto; min-height: auto;
} }
@@ -2140,6 +2278,20 @@
.save-option-grid { .save-option-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.online-save-panel {
align-items: stretch;
grid-template-columns: 1fr;
}
.online-save-login {
grid-column: auto;
grid-template-columns: 1fr;
}
.online-save-summary {
grid-template-columns: 1fr;
}
} }
@media (max-width: 1000px) and (max-height: 620px) { @media (max-width: 1000px) and (max-height: 620px) {
@@ -2171,8 +2323,8 @@
} }
.save-option-card { .save-option-card {
gap: 8px; gap: 6px;
padding: 10px; padding: 8px;
} }
.save-option-heading > span { .save-option-heading > span {
@@ -2206,6 +2358,35 @@
padding: 6px 8px; padding: 6px 8px;
} }
.slot-save-actions {
gap: 5px;
}
.character-create-form {
gap: 6px;
}
.character-create-form input,
.online-save-destination select {
font-size: 14px;
min-height: 32px;
padding: 5px 7px;
}
.character-create-form > small {
font-size: 10px;
}
.online-save-panel {
gap: 8px;
grid-template-columns: 115px minmax(230px, 1fr) 145px minmax(160px, 190px);
padding: 7px 9px;
}
.online-save-summary {
gap: 5px;
}
.online-save-login { .online-save-login {
gap: 6px; gap: 6px;
} }
+22 -4
View File
@@ -1,9 +1,13 @@
import { useState } from 'react' import { lazy, Suspense, useState } from 'react'
import { AuthScreen } from './components/AuthScreen' import { AuthScreen } from './components/AuthScreen'
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository'
import type { Iwt2Save } from './modes/iwt2/save/iwt2Repository'
const LazyIWantToHeal2App = lazy(() => import('./modes/iwt2/IWantToHeal2App').then((module) => ({
default: module.IWantToHeal2App,
})))
type Iwt2Launch = { type Iwt2Launch = {
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean onlineBackupsAvailable: boolean
save: Iwt2Save save: Iwt2Save
} }
@@ -13,15 +17,29 @@ function App() {
if (launch) { if (launch) {
return ( return (
<IWantToHeal2App <Suspense fallback={<Iwt2LaunchFallback />}>
<LazyIWantToHeal2App
initialSave={launch.save} initialSave={launch.save}
localSlot={launch.localSlot}
onlineBackupsAvailable={launch.onlineBackupsAvailable} onlineBackupsAvailable={launch.onlineBackupsAvailable}
onExitToSaveSelect={() => setLaunch(null)} onExitToSaveSelect={() => setLaunch(null)}
/> />
</Suspense>
) )
} }
return <AuthScreen onContinue={setLaunch} /> return <AuthScreen onContinue={setLaunch} />
} }
function Iwt2LaunchFallback() {
return (
<main className="game-shell iwt2-shell">
<section className="message-panel" aria-live="polite">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Loading...</h1>
</section>
</main>
)
}
export default App export default App
+165 -58
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import {
loadAuthSession, loadAuthSession,
loginAccount, loginAccount,
@@ -8,13 +8,18 @@ import {
import { selectOfflineMode, selectOnlineMode } from '../gameRepository' import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
import { import {
createDefaultIwt2Save, createDefaultIwt2Save,
loadExistingIwt2Save, IWT2_LOCAL_SAVE_SLOTS,
loadIwt2OnlineSave, loadIwt2OnlineSave,
loadIwt2SaveSlots,
replaceIwt2Save, replaceIwt2Save,
selectActiveIwt2SaveSlot,
type Iwt2LocalSaveSlot,
type Iwt2LocalSaveSlots,
type Iwt2Save, type Iwt2Save,
} from '../modes/iwt2/save/iwt2Repository' } from '../modes/iwt2/save/iwt2Repository'
type Iwt2Launch = { type Iwt2Launch = {
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean onlineBackupsAvailable: boolean
save: Iwt2Save save: Iwt2Save
} }
@@ -23,6 +28,8 @@ type Props = {
onContinue: (launch: Iwt2Launch) => void onContinue: (launch: Iwt2Launch) => void
} }
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
function formatSaveTimestamp(updatedAt: number) { function formatSaveTimestamp(updatedAt: number) {
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
@@ -49,18 +56,26 @@ function SaveMetadata({ save }: { save: Iwt2Save }) {
) )
} }
function firstEmptySlot(saves: Iwt2LocalSaveSlots): Iwt2LocalSaveSlot {
return IWT2_LOCAL_SAVE_SLOTS.find((slot) => !saves[slot]) ?? 1
}
export function AuthScreen({ onContinue }: Props) { export function AuthScreen({ onContinue }: Props) {
const [localSave] = useState(loadExistingIwt2Save) const [localSaves] = useState(loadIwt2SaveSlots)
const newSave = useMemo(() => createDefaultIwt2Save(), []) const [editingSlot, setEditingSlot] = useState<Iwt2LocalSaveSlot | null>(null)
const [characterName, setCharacterName] = useState('')
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(null)
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null) const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [onlineDestinationSlot, setOnlineDestinationSlot] = useState<Iwt2LocalSaveSlot>(
() => firstEmptySlot(localSaves),
)
const [confirmOnlineDestination, setConfirmOnlineDestination] = useState<Iwt2LocalSaveSlot | null>(null)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [checkingSession, setCheckingSession] = useState(true) const [checkingSession, setCheckingSession] = useState(true)
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false) const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [confirmNewSave, setConfirmNewSave] = useState(false) const [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
const [message, setMessage] = useState('Local play is ready while online saves are checked.')
const [messageIsError, setMessageIsError] = useState(false) const [messageIsError, setMessageIsError] = useState(false)
const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => { const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => {
@@ -69,7 +84,9 @@ export function AuthScreen({ onContinue }: Props) {
const result = await loadIwt2OnlineSave() const result = await loadIwt2OnlineSave()
if (cancelled()) return if (cancelled()) return
setOnlineSave(result.save) setOnlineSave(result.save)
setMessage(result.save ? 'Online save ready to load.' : 'Account has no online IWT2 save yet.') setMessage(result.save
? 'Online save ready. Load it below, or continue a local slot and upload it from Cloud Backup.'
: 'No online save yet. Continue a local slot, then use Cloud Backup to upload it.')
setMessageIsError(false) setMessageIsError(false)
} catch (reason) { } catch (reason) {
if (cancelled()) return if (cancelled()) return
@@ -94,8 +111,8 @@ export function AuthScreen({ onContinue }: Props) {
if (cancelled) return if (cancelled) return
setMessage( setMessage(
reason instanceof Error reason instanceof Error
? `${reason.message} Local saves remain available.` ? `${reason.message} Local slots remain available.`
: 'Online service unavailable. Local saves remain available.', : 'Online service unavailable. Local slots remain available.',
) )
setMessageIsError(true) setMessageIsError(true)
}) })
@@ -107,22 +124,36 @@ export function AuthScreen({ onContinue }: Props) {
} }
}, [inspectOnlineSave]) }, [inspectOnlineSave])
function continueLocal() { function continueLocal(slot: Iwt2LocalSaveSlot) {
if (!localSave) return const save = localSaves[slot]
if (!save) return
selectActiveIwt2SaveSlot(slot)
selectOfflineMode() selectOfflineMode()
onContinue({ save: localSave, onlineBackupsAvailable: false }) onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
} }
function continueNew() { function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
if (localSave && !confirmNewSave) { setEditingSlot(slot)
setConfirmNewSave(true) setCharacterName('')
setMessage('New save replaces current local file. Select again to confirm.') setMessage(localSaves[slot]
? `Creating a new character will replace Slot ${slot}.`
: `Enter a name for the new character in Slot ${slot}.`)
setMessageIsError(false) setMessageIsError(false)
}
function createCharacter(event: React.FormEvent, slot: Iwt2LocalSaveSlot) {
event.preventDefault()
const name = characterName.trim()
if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) {
setMessage('Character name must be 218 characters, start with a letter, and use letters, numbers, spaces, or hyphens.')
setMessageIsError(true)
return return
} }
replaceIwt2Save(newSave) const save = createDefaultIwt2Save(name)
replaceIwt2Save(save, slot)
selectActiveIwt2SaveSlot(slot)
selectOfflineMode() selectOfflineMode()
onContinue({ save: newSave, onlineBackupsAvailable: false }) onContinue({ localSlot: slot, save, onlineBackupsAvailable: Boolean(account) })
} }
async function signIn(event: React.FormEvent) { async function signIn(event: React.FormEvent) {
@@ -148,8 +179,21 @@ export function AuthScreen({ onContinue }: Props) {
function continueOnline() { function continueOnline() {
if (!account || !onlineSave) return if (!account || !onlineSave) return
const destinationSave = localSaves[onlineDestinationSlot]
if (destinationSave && confirmOnlineDestination !== onlineDestinationSlot) {
setConfirmOnlineDestination(onlineDestinationSlot)
setMessage(`Online save will replace ${destinationSave.character.name} in Slot ${onlineDestinationSlot}. Select again to confirm.`)
setMessageIsError(false)
return
}
replaceIwt2Save(onlineSave, onlineDestinationSlot)
selectActiveIwt2SaveSlot(onlineDestinationSlot)
selectOnlineMode() selectOnlineMode()
onContinue({ save: onlineSave, onlineBackupsAvailable: true }) onContinue({
localSlot: onlineDestinationSlot,
onlineBackupsAvailable: true,
save: onlineSave,
})
} }
async function handleDifferentAccount() { async function handleDifferentAccount() {
@@ -177,59 +221,102 @@ export function AuthScreen({ onContinue }: Props) {
<header className="save-gateway-heading"> <header className="save-gateway-heading">
<div> <div>
<p className="eyebrow">I Want To Heal 2</p> <p className="eyebrow">I Want To Heal 2</p>
<h1>Choose Save</h1> <h1>Choose Character</h1>
</div> </div>
<p>Compare last update and character XP before entering the arena.</p> <p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
</header> </header>
<div className="save-option-grid"> <div className="save-option-grid">
<article className="save-option-card local-save-card"> {IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
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"> <div className="save-option-heading">
<span aria-hidden="true">L</span> <span aria-hidden="true">{slot}</span>
<div> <div>
<p className="eyebrow">Offline</p> <p className="eyebrow">Local Save</p>
<h2>Local Save</h2> <h2>Slot {slot}</h2>
</div> </div>
</div> </div>
{localSave ? (
{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">{localSave.character.name}</strong> <strong className="save-character-name">{save.character.name}</strong>
<SaveMetadata save={localSave} /> <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">No local IWT2 save found.</p> <>
)} <p className="save-empty-state">Empty slot</p>
<button <button
aria-label={`Create character in Slot ${slot}`}
className="save-continue-button" className="save-continue-button"
disabled={!localSave} onClick={() => openCharacterEditor(slot)}
onClick={continueLocal}
type="button" type="button"
> >
Continue Offline Local Create Character
</button> </button>
</>
)}
</article> </article>
)
<article className="save-option-card new-save-card"> })}
<div className="save-option-heading">
<span aria-hidden="true">N</span>
<div>
<p className="eyebrow">Offline</p>
<h2>New Save</h2>
</div> </div>
</div>
<strong className="save-character-name">{newSave.character.name}</strong>
<SaveMetadata save={newSave} />
<button
className={`save-continue-button ${confirmNewSave ? 'danger' : ''}`}
onClick={continueNew}
type="button"
>
{confirmNewSave ? 'Confirm Replace Local Save' : 'Continue Offline — New'}
</button>
</article>
<article className="save-option-card online-save-card"> <section className="online-save-panel">
<div className="save-option-heading"> <div className="save-option-heading online-save-heading">
<span aria-hidden="true">O</span> <span aria-hidden="true">O</span>
<div> <div>
<p className="eyebrow">Account</p> <p className="eyebrow">Account</p>
@@ -239,6 +326,7 @@ export function AuthScreen({ onContinue }: Props) {
{account ? ( {account ? (
<> <>
<div className="online-save-summary">
<strong className="save-character-name"> <strong className="save-character-name">
{onlineSave?.character.name ?? account.username} {onlineSave?.character.name ?? account.username}
</strong> </strong>
@@ -249,14 +337,34 @@ export function AuthScreen({ onContinue }: Props) {
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'} {onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
</p> </p>
)} )}
</div>
<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"> <div className="online-save-actions">
<button <button
className="save-continue-button" className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
disabled={onlineBusy || !onlineSave} disabled={onlineBusy || !onlineSave}
onClick={continueOnline} onClick={continueOnline}
type="button" type="button"
> >
Load Online Save {confirmOnlineDestination === onlineDestinationSlot
? `Confirm Replace Slot ${onlineDestinationSlot}`
: `Load Into Slot ${onlineDestinationSlot}`}
</button> </button>
<button <button
className="save-secondary-button" className="save-secondary-button"
@@ -264,7 +372,7 @@ export function AuthScreen({ onContinue }: Props) {
onClick={() => { void handleDifferentAccount() }} onClick={() => { void handleDifferentAccount() }}
type="button" type="button"
> >
Use Different Account Switch Account
</button> </button>
</div> </div>
</> </>
@@ -299,8 +407,7 @@ export function AuthScreen({ onContinue }: Props) {
</button> </button>
</form> </form>
)} )}
</article> </section>
</div>
<p <p
aria-live="polite" aria-live="polite"
+7 -5
View File
@@ -22,7 +22,7 @@ import {
Iwt2RoguelikeUpgradeScreen, Iwt2RoguelikeUpgradeScreen,
Iwt2SettingsScreen, Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens' } from './screens/Iwt2ShellScreens'
import { writeIwt2Save, type Iwt2Save } from './save/iwt2Repository' import { writeIwt2Save, type Iwt2LocalSaveSlot, type Iwt2Save } from './save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { iwt2BossCoinRewardFor } from './content/bossRewards' import { iwt2BossCoinRewardFor } from './content/bossRewards'
import { import {
@@ -136,8 +136,8 @@ const MENU_ITEMS: Array<{
}, },
{ {
screen: 'cloud-save', screen: 'cloud-save',
title: 'Backup Slot', title: 'Cloud Backup',
description: 'Choose local, online, or fresh IWT2 progress.', description: 'Compare the active local slot with your online backup.',
glyph: 'C', glyph: 'C',
}, },
{ {
@@ -150,10 +150,12 @@ const MENU_ITEMS: Array<{
export function IWantToHeal2App({ export function IWantToHeal2App({
initialSave, initialSave,
localSlot,
onlineBackupsAvailable, onlineBackupsAvailable,
onExitToSaveSelect, onExitToSaveSelect,
}: { }: {
initialSave: Iwt2Save initialSave: Iwt2Save
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean onlineBackupsAvailable: boolean
onExitToSaveSelect: () => void onExitToSaveSelect: () => void
}) { }) {
@@ -174,8 +176,8 @@ export function IWantToHeal2App({
const cancelPvpQueueRef = useRef<(() => void) | null>(null) const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => { useEffect(() => {
writeIwt2Save(save) writeIwt2Save(save, localSlot)
}, [save]) }, [localSlot, save])
useEffect(() => () => { useEffect(() => () => {
cancelPvpQueueRef.current?.() cancelPvpQueueRef.current?.()
+24 -1
View File
@@ -4,14 +4,18 @@ import type { MovementVector } from '../../../input'
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene' import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
import type { Iwt2ArenaState } from '../sim/arenaState' import type { Iwt2ArenaState } from '../sim/arenaState'
const IWT2_ARENA_FPS = 60
type PhaserArenaProps = { type PhaserArenaProps = {
active: boolean
movementRef: MutableRefObject<MovementVector> movementRef: MutableRefObject<MovementVector>
selectedPartyIdRef: MutableRefObject<string> selectedPartyIdRef: MutableRefObject<string>
stateRef: MutableRefObject<Iwt2ArenaState> stateRef: MutableRefObject<Iwt2ArenaState>
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
} }
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { export function PhaserArena({ active, movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const gameRef = useRef<Phaser.Game | null>(null)
const hostRef = useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const onStepRef = useRef(onStep) const onStepRef = useRef(onStep)
@@ -35,17 +39,36 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
height: initialState.bounds.height, height: initialState.bounds.height,
backgroundColor: '#10141b', backgroundColor: '#10141b',
pixelArt: false, pixelArt: false,
fps: {
target: IWT2_ARENA_FPS,
limit: IWT2_ARENA_FPS,
},
scale: { scale: {
mode: Phaser.Scale.FIT, mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH, autoCenter: Phaser.Scale.CENTER_BOTH,
}, },
scene, scene,
}) })
gameRef.current = game
return () => { return () => {
if (gameRef.current === game) gameRef.current = null
game.destroy(true) game.destroy(true)
} }
}, [movementRef, selectedPartyIdRef, stateRef]) }, [movementRef, selectedPartyIdRef, stateRef])
useEffect(() => {
const game = gameRef.current
if (!game?.loop.started) return
if (active) {
if (!game.loop.running) {
game.loop.resetDelta()
game.loop.wake()
}
return
}
game.loop.sleep()
}, [active])
return <div className="iwt2-phaser-host" ref={hostRef} /> return <div className="iwt2-phaser-host" ref={hostRef} />
} }
+46 -46
View File
@@ -52,20 +52,17 @@ const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionPro
const WARM_TINT = 0xffc88a const WARM_TINT = 0xffc88a
const IMPACT_TINT = 0xff9b5f const IMPACT_TINT = 0xff9b5f
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion { export function bossRenderMotion(
entity: Iwt2BossEntityState,
timeSeconds: number,
motion: BossRenderMotion = createBossRenderMotion(),
): BossRenderMotion {
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
const phase = String(entity.attackPhase) const phase = String(entity.attackPhase)
const speed = Math.hypot(entity.velocity.x, entity.velocity.y) const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const moving = speed > 8 || phase === 'relocating' const moving = speed > 8 || phase === 'relocating'
const facingX = entity.facing.x < -0.05 ? -1 : 1 const facingX = entity.facing.x < -0.05 ? -1 : 1
resetBossRenderMotion(motion)
const motion: BossRenderMotion = {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX) if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX) if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
@@ -76,12 +73,10 @@ export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: numbe
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion { function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2) const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
return { motion.y = -Math.max(0, wave) * profile.idleBob
...motion, motion.scaleY = 1 + Math.max(0, wave) * 0.025
y: -Math.max(0, wave) * profile.idleBob, motion.scaleX = 1 - Math.max(0, wave) * 0.01
scaleY: 1 + Math.max(0, wave) * 0.025, return motion
scaleX: 1 - Math.max(0, wave) * 0.01,
}
} }
function movingMotion( function movingMotion(
@@ -94,13 +89,11 @@ function movingMotion(
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180) const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2) const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
const lift = Math.abs(stride) * profile.moveBob const lift = Math.abs(stride) * profile.moveBob
return { motion.y = -lift
...motion, motion.scaleX = 1 + 0.012 * speedFactor
y: -lift, motion.scaleY = 1 - 0.009 * Math.abs(stride)
scaleX: 1 + 0.012 * speedFactor, motion.rotation = facingX * 0.025 * speedFactor
scaleY: 1 - 0.009 * Math.abs(stride), return motion
rotation: facingX * 0.025 * speedFactor,
}
} }
function windupMotion( function windupMotion(
@@ -112,28 +105,24 @@ function windupMotion(
): BossRenderMotion { ): BossRenderMotion {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5 const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter') const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
return { motion.x = -facingX * (isSlam ? 1.5 : 3)
...motion, motion.y = isSlam ? -2 : 2
x: -facingX * (isSlam ? 1.5 : 3), motion.scaleX = 1.035
y: isSlam ? -2 : 2, motion.scaleY = 0.965
scaleX: 1.035, motion.rotation = -facingX * 0.02
scaleY: 0.965, motion.tint = pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined
rotation: -facingX * 0.02, return motion
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
}
} }
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion { function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8 const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
return { motion.x = facingX * 3 + shake
...motion, motion.y = Math.cos(timeSeconds * Math.PI * 42) * 1.2
x: facingX * 3 + shake, motion.scaleX = 1.045
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2, motion.scaleY = 0.96
scaleX: 1.045, motion.rotation = facingX * 0.018
scaleY: 0.96, motion.tint = WARM_TINT
rotation: facingX * 0.018, return motion
tint: WARM_TINT,
}
} }
function recoverMotion( function recoverMotion(
@@ -145,13 +134,24 @@ function recoverMotion(
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45)) const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
const pop = profile.impactScale * impactPulse const pop = profile.impactScale * impactPulse
return { motion.y = -2 * impactPulse
...motion, motion.scaleX = 1 + pop
y: -2 * impactPulse, motion.scaleY = 1 + pop * 0.45
scaleX: 1 + pop, motion.tint = impactPulse > 0.45 ? IMPACT_TINT : undefined
scaleY: 1 + pop * 0.45, return motion
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
} }
function createBossRenderMotion(): BossRenderMotion {
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
}
function resetBossRenderMotion(motion: BossRenderMotion) {
motion.x = 0
motion.y = 0
motion.scaleX = 1
motion.scaleY = 1
motion.rotation = 0
motion.tint = undefined
} }
function isActionPhase(phase: string, suffix: string): boolean { function isActionPhase(phase: string, suffix: string): boolean {
+60 -42
View File
@@ -27,65 +27,59 @@ export function partyRenderMotion(
entity: Iwt2PartyEntityState, entity: Iwt2PartyEntityState,
timeSeconds: number, timeSeconds: number,
attackPulse?: PartyAttackPulse, attackPulse?: PartyAttackPulse,
motion: PartyRenderMotion = createPartyRenderMotion(),
): PartyRenderMotion { ): PartyRenderMotion {
const speed = Math.hypot(entity.velocity.x, entity.velocity.y) const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const facingX = entity.facing.x < -0.05 ? -1 : 1 const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: PartyRenderMotion = { resetPartyRenderMotion(motion)
x: 0,
y: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
}
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 } if (entity.health <= 0) {
motion.rotation = facingX * 0.08
motion.scaleY = 0.92
return motion
}
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) { if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 } motion.y = 3
motion.rotation = -facingX * 0.12
motion.scaleY = 0.9
return motion
} }
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1 const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
if (attackProgress < 1) { if (attackProgress < 1) {
const strike = Math.sin(attackProgress * Math.PI) const strike = Math.sin(attackProgress * Math.PI)
const ranged = entity.projectileSpeed > 0 const ranged = entity.projectileSpeed > 0
return { motion.x = facingX * (ranged ? -3 : 4) * strike
...motion, motion.y = -1.5 * strike
x: facingX * (ranged ? -3 : 4) * strike, motion.rotation = facingX * (ranged ? -0.05 : 0.09) * strike
y: -1.5 * strike, motion.scaleX = 1 + (ranged ? 0.012 : 0.035) * strike
rotation: facingX * (ranged ? -0.05 : 0.09) * strike, motion.scaleY = 1 - (ranged ? 0.006 : 0.018) * strike
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike, motion.tint = ranged ? 0xdff3ff : 0xffe1a6
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike, return motion
tint: ranged ? 0xdff3ff : 0xffe1a6,
}
} }
if (entity.castSecondsRemaining > 0) { if (entity.castSecondsRemaining > 0) {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5 const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
return { motion.y = -1 - pulse * 1.5
...motion, motion.scaleX = 1 + pulse * 0.012
y: -1 - pulse * 1.5, motion.scaleY = 1 + pulse * 0.012
scaleX: 1 + pulse * 0.012, motion.tint = entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff
scaleY: 1 + pulse * 0.012, return motion
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
}
} }
if (speed > 10) { if (speed > 10) {
const stride = Math.sin(timeSeconds * Math.PI * 9) const stride = Math.sin(timeSeconds * Math.PI * 9)
const lift = Math.abs(stride) * Math.min(2.8, speed / 90) const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
return { motion.y = -lift
...motion, motion.rotation = facingX * 0.025 * Math.min(1, speed / 190)
y: -lift, motion.scaleY = 1 - Math.abs(stride) * 0.01
rotation: facingX * 0.025 * Math.min(1, speed / 190), return motion
scaleY: 1 - Math.abs(stride) * 0.01,
}
} }
const idle = Math.sin(timeSeconds * Math.PI * 2.2) const idle = Math.sin(timeSeconds * Math.PI * 2.2)
return { motion.y = -Math.max(0, idle) * 0.9
...motion, motion.scaleY = 1 + Math.max(0, idle) * 0.008
y: -Math.max(0, idle) * 0.9, return motion
scaleY: 1 + Math.max(0, idle) * 0.008,
}
} }
export function isPartyAttackPulseActive( export function isPartyAttackPulseActive(
@@ -101,17 +95,41 @@ export function partyWeaponLayerMotion(
layer: Iwt2ClassWeaponLayer, layer: Iwt2ClassWeaponLayer,
timeSeconds: number, timeSeconds: number,
attackPulse?: PartyAttackPulse, attackPulse?: PartyAttackPulse,
motion: PartyWeaponLayerMotion = createPartyWeaponLayerMotion(),
): PartyWeaponLayerMotion { ): PartyWeaponLayerMotion {
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 } resetPartyWeaponLayerMotion(motion)
if (!attackPulse || entity.health <= 0) return motion
const progress = attackProgressFor(entity, timeSeconds, attackPulse) const progress = attackProgressFor(entity, timeSeconds, attackPulse)
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 } if (progress >= 1) return motion
const strike = Math.sin(progress * Math.PI) const strike = Math.sin(progress * Math.PI)
const attackMotion = layer.attackMotion const attackMotion = layer.attackMotion
return { motion.offsetXScale = (attackMotion?.offsetXScale ?? 0) * strike
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike, motion.offsetYScale = (attackMotion?.offsetYScale ?? 0) * strike
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike, motion.rotation = (attackMotion?.rotation ?? 0) * strike
rotation: (attackMotion?.rotation ?? 0) * strike, return motion
} }
function createPartyRenderMotion(): PartyRenderMotion {
return { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 }
}
function resetPartyRenderMotion(motion: PartyRenderMotion) {
motion.x = 0
motion.y = 0
motion.rotation = 0
motion.scaleX = 1
motion.scaleY = 1
motion.tint = undefined
}
function createPartyWeaponLayerMotion(): PartyWeaponLayerMotion {
return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
}
function resetPartyWeaponLayerMotion(motion: PartyWeaponLayerMotion) {
motion.offsetXScale = 0
motion.offsetYScale = 0
motion.rotation = 0
} }
function attackProgressFor( function attackProgressFor(
@@ -2,12 +2,14 @@ import Phaser from 'phaser'
import type { MovementVector } from '../../../../input' import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes' import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation' import { bossRenderMotion, type BossRenderMotion } from '../bossAnimation'
import { import {
isPartyAttackPulseActive, isPartyAttackPulseActive,
partyRenderMotion, partyRenderMotion,
partyWeaponLayerMotion, partyWeaponLayerMotion,
type PartyAttackPulse, type PartyAttackPulse,
type PartyRenderMotion,
type PartyWeaponLayerMotion,
} from '../partyAnimation' } from '../partyAnimation'
import type { import type {
Iwt2ArenaEvent, Iwt2ArenaEvent,
@@ -41,10 +43,15 @@ type PartyLayerSprites = {
export class BulldromeArenaScene extends Phaser.Scene { export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps private deps: SceneDeps
private drawableEntities: DrawableEntity[] = []
private arenaGraphics?: Phaser.GameObjects.Graphics private arenaGraphics?: Phaser.GameObjects.Graphics
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics private telegraphGraphics?: Phaser.GameObjects.Graphics
private liveBossIds = new Set<string>()
private liveLabelIds = new Set<string>()
private livePartyIds = new Set<string>()
private liveProjectileIds = new Set<string>()
private labels = new Map<string, Phaser.GameObjects.Text>() private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>() private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>() private partySprites = new Map<string, Phaser.GameObjects.Image>()
@@ -54,11 +61,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
private livePartyEffectIds = new Set<string>() private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>() private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>() private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private currentEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>() private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private liveEntityAnchorIds = new Set<string>()
private bossMotionScratch: BossRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
private partyMotionScratch: PartyRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
private weaponMotionScratch: PartyWeaponLayerMotion = { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
private lastPartyAnimationEventId = 0 private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0 private lastFloatingEventId = 0
private lastStateTime = 0 private lastStateTime = 0
private lastHudPublish = 0
constructor(deps: SceneDeps) { constructor(deps: SceneDeps) {
super('bulldrome-arena') super('bulldrome-arena')
@@ -66,7 +77,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
preload() { preload() {
for (const metadata of Object.values(IWT2_BOSS_METADATA)) { const encounterBossIds = new Set(this.deps.getState().bosses.map((boss) => boss.bossId))
for (const bossId of encounterBossIds) {
const metadata = IWT2_BOSS_METADATA[bossId]
if (metadata.spriteUrl.endsWith('.svg')) { if (metadata.spriteUrl.endsWith('.svg')) {
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 }) this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
} else { } else {
@@ -92,7 +105,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.telegraphGraphics = this.add.graphics().setDepth(10) this.telegraphGraphics = this.add.graphics().setDepth(10)
this.bossUnderlayGraphics = this.add.graphics().setDepth(20) this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
this.entityGraphics = this.add.graphics().setDepth(35) this.entityGraphics = this.add.graphics().setDepth(35)
this.drawState(this.deps.getState()) const state = this.deps.getState()
this.drawArena(state)
this.drawState(state)
} }
update(_: number, delta: number) { update(_: number, delta: number) {
@@ -102,13 +117,11 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawState(state: Iwt2ArenaState) { private drawState(state: Iwt2ArenaState) {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state) this.drawTelegraphs(state)
this.updatePartyAnimations(state) this.updatePartyAnimations(state)
this.drawEntities(state) this.drawEntities(state)
this.drawProjectiles(state) this.drawProjectiles(state)
this.drawFloatingCombatTexts(state) this.drawFloatingCombatTexts(state)
this.lastHudPublish += 1
} }
private drawArena(state: Iwt2ArenaState) { private drawArena(state: Iwt2ArenaState) {
@@ -140,12 +153,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
bossUnderlay.clear() bossUnderlay.clear()
graphics.clear() graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard) for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses] const entities = this.drawableEntities
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id)) const liveBossIds = this.liveBossIds
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id)) const liveLabelIds = this.liveLabelIds
const livePartyIds = new Set<string>(state.party.map((member) => member.id)) const livePartyIds = this.livePartyIds
entities.length = 0
liveBossIds.clear()
liveLabelIds.clear()
livePartyIds.clear()
for (const boss of state.bosses) {
entities.push(boss)
liveBossIds.add(boss.id)
if (shouldDrawLabel(boss)) liveLabelIds.add(boss.id)
}
for (const add of state.hostileAdds) {
entities.push(add)
if (shouldDrawLabel(add)) liveLabelIds.add(add.id)
}
for (const member of state.party) {
entities.push(member)
livePartyIds.add(member.id)
if (shouldDrawLabel(member)) liveLabelIds.add(member.id)
}
for (const entity of entities.sort(entitySort)) { for (const entity of entities) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1 const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
const color = entityColor(entity) const color = entityColor(entity)
@@ -232,8 +263,10 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawProjectiles(state: Iwt2ArenaState) { private drawProjectiles(state: Iwt2ArenaState) {
const graphics = this.entityGraphics! const graphics = this.entityGraphics!
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id)) const liveProjectileIds = this.liveProjectileIds
liveProjectileIds.clear()
for (const projectile of state.projectiles) { for (const projectile of state.projectiles) {
liveProjectileIds.add(projectile.id)
const spriteKey = projectileSpriteKey(projectile.projectileKind) const spriteKey = projectileSpriteKey(projectile.projectileKind)
if (this.textures.exists(spriteKey)) { if (this.textures.exists(spriteKey)) {
let sprite = this.projectileSprites.get(projectile.id) let sprite = this.projectileSprites.get(projectile.id)
@@ -244,7 +277,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x) const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius) const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
sprite sprite
.setTexture(spriteKey)
.setPosition(projectile.position.x, projectile.position.y) .setPosition(projectile.position.x, projectile.position.y)
.setRotation(velocityAngle) .setRotation(velocityAngle)
.setDisplaySize(size.width, size.height) .setDisplaySize(size.width, size.height)
@@ -280,14 +312,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35) const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35) const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0) const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
const motion = bossRenderMotion(entity, timeSeconds) const motion = bossRenderMotion(entity, timeSeconds, this.bossMotionScratch)
if (motion.tint) { if (motion.tint) {
sprite.setTint(motion.tint) sprite.setTint(motion.tint)
} else { } else {
sprite.clearTint() sprite.clearTint()
} }
sprite sprite
.setTexture(key)
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y) .setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
.setDisplaySize(width * motion.scaleX, height * motion.scaleY) .setDisplaySize(width * motion.scaleX, height * motion.scaleY)
.setRotation(rotation + motion.rotation) .setRotation(rotation + motion.rotation)
@@ -314,10 +345,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55) const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id)) const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
applyMotionTint(sprites.body, motion.tint) applyMotionTint(sprites.body, motion.tint)
sprites.body sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y) .setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY) .setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
.setRotation(motion.rotation) .setRotation(motion.rotation)
@@ -335,7 +365,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
const flipX = entity.facing.x < -0.05 const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1 const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id)) const layerMotion = partyWeaponLayerMotion(
entity,
layer,
timeSeconds,
this.partyAttackPulses.get(entity.id),
this.weaponMotionScratch,
)
const layerHeight = entity.radius * layer.heightScale const layerHeight = entity.radius * layer.heightScale
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale) const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale) const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
@@ -358,7 +394,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
}) })
applyMotionTint(sprite, motion.tint) applyMotionTint(sprite, motion.tint)
sprite sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42) .setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY) .setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight) .setDisplaySize(displayWidth, displayHeight)
@@ -420,7 +455,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
accent accent
.setVisible(true) .setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41) .setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY) .setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12) .setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
@@ -446,14 +480,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
const displayHeight = entity.radius * 4.55 const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id)) const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
if (motion.tint) { if (motion.tint) {
sprite.setTint(motion.tint) sprite.setTint(motion.tint)
} else { } else {
sprite.clearTint() sprite.clearTint()
} }
sprite sprite
.setTexture(key)
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y) .setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize( .setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX, displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
@@ -541,6 +574,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
private drawFloatingCombatTexts(state: Iwt2ArenaState) { private drawFloatingCombatTexts(state: Iwt2ArenaState) {
const currentEntityAnchors = this.currentEntityAnchors
updateEntityAnchors(currentEntityAnchors, this.liveEntityAnchorIds, state)
if (state.time < this.lastStateTime) { if (state.time < this.lastStateTime) {
this.lastFloatingEventId = 0 this.lastFloatingEventId = 0
for (const text of this.floatingCombatTexts.values()) text.destroy() for (const text of this.floatingCombatTexts.values()) text.destroy()
@@ -557,17 +592,21 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id) this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
continue continue
} }
this.spawnFloatingCombatText(event, state) this.spawnFloatingCombatText(event, currentEntityAnchors)
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id) this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
} }
this.lastStateTime = state.time this.lastStateTime = state.time
this.lastEntityAnchors = entityAnchors(state) this.currentEntityAnchors = this.lastEntityAnchors
this.lastEntityAnchors = currentEntityAnchors
} }
private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) { private spawnFloatingCombatText(
event: Iwt2ArenaEvent,
currentEntityAnchors: Map<string, FloatingCombatTextAnchor>,
) {
const anchor = event.targetId const anchor = event.targetId
? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId) ? currentEntityAnchors.get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
: undefined : undefined
if (!anchor || !event.value || event.value <= 0) return if (!anchor || !event.value || event.value <= 0) return
@@ -630,11 +669,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
} }
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
return rank[a.kind] - rank[b.kind]
}
function entityColor(entity: DrawableEntity): string { function entityColor(entity: DrawableEntity): string {
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
if (entity.kind === 'hostileAdd') return '#f0b84f' if (entity.kind === 'hostileAdd') return '#f0b84f'
@@ -710,16 +744,38 @@ function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean {
&& event.targetId !== undefined && event.targetId !== undefined
} }
function entityAnchors(state: Iwt2ArenaState): Map<string, FloatingCombatTextAnchor> { function updateEntityAnchors(
const anchors = new Map<string, FloatingCombatTextAnchor>() anchors: Map<string, FloatingCombatTextAnchor>,
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) { liveIds: Set<string>,
state: Iwt2ArenaState,
) {
liveIds.clear()
for (const entity of state.party) updateEntityAnchor(anchors, liveIds, entity)
for (const entity of state.hostileAdds) updateEntityAnchor(anchors, liveIds, entity)
for (const entity of state.bosses) updateEntityAnchor(anchors, liveIds, entity)
for (const id of anchors.keys()) {
if (!liveIds.has(id)) anchors.delete(id)
}
}
function updateEntityAnchor(
anchors: Map<string, FloatingCombatTextAnchor>,
liveIds: Set<string>,
entity: DrawableEntity,
) {
liveIds.add(entity.id)
const anchor = anchors.get(entity.id)
if (!anchor) {
anchors.set(entity.id, { anchors.set(entity.id, {
kind: entity.kind, kind: entity.kind,
position: { ...entity.position }, position: entity.position,
radius: entity.radius, radius: entity.radius,
}) })
return
} }
return anchors anchor.kind = entity.kind
anchor.position = entity.position
anchor.radius = entity.radius
} }
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number { function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
@@ -792,9 +848,7 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09)) graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
if (indicator.kind === 'lane') { if (indicator.kind === 'lane') {
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width) drawLaneDangerPath(graphics, indicator.start, indicator.end, indicator.width)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return return
} }
@@ -805,27 +859,25 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
} }
if (indicator.kind === 'cone') { if (indicator.kind === 'cone') {
const points = coneDangerPolygon( drawConeDangerPath(
graphics,
indicator.origin, indicator.origin,
indicator.direction, indicator.direction,
indicator.range, indicator.range,
indicator.angleRadians, indicator.angleRadians,
) )
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return return
} }
if (indicator.kind === 'arc') { if (indicator.kind === 'arc') {
const points = arcDangerPolygon( drawArcDangerPath(
graphics,
indicator.position, indicator.position,
indicator.direction, indicator.direction,
indicator.innerRadius, indicator.innerRadius,
indicator.outerRadius, indicator.outerRadius,
indicator.angleRadians, indicator.angleRadians,
) )
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return return
} }
@@ -835,7 +887,8 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius) graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
} }
function arcDangerPolygon( function drawArcDangerPath(
graphics: Phaser.GameObjects.Graphics,
position: Iwt2Vec2, position: Iwt2Vec2,
direction: Iwt2Vec2, direction: Iwt2Vec2,
innerRadius: number, innerRadius: number,
@@ -845,43 +898,50 @@ function arcDangerPolygon(
const baseAngle = Math.atan2(direction.y, direction.x) const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2 const halfAngle = angleRadians / 2
const segmentCount = 18 const segmentCount = 18
const points: Phaser.Math.Vector2[] = [] graphics.beginPath()
for (let index = 0; index <= segmentCount; index += 1) { for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2( const x = position.x + Math.cos(angle) * outerRadius
position.x + Math.cos(angle) * outerRadius, const y = position.y + Math.sin(angle) * outerRadius
position.y + Math.sin(angle) * outerRadius, if (index === 0) graphics.moveTo(x, y)
)) else graphics.lineTo(x, y)
} }
for (let index = segmentCount; index >= 0; index -= 1) { for (let index = segmentCount; index >= 0; index -= 1) {
const t = index / segmentCount const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2( graphics.lineTo(
position.x + Math.cos(angle) * innerRadius, position.x + Math.cos(angle) * innerRadius,
position.y + Math.sin(angle) * innerRadius, position.y + Math.sin(angle) * innerRadius,
)) )
} }
return points graphics.closePath().fillPath().strokePath()
} }
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) { function drawLaneDangerPath(
graphics: Phaser.GameObjects.Graphics,
start: Iwt2Vec2,
end: Iwt2Vec2,
halfWidth: number,
) {
const dx = end.x - start.x const dx = end.x - start.x
const dy = end.y - start.y const dy = end.y - start.y
const length = Math.max(1, Math.hypot(dx, dy)) const length = Math.max(1, Math.hypot(dx, dy))
const normal = { const normalX = (-dy / length) * halfWidth
x: (-dy / length) * halfWidth, const normalY = (dx / length) * halfWidth
y: (dx / length) * halfWidth, graphics
} .beginPath()
return [ .moveTo(start.x + normalX, start.y + normalY)
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y), .lineTo(end.x + normalX, end.y + normalY)
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y), .lineTo(end.x - normalX, end.y - normalY)
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y), .lineTo(start.x - normalX, start.y - normalY)
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y), .closePath()
] .fillPath()
.strokePath()
} }
function coneDangerPolygon( function drawConeDangerPath(
graphics: Phaser.GameObjects.Graphics,
origin: Iwt2Vec2, origin: Iwt2Vec2,
direction: Iwt2Vec2, direction: Iwt2Vec2,
range: number, range: number,
@@ -889,15 +949,15 @@ function coneDangerPolygon(
) { ) {
const baseAngle = Math.atan2(direction.y, direction.x) const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2 const halfAngle = angleRadians / 2
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
const segmentCount = 14 const segmentCount = 14
graphics.beginPath().moveTo(origin.x, origin.y)
for (let index = 0; index <= segmentCount; index += 1) { for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2( graphics.lineTo(
origin.x + Math.cos(angle) * range, origin.x + Math.cos(angle) * range,
origin.y + Math.sin(angle) * range, origin.y + Math.sin(angle) * range,
)) )
} }
return points graphics.closePath().fillPath().strokePath()
} }
+51 -11
View File
@@ -101,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
save: Iwt2Save | null save: Iwt2Save | null
} }
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1' export const IWT2_LOCAL_SAVE_SLOTS = [1, 2, 3] as const
export type Iwt2LocalSaveSlot = (typeof IWT2_LOCAL_SAVE_SLOTS)[number]
export type Iwt2LocalSaveSlots = Record<Iwt2LocalSaveSlot, Iwt2Save | null>
export function createDefaultIwt2Save(): Iwt2Save { const IWT2_LEGACY_SAVE_KEY = 'i-want-to-heal-2:save:v1'
const IWT2_SAVE_MIGRATION_KEY = 'i-want-to-heal-2:save-slots:migrated:v1'
const IWT2_ACTIVE_SAVE_SLOT_KEY = 'i-want-to-heal-2:active-save-slot:v1'
export function createDefaultIwt2Save(characterName = 'Healer'): Iwt2Save {
return { return {
version: 2, version: 2,
updatedAt: Date.now(), updatedAt: Date.now(),
character: { character: {
name: 'Healer', name: normalizeCharacterName(characterName),
level: 1, level: 1,
experience: 0, experience: 0,
healerStyle: 'dawnweaver', healerStyle: 'dawnweaver',
@@ -153,12 +159,38 @@ function normalizeSave(value: unknown): Iwt2Save {
} }
} }
export function loadIwt2Save(): Iwt2Save { function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) {
return loadExistingIwt2Save() ?? createDefaultIwt2Save() return `i-want-to-heal-2:save:slot-${slot}:v1`
} }
export function loadExistingIwt2Save(): Iwt2Save | null { function migrateLegacyIwt2Save() {
const serialized = window.localStorage.getItem(IWT2_SAVE_KEY) if (window.localStorage.getItem(IWT2_SAVE_MIGRATION_KEY)) return
const legacySave = window.localStorage.getItem(IWT2_LEGACY_SAVE_KEY)
const firstSlotKey = iwt2SaveSlotKey(1)
if (legacySave && !window.localStorage.getItem(firstSlotKey)) {
window.localStorage.setItem(firstSlotKey, legacySave)
}
window.localStorage.setItem(IWT2_SAVE_MIGRATION_KEY, 'true')
}
export function activeIwt2SaveSlot(): Iwt2LocalSaveSlot {
const stored = Number(window.localStorage.getItem(IWT2_ACTIVE_SAVE_SLOT_KEY))
return IWT2_LOCAL_SAVE_SLOTS.includes(stored as Iwt2LocalSaveSlot)
? stored as Iwt2LocalSaveSlot
: 1
}
export function selectActiveIwt2SaveSlot(slot: Iwt2LocalSaveSlot) {
window.localStorage.setItem(IWT2_ACTIVE_SAVE_SLOT_KEY, String(slot))
}
export function loadIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save {
return loadExistingIwt2Save(slot) ?? createDefaultIwt2Save()
}
export function loadExistingIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save | null {
migrateLegacyIwt2Save()
const serialized = window.localStorage.getItem(iwt2SaveSlotKey(slot))
if (!serialized) return null if (!serialized) return null
try { try {
return normalizeSave(JSON.parse(serialized)) return normalizeSave(JSON.parse(serialized))
@@ -167,6 +199,14 @@ export function loadExistingIwt2Save(): Iwt2Save | null {
} }
} }
export function loadIwt2SaveSlots(): Iwt2LocalSaveSlots {
return {
1: loadExistingIwt2Save(1),
2: loadExistingIwt2Save(2),
3: loadExistingIwt2Save(3),
}
}
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> { export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save') const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
return { return {
@@ -185,12 +225,12 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSav
} }
} }
export function writeIwt2Save(save: Iwt2Save) { export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
replaceIwt2Save(save) replaceIwt2Save(save, slot)
} }
export function replaceIwt2Save(save: Iwt2Save) { export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify(normalizeSave(save))) window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
} }
export function recordIwt2BossKill( export function recordIwt2BossKill(
+20 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input' import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input' import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import { import {
@@ -14,7 +14,6 @@ import {
} from '../sim/arenaState' } from '../sim/arenaState'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim' import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim' import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import { import {
recordIwt2BossKillReward, recordIwt2BossKillReward,
type Iwt2BossDropAward, type Iwt2BossDropAward,
@@ -47,6 +46,10 @@ import {
} from '../content/roguelike' } from '../content/roguelike'
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization' import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
const PhaserArena = lazy(() => import('../render/PhaserArena').then((module) => ({
default: module.PhaserArena,
})))
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat' type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type PvpResultReason = 'opponent-defeated' | null type PvpResultReason = 'opponent-defeated' | null
type OverlayAction = 'primary' | 'requeue' | 'menu' type OverlayAction = 'primary' | 'requeue' | 'menu'
@@ -56,10 +59,14 @@ type OverlayNavEntry = {
column: number column: number
} }
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ const PAUSE_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0, column: 0 }, { action: 'primary', row: 0, column: 0 },
{ action: 'menu', row: 1, column: 0 }, { action: 'menu', row: 1, column: 0 },
] ]
const RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0, column: 0 },
{ action: 'menu', row: 0, column: 1 },
]
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0, column: 0 }, { action: 'primary', row: 0, column: 0 },
{ action: 'requeue', row: 0, column: 1 }, { action: 'requeue', row: 0, column: 1 },
@@ -71,6 +78,7 @@ const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154 const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000 const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620 const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
const IWT2_HUD_PUBLISH_INTERVAL_SECONDS = 0.1
type BossArenaScreenProps = { type BossArenaScreenProps = {
bossId: Iwt2BossId bossId: Iwt2BossId
@@ -157,7 +165,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const saveRef = useRef(save) const saveRef = useRef(save)
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set()) const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
const lastPublishTimeRef = useRef(0) const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({}) const abilityCooldownsRef = useRef<Record<string, number>>({})
const movementRef = useMovementVectorRef(status === 'playing') const movementRef = useMovementVectorRef(status === 'playing')
const { const {
@@ -169,14 +176,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const { enabled: dualScreenEnabled } = useDualScreen() const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice] const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => { useEffect(() => {
statusRef.current = status statusRef.current = status
}, [status]) }, [status])
@@ -219,7 +218,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
: null : null
recordedKillIdsRef.current = new Set() recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {} abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next) lastPublishTimeRef.current = next.time
stateRef.current = next stateRef.current = next
opponentStateRef.current = nextOpponentState opponentStateRef.current = nextOpponentState
setArenaState(next) setArenaState(next)
@@ -278,7 +277,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
} }
abilityCooldownsRef.current = nextCooldowns abilityCooldownsRef.current = nextCooldowns
stateRef.current = result.state stateRef.current = result.state
lastHudSignatureRef.current = arenaHudSignature(result.state) lastPublishTimeRef.current = result.state.time
setAbilityCooldowns(nextCooldowns) setAbilityCooldowns(nextCooldowns)
setArenaState(result.state) setArenaState(result.state)
}, []) }, [])
@@ -444,13 +443,8 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
showOverlay('defeat') showOverlay('defeat')
} }
const hudSignature = arenaHudSignature(next) const fightEnded = statusRef.current !== 'playing'
if ( if (fightEnded || next.time - lastPublishTimeRef.current >= IWT2_HUD_PUBLISH_INTERVAL_SECONDS) {
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.bosses.some((boss) => boss.health <= 0)
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time lastPublishTimeRef.current = next.time
setArenaState(next) setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState) if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
@@ -546,12 +540,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
targetBindings={targetBindings} targetBindings={targetBindings}
/> />
<Suspense fallback={<div className="iwt2-phaser-host iwt2-arena-loading" role="status">Loading arena...</div>}>
<PhaserArena <PhaserArena
active={status === 'playing'}
movementRef={movementRef} movementRef={movementRef}
onStep={onStep} onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef} selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef} stateRef={stateRef}
/> />
</Suspense>
{status === 'paused' && ( {status === 'paused' && (
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true"> <div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
<div> <div>
@@ -700,28 +697,9 @@ function rewardDifficultySlugForKill(
} }
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] { function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (status === 'paused') return PAUSE_OVERLAY_NAV_ENTRIES
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES return RESULT_OVERLAY_NAV_ENTRIES
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
...state.bosses.map((boss) => [
boss.id,
Math.ceil(boss.health),
boss.attackPhase,
].join(':')),
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
member.id,
Math.ceil(member.health),
Math.ceil(member.shield),
Math.ceil(member.mana),
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
].join(':')),
].join('|')
} }
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string { function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
+5 -17
View File
@@ -9,7 +9,6 @@ import {
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen' import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository' import { getGameMode } from '../../../gameRepository'
import { import {
createDefaultIwt2Save,
loadIwt2OnlineSave, loadIwt2OnlineSave,
setIwt2InfusionAbility, setIwt2InfusionAbility,
setIwt2PassiveInfusion, setIwt2PassiveInfusion,
@@ -1887,7 +1886,9 @@ export function Iwt2CloudSaveScreen({
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null) const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false) const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '') const statusMessage = message || (!onlineBackupsAvailable
? 'Sign in at character select to upload or download this local save.'
: '')
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => { const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
setSyncingOnlineSave(true) setSyncingOnlineSave(true)
@@ -1924,7 +1925,7 @@ export function Iwt2CloudSaveScreen({
const handleUseLocalSave = useCallback(async () => { const handleUseLocalSave = useCallback(async () => {
if (!onlineBackupsAvailable) { if (!onlineBackupsAvailable) {
setMessage('Using local save. Sign in online to update the server copy.') setMessage('Using local save. Sign in at character select to update the server copy.')
return return
} }
setSyncingOnlineSave(true) setSyncingOnlineSave(true)
@@ -1946,11 +1947,6 @@ export function Iwt2CloudSaveScreen({
setMessage('Local save now uses online progress.') setMessage('Local save now uses online progress.')
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated]) }, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([ const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{ {
key: 'use-local', key: 'use-local',
@@ -1970,16 +1966,8 @@ export function Iwt2CloudSaveScreen({
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave, disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave, onConfirm: handleUseOnlineSave,
}, },
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: handleUseNewSave,
},
], onBack), [ ], onBack), [
handleUseLocalSave, handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave, handleUseOnlineSave,
onlineBackupsAvailable, onlineBackupsAvailable,
onlineSave, onlineSave,
@@ -1989,7 +1977,7 @@ export function Iwt2CloudSaveScreen({
]) ])
return ( return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}> <Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>} {statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
<Iwt2ActionList actions={actions} /> <Iwt2ActionList actions={actions} />
</Iwt2ScreenShell> </Iwt2ScreenShell>
+10 -2
View File
@@ -197,7 +197,7 @@ function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
const step = Math.max(0, Math.min(dt, MAX_DT)) const step = Math.max(0, Math.min(dt, MAX_DT))
if (step <= 0) return { ...state, events: [...state.events] } if (step <= 0) return state
const inputMove = { const inputMove = {
x: clampInputAxis(input.moveX), x: clampInputAxis(input.moveX),
@@ -283,10 +283,18 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
nextAddId: bossResult.nextAddId ?? state.nextAddId, nextAddId: bossResult.nextAddId ?? state.nextAddId,
nextHazardId: projectileResult.nextHazardId, nextHazardId: projectileResult.nextHazardId,
nextEventId: state.nextEventId + nextEvents.length, nextEventId: state.nextEventId + nextEvents.length,
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS), events: appendEventHistory(state.events, nextEvents),
} }
} }
function appendEventHistory(previous: Iwt2ArenaEvent[], next: Iwt2ArenaEvent[]): Iwt2ArenaEvent[] {
if (next.length === 0) return previous
const overflow = Math.max(0, previous.length + next.length - MAX_EVENTS)
return overflow > 0
? [...previous.slice(overflow), ...next]
: [...previous, ...next]
}
function tickPartyHotEffects( function tickPartyHotEffects(
party: Iwt2PartyEntityState[], party: Iwt2PartyEntityState[],
dt: number, dt: number,
+4 -133
View File
@@ -1,60 +1,16 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import type { Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { import {
createInitialIwt2ArenaState as createCoreIwt2ArenaState, createInitialIwt2ArenaState as createCoreIwt2ArenaState,
tickIwt2Arena as tickCoreIwt2Arena, tickIwt2Arena as tickCoreIwt2Arena,
} from './arena' } from './arena'
import type { import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput, Iwt2ArenaInput,
Iwt2ArenaBounds, Iwt2ArenaBounds,
Iwt2ArenaState as Iwt2CoreArenaState, Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2RoguelikePressureState, Iwt2RoguelikePressureState,
} from './types' } from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd' export type Iwt2ArenaState = Iwt2CoreArenaState
export type Iwt2ArenaEntity = {
id: string
kind: Iwt2ArenaEntityKind
icon: string
color: string
x: number
y: number
radius: number
health: number
maxHealth: number
stunnedFor: number
}
export type Iwt2ArenaTelegraph =
| {
kind: 'charge'
x: number
y: number
width: number
height: number
active: boolean
}
| {
kind: 'slam'
x: number
y: number
radius: number
active: boolean
}
export type Iwt2ArenaState = Iwt2CoreArenaState & {
arena: {
width: number
height: number
}
entities: Iwt2ArenaEntity[]
telegraphs: Iwt2ArenaTelegraph[]
}
export function createInitialIwt2ArenaState( export function createInitialIwt2ArenaState(
bossId?: Iwt2BossId, bossId?: Iwt2BossId,
@@ -64,94 +20,9 @@ export function createInitialIwt2ArenaState(
roguelikePressure?: Iwt2RoguelikePressureState, roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds, bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState { ): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds)) return createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds)
} }
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
return decorateArenaState(tickCoreIwt2Arena(state, input, dt)) return tickCoreIwt2Arena(state, input, dt)
}
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
return {
...state,
arena: { ...state.bounds },
entities: [
...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity),
...state.bosses.map(toBossArenaEntity),
],
telegraphs: createTelegraphs(state.indicators),
}
}
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
return {
id: add.id,
kind: 'hostileAdd',
icon: 'v',
color: '#f0b84f',
x: add.position.x,
y: add.position.y,
radius: add.radius,
health: add.health,
maxHealth: add.maxHealth,
stunnedFor: 0,
}
}
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
kind: member.aiRole === 'player' ? 'player' : 'party',
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
}
}
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
kind: 'boss',
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
stunnedFor: 0,
}
}
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
if (indicator.kind === 'lane') {
return [{
active: indicator.phase === 'active',
height: indicator.width * 2,
kind: 'charge' as const,
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
x: Math.min(indicator.start.x, indicator.end.x),
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
}]
}
if (indicator.kind === 'circle') {
return [{
active: indicator.phase === 'active',
kind: 'slam' as const,
radius: indicator.radius,
x: indicator.position.x,
y: indicator.position.y,
}]
}
return []
})
} }