Compare commits

...
2 Commits
Author SHA1 Message Date
Warren H c2a14bac7d Android build v1.1.17 2026-07-04 18:07:50 -04:00
Warren H 0d269a6041 Android build v1.1.16 2026-07-04 17:41:45 -04:00
46 changed files with 2899 additions and 310 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.
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.
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 94
versionName "1.1.15"
versionCode 96
versionName "1.1.17"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+407 -11
View File
@@ -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;
}
@@ -787,6 +837,285 @@
}
}
@media (max-width: 1000px) and (max-height: 620px) {
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
}
.iwt2-shell .app-header {
min-height: 44px;
padding: 6px 10px;
}
.iwt2-shell .brand-button strong {
font-size: 10px;
}
.iwt2-shell .character-summary {
gap: 7px;
}
.iwt2-shell .character-summary strong {
font-size: 14px;
}
.iwt2-shell .character-summary small {
font-size: 6px;
}
.iwt2-menu-screen {
flex: 1;
gap: 10px;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-menu-heading {
padding-bottom: 8px;
}
.iwt2-menu-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-menu-heading h1 {
font-size: 1.55rem;
}
.iwt2-menu-grid {
gap: 10px;
}
.iwt2-menu-card {
gap: 8px;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 92px;
padding: 8px;
}
.iwt2-menu-card > span {
font-size: 0.95rem;
height: 42px;
width: 42px;
}
.iwt2-menu-card strong {
font-size: 0.8rem;
margin-bottom: 3px;
}
.iwt2-menu-card small {
font-size: 0.64rem;
line-height: 1.25;
}
.iwt2-screen-shell {
flex: 1;
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .back-button {
min-height: 30px;
padding: 5px 9px;
}
.iwt2-action-list,
.iwt2-info-list {
gap: 7px;
min-height: 0;
overflow-y: auto;
padding: 3px;
}
.iwt2-action-row,
.iwt2-info-row {
gap: 8px;
min-height: 54px;
padding: 8px 10px;
}
.iwt2-action-row strong,
.iwt2-info-row strong {
font-size: 0.78rem;
}
.iwt2-action-row small,
.iwt2-info-row small {
font-size: 0.62rem;
line-height: 1.2;
margin-top: 2px;
}
.iwt2-profile-grid,
.iwt2-settings-panel,
.iwt2-customize-layout {
min-height: 0;
overflow: hidden;
}
.iwt2-settings-panel,
.iwt2-customize-layout {
gap: 10px;
}
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
overflow: hidden;
}
.iwt2-loadout-preview {
gap: 8px;
}
.iwt2-loadout-preview h2 {
font-size: 12px;
line-height: 1.1;
}
.iwt2-loadout-preview span {
font-size: 0.62rem;
}
.iwt2-controller-preview {
min-height: 112px;
padding: 10px;
}
.iwt2-boss-hud {
min-width: 280px;
padding: 6px 10px 8px;
top: 6px;
}
.iwt2-boss-hud strong {
font-size: 0.82rem;
}
.iwt2-boss-phase {
font-size: 0.5rem;
height: 10px;
line-height: 10px;
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
overflow: hidden;
padding: 4px;
top: 10px;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 20px minmax(0, 1fr);
height: 62px;
min-height: 62px;
padding: 4px;
}
.iwt2-party-row.has-target-binding {
grid-template-columns: 24px 20px minmax(0, 1fr);
}
.iwt2-party-row > span,
.iwt2-party-row .iwt2-party-target-key {
height: 20px;
min-width: 20px;
width: 20px;
}
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
height: 16px;
min-width: 16px;
}
.iwt2-party-row-title {
gap: 4px;
}
.iwt2-party-row strong {
font-size: 0.62rem;
}
.iwt2-party-row small {
font-size: 0.52rem;
line-height: 1.15;
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
}
.iwt2-party-effects {
height: 10px;
margin-top: 2px;
}
.iwt2-effect-badge {
font-size: 0.44rem;
line-height: 9px;
padding: 0 2px;
}
.iwt2-party-damage {
display: none;
}
.iwt2-ability-bar {
bottom: 8px;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
width: 256px;
}
.iwt2-ability-button {
min-height: 44px;
padding: 3px;
}
.iwt2-ability-button > span {
font-size: 0.9rem;
height: 24px;
width: 24px;
}
.iwt2-ability-button strong {
font-size: 0.5rem;
}
.iwt2-ability-button i {
font-size: 0.46rem;
}
}
.combat-touch-lock-status {
background: #101216;
border: 2px solid #d9b55a;
@@ -2038,7 +2367,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 +2380,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 +2527,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 +7331,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;
@@ -11513,3 +11855,57 @@ h2 {
height: 20px;
}
}
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-screen-shell.content-screen {
gap: 10px;
margin-top: 8px;
max-width: none;
min-height: 0;
overflow: hidden;
padding: 10px 12px;
}
.iwt2-screen-shell.content-screen > .screen-heading {
padding-bottom: 8px;
}
.iwt2-screen-shell.content-screen > .screen-heading .eyebrow {
font-size: 6px;
margin-bottom: 4px;
}
.iwt2-screen-shell.content-screen > .screen-heading h1 {
font-size: 17px;
line-height: 1.1;
}
.iwt2-screen-shell .iwt2-action-list,
.iwt2-screen-shell .iwt2-info-list,
.iwt2-settings-panel .iwt2-action-list,
.iwt2-customize-layout .iwt2-action-list,
.iwt2-loadout-preview .iwt2-info-list {
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.iwt2-screen-shell.content-screen > .iwt2-action-list,
.iwt2-screen-shell.content-screen > .iwt2-info-list {
flex: 1 1 auto;
}
.iwt2-action-row:not(:has(small)),
.iwt2-info-row:not(:has(small)) {
min-height: 32px;
padding: 5px 10px;
}
.iwt2-settings-panel,
.iwt2-customize-layout,
.iwt2-settings-panel > *,
.iwt2-customize-layout > *,
.iwt2-loadout-preview {
min-height: 0;
}
}
+6 -9
View File
@@ -1396,16 +1396,13 @@ function applyIwt2AdminOverrides(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
overrides: Iwt2BalanceOverrides,
): Record<Iwt2BossId, Iwt2BossMetadata> {
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<Iwt2BossId, Iwt2BossMetadata>)
}
function createIwt2AdminRows(
+365 -21
View File
@@ -1,12 +1,22 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useGameAction } from '../../input'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
import {
useDualScreen,
useDualScreenSetupPublisher,
useDualScreenWorkshopPublisher,
type DualScreenSetupState,
type DualScreenWorkshopState,
} from '../../dualScreen'
import { BossArenaScreen } from './screens/BossArenaScreen'
import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2HunterProfileScreen,
Iwt2ModePlaceholderScreen,
Iwt2ModeScreen,
Iwt2RoguelikeScreen,
Iwt2RoguelikeUpgradeScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
@@ -14,7 +24,18 @@ import {
writeIwt2Save,
type Iwt2Save,
} from './save/iwt2Repository'
import type { Iwt2BossId } from './content/bosses'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { abilitiesForHealer, IWT2_HEALER_METADATA } 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 +44,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<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
stage: number
variant: Iwt2RoguelikeVariant
}
const MENU_ITEMS: Array<{
screen: Iwt2Screen
@@ -38,32 +71,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 +108,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',
},
{
@@ -87,15 +120,32 @@ const MENU_ITEMS: Array<{
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
[save, screen],
)
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
@@ -103,7 +153,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 +165,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 (
<BossArenaScreen
bossId={selectedBossId}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
@@ -126,6 +190,57 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
}
if (screen === 'roguelike-arena' && roguelikeRun) {
return (
<BossArenaScreen
bossId={selectedBossId}
roguelikeRun={{
buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType,
debuffs: roguelikeRun.debuffs,
onVictory: () => {
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 (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2RoguelikeUpgradeScreen
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
contentType={roguelikeRun.contentType}
debuffChoices={roguelikeRun.debuffChoices}
selfChoices={roguelikeRun.selfChoices}
stage={roguelikeRun.stage}
variant={roguelikeRun.variant}
onBack={() => 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')
}}
/>
</main>
)
}
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
@@ -133,6 +248,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<Iwt2DungeonsScreen
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Dungeon')
setSelectedBossId(bossId)
setScreen('arena')
}}
@@ -141,12 +257,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 (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModePlaceholderScreen mode={modeName} onBack={() => setScreen('menu')} />
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
onBack={() => 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')
}
}}
/>
</main>
)
}
if (screen === 'raids') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModeScreen
mode="Raids"
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Raid')
setSelectedBossId(bossId)
setScreen('arena')
}}
/>
</main>
)
}
@@ -164,7 +313,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CustomizeCharacterScreen save={save} onBack={() => setScreen('menu')} />
<Iwt2CustomizeCharacterScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
@@ -173,7 +326,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen save={save} onBack={() => setScreen('menu')} />
<Iwt2CloudSaveScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
@@ -202,8 +359,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={item.screen}
onClick={() => setScreen(item.screen)}
key={`${item.screen}-${item.title}`}
onClick={() => openMenuItem(item)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
@@ -221,6 +378,106 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
}
function bossForRoguelike(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
stage = 1,
): Iwt2BossId {
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
const pool = contentType === 'raid'
? raidBosses
: contentType === 'stadium'
? stadiumBosses
: dungeonBosses
return pool[(stage - 1) % pool.length]
}
function createRoguelikeRun(
save: Iwt2Save,
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
buffs: [],
contentType,
debuffs: [],
...buildRoguelikeChoices(save, variant),
stage: 1,
variant,
}
}
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
const abilities = abilitiesForHealer(save.character.healerStyle)
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
return {
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
debuffChoices: variant === 'pvp'
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
: [],
}
}
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
if (buffs.length === 0) return ''
const abilities = abilitiesForHealer(save.character.healerStyle)
return summarizeChoiceStacks(
buffs,
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
'None',
)
}
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
if (debuffs.length === 0) return ''
const abilities = abilitiesForHealer(save.character.healerStyle)
return summarizeChoiceStacks(
debuffs,
buildIwt2OpponentDebuffChoices(abilities),
'None',
)
}
function applyRoguelikeChoice(
run: Iwt2RoguelikeRunState,
buffId: Iwt2RoguelikeSelfBuffId,
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
save: Iwt2Save,
): Iwt2RoguelikeRunState {
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
? {
buffs: run.buffs,
debuffs: nextDebuffs.slice(1),
}
: {
buffs: [...run.buffs, buffId],
debuffs: nextDebuffs,
}
return {
...run,
...nextBase,
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
const pool = [...items]
const choices: T[] = []
while (pool.length > 0 && choices.length < count) {
const index = Math.floor(Math.random() * pool.length)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
function Iwt2Header({
onBackToGameSelect,
save,
@@ -241,3 +498,90 @@ function Iwt2Header({
</header>
)
}
function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
initials: boss.icon,
itemLevel: save.character.level,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
): DualScreenWorkshopState | null {
if (screen === 'hunter-profile') {
return {
items: [
{
glyph: 'H',
meta: `${save.character.experience} XP`,
status: `Level ${save.character.level}`,
title: save.character.name,
},
{
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
status: 'Class',
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
},
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
glyph: IWT2_BOSS_METADATA[bossId].icon,
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
title: IWT2_BOSS_METADATA[bossId].name,
})),
],
mode: 'collection',
subtitle: 'IWT2 inventory and collection log',
summary: `${save.inventory.length} inventory slots`,
title: 'Hunter Profile',
}
}
if (screen === 'customize-character') {
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
return {
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
glyph: ability.icon,
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
status: `Slot ${ability.slot}`,
title: ability.name,
})),
mode: 'class',
subtitle: healer.description,
summary: healer.name,
title: 'Customize Character',
}
}
return null
}
function bossDropIdForAppSummary(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'
}
+14 -11
View File
@@ -2,7 +2,8 @@ import { ControllerBindingLabel } from '../../components/ControllerIcons'
import { DEFAULT_BINDINGS, useInput } from '../../input'
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
import { abilitiesForHealer } from './content/healerAbilities'
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import { loadIwt2Save } from './save/iwt2Repository'
export function Iwt2BottomDisplay() {
const {
@@ -13,7 +14,9 @@ export function Iwt2BottomDisplay() {
const activeBindings = lastDevice === 'controller'
? bindings.controller
: DEFAULT_BINDINGS.controller
const abilities = abilitiesForHealer('field_medic')
const save = loadIwt2Save()
const abilities = abilitiesForHealer(save.character.healerStyle)
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
return (
@@ -21,7 +24,7 @@ export function Iwt2BottomDisplay() {
<section className="dual-controls-resource iwt2-bottom-resource">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<strong>Field Medic</strong>
<strong>{healer.name}</strong>
</div>
<div className="dual-controls-mana">
<span>Mana 100 / 100</span>
@@ -34,23 +37,23 @@ export function Iwt2BottomDisplay() {
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
return (
<button type="button" key={target.id}>
<div className="dual-control-chip" key={target.id}>
<ControllerBindingLabel
binding={activeBindings[action]}
iconStyle="playstation"
/>{' '}
{label}
</button>
</div>
)
})
) : (
<>
<button type="button">
<div className="dual-control-chip">
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
</button>
<button type="button">
</div>
<div className="dual-control-chip">
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
</button>
</div>
</>
)}
</section>
@@ -58,7 +61,7 @@ export function Iwt2BottomDisplay() {
{abilities.map((ability, index) => {
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
return (
<button className="spell iwt2-bottom-spell" key={ability.id} type="button">
<div className="spell iwt2-bottom-spell" key={ability.id}>
<kbd>
<ControllerBindingLabel
binding={activeBindings[action]}
@@ -69,7 +72,7 @@ export function Iwt2BottomDisplay() {
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
<strong>{ability.name}</strong>
<small>{ability.manaCost} Mana</small>
</button>
</div>
)
})}
</section>
+93 -1
View File
@@ -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<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
@@ -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<Iwt2BossId, Iwt2BossMetadata> = {
bulldrome: BULLDROME_BOSS_METADATA,
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
khezu: KHEZU_BOSS_METADATA,
}
+91 -100
View File
@@ -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<Iwt2HealerId, Iwt2HealerMetadata> = {
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<Iwt2HealerId, Iwt2HealerAbility[]> = {
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<string, Iwt2HealerAbility[]> = {
field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability),
}
export function abilitiesForHealer(healerId: string) {
return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic
}
+65
View File
@@ -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<T extends string> = {
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<Iwt2RoguelikeSelfBuffId> = {
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<Iwt2RoguelikeSelfBuffId>({
slots: IWT2_ROGUELIKE_SLOTS,
spells: abilities.map(iwt2AbilityToSpell),
labelMode: 'ability',
})
}
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
return buildOpponentSlotDebuffChoices<Iwt2RoguelikeOpponentDebuffId>({
slots: IWT2_ROGUELIKE_SLOTS,
spells: abilities.map(iwt2AbilityToSpell),
labelMode: 'ability',
})
}
+110 -5
View File
@@ -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<string, number>
dropsFound: Record<string, number>
}
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<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
): 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<Iwt2CloudSlot>
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 ?? {},
},
}
}
+322 -26
View File
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../../../dualScreen'
import type { PartyMember, Role, Spell } from '../../../game'
import {
createInitialIwt2ArenaState,
tickIwt2Arena,
@@ -16,21 +22,46 @@ import {
import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
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<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
@@ -39,6 +70,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const killRecordedRef = useRef(false)
@@ -52,6 +84,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
directPartyTargeting,
lastDevice,
} = useInput()
const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice]
useEffect(() => {
@@ -62,6 +95,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 +125,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 +150,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 +271,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 +291,45 @@ 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'}`
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings: activeBindings,
bossMetadata,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel: modeLabel ?? 'Arena',
playerMana,
selectedPartyId,
status,
}),
[
abilities,
activeBindings,
arenaState,
abilityCooldowns,
bossMetadata,
controllerIconStyle,
directPartyTargeting,
modeLabel,
playerMana,
selectedPartyId,
status,
],
)
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
@@ -241,7 +354,35 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status !== 'playing' && (
{status === 'paused' && (
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{pauseTitle}</h2>
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
{pauseLeaveLabel}
</button>
</div>
</div>
)}
{status !== 'playing' && status !== 'paused' && (
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
@@ -249,22 +390,25 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
{status !== 'paused' && (
<div className="iwt2-result-summary" aria-label="Arena result summary">
<span>
<strong>{formatArenaTime(arenaState.time)}</strong>
Clear
</span>
<span>
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
{roguelikeRun && (
<p className="iwt2-result-hint">
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
</p>
)}
<div className="iwt2-result-summary" aria-label="Arena result summary">
<span>
<strong>{formatArenaTime(arenaState.time)}</strong>
Clear
</span>
<span>
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+125 XP</span>
@@ -276,7 +420,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
<button
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
onClick={() => activateOverlayAction('primary')}
type="button"
>
{overlayPrimaryLabel}
@@ -284,7 +428,7 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={onBack}
onClick={() => activateOverlayAction('menu')}
type="button"
>
Menu
@@ -352,3 +496,155 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
}
return changed ? next : cooldowns
}
function applyRoguelikeModifiers(
abilities: Iwt2HealerAbility[],
buffs: Iwt2RoguelikeSelfBuffId[],
debuffs: Iwt2RoguelikeOpponentDebuffId[],
): Iwt2HealerAbility[] {
if (buffs.length === 0 && debuffs.length === 0) return abilities
return abilities.map((ability) => {
const slot = String(ability.slot)
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
return {
...ability,
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
}
})
}
function countStacks(items: readonly string[], id: string) {
return items.filter((item) => item === id).length
}
function roundModifier(value: number) {
return Math.max(0.1, Math.round(value * 100) / 100)
}
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
if (contentType === 'raid') return 'Raid'
if (contentType === 'stadium') return 'Stadium'
return 'Dungeon'
}
function arenaPauseTitle(
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
fallbackTitle: string,
) {
if (!roguelikeRun) return fallbackTitle
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
if (roguelikeRun.variant === 'pvp') {
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
}
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
}
function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
bossMetadata,
cooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
playerMana,
selectedPartyId,
status,
}: {
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
bossMetadata: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean
modeLabel: string
playerMana: number
selectedPartyId: Iwt2EntityId
status: ArenaStatus
}): DualScreenCombatState {
return {
bindings,
contentName: modeLabel,
controllerIconStyle,
difficultyName: 'IWT2',
directPartyTargeting,
dungeonName: `${bossMetadata.name} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaState.boss.health,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaState.boss.maxHealth,
encounterName: bossMetadata.name,
floatingTexts: [],
maxResource: 100,
party: arenaState.party.map(toDualScreenPartyMember),
partySize: arenaState.party.length,
paused: status === 'paused',
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
resource: playerMana,
resourceName: 'Mana',
selectedId: selectedPartyId,
speedMultiplier: 1,
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
targetGroup: 0,
}
}
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number]): PartyMember {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
bounceHeals: [],
health: member.health,
hotEffects: member.hotEffects.map((effect) => ({
id: effect.id,
label: effect.label,
power: effect.power,
spellId: effect.id,
ticks: Math.ceil(effect.remainingSeconds),
})),
hotTicks: member.hotEffects.length,
id: member.id,
maxHealth: member.maxHealth,
name: metadata.name,
role: toDualScreenRole(metadata.role),
shield: member.shield,
}
}
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
if (role === 'tank') return 'Tank'
if (role === 'healer') return 'Healer'
return 'Damage'
}
function toDualScreenSpell(
ability: Iwt2HealerAbility,
slotIndex: number,
remaining: number,
): Spell & { slotIndex: number; remaining: number } {
return {
cooldown: ability.cooldownSeconds,
cost: ability.manaCost,
description: 'IWT2 arena ability',
effectType: ability.effectType,
glyph: ability.icon,
id: ability.id,
key: String(ability.slot),
kind: ability.kind,
name: ability.name,
power: ability.power,
remaining,
slotIndex,
}
}
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -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] }
+4
View File
@@ -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)
}
+211
View File
@@ -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, 'indicators'>): 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,
}))
}
+10 -2
View File
@@ -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 {
+258
View File
@@ -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, 'indicators'>): 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
}
+11 -2
View File
@@ -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({
+49 -1
View File
@@ -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)
+21
View File
@@ -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 = {