diff --git a/IWantToHeal-Thor-v1.1.16.apk b/IWantToHeal-Thor-v1.1.16.apk new file mode 100644 index 0000000..c24baad Binary files /dev/null and b/IWantToHeal-Thor-v1.1.16.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 18fe586..4ecf5cb 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 94 - versionName "1.1.15" + versionCode 95 + versionName "1.1.16" 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/App.css b/src/App.css index 779a71d..4589efe 100644 --- a/src/App.css +++ b/src/App.css @@ -630,23 +630,44 @@ } .iwt2-profile-grid, -.iwt2-settings-panel { +.iwt2-settings-panel, +.iwt2-customize-layout { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); } -.iwt2-action-list { +.iwt2-loadout-preview { + display: grid; + gap: 12px; +} + +.iwt2-loadout-preview > div { + display: grid; + gap: 8px; +} + +.iwt2-loadout-preview h2 { + margin: 0; +} + +.iwt2-loadout-preview span { + color: var(--muted); + font-size: 0.75rem; +} + +.iwt2-action-list, +.iwt2-info-list { display: grid; gap: 10px; } -.iwt2-action-row { +.iwt2-action-row, +.iwt2-info-row { align-items: center; background: #10141b; border: 2px solid #090a0d; color: var(--ink); - cursor: pointer; display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr) auto; @@ -656,12 +677,22 @@ text-align: left; } -.iwt2-action-row strong { +.iwt2-action-row { + cursor: pointer; +} + +.iwt2-info-row { + cursor: default; +} + +.iwt2-action-row strong, +.iwt2-info-row strong { display: block; font-size: 0.95rem; } -.iwt2-action-row small { +.iwt2-action-row small, +.iwt2-info-row small { color: var(--muted); display: block; font-size: 0.72rem; @@ -669,12 +700,23 @@ margin-top: 4px; } -.iwt2-action-row em { +.iwt2-action-row em, +.iwt2-info-row em { color: #fff4a8; font-style: normal; white-space: nowrap; } +.iwt2-action-row:disabled { + color: var(--muted); + cursor: default; + opacity: 0.72; +} + +.iwt2-action-row:disabled em { + color: var(--muted); +} + .iwt2-action-row.selected, .iwt2-menu-card.game-selected { background: #1c2633; @@ -708,6 +750,13 @@ grid-template-rows: auto auto minmax(0, 1fr); } +.iwt2-bottom-display .dual-control-chip { + background: var(--panel-light); + border: 2px solid #090a0d; + outline: 2px solid #494756; +} + + @media (max-width: 760px) { .game-version-grid, .iwt2-menu-grid { @@ -725,7 +774,8 @@ } .iwt2-profile-grid, - .iwt2-settings-panel { + .iwt2-settings-panel, + .iwt2-customize-layout { grid-template-columns: 1fr; } @@ -2038,7 +2088,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo padding: 8px; } -.dual-controls-targets button { +.dual-controls-targets button, +.dual-controls-targets .dual-control-chip { align-items: center; display: inline-flex; gap: 6px; @@ -2050,7 +2101,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo grid-template-columns: repeat(3, minmax(0, 1fr)); } -.dual-controls-targets.direct button { +.dual-controls-targets.direct button, +.dual-controls-targets.direct .dual-control-chip { font-size: 15px; min-height: 34px; } @@ -2196,7 +2248,9 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo } .dual-controls-targets button, - .dual-controls-targets.direct button { + .dual-controls-targets.direct button, + .dual-controls-targets .dual-control-chip, + .dual-controls-targets.direct .dual-control-chip { font-size: 12px; min-height: 30px; padding: 4px 5px; @@ -6998,6 +7052,15 @@ h2 { opacity: 0.48; } +.iwt2-bottom-display .iwt2-bottom-spell { + cursor: default; +} + +.iwt2-bottom-display .iwt2-bottom-spell:hover { + outline-color: #494756; + transform: none; +} + .spell kbd { align-items: center; background: #090a0d; diff --git a/src/components/AdminScreen.tsx b/src/components/AdminScreen.tsx index 396a9ec..eab0c68 100644 --- a/src/components/AdminScreen.tsx +++ b/src/components/AdminScreen.tsx @@ -1396,16 +1396,13 @@ function applyIwt2AdminOverrides( metadata: Record, overrides: Iwt2BalanceOverrides, ): Record { - return { - bulldrome: { - ...metadata.bulldrome, - ...overrides.bosses?.bulldrome, + return (Object.keys(metadata) as Iwt2BossId[]).reduce((nextMetadata, bossId) => ({ + ...nextMetadata, + [bossId]: { + ...metadata[bossId], + ...overrides.bosses?.[bossId], }, - 'yian-kut-ku': { - ...metadata['yian-kut-ku'], - ...overrides.bosses?.['yian-kut-ku'], - }, - } + }), {} as Record) } function createIwt2AdminRows( diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index 070549c..5753d6a 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from 'react' import { useGameAction } from '../../input' +import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades' import { BossArenaScreen } from './screens/BossArenaScreen' import { Iwt2CloudSaveScreen, Iwt2CustomizeCharacterScreen, Iwt2DungeonsScreen, Iwt2HunterProfileScreen, - Iwt2ModePlaceholderScreen, + Iwt2ModeScreen, + Iwt2RoguelikeScreen, + Iwt2RoguelikeUpgradeScreen, Iwt2SettingsScreen, } from './screens/Iwt2ShellScreens' import { @@ -15,6 +18,17 @@ import { type Iwt2Save, } from './save/iwt2Repository' import type { Iwt2BossId } from './content/bosses' +import { abilitiesForHealer } from './content/healerAbilities' +import { + buildIwt2OpponentDebuffChoices, + buildIwt2SelfBuffChoices, + IWT2_REVIVE_PARTY_CHOICE, + type Iwt2RoguelikeChoice, + type Iwt2RoguelikeContentType, + type Iwt2RoguelikeOpponentDebuffId, + type Iwt2RoguelikeSelfBuffId, + type Iwt2RoguelikeVariant, +} from './content/roguelike' type Iwt2Screen = | 'menu' @@ -23,12 +37,24 @@ type Iwt2Screen = | 'dungeons' | 'raids' | 'roguelike' - | 'pvp' + | 'roguelike-arena' + | 'roguelike-upgrade' | 'hunter-profile' | 'customize-character' | 'settings' const IWT2_MENU_COLUMNS = 4 +const IWT2_ROGUELIKE_CHOICE_COUNT = 3 + +type Iwt2RoguelikeRunState = { + buffs: Iwt2RoguelikeSelfBuffId[] + contentType: Iwt2RoguelikeContentType + debuffs: Iwt2RoguelikeOpponentDebuffId[] + debuffChoices: Array> + selfChoices: Array> + stage: number + variant: Iwt2RoguelikeVariant +} const MENU_ITEMS: Array<{ screen: Iwt2Screen @@ -38,32 +64,32 @@ const MENU_ITEMS: Array<{ }> = [ { screen: 'cloud-save', - title: 'Cloud Save', - description: 'Account sync shell for IWT2 progression and local save status.', + title: 'Backup Slot', + description: 'Save or restore the isolated IWT2 progress slot.', glyph: 'C', }, { screen: 'dungeons', title: 'Dungeons', - description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.', + description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.', glyph: 'D', }, { screen: 'raids', title: 'Raids', - description: 'Large-party encounter shell for future multi-group boss fights.', + description: 'Open raid assignments built from active IWT2 boss mechanics.', glyph: 'R', }, { screen: 'roguelike', title: 'Roguelike', - description: 'Run-based IWT2 progression shell for room chains and reward drafts.', - glyph: 'G', + description: 'Draft upgrades through escalating random encounters.', + glyph: 'L', }, { - screen: 'pvp', + screen: 'roguelike', title: 'PvP', - description: 'Competitive healing shell with controller targeting preserved.', + description: 'Race another healer through roguelike encounters with buffs and sabotage.', glyph: 'P', }, { @@ -75,7 +101,7 @@ const MENU_ITEMS: Array<{ { screen: 'customize-character', title: 'Customize Character', - description: 'IWT2-only character identity and cosmetic setup shell.', + description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.', glyph: 'K', }, { @@ -91,6 +117,10 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () const [save, setSave] = useState(loadIwt2Save) const [selectedIndex, setSelectedIndex] = useState(0) const [selectedBossId, setSelectedBossId] = useState('bulldrome') + const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon') + const [roguelikeVariant, setRoguelikeVariant] = useState('pve') + const [roguelikeContentType, setRoguelikeContentType] = useState('dungeon') + const [roguelikeRun, setRoguelikeRun] = useState(null) useEffect(() => { writeIwt2Save(save) @@ -103,7 +133,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () return } if (action === 'confirm') { - setScreen(MENU_ITEMS[selectedIndex].screen) + openMenuItem(MENU_ITEMS[selectedIndex]) return } if (action === 'navigateUp' || action === 'navigateLeft') { @@ -115,10 +145,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () } }) + function openMenuItem(item: (typeof MENU_ITEMS)[number]) { + if (item.title === 'PvP') { + setRoguelikeVariant('pvp') + setScreen('roguelike') + return + } + if (item.title === 'Roguelike') { + setRoguelikeVariant('pve') + setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current) + } + setScreen(item.screen) + } + if (screen === 'arena') { return ( setScreen('menu')} onSaveUpdated={setSave} @@ -126,6 +170,57 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () ) } + if (screen === 'roguelike-arena' && roguelikeRun) { + return ( + { + setRoguelikeRun((current) => current + ? { + ...current, + ...buildRoguelikeChoices(save, current.variant), + } + : current) + setScreen('roguelike-upgrade') + }, + stage: roguelikeRun.stage, + variant: roguelikeRun.variant, + }} + save={save} + onBack={() => setScreen('roguelike')} + onSaveUpdated={setSave} + /> + ) + } + + if (screen === 'roguelike-upgrade' && roguelikeRun) { + return ( +
+ + setScreen('roguelike')} + onChoose={(buffId, debuffId) => { + const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save) + setRoguelikeRun(nextRun) + setSelectedBossId(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage)) + setScreen('roguelike-arena') + }} + /> +
+ ) + } + if (screen === 'dungeons') { return (
@@ -133,6 +228,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () setScreen('menu')} onOpenBoss={(bossId) => { + setArenaModeLabel('Dungeon') setSelectedBossId(bossId) setScreen('arena') }} @@ -141,12 +237,45 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () ) } - if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') { - const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP' + if (screen === 'roguelike') { return (
- setScreen('menu')} /> + setScreen('menu')} + onContentTypeChange={setRoguelikeContentType} + onStart={() => { + const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType) + setRoguelikeRun(nextRun) + setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage)) + setScreen('roguelike-arena') + }} + onVariantChange={(nextVariant) => { + setRoguelikeVariant(nextVariant) + if (nextVariant === 'pve' && roguelikeContentType === 'stadium') { + setRoguelikeContentType('dungeon') + } + }} + /> +
+ ) + } + + if (screen === 'raids') { + return ( +
+ + setScreen('menu')} + onOpenBoss={(bossId) => { + setArenaModeLabel('Raid') + setSelectedBossId(bossId) + setScreen('arena') + }} + />
) } @@ -164,7 +293,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () return (
- setScreen('menu')} /> + setScreen('menu')} + onSaveUpdated={setSave} + />
) } @@ -173,7 +306,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () return (
- setScreen('menu')} /> + setScreen('menu')} + onSaveUpdated={setSave} + />
) } @@ -202,8 +339,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () + ) }) ) : ( <> - - + )} @@ -58,7 +61,7 @@ export function Iwt2BottomDisplay() { {abilities.map((ability, index) => { const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1' return ( - + ) })} diff --git a/src/modes/iwt2/content/bosses.ts b/src/modes/iwt2/content/bosses.ts index 1727f44..1e52d37 100644 --- a/src/modes/iwt2/content/bosses.ts +++ b/src/modes/iwt2/content/bosses.ts @@ -1,6 +1,6 @@ import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides' -export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' +export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu' export type Iwt2BalanceOverrides = { bosses?: Partial>>> @@ -46,6 +46,24 @@ export type Iwt2BossMetadata = { birdRadius?: number birdContactDamage?: number birdStunSeconds?: number + packHowlCooldown?: number + packHowlWindup?: number + packLaneDamage?: number + packLaneStunSeconds?: number + packLaneWidth?: number + packLaneCount?: number + thunderRingCooldown?: number + thunderRingWindup?: number + thunderRingInnerRadius?: number + thunderRingOuterRadius?: number + thunderRingDamage?: number + thunderRingStunSeconds?: number + lightningStrikeCooldown?: number + lightningStrikeWindup?: number + lightningStrikeRadius?: number + lightningStrikeDamage?: number + lightningStrikeStunSeconds?: number + lightningStrikeCount?: number } const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = { @@ -116,6 +134,76 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = { birdStunSeconds: 0.75, } +const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = { + id: 'great-jaggi', + name: 'Great Jaggi', + icon: 'J', + color: '#3f8f73', + accentColor: '#b8f0aa', + maxHealth: 620, + radius: 27, + moveSpeed: 146, + meleeRange: 52, + meleeDamage: 7, + meleeCooldown: 0.82, + chargeCooldown: 0, + chargeWindup: 0, + chargeSpeed: 0, + chargeDamage: 0, + chargeStunSeconds: 0, + chargeLength: 0, + chargeOvershoot: 0, + chargeWidth: 0, + slamWindup: 0, + slamRadius: 0, + slamDamage: 0, + slamStunSeconds: 0, + packHowlCooldown: 5.8, + packHowlWindup: 0.95, + packLaneDamage: 15, + packLaneStunSeconds: 0.45, + packLaneWidth: 24, + packLaneCount: 3, +} + +const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = { + id: 'khezu', + name: 'Khezu', + icon: 'K', + color: '#d8d7c9', + accentColor: '#77d9ff', + maxHealth: 760, + radius: 30, + moveSpeed: 92, + meleeRange: 58, + meleeDamage: 10, + meleeCooldown: 1.15, + chargeCooldown: 0, + chargeWindup: 0, + chargeSpeed: 0, + chargeDamage: 0, + chargeStunSeconds: 0, + chargeLength: 0, + chargeOvershoot: 0, + chargeWidth: 0, + slamWindup: 0, + slamRadius: 0, + slamDamage: 0, + slamStunSeconds: 0, + thunderRingCooldown: 7.2, + thunderRingWindup: 0.9, + thunderRingInnerRadius: 70, + thunderRingOuterRadius: 172, + thunderRingDamage: 22, + thunderRingStunSeconds: 0.7, + lightningStrikeCooldown: 4.2, + lightningStrikeWindup: 0.78, + lightningStrikeRadius: 46, + lightningStrikeDamage: 17, + lightningStrikeStunSeconds: 0.55, + lightningStrikeCount: 2, +} + function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata { const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id] if (!override) return metadata @@ -127,8 +215,12 @@ function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata { export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA) export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA) +export const GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA) +export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA) export const IWT2_BOSS_METADATA: Record = { bulldrome: BULLDROME_BOSS_METADATA, 'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA, + 'great-jaggi': GREAT_JAGGI_BOSS_METADATA, + khezu: KHEZU_BOSS_METADATA, } diff --git a/src/modes/iwt2/content/healerAbilities.ts b/src/modes/iwt2/content/healerAbilities.ts index 35e1655..c474e51 100644 --- a/src/modes/iwt2/content/healerAbilities.ts +++ b/src/modes/iwt2/content/healerAbilities.ts @@ -1,12 +1,18 @@ import type { Spell } from '../../../game' -import type { Ability } from '../../../profile' -import { toCombatSpell } from '../../../combat/rules' export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground' +export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage' + +export type Iwt2HealerMetadata = { + id: Iwt2HealerId + name: string + icon: string + description: string +} export type Iwt2HealerAbility = { id: string - healerId: string + healerId: Iwt2HealerId slot: number name: string icon: string @@ -16,110 +22,95 @@ export type Iwt2HealerAbility = { manaCost: number cooldownSeconds: number target: Iwt2AbilityTarget + extraTargets?: number } -const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [ - { - id: 1, - classId: 1, - slug: 'mend', - name: 'Mend', - spellType: 'direct_heal', - cost: 5, - cooldown: 0.5, - power: 30, - unlockLevel: 1, - glyph: '+', - description: 'A fast, efficient single-target heal.', +export const IWT2_HEALER_METADATA: Record = { + dawnweaver: { + id: 'dawnweaver', + name: 'Dawnweaver', + icon: '+', + description: 'Direct heals, radiant group recovery, shields, and cleanse.', }, - { - id: 2, - classId: 1, - slug: 'renew', - name: 'Renew', - spellType: 'heal_over_time', - cost: 7, - cooldown: 0.5, - power: 12, - unlockLevel: 1, - glyph: '~', - description: 'Heals now and continues healing over time.', + lifebinder: { + id: 'lifebinder', + name: 'Lifebinder', + icon: '~', + description: 'Verdant healing over time, barkskin shielding, and steady group recovery.', }, - { - id: 3, - classId: 1, - slug: 'radiance', - name: 'Radiance', - spellType: 'party_heal', - cost: 12, - cooldown: 8, - power: 18, - unlockLevel: 1, - glyph: '*', - description: 'Restores health to up to 4 injured party members.', + runesage: { + id: 'runesage', + name: 'Runesage', + icon: 'R', + description: 'Rune-scripted mends, concordance healing, aegis shielding, and unraveling cleanse.', }, - { - id: 4, - classId: 1, - slug: 'sun-ward', - name: 'Sun Ward', - spellType: 'absorb', - cost: 8, - cooldown: 7, - power: 36, - unlockLevel: 1, - glyph: 'O', - description: 'Places a damage-absorbing shield on your target.', - }, - { - id: 5, - classId: 1, - slug: 'purify', - name: 'Purify', - spellType: 'cleanse', - cost: 5, - cooldown: 5, - power: 10, - unlockLevel: 1, - glyph: 'x', - description: 'Removes a harmful effect and restores health.', - }, - { - id: 6, - classId: 1, - slug: 'dawn-burst', - name: 'Dawn Burst', - spellType: 'party_heal', - cost: 16, - cooldown: 12, - power: 28, - unlockLevel: 5, - glyph: 'D', - description: 'A brilliant wave of healing for up to 4 injured allies.', - }, -] +} -function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility { - const spell = toCombatSpell(ability, String(index + 1)) +export const IWT2_HEALER_ORDER: Iwt2HealerId[] = ['dawnweaver', 'lifebinder', 'runesage'] + +export const IWT2_HEALER_ABILITIES: Record = { + dawnweaver: [ + createAbility('dawnweaver', 1, 'mend', 'Mend', '+', 'direct', 30, 5, 0.5), + createAbility('dawnweaver', 2, 'renew', 'Renew', '~', 'hot', 12, 7, 0.5, 'heal_over_time'), + createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8), + createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'), + createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'), + createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12), + ], + lifebinder: [ + createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55), + createAbility('lifebinder', 2, 'seed-of-life', 'Seed of Life', '~', 'hot', 16, 9, 1, 'heal_over_time'), + createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5), + createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'), + createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'), + createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'), + ], + runesage: [ + createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35), + createAbility('runesage', 2, 'mending-rune', 'Mending Rune', '~', 'hot', 10, 5, 0.45, 'heal_over_time'), + createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5), + createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'), + createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'), + createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5), + ], +} + +export function abilitiesForHealer(healerId: Iwt2HealerId | string) { + return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)] +} + +export function asIwt2HealerId(value: unknown): Iwt2HealerId { + if (value === 'field_medic') return 'dawnweaver' + if (value === 'ward_sage') return 'lifebinder' + if (value === 'storm_chanter') return 'runesage' + return IWT2_HEALER_ORDER.includes(value as Iwt2HealerId) + ? value as Iwt2HealerId + : 'dawnweaver' +} + +function createAbility( + healerId: Iwt2HealerId, + slot: number, + slug: string, + name: string, + icon: string, + kind: Spell['kind'], + power: number, + manaCost: number, + cooldownSeconds: number, + effectType?: string, +): Iwt2HealerAbility { return { - id: spell.id, - healerId: 'field_medic', - slot: index + 1, - name: spell.name, - icon: spell.glyph, - kind: spell.kind, - power: spell.power, - effectType: spell.effectType, - manaCost: spell.cost, - cooldownSeconds: spell.cooldown, + id: `${healerId}-${slug}`, + healerId, + slot, + name, + icon, + kind, + power, + effectType, + manaCost, + cooldownSeconds, target: 'party-member', } } - -export const IWT2_HEALER_ABILITIES: Record = { - field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability), -} - -export function abilitiesForHealer(healerId: string) { - return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic -} diff --git a/src/modes/iwt2/content/roguelike.ts b/src/modes/iwt2/content/roguelike.ts new file mode 100644 index 0000000..7903935 --- /dev/null +++ b/src/modes/iwt2/content/roguelike.ts @@ -0,0 +1,65 @@ +import { + buildOpponentSlotDebuffChoices, + buildSelfSlotUpgradeChoices, +} from '../../../combat/roguelikeUpgrades' +import type { Spell } from '../../../game' +import { type Iwt2HealerAbility } from './healerAbilities' + +export type Iwt2RoguelikeVariant = 'pve' | 'pvp' +export type Iwt2RoguelikeContentType = 'dungeon' | 'raid' | 'stadium' +export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5' + +export type Iwt2RoguelikeSelfBuffId = + | 'revive-party-members' + | `slot${Iwt2RoguelikeSlot}-extra-target` + | `slot${Iwt2RoguelikeSlot}-cost-down` + | `slot${Iwt2RoguelikeSlot}-cooldown-down` + +export type Iwt2RoguelikeOpponentDebuffId = + | `opp-slot${Iwt2RoguelikeSlot}-cost-up` + | `opp-slot${Iwt2RoguelikeSlot}-cooldown-up` + +export type Iwt2RoguelikeChoice = { + id: T + name: string + description: string +} + +export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5'] + +export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice = { + id: 'revive-party-members', + name: 'Revive Party Members', + description: 'Revive fallen party members before the next IWT2 arena.', +} + +export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell { + return { + id: ability.id, + key: String(ability.slot), + name: ability.name, + description: ability.name, + cost: ability.manaCost, + cooldown: ability.cooldownSeconds, + power: ability.power, + glyph: ability.icon, + kind: ability.kind, + effectType: ability.effectType, + } +} + +export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) { + return buildSelfSlotUpgradeChoices({ + slots: IWT2_ROGUELIKE_SLOTS, + spells: abilities.map(iwt2AbilityToSpell), + labelMode: 'ability', + }) +} + +export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) { + return buildOpponentSlotDebuffChoices({ + slots: IWT2_ROGUELIKE_SLOTS, + spells: abilities.map(iwt2AbilityToSpell), + labelMode: 'ability', + }) +} diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts index dec1d8e..2f97329 100644 --- a/src/modes/iwt2/save/iwt2Repository.ts +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -1,3 +1,5 @@ +import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities' + export type Iwt2InventoryItem = { id: string name: string @@ -5,21 +7,35 @@ export type Iwt2InventoryItem = { rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' } +export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue' + export type Iwt2CollectionLog = { bossKills: Record dropsFound: Record } +export type Iwt2Character = { + name: string + level: number + experience: number + healerStyle: Iwt2HealerId + armorPalette: Iwt2ArmorPaletteId +} + +export type Iwt2CloudSlot = { + savedAt: number + character: Iwt2Character + inventory: Iwt2InventoryItem[] + collectionLog: Iwt2CollectionLog +} + export type Iwt2Save = { version: 1 updatedAt: number - character: { - name: string - level: number - experience: number - } + character: Iwt2Character inventory: Iwt2InventoryItem[] collectionLog: Iwt2CollectionLog + cloudSlot?: Iwt2CloudSlot } const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1' @@ -32,6 +48,8 @@ export function createDefaultIwt2Save(): Iwt2Save { name: 'Healer', level: 1, experience: 0, + healerStyle: 'dawnweaver', + armorPalette: 'guild_green', }, inventory: [], collectionLog: { @@ -52,12 +70,15 @@ function normalizeSave(value: unknown): Iwt2Save { name: candidate.character?.name || 'Healer', level: Math.max(1, Math.floor(candidate.character?.level ?? 1)), experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)), + healerStyle: asIwt2HealerId(candidate.character?.healerStyle), + armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette), }, inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [], collectionLog: { bossKills: candidate.collectionLog?.bossKills ?? {}, dropsFound: candidate.collectionLog?.dropsFound ?? {}, }, + cloudSlot: normalizeCloudSlot(candidate.cloudSlot), } } @@ -109,7 +130,91 @@ export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save { } } +export function updateIwt2CharacterSettings( + save: Iwt2Save, + settings: Partial>, +): Iwt2Save { + return { + ...save, + updatedAt: Date.now(), + character: { + ...save.character, + ...settings, + name: normalizeCharacterName(settings.name ?? save.character.name), + healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle), + armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette), + }, + } +} + +export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save { + return { + ...save, + updatedAt: Date.now(), + cloudSlot: { + savedAt: Date.now(), + character: { ...save.character }, + inventory: save.inventory.map((item) => ({ ...item })), + collectionLog: { + bossKills: { ...save.collectionLog.bossKills }, + dropsFound: { ...save.collectionLog.dropsFound }, + }, + }, + } +} + +export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save { + if (!save.cloudSlot) return save + return { + ...save, + updatedAt: Date.now(), + character: { ...save.cloudSlot.character }, + inventory: save.cloudSlot.inventory.map((item) => ({ ...item })), + collectionLog: { + bossKills: { ...save.cloudSlot.collectionLog.bossKills }, + dropsFound: { ...save.cloudSlot.collectionLog.dropsFound }, + }, + } +} + function bossDropFor(bossId: string): { id: string, name: string } { if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' } + if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' } + if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' } return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' } } + +function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId { + return value === 'sun_gold' + || value === 'ember_red' + || value === 'moon_blue' + || value === 'guild_green' + ? value + : 'guild_green' +} + +function normalizeCharacterName(name: string): string { + const trimmed = name.trim().slice(0, 18) + return trimmed || 'Healer' +} + +function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Partial + if (!candidate.character || !candidate.collectionLog) return undefined + return { + savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(), + character: { + name: normalizeCharacterName(candidate.character.name ?? 'Healer'), + level: Math.max(1, Math.floor(candidate.character.level ?? 1)), + experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)), + healerStyle: asIwt2HealerId(candidate.character.healerStyle), + armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette), + }, + inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [], + collectionLog: { + bossKills: candidate.collectionLog.bossKills ?? {}, + dropsFound: candidate.collectionLog.dropsFound ?? {}, + }, + } +} diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index b409d6d..0a4738f 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -19,18 +19,42 @@ import { PartyFrames } from '../components/PartyFrames' import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls' import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities' import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' +import type { + Iwt2RoguelikeContentType, + Iwt2RoguelikeOpponentDebuffId, + Iwt2RoguelikeSelfBuffId, + Iwt2RoguelikeVariant, +} from '../content/roguelike' type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat' type OverlayAction = 'primary' | 'menu' +type OverlayNavEntry = { + action: OverlayAction + row: number +} + +const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [ + { action: 'primary', row: 0 }, + { action: 'menu', row: 1 }, +] type BossArenaScreenProps = { bossId: Iwt2BossId + modeLabel?: string save: Iwt2Save onBack: () => void onSaveUpdated: (save: Iwt2Save) => void + roguelikeRun?: { + buffs: Iwt2RoguelikeSelfBuffId[] + contentType: Iwt2RoguelikeContentType + debuffs: Iwt2RoguelikeOpponentDebuffId[] + onVictory: () => void + stage: number + variant: Iwt2RoguelikeVariant + } } -export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) { +export function BossArenaScreen({ bossId, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { const bossMetadata = IWT2_BOSS_METADATA[bossId] const [arenaState, setArenaState] = useState(() => createInitialIwt2ArenaState(bossId)) const [abilityCooldowns, setAbilityCooldowns] = useState>({}) @@ -39,6 +63,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre const [selectedPartyId, setSelectedPartyId] = useState('player-healer') const stateRef = useRef(arenaState) const statusRef = useRef(status) + const selectedOverlayActionRef = useRef(selectedOverlayAction) const selectedPartyIdRef = useRef(selectedPartyId) const saveRef = useRef(save) const killRecordedRef = useRef(false) @@ -62,6 +87,10 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre statusRef.current = status }, [status]) + useEffect(() => { + selectedOverlayActionRef.current = selectedOverlayAction + }, [selectedOverlayAction]) + useEffect(() => { selectedPartyIdRef.current = selectedPartyId }, [selectedPartyId]) @@ -88,8 +117,12 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre }, []) const abilities = useMemo( - () => abilitiesForHealer('field_medic'), - [], + () => applyRoguelikeModifiers( + abilitiesForHealer(save.character.healerStyle), + roguelikeRun?.buffs ?? [], + roguelikeRun?.debuffs ?? [], + ), + [roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle], ) const castAbility = useCallback((ability: Iwt2HealerAbility) => { @@ -109,16 +142,45 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre setArenaState(result.state) }, []) + const moveOverlaySelection = useCallback((action: string) => { + setSelectedOverlayAction((current) => { + const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0] + const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => { + if (entry.action === current) return false + if (action === 'navigateUp') return entry.row < active.row + if (action === 'navigateDown') return entry.row > active.row + return false + }) + if (candidates.length === 0) return current + candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row)) + return candidates[0]?.action ?? current + }) + }, []) + + const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => { + if (overlayAction === 'menu') { + onBack() + return + } + if (statusRef.current === 'paused') { + setStatus('playing') + return + } + if (statusRef.current === 'victory' && roguelikeRun) { + roguelikeRun.onVictory() + return + } + resetArena() + }, [onBack, resetArena, roguelikeRun]) + useGameAction((action, device) => { if (device === 'controller' && statusRef.current !== 'playing') { if (action.startsWith('navigate')) { - setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary') + moveOverlaySelection(action) return } if (action === 'confirm') { - if (selectedOverlayAction === 'menu') onBack() - else if (statusRef.current === 'paused') setStatus('playing') - else resetArena() + activateOverlayAction() return } if (action === 'back') { @@ -201,7 +263,11 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0 const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0) - const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart' + const overlayPrimaryLabel = status === 'paused' + ? 'Resume' + : status === 'victory' && roguelikeRun + ? 'Choose Upgrade' + : 'Restart' const overlayTitle = status === 'victory' ? `${bossMetadata.name} Down` : status === 'defeat' @@ -217,6 +283,15 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre : status === 'defeat' ? 'is-defeat' : 'is-paused' + const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name) + const pauseCopy = roguelikeRun?.variant === 'pvp' + ? undefined + : 'Combat is stopped. Resume the fight or leave the current run.' + const pauseLeaveLabel = roguelikeRun?.variant === 'pvp' + ? 'Leave' + : roguelikeRun + ? 'Leave Roguelike' + : `Leave ${modeLabel ?? 'Arena'}` return (
- {status !== 'playing' && ( + {status === 'paused' && ( +
+
+

Paused

+

{pauseTitle}

+ {pauseCopy &&

{pauseCopy}

} + + +
+
+ )} + + {status !== 'playing' && status !== 'paused' && (
@@ -249,22 +352,25 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre

{overlayEyebrow}

{overlayTitle}

- {status !== 'paused' && ( -
- - {formatArenaTime(arenaState.time)} - Clear - - - {alivePartyCount}/{arenaState.party.length} - Standing - - - {Math.round(totalPartyDamage)} - Damage - -
+ {roguelikeRun && ( +

+ {roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage} +

)} +
+ + {formatArenaTime(arenaState.time)} + Clear + + + {alivePartyCount}/{arenaState.party.length} + Standing + + + {Math.round(totalPartyDamage)} + Damage + +
{status === 'victory' && (
+125 XP @@ -276,7 +382,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre +
+
+ + +
+ {variant === 'pve' && ( + <> +
+
+

Run Type

+

PvE Roguelike

+
+
+ + +
+
+
+ {contentType === 'raid' ? 'R' : 'D'} +
+ {pveCardTitle} + {pveCardCopy} +
+ +
+ + )} + {variant === 'pvp' && ( + <> +
+
+

Match Type

+

PvP Roguelike

+
+
+ + + +
+
+
+ {gameMode === 'offline' ? 'C' : 'Q'} +
+ {pvpCardTitle} + {pvpCardCopy} +
+ +
+ + )} + + ) +} + +export function Iwt2RoguelikeUpgradeScreen({ + activeBuffSummary, + activeDebuffSummary, + contentType, + debuffChoices, + onBack, + onChoose, + selfChoices, + stage, + variant, +}: { + activeBuffSummary?: string + activeDebuffSummary?: string + contentType: Iwt2RoguelikeContentType + debuffChoices: Array> + onBack: () => void + onChoose: (buffId: Iwt2RoguelikeSelfBuffId, debuffId?: Iwt2RoguelikeOpponentDebuffId) => void + selfChoices: Array> + stage: number + variant: Iwt2RoguelikeVariant +}) { + const [selectedBuffId, setSelectedBuffId] = useState() + const [selectedDebuffId, setSelectedDebuffId] = useState() + const [selectedIndex, setSelectedIndex] = useState(0) + const activeBuffId = selfChoices.some((choice) => choice.id === selectedBuffId) ? selectedBuffId : undefined + const activeDebuffId = debuffChoices.some((choice) => choice.id === selectedDebuffId) ? selectedDebuffId : undefined + + const entries = useMemo(() => { + if (variant !== 'pvp') { + return [ + ...selfChoices.map((_, index) => ({ + kind: 'upgradeBuff' as const, + index, + row: index, + column: 0, + })), + { + kind: 'upgradeLeave' as const, + row: selfChoices.length, + column: 0, + }, + ] + } + const nextEntries: Iwt2UpgradeNavEntry[] = [ + ...selfChoices.map((_, index) => ({ + kind: 'upgradeBuff' as const, + index, + row: index, + column: 0, + })), + ...debuffChoices.map((_, index) => ({ + kind: 'upgradeDebuff' as const, + index, + row: index, + column: 1, + })), + { + kind: 'upgradeContinue' as const, + row: Math.max(selfChoices.length, debuffChoices.length), + column: 1, + disabled: !activeBuffId || !activeDebuffId, + }, + ] + return nextEntries + }, [activeBuffId, activeDebuffId, debuffChoices, selfChoices, variant]) + + const activeEntry = activeUpgradeEntry(entries, selectedIndex) + const pvpUpgrade = variant === 'pvp' + const buffHeader = pvpUpgrade ? 'Self Buff' : 'Run Buff' + const headerEyebrow = pvpUpgrade && contentType === 'stadium' ? 'Stadium Buy Round' : 'Choose Edge' + const title = pvpUpgrade + ? contentType === 'stadium' ? `Round ${stage} Complete` : `Stage ${stage} Boss Cleared` + : `Roguelike Stage ${stage} Complete` + const continueLabel = contentType === 'stadium' ? 'Finished Buying' : 'Continue' + + function entrySelected(kind: Iwt2UpgradeNavEntry['kind'], index?: number) { + if (!activeEntry || activeEntry.kind !== kind) return false + if ('index' in activeEntry || index !== undefined) return 'index' in activeEntry && activeEntry.index === index + return true + } + + function setCursor(kind: Iwt2UpgradeNavEntry['kind'], index?: number) { + const nextIndex = entries.findIndex((entry) => { + if (entry.kind !== kind) return false + if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index + return true + }) + if (nextIndex >= 0) setSelectedIndex(nextIndex) + } + + function openEntry(entry: Iwt2UpgradeNavEntry | undefined) { + if (!entry || upgradeEntryDisabled(entry)) return + if (entry.kind === 'upgradeBuff') { + const choice = selfChoices[entry.index] + if (!choice) return + if (pvpUpgrade) setSelectedBuffId(choice.id) + else onChoose(choice.id) + } else if (entry.kind === 'upgradeDebuff') { + const choice = debuffChoices[entry.index] + if (choice) setSelectedDebuffId(choice.id) + } else if (entry.kind === 'upgradeContinue') { + if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId) + } else if (entry.kind === 'upgradeLeave') { + onBack() + } + } + + useGameAction((action, device) => { + if (device !== 'controller') return + if (action === 'confirm') { + openEntry(activeEntry) + return + } + if (action.startsWith('navigate')) { + setSelectedIndex((current) => moveUpgradeSelection(entries, current, action)) + return + } + if ((action === 'back' || action === 'pause') && !pvpUpgrade) { + onBack() + } + }) + + return ( +
+
+ {pvpUpgrade ? ( +
+
+

{headerEyebrow}

+

{title}

+
+
+ ) : ( + <> +

{title}

+

Choose Upgrade

+

Pick one upgrade before the next fight.

+ + )} +
+
+ {buffHeader} +
+ {selfChoices.map((choice, index) => ( + + ))} +
+
+ {pvpUpgrade && ( +
+ Opponent Debuff +
+ {debuffChoices.map((choice, index) => ( + + ))} +
+
+ )} +
+ {!pvpUpgrade && activeBuffSummary && ( +

+ Active: {activeBuffSummary} +

+ )} + {pvpUpgrade && (activeBuffSummary || activeDebuffSummary) && ( +

+ Buffs: {activeBuffSummary || 'None'} | Debuffs: {activeDebuffSummary || 'None'} +

+ )} + {pvpUpgrade ? ( + + ) : ( + + )} +
+
+ ) +} + export function Iwt2HunterProfileScreen({ onBack, save, @@ -175,108 +752,144 @@ export function Iwt2HunterProfileScreen({ onBack: () => void save: Iwt2Save }) { - const bulldromeKills = save.collectionLog.bossKills.bulldrome ?? 0 - const yianKills = save.collectionLog.bossKills['yian-kut-ku'] ?? 0 - const bulldromeDrops = save.collectionLog.dropsFound['raw-bulldrome-coin'] ?? 0 - const yianDrops = save.collectionLog.dropsFound['yian-kut-ku-scale'] ?? 0 + const bossRows = (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({ + bossId, + drops: save.collectionLog.dropsFound[bossDropIdForProfile(bossId)] ?? 0, + kills: save.collectionLog.bossKills[bossId] ?? 0, + name: IWT2_BOSS_METADATA[bossId].name, + })) + const actions = useMemo(() => withBackAction([ + { + key: 'name', + label: 'Name', + value: save.character.name, + onConfirm: () => {}, + }, + { + key: 'level', + label: 'Level', + value: String(save.character.level), + onConfirm: () => {}, + }, + { + key: 'experience', + label: 'Experience', + value: `${save.character.experience} XP`, + onConfirm: () => {}, + }, + { + key: 'class', + label: 'Healing Class', + value: IWT2_HEALER_METADATA[save.character.healerStyle].name, + onConfirm: () => {}, + }, + ...bossRows.map((row) => ({ + key: `boss-${row.bossId}`, + label: row.name, + value: `${row.kills} kills / ${row.drops} drops`, + onConfirm: () => {}, + })), + { + key: 'inventory', + label: 'IWT2 Inventory', + value: `${save.inventory.length} slots`, + onConfirm: () => {}, + }, + ], onBack), [bossRows, onBack, save.character.experience, save.character.healerStyle, save.character.level, save.character.name, save.inventory.length]) return ( -
-
-
- Name - {save.character.name} -
-
- Level - {save.character.level} -
-
- Experience - {save.character.experience} XP -
+ + + ) +} + +function bossDropIdForProfile(bossId: Iwt2BossId): string { + if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale' + if (bossId === 'great-jaggi') return 'great-jaggi-hide' + if (bossId === 'khezu') return 'khezu-pearl' + return 'raw-bulldrome-coin' +} + +export function Iwt2CustomizeCharacterScreen({ + onBack, + onSaveUpdated, + save, +}: { + onBack: () => void + onSaveUpdated: (save: Iwt2Save) => void + save: Iwt2Save +}) { + const activeAbilities = abilitiesForHealer(save.character.healerStyle) + const abilityRows = activeAbilities.map((ability) => ({ + key: ability.id, + label: ability.name, + detail: `${ability.manaCost} resource. ${ability.cooldownSeconds}s cooldown.`, + value: ability.icon, + })) + const actions = useMemo(() => withBackAction([ + ...IWT2_HEALER_ORDER.map((healerId) => { + const healer = IWT2_HEALER_METADATA[healerId] + return { + key: `class-${healerId}`, + label: healer.name, + detail: healer.description, + value: save.character.healerStyle === healerId ? 'Selected' : healer.icon, + onConfirm: () => onSaveUpdated(updateIwt2CharacterSettings(save, { + healerStyle: healerId, + })), + } + }), + ], onBack), [onBack, onSaveUpdated, save]) + + return ( + +
+
+

Healing Class

+
-
-
- Bulldrome Kills - {bulldromeKills} -
-
- Raw Bulldrome Coins - {bulldromeDrops} -
-
- Yian Kut Ku Kills - {yianKills} -
-
- Yian Kut Ku Scales - {yianDrops} -
-
- IWT2 Inventory - {save.inventory.length} slots +
+
+

Active Loadout

+

Ability Bar

+ Select a class, then start a run.
+
) } -export function Iwt2CustomizeCharacterScreen({ - onBack, - save, -}: { - onBack: () => void - save: Iwt2Save -}) { - const actions = useMemo(() => [ - { - key: 'class', - label: 'Healer Style', - detail: 'Current vertical slice uses Field Medic abilities based on IWT1 dungeon spells.', - value: 'Field Medic', - onConfirm: () => {}, - }, - { - key: 'appearance', - label: 'Armor Palette', - detail: 'Character cosmetics will stay scoped to IWT2 save data.', - value: 'Default', - onConfirm: () => {}, - }, - { - key: 'name', - label: 'Character Name', - detail: 'Name editing shell for the IWT2 hunter profile.', - value: save.character.name, - onConfirm: () => {}, - }, - ], [save.character.name]) - - return ( - - - - ) -} - export function Iwt2CloudSaveScreen({ onBack, + onSaveUpdated, save, }: { onBack: () => void + onSaveUpdated: (save: Iwt2Save) => void save: Iwt2Save }) { const savedAt = new Date(save.updatedAt).toLocaleString() - const actions = useMemo(() => [ + const cloudSavedAt = save.cloudSlot + ? new Date(save.cloudSlot.savedAt).toLocaleString() + : 'No snapshot' + const actions = useMemo(() => withBackAction([ { - key: 'status', - label: 'Cloud Save Slot', - detail: 'Local IWT2 save is isolated and ready for cloud sync integration.', - value: 'Not linked', - onConfirm: () => {}, + key: 'snapshot', + label: 'Update Local Backup Slot', + detail: 'Copies current IWT2 character, inventory, and collection log into the isolated backup slot.', + value: 'Save', + onConfirm: () => onSaveUpdated(snapshotIwt2CloudSlot(save)), + }, + { + key: 'restore', + label: 'Restore Local Backup Slot', + detail: cloudSavedAt, + value: save.cloudSlot ? 'Restore' : 'Empty', + disabled: !save.cloudSlot, + onConfirm: () => onSaveUpdated(restoreIwt2CloudSlot(save)), }, { key: 'last-save', @@ -284,16 +897,117 @@ export function Iwt2CloudSaveScreen({ detail: savedAt, value: 'Local', onConfirm: () => {}, + disabled: true, }, - ], [savedAt]) + ], onBack), [cloudSavedAt, onBack, onSaveUpdated, save, savedAt]) return ( - + ) } +function clampToEnabled(actions: Iwt2NavAction[], index: number) { + if (actions.length === 0) return 0 + const bounded = Math.min(Math.max(0, index), actions.length - 1) + if (!actions[bounded]?.disabled) return bounded + const firstEnabled = actions.findIndex((action) => !action.disabled) + return firstEnabled >= 0 ? firstEnabled : 0 +} + +function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) { + if (actions.length === 0) return 0 + let index = current + for (let steps = 0; steps < actions.length; steps += 1) { + index = Math.min(actions.length - 1, Math.max(0, index + direction)) + if (!actions[index]?.disabled) return index + if (index === 0 || index === actions.length - 1) break + } + return clampToEnabled(actions, current) +} + +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)) +} + +function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) { + return entry.kind === 'upgradeContinue' && Boolean(entry.disabled) +} + +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 + 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 +} + +function moveSpatialSelection(actions: Iwt2NavAction[], current: number, action: string) { + if (actions.length === 0) return 0 + const bounded = Math.min(current, actions.length - 1) + const active = actions[bounded] + if (!active) return bounded + const activePosition = roguelikeActionPosition(active.key) + const candidates = actions + .map((entry, index) => ({ entry, index, position: roguelikeActionPosition(entry.key) })) + .filter(({ index, position }) => { + if (index === bounded) return false + if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column + if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column + if (action === 'navigateUp') return position.row < activePosition.row + return position.row > activePosition.row + }) + if (candidates.length === 0) return bounded + candidates.sort((a, b) => { + const aPrimary = Math.abs(a.position.row - activePosition.row) + + Math.abs(a.position.column - activePosition.column) + const bPrimary = Math.abs(b.position.row - activePosition.row) + + Math.abs(b.position.column - activePosition.column) + const aSecondary = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(a.position.column - activePosition.column) + : Math.abs(a.position.row - activePosition.row) + const bSecondary = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(b.position.column - activePosition.column) + : Math.abs(b.position.row - activePosition.row) + return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index + }) + return candidates[0]?.index ?? bounded +} + +function roguelikeActionPosition(key: string): Iwt2NavPosition { + if (key === 'back') return { row: 0, column: 0 } + if (key === 'variant-pve') return { row: 1, column: 0 } + if (key === 'variant-pvp') return { row: 1, column: 1 } + if (key === 'pve-dungeon' || key === 'pvp-dungeon') return { row: 2, column: 0 } + if (key === 'pve-raid' || key === 'pvp-raid') return { row: 2, column: 1 } + if (key === 'pvp-stadium') return { row: 2, column: 2 } + if (key === 'pvp-start') return { row: 3, column: 1 } + return { row: 3, column: 0 } +} + export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) { const { controllerIconStyle, @@ -301,7 +1015,7 @@ export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) { setControllerIconStyle, setDirectPartyTargeting, } = useInput() - const actions = useMemo(() => [ + const actions = useMemo(() => withBackAction([ { key: 'direct-targeting', label: 'Target Party Members With Bindings', @@ -316,9 +1030,10 @@ export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) { value: controllerIconStyle === style ? 'Selected' : '', onConfirm: () => setControllerIconStyle(style), })), - ], [ + ], onBack), [ controllerIconStyle, directPartyTargeting, + onBack, setControllerIconStyle, setDirectPartyTargeting, ]) diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index bbd10c0..c96073e 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -75,7 +75,7 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I health: bossMetadata.maxHealth, maxHealth: bossMetadata.maxHealth, meleeCooldownRemaining: 0.6, - chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0, + chargeCooldownRemaining: initialBossSpecialCooldown(bossId), chargeCount: 0, attackPhase: 'idle', phaseSecondsRemaining: 0, @@ -85,9 +85,11 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I slamApplied: false, wallContactSeconds: 0, relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 }, - fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0, + fireballCooldownRemaining: initialBossSecondaryCooldown(bossId), fireballTarget: { x: 320, y: 250 }, birdWaveThresholdsTriggered: [], + mechanicLanes: [], + mechanicCircles: [], }, nextEventId: 1, nextProjectileId: 1, @@ -97,6 +99,19 @@ export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): I } } +function initialBossSpecialCooldown(bossId: Iwt2BossId): number { + if (bossId === 'bulldrome') return 2 + if (bossId === 'great-jaggi') return 2.4 + if (bossId === 'khezu') return 3 + return 0 +} + +function initialBossSecondaryCooldown(bossId: Iwt2BossId): number { + if (bossId === 'yian-kut-ku') return 1.2 + if (bossId === 'khezu') return 1.6 + return 0 +} + export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { const step = Math.max(0, Math.min(dt, MAX_DT)) if (step <= 0) return { ...state, events: [...state.events] } diff --git a/src/modes/iwt2/sim/bossAi.ts b/src/modes/iwt2/sim/bossAi.ts index b383b63..d093e62 100644 --- a/src/modes/iwt2/sim/bossAi.ts +++ b/src/modes/iwt2/sim/bossAi.ts @@ -11,6 +11,8 @@ import type { Iwt2ProjectileEntityState, Iwt2Vec2, } from './types' +import { tickGreatJaggi } from './greatJaggiAi' +import { tickKhezu } from './khezuAi' import { tickYianKutKu } from './yianKutKuAi' import { applyPartyDamageInShape, @@ -44,6 +46,8 @@ export type Iwt2BossTickResult = { export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt) + if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt) + if (state.boss.bossId === 'khezu') return tickKhezu(state, dt) return tickBulldrome(state, dt) } diff --git a/src/modes/iwt2/sim/greatJaggiAi.ts b/src/modes/iwt2/sim/greatJaggiAi.ts new file mode 100644 index 0000000..3294121 --- /dev/null +++ b/src/modes/iwt2/sim/greatJaggiAi.ts @@ -0,0 +1,211 @@ +import { GREAT_JAGGI_BOSS_METADATA } from '../content/bosses' +import type { Iwt2BossTickResult } from './bossAi' +import type { + Iwt2ArenaEvent, + Iwt2ArenaIndicator, + Iwt2ArenaState, + Iwt2BossEntityState, + Iwt2MechanicLaneState, + Iwt2PartyEntityState, +} from './types' +import { + applyPartyDamageInShape, + createLaneIndicator, + indicatorPhaseFromAttack, +} from './mechanics' +import { + clamp, + clampVec2ToArena, + distanceVec2, + moveToward, + scaleVec2, + subtractVec2, + withFallbackFacing, +} from './vector' + +export function tickGreatJaggi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { + const events: Iwt2ArenaEvent[] = [] + let party = state.party + let boss = { + ...state.boss, + meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), + chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), + phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), + velocity: { x: 0, y: 0 }, + } + const target = getBossTarget(party) + + if (boss.health <= 0 || !target) return withGreatJaggiIndicators({ boss, party, events }) + + if (boss.attackPhase === 'packHowlWindup') { + boss = { + ...boss, + facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing), + } + if (boss.phaseSecondsRemaining <= 0) { + const result = applyPackLanes(party, boss, state.time + dt) + party = result.party + events.push(...result.events) + boss = { + ...boss, + attackPhase: 'packHowlRecover', + phaseSecondsRemaining: 0.45, + } + } + return withGreatJaggiIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'packHowlRecover') { + if (boss.phaseSecondsRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'idle', + mechanicLanes: [], + phaseSecondsRemaining: 0, + } + } + return withGreatJaggiIndicators({ boss, party, events }) + } + + if (boss.chargeCooldownRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'packHowlWindup', + chargeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlCooldown!, + mechanicLanes: createPackLanes(state, party), + phaseSecondsRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlWindup!, + velocity: { x: 0, y: 0 }, + } + return withGreatJaggiIndicators({ boss, party, events }) + } + + const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) + party = meleeResult.party + events.push(...meleeResult.events) + boss = meleeResult.boss + if (events.length > 0) return withGreatJaggiIndicators({ boss, party, events }) + + const nextPosition = clampVec2ToArena( + moveToward(boss.position, target.position, GREAT_JAGGI_BOSS_METADATA.moveSpeed * dt), + boss.radius, + state.bounds, + ) + boss = { + ...boss, + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + } + return withGreatJaggiIndicators({ boss, party, events }) +} + +function createPackLanes(state: Iwt2ArenaState, party: Iwt2PartyEntityState[]): Iwt2MechanicLaneState[] { + const living = party.filter((member) => member.health > 0) + const count = GREAT_JAGGI_BOSS_METADATA.packLaneCount! + const lanes: Iwt2MechanicLaneState[] = [] + for (let index = 0; index < count; index += 1) { + const target = living[(index * 2) % Math.max(1, living.length)] + const baseY = target + ? target.position.y + : state.bounds.height * ((index + 1) / (count + 1)) + const slope = index % 2 === 0 ? 54 : -54 + const y = clamp(baseY + (index - 1) * 28, 54, state.bounds.height - 54) + lanes.push({ + id: `pack-lane-${index}`, + start: { x: -28, y: clamp(y - slope, 36, state.bounds.height - 36) }, + end: { x: state.bounds.width + 28, y: clamp(y + slope, 36, state.bounds.height - 36) }, + width: GREAT_JAGGI_BOSS_METADATA.packLaneWidth!, + }) + } + return lanes +} + +function applyPackLanes( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + time: number, +): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + let nextParty = party + const events: Iwt2ArenaEvent[] = [] + const hitEntityIds: string[] = [] + for (const lane of boss.mechanicLanes) { + const result = applyPartyDamageInShape(nextParty, { + kind: 'lane', + start: lane.start, + end: lane.end, + width: lane.width, + }, { + damage: GREAT_JAGGI_BOSS_METADATA.packLaneDamage!, + excludedEntityIds: hitEntityIds, + knockdownSeconds: 0, + sourceId: boss.id, + stunSeconds: GREAT_JAGGI_BOSS_METADATA.packLaneStunSeconds!, + time, + }) + nextParty = result.party + hitEntityIds.push(...result.hitEntityIds) + events.push(...result.events) + } + return { party: nextParty, events } +} + +function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { + const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) + if (livingTank) return livingTank + return party.find((member) => member.health > 0) +} + +function maybeApplyMelee( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + target: Iwt2PartyEntityState, + time: number, +): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } + if (distanceVec2(boss.position, target.position) > GREAT_JAGGI_BOSS_METADATA.meleeRange + target.radius) { + return { boss, party, events: [] } + } + const result = applyPartyDamageInShape(party, { + kind: 'circle', + position: boss.position, + radius: GREAT_JAGGI_BOSS_METADATA.meleeRange, + }, { + damage: GREAT_JAGGI_BOSS_METADATA.meleeDamage, + sourceId: boss.id, + time, + }) + return { + boss: { ...boss, meleeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.meleeCooldown }, + party: result.party, + events: result.events, + } +} + +function withGreatJaggiIndicators(result: Omit): Iwt2BossTickResult { + return { + ...result, + indicators: createGreatJaggiIndicators(result.boss), + } +} + +function createGreatJaggiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { + if ( + boss.attackPhase !== 'packHowlWindup' + && boss.attackPhase !== 'packHowlRecover' + ) { + return [] + } + return boss.mechanicLanes.map((lane) => createLaneIndicator({ + color: boss.attackPhase === 'packHowlRecover' ? '#f05b4f' : '#b8f0aa', + end: lane.end, + id: `${boss.id}:${lane.id}`, + mechanicId: 'great-jaggi-pack-lane', + phase: indicatorPhaseFromAttack( + boss.attackPhase === 'packHowlWindup', + boss.attackPhase === 'packHowlRecover', + ), + sourceId: boss.id, + start: lane.start, + width: lane.width, + })) +} diff --git a/src/modes/iwt2/sim/healing.ts b/src/modes/iwt2/sim/healing.ts index cb31dc2..9512455 100644 --- a/src/modes/iwt2/sim/healing.ts +++ b/src/modes/iwt2/sim/healing.ts @@ -63,14 +63,22 @@ function targetIdsForAbility( ): Iwt2EntityId[] { const living = party.filter((member) => member.health > 0) if (living.length === 0) return [] + const extraTargets = Math.max(0, Math.floor(ability.extraTargets ?? 0)) if (ability.kind === 'group') { return [...living] .sort((a, b) => healthRatio(a) - healthRatio(b)) - .slice(0, 4) + .slice(0, 4 + extraTargets) .map((member) => member.id) } const selected = living.find((member) => member.id === selectedTargetId) - return [selected?.id ?? living[0].id] + const primaryId = selected?.id ?? living[0].id + if (extraTargets === 0) return [primaryId] + const additionalTargets = living + .filter((member) => member.id !== primaryId) + .sort((a, b) => healthRatio(a) - healthRatio(b)) + .slice(0, extraTargets) + .map((member) => member.id) + return [primaryId, ...additionalTargets] } function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState { diff --git a/src/modes/iwt2/sim/khezuAi.ts b/src/modes/iwt2/sim/khezuAi.ts new file mode 100644 index 0000000..daabaaf --- /dev/null +++ b/src/modes/iwt2/sim/khezuAi.ts @@ -0,0 +1,258 @@ +import { KHEZU_BOSS_METADATA } from '../content/bosses' +import type { Iwt2BossTickResult } from './bossAi' +import type { + Iwt2ArenaEvent, + Iwt2ArenaIndicator, + Iwt2ArenaState, + Iwt2BossEntityState, + Iwt2MechanicCircleState, + Iwt2PartyEntityState, +} from './types' +import { + applyPartyDamageInShape, + createCircleIndicator, + createDonutIndicator, + indicatorPhaseFromAttack, +} from './mechanics' +import { + clampVec2ToArena, + distanceVec2, + moveToward, + scaleVec2, + subtractVec2, + withFallbackFacing, +} from './vector' + +export function tickKhezu(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { + const events: Iwt2ArenaEvent[] = [] + let party = state.party + let boss = { + ...state.boss, + meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), + chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), + fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt), + phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), + velocity: { x: 0, y: 0 }, + } + const target = getBossTarget(party) + + if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events }) + + if (boss.attackPhase === 'thunderRingWindup') { + boss = { + ...boss, + facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing), + } + if (boss.phaseSecondsRemaining <= 0) { + const result = applyThunderRing(party, boss, state.time + dt) + party = result.party + events.push(...result.events) + boss = { + ...boss, + attackPhase: 'thunderRingRecover', + phaseSecondsRemaining: 0.45, + } + } + return withKhezuIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'lightningStrikeWindup') { + if (boss.phaseSecondsRemaining <= 0) { + const result = applyLightningStrikes(party, boss, state.time + dt) + party = result.party + events.push(...result.events) + boss = { + ...boss, + attackPhase: 'lightningStrikeRecover', + phaseSecondsRemaining: 0.38, + } + } + return withKhezuIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') { + if (boss.phaseSecondsRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'idle', + mechanicCircles: [], + phaseSecondsRemaining: 0, + } + } + return withKhezuIndicators({ boss, party, events }) + } + + if (boss.chargeCooldownRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'thunderRingWindup', + chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!, + phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!, + velocity: { x: 0, y: 0 }, + } + return withKhezuIndicators({ boss, party, events }) + } + + if (boss.fireballCooldownRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'lightningStrikeWindup', + fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!, + mechanicCircles: createLightningTargets(party), + phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!, + velocity: { x: 0, y: 0 }, + } + return withKhezuIndicators({ boss, party, events }) + } + + const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) + party = meleeResult.party + events.push(...meleeResult.events) + boss = meleeResult.boss + if (events.length > 0) return withKhezuIndicators({ boss, party, events }) + + const nextPosition = clampVec2ToArena( + moveToward(boss.position, target.position, KHEZU_BOSS_METADATA.moveSpeed * dt), + boss.radius, + state.bounds, + ) + boss = { + ...boss, + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + } + return withKhezuIndicators({ boss, party, events }) +} + +function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] { + return [...party] + .filter((member) => member.health > 0) + .sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth)) + .slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!) + .map((member, index) => ({ + id: `lightning-${index}`, + position: { ...member.position }, + radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!, + })) +} + +function applyThunderRing( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + time: number, +): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + const result = applyPartyDamageInShape(party, { + kind: 'donut', + position: boss.position, + innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!, + outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!, + }, { + damage: KHEZU_BOSS_METADATA.thunderRingDamage!, + knockdownSeconds: 0, + sourceId: boss.id, + stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!, + time, + }) + return { party: result.party, events: result.events } +} + +function applyLightningStrikes( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + time: number, +): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + let nextParty = party + const events: Iwt2ArenaEvent[] = [] + for (const circle of boss.mechanicCircles) { + const result = applyPartyDamageInShape(nextParty, { + kind: 'circle', + position: circle.position, + radius: circle.radius, + }, { + damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!, + knockdownSeconds: 0, + sourceId: boss.id, + stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!, + time, + }) + nextParty = result.party + events.push(...result.events) + } + return { party: nextParty, events } +} + +function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { + const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) + if (livingTank) return livingTank + return party.find((member) => member.health > 0) +} + +function maybeApplyMelee( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + target: Iwt2PartyEntityState, + time: number, +): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } + if (distanceVec2(boss.position, target.position) > KHEZU_BOSS_METADATA.meleeRange + target.radius) { + return { boss, party, events: [] } + } + const result = applyPartyDamageInShape(party, { + kind: 'circle', + position: boss.position, + radius: KHEZU_BOSS_METADATA.meleeRange, + }, { + damage: KHEZU_BOSS_METADATA.meleeDamage, + sourceId: boss.id, + time, + }) + return { + boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown }, + party: result.party, + events: result.events, + } +} + +function withKhezuIndicators(result: Omit): Iwt2BossTickResult { + return { + ...result, + indicators: createKhezuIndicators(result.boss), + } +} + +function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { + const indicators: Iwt2ArenaIndicator[] = [] + if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') { + indicators.push(createDonutIndicator({ + color: '#77d9ff', + id: `${boss.id}:thunder-ring`, + innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!, + mechanicId: 'khezu-thunder-ring', + outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!, + phase: indicatorPhaseFromAttack( + boss.attackPhase === 'thunderRingWindup', + boss.attackPhase === 'thunderRingRecover', + ), + position: boss.position, + sourceId: boss.id, + })) + } + if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') { + for (const circle of boss.mechanicCircles) { + indicators.push(createCircleIndicator({ + color: '#9be8ff', + id: `${boss.id}:${circle.id}`, + mechanicId: 'khezu-lightning-strike', + phase: indicatorPhaseFromAttack( + boss.attackPhase === 'lightningStrikeWindup', + boss.attackPhase === 'lightningStrikeRecover', + ), + position: circle.position, + radius: circle.radius, + sourceId: boss.id, + })) + } + } + return indicators +} diff --git a/src/modes/iwt2/sim/mechanics.ts b/src/modes/iwt2/sim/mechanics.ts index 3c4a67e..e9b4700 100644 --- a/src/modes/iwt2/sim/mechanics.ts +++ b/src/modes/iwt2/sim/mechanics.ts @@ -51,7 +51,14 @@ export type Iwt2CircleHitShape = { radius: number } -export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape +export type Iwt2DonutHitShape = { + kind: 'donut' + position: Iwt2Vec2 + innerRadius: number + outerRadius: number +} + +export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape export function createArenaEvent( id: number, @@ -155,7 +162,9 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: shape.width, ) } - return distanceVec2(member.position, shape.position) <= shape.radius + member.radius + const distance = distanceVec2(member.position, shape.position) + if (shape.kind === 'circle') return distance <= shape.radius + member.radius + return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius) } export function createLaneIndicator({ diff --git a/src/modes/iwt2/sim/partyAi.ts b/src/modes/iwt2/sim/partyAi.ts index db3a0b6..9936411 100644 --- a/src/modes/iwt2/sim/partyAi.ts +++ b/src/modes/iwt2/sim/partyAi.ts @@ -1,5 +1,5 @@ import { IWT2_CLASS_METADATA } from '../content/classes' -import { BULLDROME_BOSS_METADATA } from '../content/bosses' +import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses' import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types' import { addVec2, @@ -14,6 +14,11 @@ import { withFallbackFacing, } from './vector' +const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu']) +const CENTER_LEASH_START_DISTANCE = 230 +const CENTER_LEASH_WALL_MARGIN = 96 +const CENTER_LEASH_EXTRA_DISTANCE = 36 + export function tickPartyMember( member: Iwt2PartyEntityState, state: Iwt2ArenaState, @@ -115,12 +120,55 @@ function getPartyDesiredPosition( const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 } const attackTarget = getPriorityAttackTarget(member, state) const anchor = attackTarget?.position ?? state.boss.position + const centerLeash = getTankCenterLeashPosition(member, state) + if (centerLeash) return centerLeash return { x: anchor.x + member.preferredOffset.x + drift.x, y: anchor.y + member.preferredOffset.y + drift.y, } } +function getTankCenterLeashPosition( + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, +): Iwt2Vec2 | null { + if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(state.boss.bossId)) return null + + const center = arenaCenter(state) + const bossCenterDistance = distanceVec2(state.boss.position, center) + const nearWall = isBossNearWall(state) + if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null + + const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId] + const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), { + x: state.boss.position.x < center.x ? 1 : -1, + y: state.boss.position.y < center.y ? 0.35 : -0.35, + }) + const leashDistance = bossMetadata.meleeRange + state.boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE + return clampVec2ToArena( + addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)), + member.radius, + state.bounds, + ) +} + +function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 { + return { + x: state.bounds.width * 0.5, + y: state.bounds.height * 0.5, + } +} + +function isBossNearWall(state: Iwt2ArenaState): boolean { + const margin = state.boss.radius + CENTER_LEASH_WALL_MARGIN + return ( + state.boss.position.x <= margin + || state.boss.position.x >= state.bounds.width - margin + || state.boss.position.y <= margin + || state.boss.position.y >= state.bounds.height - margin + ) +} + function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null { const boss = state.boss const hazardEscape = getHazardAvoidancePosition(member, state) diff --git a/src/modes/iwt2/sim/types.ts b/src/modes/iwt2/sim/types.ts index d384010..090410c 100644 --- a/src/modes/iwt2/sim/types.ts +++ b/src/modes/iwt2/sim/types.ts @@ -94,6 +94,12 @@ export type Iwt2BossAttackPhase = | 'fireballRecover' | 'birdSummonWindup' | 'birdSummonRecover' + | 'packHowlWindup' + | 'packHowlRecover' + | 'thunderRingWindup' + | 'thunderRingRecover' + | 'lightningStrikeWindup' + | 'lightningStrikeRecover' export type Iwt2HostileAddAttackPhase = | 'idle' @@ -125,6 +131,21 @@ export type Iwt2BossEntityState = { fireballCooldownRemaining: number fireballTarget: Iwt2Vec2 birdWaveThresholdsTriggered: number[] + mechanicLanes: Iwt2MechanicLaneState[] + mechanicCircles: Iwt2MechanicCircleState[] +} + +export type Iwt2MechanicLaneState = { + id: string + start: Iwt2Vec2 + end: Iwt2Vec2 + width: number +} + +export type Iwt2MechanicCircleState = { + id: string + position: Iwt2Vec2 + radius: number } export type Iwt2HostileAddState = {