Compare commits

..
4 Commits
Author SHA1 Message Date
Warren H a6cca2810c Android build v1.1.31 2026-07-06 23:49:14 -04:00
Warren H f48e6130b8 Android build v1.1.30 2026-07-06 15:35:18 -04:00
Warren H 61de17d63e Android build v1.1.29 2026-07-06 15:31:42 -04:00
Warren H 5629041879 Android build v1.1.29 2026-07-06 15:30:04 -04:00
29 changed files with 391 additions and 101 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 108
versionName "1.1.28"
versionCode 112
versionName "1.1.31"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+59 -15
View File
@@ -212,6 +212,8 @@
}
.iwt2-arena-layout {
display: grid;
grid-template-columns: 172px minmax(0, 1fr);
height: 100vh;
overflow: hidden;
padding: 0;
@@ -221,6 +223,7 @@
.iwt2-arena-stage {
background: #090c10;
border: 0;
grid-column: 2;
height: 100%;
min-height: 0;
overflow: hidden;
@@ -317,6 +320,22 @@
top: 0;
}
.iwt2-bar em {
color: #fff7df;
font-size: 0.56rem;
font-style: normal;
font-weight: 800;
left: 0;
line-height: 1;
pointer-events: none;
position: absolute;
right: 0;
text-align: center;
text-shadow: 0 1px 0 #050608;
top: 50%;
transform: translateY(-50%);
}
.iwt2-bar.boss span {
background: #dc5162;
}
@@ -337,17 +356,18 @@
.iwt2-party-list {
background: rgba(15, 19, 26, 0.88);
border: 0;
left: 28px;
max-height: calc(100% - 112px);
bottom: 0;
left: 0;
max-height: none;
outline: 0;
padding: 6px;
position: absolute;
top: 28px;
position: fixed;
top: 0;
width: 172px;
z-index: 4;
display: grid;
gap: 8px;
align-content: start;
align-content: center;
overflow: auto;
}
@@ -1099,7 +1119,8 @@
.iwt2-gear-class,
.iwt2-gear-slot,
.iwt2-infusion-row,
.iwt2-gear-upgrade-button {
.iwt2-gear-upgrade-button,
.iwt2-gear-back {
background: #151922;
border: 2px solid #08090d;
color: var(--ink);
@@ -1177,7 +1198,8 @@
.iwt2-gear-class.game-selected,
.iwt2-gear-slot.game-selected,
.iwt2-infusion-row.game-selected,
.iwt2-gear-upgrade-button.game-selected {
.iwt2-gear-upgrade-button.game-selected,
.iwt2-gear-back.game-selected {
outline-color: #e5b95f;
}
@@ -1187,6 +1209,12 @@
text-align: center;
}
.iwt2-gear-back {
min-height: 36px;
padding: 8px 10px;
text-align: center;
}
.iwt2-gear-upgrade-button:disabled,
.iwt2-infusion-row:disabled {
color: var(--muted);
@@ -1401,6 +1429,10 @@
}
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-arena-layout {
grid-template-columns: 154px minmax(0, 1fr);
}
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
@@ -1640,6 +1672,11 @@
padding: 7px 9px;
}
.iwt2-gear-back {
min-height: 30px;
padding: 5px 8px;
}
.iwt2-gear-detail {
gap: 6px;
padding: 7px;
@@ -1735,20 +1772,22 @@
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
align-content: center;
gap: 7px;
bottom: 0;
left: 0;
max-height: none;
overflow: hidden;
padding: 4px;
top: 10px;
top: 0;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 22px minmax(0, 1fr);
height: 62px;
min-height: 62px;
height: 70px;
min-height: 70px;
padding: 4px;
}
@@ -1782,8 +1821,12 @@
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
height: 12px;
margin-top: 3px;
}
.iwt2-party-row .iwt2-bar.mana {
height: 8px;
}
.iwt2-party-effects {
@@ -1803,6 +1846,7 @@
.iwt2-ability-bar {
bottom: 8px;
display: none;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
+18 -2
View File
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
).some(isVisible)
}
function isDedicatedNavigationAction(action: InputAction) {
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (
device === 'controller'
&& dedicatedNavAction
&& !keyboardInputRef.current
&& hasDedicatedGameNavigation()
) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateRight',
'confirm',
'back',
'pause',
] satisfies InputAction[]
const directTargetActions = [
'targetParty1',
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
const dedicatedNavigationActive = hasDedicatedGameNavigation()
const action = dedicatedNavigationActive
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
? DPAD_NAV_ACTIONS[token]
: undefined)
: DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? DPAD_NAV_ACTIONS[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
+12 -5
View File
@@ -46,6 +46,11 @@ import {
type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant,
} from './content/roguelike'
import {
createIwt2PveRoguelikeBossPair,
createUniformIwt2RoguelikeBossPair,
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
} from './content/roguelikeBossProgression'
type Iwt2Screen =
| 'menu'
@@ -522,7 +527,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(variant, 1),
buffs: [],
contentType,
debuffs: [],
@@ -590,15 +595,17 @@ function applyRoguelikeChoice(
return {
...run,
...nextBase,
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(run.variant, run.stage + 1),
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
return chooseRunChoices(pool, 2)
function createRoguelikeBossPair(variant: Iwt2RoguelikeVariant, stage: number): Iwt2BossId[] {
if (variant === 'pve' && IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED) {
return createIwt2PveRoguelikeBossPair(stage)
}
return createUniformIwt2RoguelikeBossPair()
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
+3
View File
@@ -1,11 +1,13 @@
export function ArenaBar({
className = '',
current,
label,
max,
shield = 0,
}: {
className?: string
current: number
label?: string
max: number
shield?: number
}) {
@@ -15,6 +17,7 @@ export function ArenaBar({
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
{label && <em>{label}</em>}
</div>
)
}
+1 -2
View File
@@ -46,9 +46,8 @@ export function PartyFrames({
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
@@ -0,0 +1,160 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
type TierWeight = {
tier: Iwt2RoguelikeBossTier
weight: number
}
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
late: ['rimebastion', 'cinderback-ricochet', 'obsidian-ram-golem', 'venom-orchid-hydra', 'sandglass-scorpion'],
}
const IWT2_ROGUELIKE_BOSS_THREAT: Record<Iwt2RoguelikeBossTier, number> = {
early: 1,
mid: 2,
late: 3,
}
const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record<Iwt2BossId, Iwt2RoguelikeBossTier> = {
bulldrome: 'early',
'yian-kut-ku': 'early',
'great-jaggi': 'early',
rathian: 'early',
'stormcoil-wyrm': 'early',
khezu: 'mid',
barroth: 'mid',
'tobi-kadachi': 'mid',
'ember-mantis-duelist': 'mid',
'crystal-bat-matriarch': 'mid',
'hollowcrown-revenant': 'mid',
rimebastion: 'late',
'cinderback-ricochet': 'late',
'obsidian-ram-golem': 'late',
'venom-orchid-hydra': 'late',
'sandglass-scorpion': 'late',
}
export function createIwt2PveRoguelikeBossPair(
stage: number,
random: () => number = Math.random,
): Iwt2BossId[] {
const choices: Iwt2BossId[] = []
const maxThreat = maxThreatForStage(stage)
while (choices.length < 2) {
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random)
if (!next) break
choices.push(next)
}
return choices.length === 2
? choices
: createUniformIwt2RoguelikeBossPair(random)
}
export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
const choices: Iwt2BossId[] = []
while (pool.length > 0 && choices.length < 2) {
const index = randomIndex(pool.length, random)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
function chooseWeightedBossForStage(
stage: number,
selected: readonly Iwt2BossId[],
maxThreat: number,
random: () => number,
): Iwt2BossId | undefined {
const selectedSet = new Set(selected)
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
const weightedTiers = tierWeightsForStage(stage)
.map((entry) => ({
...entry,
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
!selectedSet.has(bossId) && selectedThreat + threatForBoss(bossId) <= maxThreat
)),
}))
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
const totalWeight = weightedTiers.reduce((total, entry) => total + entry.weight, 0)
if (totalWeight <= 0) return undefined
let roll = safeRandom(random) * totalWeight
for (const entry of weightedTiers) {
roll -= entry.weight
if (roll <= 0) {
return entry.bosses[randomIndex(entry.bosses.length, random)]
}
}
const lastEntry = weightedTiers[weightedTiers.length - 1]
return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)]
}
function tierWeightsForStage(stage: number): TierWeight[] {
const safeStage = Math.max(1, Math.floor(stage))
if (safeStage <= 2) {
return [
{ tier: 'early', weight: 85 },
{ tier: 'mid', weight: 15 },
{ tier: 'late', weight: 0 },
]
}
if (safeStage === 3) {
return [
{ tier: 'early', weight: 60 },
{ tier: 'mid', weight: 35 },
{ tier: 'late', weight: 5 },
]
}
if (safeStage <= 5) {
return [
{ tier: 'early', weight: 25 },
{ tier: 'mid', weight: 60 },
{ tier: 'late', weight: 15 },
]
}
if (safeStage === 6) {
return [
{ tier: 'early', weight: 10 },
{ tier: 'mid', weight: 50 },
{ tier: 'late', weight: 40 },
]
}
return [
{ tier: 'early', weight: 5 },
{ tier: 'mid', weight: 30 },
{ tier: 'late', weight: 65 },
]
}
function maxThreatForStage(stage: number): number {
const safeStage = Math.max(1, Math.floor(stage))
if (safeStage <= 2) return 3
if (safeStage === 3) return 4
if (safeStage <= 5) return 5
return 6
}
function threatForBoss(bossId: Iwt2BossId): number {
return IWT2_ROGUELIKE_BOSS_THREAT[IWT2_ROGUELIKE_BOSS_TIER_BY_ID[bossId]]
}
function randomIndex(length: number, random: () => number): number {
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
}
function safeRandom(random: () => number): number {
const value = random()
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
}
+25 -2
View File
@@ -12,7 +12,7 @@ import {
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
@@ -64,6 +64,10 @@ const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
type BossArenaScreenProps = {
bossId: Iwt2BossId
@@ -103,6 +107,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
@@ -111,6 +116,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
arenaBounds,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike
@@ -120,6 +126,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedBossHealthScale,
combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType),
arenaBounds,
)
: null
))
@@ -183,6 +190,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
arenaBounds,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
@@ -191,6 +199,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedBossHealthScale,
combinedDamageScale,
pressureState,
arenaBounds,
)
: null
recordedKillIdsRef.current = new Set()
@@ -205,7 +214,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setPetAwards([])
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
}, [arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
@@ -337,6 +346,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action.startsWith('targetParty')) {
if (statusRef.current !== 'playing') return
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
@@ -701,6 +711,7 @@ function createArenaState(
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
@@ -708,6 +719,7 @@ function createArenaState(
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
bounds,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
@@ -723,6 +735,17 @@ function createArenaState(
}
}
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
if (typeof window === 'undefined') return { width: 960, height: 540 }
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
return {
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
height: Math.max(240, Math.round(window.innerHeight)),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
+85 -60
View File
@@ -1256,7 +1256,7 @@ export function Iwt2RoguelikeUpgradeScreen({
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return
}
if (action === 'back' || action === 'pause') return
if (action === 'back') onBack()
})
return (
@@ -1547,33 +1547,10 @@ export function Iwt2HunterProfileScreen({
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action)
const nextEntry = navEntries[nextIndex]
setSelectedIndex(nextIndex)
if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey)
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
@@ -1863,6 +1840,7 @@ function Iwt2ProfileNameEditor({
<label>
<span>Profile Name</span>
<input
data-controller-nav="skip"
maxLength={IWT2_NAME_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
value={draft}
@@ -1901,38 +1879,42 @@ export function Iwt2CloudSaveScreen({
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
const [message, setMessage] = useState('')
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
setSyncingOnlineSave(true)
setMessage('Checking online save...')
try {
const result = await loadIwt2OnlineSave()
if (isCancelled()) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
} catch (reason) {
if (isCancelled()) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
} finally {
if (!isCancelled()) setSyncingOnlineSave(false)
}
}, [])
useEffect(() => {
let cancelled = false
if (!onlineBackupsAvailable) {
setOnlineSave(null)
setMessage('Online save unavailable in offline mode.')
return () => {
cancelled = true
}
}
setSyncingOnlineSave(true)
setMessage('Checking online save...')
loadIwt2OnlineSave()
.then((result) => {
if (cancelled) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
})
.catch((reason) => {
if (cancelled) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
})
.finally(() => {
if (!cancelled) setSyncingOnlineSave(false)
})
const timer = window.setTimeout(() => {
void checkOnlineSave(() => cancelled)
}, 0)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [onlineBackupsAvailable])
}, [checkOnlineSave, onlineBackupsAvailable])
const useLocalSave = useCallback(async () => {
const handleUseLocalSave = useCallback(async () => {
if (!onlineBackupsAvailable) {
setMessage('Using local save. Sign in online to update the server copy.')
return
@@ -1950,13 +1932,13 @@ export function Iwt2CloudSaveScreen({
}
}, [onlineBackupsAvailable, save])
const useOnlineSave = useCallback(() => {
if (!onlineSave) return
const handleUseOnlineSave = useCallback(() => {
if (!onlineBackupsAvailable || !onlineSave) return
onSaveUpdated(onlineSave)
setMessage('Local save now uses online progress.')
}, [onlineSave, onSaveUpdated])
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const useNewSave = useCallback(() => {
const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
@@ -1969,7 +1951,7 @@ export function Iwt2CloudSaveScreen({
value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave,
onConfirm: () => {
void useLocalSave()
void handleUseLocalSave()
},
},
{
@@ -1977,30 +1959,30 @@ export function Iwt2CloudSaveScreen({
label: 'Use Online Save',
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
value: onlineSave ? 'Download' : 'Empty',
disabled: syncingOnlineSave || !onlineSave,
onConfirm: useOnlineSave,
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave,
},
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: useNewSave,
onConfirm: handleUseNewSave,
},
], onBack), [
handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave,
onlineBackupsAvailable,
onlineSave,
onBack,
save,
syncingOnlineSave,
useLocalSave,
useNewSave,
useOnlineSave,
])
return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
{message && <p className="iwt2-screen-note">{message}</p>}
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
@@ -2114,6 +2096,16 @@ export function Iwt2GearUpgradeScreen({
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
<div className="iwt2-gear-layout">
<section className="iwt2-gear-column">
<button
className={`back-button iwt2-gear-back ${activeEntry?.kind === 'back' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={activeEntry?.kind === 'back' ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'back')}
type="button"
>
Back
</button>
<div className="iwt2-gear-class-list">
{IWT2_PARTY_ORDER.map((classId) => {
const metadata = IWT2_CLASS_METADATA[classId]
@@ -2372,6 +2364,39 @@ function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
return entries[Math.min(index, entries.length - 1)]
}
function moveHunterProfileSelection(
entries: Iwt2HunterProfileNavEntry[],
current: number,
action: InputAction,
) {
if (!action.startsWith('navigate') || entries.length === 0) return current
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
if (!active) return 0
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
}
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
+19 -10
View File
@@ -9,6 +9,7 @@ import type {
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2ArenaBounds,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
bossHealthScale = 1,
partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale, bounds))
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
bounds,
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
projectiles: [],
hostileAdds: [],
hazards: [],
@@ -95,9 +97,9 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number, bounds: Iwt2ArenaBounds): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
const position = scaleArenaPoint(initialBossPosition(index), bounds)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
@@ -123,9 +125,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
@@ -141,6 +143,13 @@ function initialBossPosition(index: number) {
}
}
function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: point.x * bounds.width / DEFAULT_ARENA_WIDTH,
y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
@@ -324,7 +333,7 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
})
}
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return {
@@ -332,7 +341,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
@@ -355,7 +364,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
decisionSecondsRemaining: initial.decisionOffset,
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import {
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaBounds,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
bossHealthScale?: number,
partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
+4 -2
View File
@@ -12,9 +12,11 @@ const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
_contentType: Iwt2RoguelikePressureContentType,
stage: number,
contentType: Iwt2RoguelikePressureContentType,
): number {
void stage
void contentType
return 1
}