Android build v1.1.16
This commit is contained in:
Binary file not shown.
@@ -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.
|
||||
|
||||
+74
-11
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
|
||||
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
|
||||
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<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)
|
||||
@@ -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 (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
@@ -126,6 +170,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 +228,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<Iwt2DungeonsScreen
|
||||
onBack={() => 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 (
|
||||
<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 +293,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 +306,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 +339,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 +358,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
||||
|
||||
export type Iwt2InventoryItem = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -5,23 +7,37 @@ 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 Iwt2Save = {
|
||||
version: 1
|
||||
updatedAt: number
|
||||
character: {
|
||||
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: Iwt2Character
|
||||
inventory: Iwt2InventoryItem[]
|
||||
collectionLog: Iwt2CollectionLog
|
||||
cloudSlot?: Iwt2CloudSlot
|
||||
}
|
||||
|
||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||
|
||||
export function createDefaultIwt2Save(): Iwt2Save {
|
||||
@@ -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 ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||
@@ -39,6 +63,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)
|
||||
@@ -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 (
|
||||
<main
|
||||
@@ -241,7 +316,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,7 +352,11 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
</div>
|
||||
<p className="eyebrow">{overlayEyebrow}</p>
|
||||
<h1>{overlayTitle}</h1>
|
||||
{status !== 'paused' && (
|
||||
{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>
|
||||
@@ -264,7 +371,6 @@ export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossAre
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+125 XP</span>
|
||||
@@ -276,7 +382,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 +390,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 +458,52 @@ 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'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user