Android build v1.1.23

This commit is contained in:
Warren H
2026-07-05 22:48:56 -04:00
parent 956c9e32f9
commit 9708ba9e40
106 changed files with 4294 additions and 458 deletions
+170 -37
View File
@@ -15,6 +15,7 @@ import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2GearUpgradeScreen,
Iwt2HunterProfileScreen,
Iwt2ModeScreen,
Iwt2RoguelikeScreen,
@@ -27,6 +28,13 @@ import {
type Iwt2Save,
} from './save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { iwt2BossCoinRewardFor } from './content/bossRewards'
import {
findIwt2Difficulty,
IWT2_DUNGEON_DIFFICULTIES,
IWT2_RAID_DIFFICULTIES,
type Iwt2Difficulty,
} from './content/difficulties'
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import {
buildIwt2OpponentDebuffChoices,
@@ -48,12 +56,14 @@ type Iwt2Screen =
| 'roguelike'
| 'roguelike-arena'
| 'roguelike-upgrade'
| 'gear-upgrade'
| 'hunter-profile'
| 'customize-character'
| 'settings'
const IWT2_MENU_COLUMNS = 4
const IWT2_MENU_COLUMNS = 2
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
@@ -72,12 +82,6 @@ const MENU_ITEMS: Array<{
description: string
glyph: string
}> = [
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Save or restore the isolated IWT2 progress slot.',
glyph: 'C',
},
{
screen: 'dungeons',
title: 'Dungeons',
@@ -102,6 +106,12 @@ const MENU_ITEMS: Array<{
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
glyph: 'P',
},
{
screen: 'gear-upgrade',
title: 'Gear Upgrade',
description: 'Spend boss coins on class gear slots and infusion abilities.',
glyph: 'G',
},
{
screen: 'hunter-profile',
title: 'Hunter Profile',
@@ -114,6 +124,12 @@ const MENU_ITEMS: Array<{
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
glyph: 'K',
},
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Choose local, online, or fresh IWT2 progress.',
glyph: 'C',
},
{
screen: 'settings',
title: 'Settings',
@@ -122,17 +138,27 @@ const MENU_ITEMS: Array<{
},
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
export function IWantToHeal2App({
onlineBackupsAvailable,
onBackToGameSelect,
}: {
onlineBackupsAvailable: boolean
onBackToGameSelect: () => void
}) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(IWT2_DUNGEON_DIFFICULTIES[0])
const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug)
const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug)
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
const [pvpQueueing, setPvpQueueing] = useState(false)
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
@@ -143,9 +169,16 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
cancelPvpQueueRef.current?.()
}, [])
function cancelIwt2PvpQueue() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage('')
}
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
@@ -191,7 +224,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return (
<BossArenaScreen
bossId={selectedBossId}
key={`arena-${selectedBossId}-${arenaModeLabel}`}
difficulty={arenaDifficulty}
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
@@ -224,6 +258,10 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
}}
save={save}
onBack={() => setScreen('roguelike')}
onPvpRequeue={() => {
setScreen('roguelike')
startIwt2RoguelikeRun()
}}
onSaveUpdated={setSave}
/>
)
@@ -258,12 +296,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
difficultySlug={selectedDungeonDifficultySlug}
save={save}
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Dungeon')
onDifficultyChange={setSelectedDungeonDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Dungeon`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
@@ -276,20 +319,23 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
onBack={() => setScreen('menu')}
onBack={() => {
cancelIwt2PvpQueue()
setScreen('menu')
}}
onCancelQueue={cancelIwt2PvpQueue}
onContentTypeChange={setRoguelikeContentType}
onStart={() => {
startIwt2RoguelikeRun()
}}
onVariantChange={(nextVariant) => {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
cancelIwt2PvpQueue()
setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon')
}
}}
queueing={pvpQueueing}
queueMessage={pvpQueueMessage}
/>
</main>
@@ -301,13 +347,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModeScreen
difficultySlug={selectedRaidDifficultySlug}
mode="Raids"
save={save}
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Raid')
onDifficultyChange={setSelectedRaidDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Raid`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
@@ -321,6 +372,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
}
if (screen === 'gear-upgrade') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
@@ -340,6 +409,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen
save={save}
onlineBackupsAvailable={onlineBackupsAvailable}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
@@ -362,14 +432,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
<div className="iwt2-menu-heading">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Mode Select</h1>
</div>
<div className="iwt2-menu-grid">
{MENU_ITEMS.map((item, index) => (
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
aria-label={`${item.title}. ${item.description}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`}
@@ -391,28 +458,33 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
function startIwt2RoguelikeRun() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
cancelIwt2PvpQueue()
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
setPvpQueueing(true)
setPvpQueueMessage('Queuing for PvP...')
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
contentType: roguelikeContentType,
startStage,
gameMode: getGameMode(),
liveMatchActive: () => false,
onSearching: setPvpQueueMessage,
onSearching: (message) => {
setPvpQueueing(true)
setPvpQueueMessage(message)
},
onCpuMatch: (_difficulty, message) => {
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
onLiveMatch: (...liveMatchArgs) => {
const message = liveMatchArgs[2]
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
@@ -464,7 +536,14 @@ function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
return {
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
selfChoices: variant === 'pvp'
? chooseRunChoicesWithPreferredFirst(
selfCatalog,
IWT2_ROGUELIKE_CHOICE_COUNT,
isExtraTargetBuff,
IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE,
)
: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
debuffChoices: variant === 'pvp'
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
: [],
@@ -532,23 +611,63 @@ function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
return choices
}
function chooseRunChoicesWithPreferredFirst<T>(
items: readonly T[],
count: number,
preferred: (item: T) => boolean,
preferredChance: number,
): T[] {
if (count <= 0) return []
const pool = [...items]
const choices: T[] = []
const preferredPool = pool.filter(preferred)
if (preferredPool.length > 0 && Math.random() < preferredChance) {
const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)]
const preferredIndex = pool.indexOf(preferredChoice)
if (preferredIndex >= 0) {
const [choice] = pool.splice(preferredIndex, 1)
if (choice) choices.push(choice)
}
}
while (pool.length > 0 && choices.length < count) {
const index = Math.floor(Math.random() * pool.length)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>) {
return choice.id.endsWith('-extra-target')
}
function Iwt2Header({
onBack,
onBackToGameSelect,
save,
title,
}: {
onBack?: () => void
onBackToGameSelect: () => void
save: Iwt2Save
title?: string
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
</button>
{title && <strong className="iwt2-header-title">{title}</strong>}
<div className="character-summary">
<strong>{save.character.name}</strong>
<small>IWT2 Level {save.character.level}</small>
<small>{save.character.experience} XP</small>
</div>
{onBack && (
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
)}
</header>
)
}
@@ -557,30 +676,44 @@ function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
difficulty: Iwt2Difficulty,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug)
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics. Reward: ${coinReward.name}.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`,
difficultyName: difficulty.name,
experience: Math.round(125 * difficulty.experienceMultiplier),
initials: boss.icon,
itemLevel: save.character.level,
itemLevel: difficulty.droppedItemLevel,
lockedReason: undefined,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
damage: `${difficulty.damageMultiplier.toFixed(2)}x`,
health: `${difficulty.healthMultiplier.toFixed(2)}x`,
loot: coinReward.name,
xp: `${difficulty.experienceMultiplier.toFixed(1)}x`,
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function currentSetupDifficulty(
screen: Iwt2Screen,
selectedDungeonDifficultySlug: string,
selectedRaidDifficultySlug: string,
): Iwt2Difficulty {
if (screen === 'raids') {
return findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, selectedRaidDifficultySlug)
}
return findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, selectedDungeonDifficultySlug)
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,