diff --git a/IWantToHeal-Thor-v1.1.20.apk b/IWantToHeal-Thor-v1.1.20.apk new file mode 100644 index 0000000..f5c867f Binary files /dev/null and b/IWantToHeal-Thor-v1.1.20.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 087195f..8771662 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 98 - versionName "1.1.19" + versionCode 99 + versionName "1.1.20" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index f7f9b3e..b107181 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -65,9 +65,10 @@ type BossArenaScreenProps = { export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { const bossMetadata = IWT2_BOSS_METADATA[bossId] const pvpRoguelike = roguelikeRun?.variant === 'pvp' - const [arenaState, setArenaState] = useState(() => createInitialIwt2ArenaState(bossId, bossIds)) + const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1 + const [arenaState, setArenaState] = useState(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)) const [opponentArenaState, setOpponentArenaState] = useState(() => ( - pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null + pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null )) const [abilityCooldowns, setAbilityCooldowns] = useState>({}) const [status, setStatus] = useState('playing') @@ -118,8 +119,8 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa }, [save]) const resetArena = useCallback(() => { - const next = createInitialIwt2ArenaState(bossId, bossIds) - const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null + const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) + const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null recordedKillIdsRef.current = new Set() abilityCooldownsRef.current = {} lastHudSignatureRef.current = arenaHudSignature(next) @@ -130,7 +131,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa setAbilityCooldowns({}) setSelectedOverlayAction('primary') setStatus('playing') - }, [bossId, bossIds, pvpRoguelike]) + }, [bossHealthScale, bossId, bossIds, pvpRoguelike]) const showOverlay = useCallback((nextStatus: Exclude) => { setSelectedOverlayAction('primary') @@ -206,7 +207,6 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa } if (action === 'back') { if (statusRef.current === 'paused') setStatus('playing') - else onBack() return } } @@ -481,7 +481,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa {lastDevice === 'controller' && ( - D-pad selects, A confirms, B exits + D-pad selects, A confirms )} @@ -506,6 +506,10 @@ function formatArenaTime(seconds: number): string { return `${minutes}:${remainder.toString().padStart(2, '0')}` } +function roguelikeBossHealthScale(stage: number): number { + return 0.5 + Math.max(0, stage - 1) * 0.1 +} + function arenaHudSignature(state: Iwt2ArenaState): string { return [ ...state.bosses.map((boss) => [ diff --git a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx index 5961b53..d1fba96 100644 --- a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx +++ b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx @@ -665,9 +665,7 @@ export function Iwt2RoguelikeUpgradeScreen({ setSelectedIndex((current) => moveUpgradeSelection(entries, current, action)) return } - if (action === 'back' || action === 'pause') { - onBack() - } + if (action === 'back' || action === 'pause') return }) return ( @@ -956,10 +954,8 @@ function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: } function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) { - const bounded = Math.min(index, entries.length - 1) - const active = entries[bounded] - if (active && !upgradeEntryDisabled(active)) return active - return entries.find((entry) => !upgradeEntryDisabled(entry)) + if (entries.length === 0) return undefined + return entries[Math.min(index, entries.length - 1)] } function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) { @@ -967,30 +963,17 @@ function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) { } function moveUpgradeSelection(entries: Iwt2UpgradeNavEntry[], current: number, action: string) { - const enabledEntries = entries - .map((entry, index) => ({ entry, index })) - .filter(({ entry }) => !upgradeEntryDisabled(entry)) - if (enabledEntries.length === 0) return 0 + if (entries.length === 0) return 0 const bounded = Math.min(current, entries.length - 1) - const candidate = entries[bounded] - const active = candidate && !upgradeEntryDisabled(candidate) - ? candidate - : enabledEntries[0]?.entry - if (!active) return bounded - const candidates = enabledEntries.filter(({ entry }) => { - if (entry === active) return false - if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column - if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column - if (action === 'navigateUp') return entry.row < active.row - return entry.row > active.row - }) - if (candidates.length === 0) return entries.findIndex((entry) => entry === active) - 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) - return aPrimary - bPrimary || a.index - b.index - }) - return candidates[0]?.index ?? bounded + const active = entries[bounded] + const continueIndex = entries.findIndex((entry) => entry.kind === 'upgradeContinue' || entry.kind === 'upgradeLeave') + const lastChoiceIndex = Math.max(0, continueIndex >= 0 ? continueIndex - 1 : entries.length - 1) + if (action === 'navigateRight') return Math.min(lastChoiceIndex, bounded + 1) + if (action === 'navigateLeft') return Math.max(0, bounded - 1) + if (action === 'navigateDown') return continueIndex >= 0 ? continueIndex : bounded + if (action === 'navigateUp' && active?.kind === 'upgradeContinue') return lastChoiceIndex + if (action === 'navigateUp' && active?.kind === 'upgradeLeave') return lastChoiceIndex + return bounded } function moveSpatialSelection(actions: Iwt2NavAction[], current: number, action: string) { diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index a153af2..79bcd7a 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -56,9 +56,13 @@ const INITIAL_PARTY: InitialPartyMember[] = [ { id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 }, ] -export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[]): Iwt2ArenaState { +export function createInitialIwt2ArenaState( + bossId: Iwt2BossId = 'bulldrome', + bossIds?: Iwt2BossId[], + bossHealthScale = 1, +): Iwt2ArenaState { const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId) - const bosses = initialBossIds.map((id, index) => createBossEntity(id, index)) + const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale)) return { schemaVersion: 1, time: 0, @@ -84,9 +88,10 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] { return [primaryBossId, random] } -function createBossEntity(bossId: Iwt2BossId, index: number): Iwt2BossEntityState { +function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState { const bossMetadata = IWT2_BOSS_METADATA[bossId] const position = initialBossPosition(index) + const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale))) return { id: bossId, kind: 'boss', @@ -95,8 +100,8 @@ function createBossEntity(bossId: Iwt2BossId, index: number): Iwt2BossEntityStat velocity: { x: 0, y: 0 }, facing: { x: -1, y: 0 }, radius: bossMetadata.radius, - health: bossMetadata.maxHealth, - maxHealth: bossMetadata.maxHealth, + health: maxHealth, + maxHealth, meleeCooldownRemaining: 0.6 + index * 0.25, chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7, chargeCount: 0, diff --git a/src/modes/iwt2/sim/arenaState.ts b/src/modes/iwt2/sim/arenaState.ts index d964f84..e05e1f5 100644 --- a/src/modes/iwt2/sim/arenaState.ts +++ b/src/modes/iwt2/sim/arenaState.ts @@ -54,8 +54,12 @@ export type Iwt2ArenaState = Iwt2CoreArenaState & { telegraphs: Iwt2ArenaTelegraph[] } -export function createInitialIwt2ArenaState(bossId?: Iwt2BossId, bossIds?: Iwt2BossId[]): Iwt2ArenaState { - return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds)) +export function createInitialIwt2ArenaState( + bossId?: Iwt2BossId, + bossIds?: Iwt2BossId[], + bossHealthScale?: number, +): Iwt2ArenaState { + return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale)) } export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { diff --git a/src/modes/iwt2/sim/partyAi.ts b/src/modes/iwt2/sim/partyAi.ts index 4c016fa..e092a65 100644 --- a/src/modes/iwt2/sim/partyAi.ts +++ b/src/modes/iwt2/sim/partyAi.ts @@ -14,11 +14,12 @@ import { withFallbackFacing, } from './vector' -const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu']) +const NO_CENTER_LEASH_BOSS_IDS = new Set() const CENTER_LEASH_START_DISTANCE = 230 const CENTER_LEASH_WALL_MARGIN = 96 const CENTER_LEASH_EXTRA_DISTANCE = 36 const PARTY_ARRIVAL_RADIUS = 3.5 +const PARTY_SAFE_DESTINATION_PADDING = 34 export function tickPartyMember( member: Iwt2PartyEntityState, @@ -128,24 +129,29 @@ function getPartyDesiredPosition( const anchor = attackTarget?.position ?? getPrimaryBoss(state).position const centerLeash = getTankCenterLeashPosition(member, state) if (centerLeash) return centerLeash - return { + return clampPartyDestination({ x: anchor.x + member.preferredOffset.x + drift.x, y: anchor.y + member.preferredOffset.y + drift.y, - } + }, member, state) +} + +function clampPartyDestination( + position: Iwt2Vec2, + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, +): Iwt2Vec2 { + return clampVec2ToArena(position, member.radius + PARTY_SAFE_DESTINATION_PADDING, state.bounds) } function getTankCenterLeashPosition( member: Iwt2PartyEntityState, state: Iwt2ArenaState, ): Iwt2Vec2 | null { - const boss = getPrimaryBoss(state) - if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return null + if (member.aiRole !== 'tank') return null + const boss = getCenterLeashBoss(member, state) + if (!boss) return null const center = arenaCenter(state) - const bossCenterDistance = distanceVec2(boss.position, center) - const nearWall = isBossNearWall(boss, state) - if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null - const bossMetadata = IWT2_BOSS_METADATA[boss.bossId] const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), { x: boss.position.x < center.x ? 1 : -1, @@ -159,6 +165,21 @@ function getTankCenterLeashPosition( ) } +function getCenterLeashBoss( + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, +): Iwt2ArenaState['boss'] | null { + const center = arenaCenter(state) + const candidates = state.bosses.filter((boss) => { + if (boss.health <= 0 || NO_CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return false + return isBossNearWall(boss, state) || distanceVec2(boss.position, center) >= CENTER_LEASH_START_DISTANCE + }) + if (candidates.length === 0) return null + return candidates.reduce((best, boss) => ( + distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best + ), candidates[0]) +} + function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 { return { x: state.bounds.width * 0.5,