Android build v1.1.22

This commit is contained in:
Warren H
2026-07-05 12:08:11 -04:00
parent 547044892d
commit 956c9e32f9
69 changed files with 9259 additions and 196 deletions
+25
View File
@@ -725,6 +725,21 @@
outline-color: #e5b95f;
}
.iwt2-dungeon-list {
min-height: 0;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-list {
min-height: 0;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-row {
min-height: 56px;
padding: 10px 12px;
}
.iwt2-controller-preview {
align-content: center;
background: #141922;
@@ -958,6 +973,16 @@
padding: 8px 10px;
}
.iwt2-dungeon-list .iwt2-action-list {
gap: 6px;
overflow: hidden;
}
.iwt2-dungeon-list .iwt2-action-row {
min-height: 42px;
padding: 6px 9px;
}
.iwt2-action-row strong,
.iwt2-info-row strong {
font-size: 0.78rem;
+1 -38
View File
@@ -81,7 +81,7 @@ const MENU_ITEMS: Array<{
{
screen: 'dungeons',
title: 'Dungeons',
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
description: 'Queue into seven modular IWT2 boss arenas.',
glyph: 'D',
},
{
@@ -316,7 +316,6 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
if (screen === 'hunter-profile') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
@@ -586,35 +585,6 @@ 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 {
@@ -633,10 +603,3 @@ function buildIwt2WorkshopDualScreenState(
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'
}
+2
View File
@@ -13,6 +13,8 @@ export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?:
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
{entry.maxArmor > 0 && <small>{Math.ceil(entry.armor)} / {entry.maxArmor} Armor</small>}
{entry.mechanicEnergyMax > 0 && <small>{Math.ceil(entry.mechanicEnergy)} / {entry.mechanicEnergyMax} Static</small>}
</div>
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
+59
View File
@@ -0,0 +1,59 @@
import type { Iwt2BossId } from './bosses'
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
export type Iwt2BossMaterialReward = {
id: string
name: string
}
export type Iwt2BossPetReward = {
id: string
name: string
}
const IWT2_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, Iwt2BossMaterialReward> = {
'barroth': { id: 'barroth-shell', name: 'Barroth Shell' },
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' },
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
'ember-mantis-duelist': { id: 'ember-mantis-blade', name: 'Ember Mantis Blade' },
'great-jaggi': { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' },
'hollowcrown-revenant': { id: 'hollowcrown-antler', name: 'Hollowcrown Antler' },
'khezu': { id: 'khezu-pearl', name: 'Khezu Pearl' },
'obsidian-ram-golem': { id: 'obsidian-ram-core', name: 'Obsidian Ram Core' },
'rathian': { id: 'rathian-scale', name: 'Rathian Scale' },
'rimebastion': { id: 'rimebastion-core', name: 'Rimebastion Core' },
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
'stormcoil-wyrm': { id: 'stormcoil-conductor', name: 'Stormcoil Conductor' },
'tobi-kadachi': { id: 'tobi-kadachi-pelt', name: 'Tobi Kadachi Pelt' },
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' },
}
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
'barroth': { id: 'barroth-pet', name: 'Barroth Pet' },
'bulldrome': { id: 'bulldrome-pet', name: 'Bulldrome Pet' },
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
'crystal-bat-matriarch': { id: 'crystal-bat-matriarch-pet', name: 'Crystal Bat Matriarch Pet' },
'ember-mantis-duelist': { id: 'ember-mantis-duelist-pet', name: 'Ember Mantis Duelist Pet' },
'great-jaggi': { id: 'great-jaggi-pet', name: 'Great Jaggi Pet' },
'hollowcrown-revenant': { id: 'hollowcrown-revenant-pet', name: 'Hollowcrown Revenant Pet' },
'khezu': { id: 'khezu-pet', name: 'Khezu Pet' },
'obsidian-ram-golem': { id: 'obsidian-ram-golem-pet', name: 'Obsidian Ram Golem Pet' },
'rathian': { id: 'rathian-pet', name: 'Rathian Pet' },
'rimebastion': { id: 'rimebastion-pet', name: 'Rimebastion Pet' },
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
'stormcoil-wyrm': { id: 'stormcoil-wyrm-pet', name: 'Stormcoil Wyrm Pet' },
'tobi-kadachi': { id: 'tobi-kadachi-pet', name: 'Tobi Kadachi Pet' },
'venom-orchid-hydra': { id: 'venom-orchid-hydra-pet', name: 'Venom Orchid Hydra Pet' },
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Yian Kut Ku Pet' },
}
export function iwt2BossMaterialRewardFor(bossId: Iwt2BossId): Iwt2BossMaterialReward {
return IWT2_BOSS_MATERIAL_REWARDS[bossId]
}
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
return IWT2_BOSS_PET_REWARDS[bossId]
}
+581 -1
View File
@@ -1,6 +1,22 @@
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
export type Iwt2BossId =
| 'bulldrome'
| 'yian-kut-ku'
| 'great-jaggi'
| 'khezu'
| 'rathian'
| 'barroth'
| 'tobi-kadachi'
| 'rimebastion'
| 'ember-mantis-duelist'
| 'cinderback-ricochet'
| 'obsidian-ram-golem'
| 'stormcoil-wyrm'
| 'venom-orchid-hydra'
| 'sandglass-scorpion'
| 'crystal-bat-matriarch'
| 'hollowcrown-revenant'
export type Iwt2BalanceOverrides = {
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
@@ -10,6 +26,11 @@ export type Iwt2BossMetadata = {
id: Iwt2BossId
name: string
icon: string
spriteUrl: string
spriteView?: 'topDown' | 'side'
spriteWidthScale?: number
spriteHeightScale?: number
spriteYOffsetScale?: number
color: string
accentColor: string
maxHealth: number
@@ -64,12 +85,82 @@ export type Iwt2BossMetadata = {
lightningStrikeDamage?: number
lightningStrikeStunSeconds?: number
lightningStrikeCount?: number
poisonTailCooldown?: number
tailSweepWindup?: number
tailSweepRecover?: number
tailSweepInnerRadius?: number
tailSweepOuterRadius?: number
tailSweepAngleRadians?: number
tailSweepDamage?: number
tailSweepStunSeconds?: number
poisonSpitCooldown?: number
poisonSpitWindup?: number
poisonPuddleRadius?: number
poisonPuddleDamage?: number
poisonPuddleSeconds?: number
mudArmor?: number
mudSprayCooldown?: number
mudSprayWindup?: number
mudSprayRange?: number
mudSprayAngleRadians?: number
mudSprayDamage?: number
mudSprayStunSeconds?: number
mudPuddleRadius?: number
mudPuddleSeconds?: number
staticEnergyMax?: number
staticEnergyPerSecond?: number
staticEnergyPerMelee?: number
staticPounceWindup?: number
staticPounceSpeed?: number
staticPounceDamage?: number
staticPounceStunSeconds?: number
staticPounceLength?: number
staticPounceWidth?: number
chainShockCooldown?: number
chainShockWindup?: number
chainShockRadius?: number
chainShockDamage?: number
chainShockStunSeconds?: number
iceWallCooldown?: number
iceWallWindup?: number
iceWallActiveSeconds?: number
iceWallCount?: number
iceWallLength?: number
iceWallWidth?: number
iceWallDamage?: number
iceShardDamage?: number
iceShardStunSeconds?: number
slashCooldown?: number
slashWindup?: number
slashDamage?: number
slashStunSeconds?: number
slashWidth?: number
crossSlashAngleRadians?: number
sidestepDistance?: number
ricochetCooldown?: number
ricochetWindup?: number
ricochetSpeed?: number
ricochetDamage?: number
ricochetStunSeconds?: number
ricochetSeconds?: number
lavaTrailRadius?: number
lavaTrailDamage?: number
lavaTrailSeconds?: number
armorSlamWindup?: number
armorSlamRadius?: number
armorSlamDamage?: number
armorSlamStunSeconds?: number
}
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
id: 'bulldrome',
name: 'Bulldrome',
icon: 'B',
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.2,
spriteHeightScale: 4.5,
spriteYOffsetScale: -0.14,
color: '#8f5a3c',
accentColor: '#e6b17f',
maxHealth: 950,
@@ -96,6 +187,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'yian-kut-ku',
name: 'Yian Kut Ku',
icon: 'Y',
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.7,
spriteYOffsetScale: -0.1,
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
@@ -138,6 +234,11 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'great-jaggi',
name: 'Great Jaggi',
icon: 'J',
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.1,
spriteYOffsetScale: -0.09,
color: '#3f8f73',
accentColor: '#b8f0aa',
maxHealth: 620,
@@ -170,6 +271,11 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'khezu',
name: 'Khezu',
icon: 'K',
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
spriteHeightScale: 4.4,
spriteYOffsetScale: -0.12,
color: '#d8d7c9',
accentColor: '#77d9ff',
maxHealth: 760,
@@ -204,6 +310,456 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
lightningStrikeCount: 2,
}
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
id: 'rathian',
name: 'Rathian',
icon: 'R',
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.9,
spriteHeightScale: 4.35,
spriteYOffsetScale: -0.12,
color: '#4d9a58',
accentColor: '#f0c24d',
maxHealth: 1120,
radius: 34,
moveSpeed: 118,
meleeRange: 62,
meleeDamage: 11,
meleeCooldown: 1.08,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
poisonTailCooldown: 5.4,
tailSweepWindup: 0.72,
tailSweepRecover: 0.48,
tailSweepInnerRadius: 24,
tailSweepOuterRadius: 118,
tailSweepAngleRadians: Math.PI * 1.25,
tailSweepDamage: 23,
tailSweepStunSeconds: 0.55,
poisonSpitCooldown: 6.2,
poisonSpitWindup: 0.7,
poisonPuddleRadius: 46,
poisonPuddleDamage: 6,
poisonPuddleSeconds: 8,
}
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
id: 'barroth',
name: 'Barroth',
icon: 'A',
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.3,
spriteHeightScale: 3.35,
spriteYOffsetScale: -0.07,
color: '#8d714d',
accentColor: '#d6bd80',
maxHealth: 1280,
radius: 35,
moveSpeed: 104,
meleeRange: 60,
meleeDamage: 12,
meleeCooldown: 1.18,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudArmor: 280,
mudSprayCooldown: 5.8,
mudSprayWindup: 0.82,
mudSprayRange: 172,
mudSprayAngleRadians: Math.PI * 0.58,
mudSprayDamage: 13,
mudSprayStunSeconds: 0.25,
mudPuddleRadius: 40,
mudPuddleSeconds: 7.5,
}
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'tobi-kadachi',
name: 'Tobi Kadachi',
icon: 'T',
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.7,
spriteHeightScale: 3.85,
spriteYOffsetScale: -0.08,
color: '#6a79bd',
accentColor: '#a8f3ff',
maxHealth: 920,
radius: 28,
moveSpeed: 160,
meleeRange: 53,
meleeDamage: 8,
meleeCooldown: 0.78,
chargeCooldown: 2.2,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
staticEnergyMax: 100,
staticEnergyPerSecond: 16,
staticEnergyPerMelee: 15,
staticPounceWindup: 0.58,
staticPounceSpeed: 560,
staticPounceDamage: 24,
staticPounceStunSeconds: 0.6,
staticPounceLength: 520,
staticPounceWidth: 46,
chainShockCooldown: 5.2,
chainShockWindup: 0.85,
chainShockRadius: 86,
chainShockDamage: 15,
chainShockStunSeconds: 0.45,
}
const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
id: 'rimebastion',
name: 'Rimebastion',
icon: 'I',
spriteUrl: '/iwt2/bosses/rimebastion-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.2,
spriteHeightScale: 4.5,
spriteYOffsetScale: -0.08,
color: '#5f9fd6',
accentColor: '#b8f3ff',
maxHealth: 1380,
radius: 37,
moveSpeed: 86,
meleeRange: 62,
meleeDamage: 12,
meleeCooldown: 1.2,
chargeCooldown: 5.6,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
iceWallCooldown: 6.2,
iceWallWindup: 0.9,
iceWallActiveSeconds: 0.7,
iceWallCount: 3,
iceWallLength: 180,
iceWallWidth: 22,
iceWallDamage: 10,
iceShardDamage: 22,
iceShardStunSeconds: 0.55,
}
const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
id: 'ember-mantis-duelist',
name: 'Ember Mantis Duelist',
icon: 'M',
spriteUrl: '/iwt2/bosses/ember-mantis-duelist-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.6,
spriteHeightScale: 4.6,
spriteYOffsetScale: -0.1,
color: '#b94831',
accentColor: '#ffb24a',
maxHealth: 1040,
radius: 29,
moveSpeed: 168,
meleeRange: 54,
meleeDamage: 9,
meleeCooldown: 0.78,
chargeCooldown: 3.8,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
slashCooldown: 4.2,
slashWindup: 0.48,
slashDamage: 21,
slashStunSeconds: 0.35,
slashWidth: 24,
crossSlashAngleRadians: Math.PI * 0.32,
sidestepDistance: 94,
}
const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
id: 'cinderback-ricochet',
name: 'Cinderback Ricochet',
icon: 'C',
spriteUrl: '/iwt2/bosses/cinderback-ricochet-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.6,
spriteHeightScale: 3.8,
spriteYOffsetScale: -0.06,
color: '#9a4f2f',
accentColor: '#ff8a3d',
maxHealth: 1250,
radius: 34,
moveSpeed: 106,
meleeRange: 60,
meleeDamage: 11,
meleeCooldown: 1.05,
chargeCooldown: 5.4,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
ricochetCooldown: 6.6,
ricochetWindup: 0.65,
ricochetSpeed: 440,
ricochetDamage: 24,
ricochetStunSeconds: 0.45,
ricochetSeconds: 1.45,
lavaTrailRadius: 34,
lavaTrailDamage: 6,
lavaTrailSeconds: 5.5,
armorSlamWindup: 0.42,
armorSlamRadius: 86,
armorSlamDamage: 18,
armorSlamStunSeconds: 0.5,
}
const DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = {
id: 'obsidian-ram-golem',
name: 'Obsidian Ram Golem',
icon: 'O',
spriteUrl: '/iwt2/bosses/obsidian-ram-golem-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.5,
spriteHeightScale: 4.25,
spriteYOffsetScale: -0.08,
color: '#2f3034',
accentColor: '#ff7b32',
maxHealth: 1420,
radius: 36,
moveSpeed: 98,
meleeRange: 62,
meleeDamage: 9,
meleeCooldown: 1.12,
chargeCooldown: 5.7,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudArmor: 240,
}
const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
id: 'stormcoil-wyrm',
name: 'Stormcoil Wyrm',
icon: 'S',
spriteUrl: '/iwt2/bosses/stormcoil-wyrm-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.1,
spriteHeightScale: 4.15,
spriteYOffsetScale: -0.1,
color: '#273b75',
accentColor: '#78ecff',
maxHealth: 1120,
radius: 30,
moveSpeed: 142,
meleeRange: 58,
meleeDamage: 8,
meleeCooldown: 0.95,
chargeCooldown: 5.1,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
chainShockCooldown: 5,
chainShockWindup: 0.78,
chainShockRadius: 94,
chainShockDamage: 13,
chainShockStunSeconds: 0.38,
}
const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
id: 'venom-orchid-hydra',
name: 'Venom Orchid Hydra',
icon: 'V',
spriteUrl: '/iwt2/bosses/venom-orchid-hydra-side-cel.png',
spriteView: 'side',
spriteWidthScale: 7.2,
spriteHeightScale: 4.6,
spriteYOffsetScale: -0.1,
color: '#5e316c',
accentColor: '#d8f05b',
maxHealth: 1320,
radius: 35,
moveSpeed: 84,
meleeRange: 64,
meleeDamage: 8,
meleeCooldown: 1.15,
chargeCooldown: 5.4,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
poisonSpitCooldown: 5.6,
poisonSpitWindup: 0.75,
poisonPuddleRadius: 42,
poisonPuddleDamage: 5,
poisonPuddleSeconds: 7,
}
const DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = {
id: 'sandglass-scorpion',
name: 'Sandglass Scorpion',
icon: 'G',
spriteUrl: '/iwt2/bosses/sandglass-scorpion-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.4,
spriteHeightScale: 4.25,
spriteYOffsetScale: -0.07,
color: '#9d7430',
accentColor: '#ffd15a',
maxHealth: 1180,
radius: 33,
moveSpeed: 126,
meleeRange: 58,
meleeDamage: 8,
meleeCooldown: 0.98,
chargeCooldown: 5.2,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
mudPuddleRadius: 44,
mudPuddleSeconds: 6,
}
const DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = {
id: 'crystal-bat-matriarch',
name: 'Crystal Bat Matriarch',
icon: 'Q',
spriteUrl: '/iwt2/bosses/crystal-bat-matriarch-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.8,
spriteHeightScale: 4.75,
spriteYOffsetScale: -0.12,
color: '#4e2d76',
accentColor: '#8eeaff',
maxHealth: 1050,
radius: 31,
moveSpeed: 150,
meleeRange: 56,
meleeDamage: 7,
meleeCooldown: 0.9,
chargeCooldown: 5.1,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
}
const DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = {
id: 'hollowcrown-revenant',
name: 'Hollowcrown Revenant',
icon: 'H',
spriteUrl: '/iwt2/bosses/hollowcrown-revenant-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.6,
spriteHeightScale: 4.35,
spriteYOffsetScale: -0.1,
color: '#3c3f5e',
accentColor: '#9be8ff',
maxHealth: 1080,
radius: 30,
moveSpeed: 158,
meleeRange: 56,
meleeDamage: 8,
meleeCooldown: 0.9,
chargeCooldown: 4.8,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
}
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
if (!override) return metadata
@@ -217,10 +773,34 @@ export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFA
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 RATHIAN_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RATHIAN_BOSS_METADATA)
export const BARROTH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BARROTH_BOSS_METADATA)
export const TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_TOBI_KADACHI_BOSS_METADATA)
export const RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RIMEBASTION_BOSS_METADATA)
export const EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA)
export const CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA)
export const OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA)
export const STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_STORMCOIL_WYRM_BOSS_METADATA)
export const VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA)
export const SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA)
export const CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA)
export const HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_HOLLOWCROWN_REVENANT_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,
rathian: RATHIAN_BOSS_METADATA,
barroth: BARROTH_BOSS_METADATA,
'tobi-kadachi': TOBI_KADACHI_BOSS_METADATA,
rimebastion: RIMEBASTION_BOSS_METADATA,
'ember-mantis-duelist': EMBER_MANTIS_DUELIST_BOSS_METADATA,
'cinderback-ricochet': CINDERBACK_RICOCHET_BOSS_METADATA,
'obsidian-ram-golem': OBSIDIAN_RAM_GOLEM_BOSS_METADATA,
'stormcoil-wyrm': STORMCOIL_WYRM_BOSS_METADATA,
'venom-orchid-hydra': VENOM_ORCHID_HYDRA_BOSS_METADATA,
'sandglass-scorpion': SANDGLASS_SCORPION_BOSS_METADATA,
'crystal-bat-matriarch': CRYSTAL_BAT_MATRIARCH_BOSS_METADATA,
'hollowcrown-revenant': HOLLOWCROWN_REVENANT_BOSS_METADATA,
}
+4 -4
View File
@@ -64,8 +64,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
radius: 14,
attackRange: 245,
attackDamage: 8,
attackCooldown: 0.62,
castTime: 0.32,
attackCooldown: 0.31,
castTime: 0.16,
projectileSpeed: 410,
},
mage: {
@@ -80,8 +80,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
radius: 14,
attackRange: 220,
attackDamage: 11,
attackCooldown: 0.78,
castTime: 0.42,
attackCooldown: 0.39,
castTime: 0.21,
projectileSpeed: 330,
},
rogue: {
@@ -1,6 +1,6 @@
import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA } from '../../content/bosses'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA } from '../../content/classes'
import type {
Iwt2ArenaIndicator,
@@ -24,9 +24,11 @@ type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAd
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
private arenaGraphics?: Phaser.GameObjects.Graphics
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private lastHudPublish = 0
constructor(deps: SceneDeps) {
@@ -34,11 +36,22 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.deps = deps
}
preload() {
for (const metadata of Object.values(IWT2_BOSS_METADATA)) {
if (metadata.spriteUrl.endsWith('.svg')) {
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
} else {
this.load.image(bossSpriteKey(metadata.id), metadata.spriteUrl)
}
}
}
create() {
this.cameras.main.setBackgroundColor('#10141b')
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.entityGraphics = this.add.graphics()
this.arenaGraphics = this.add.graphics().setDepth(0)
this.telegraphGraphics = this.add.graphics().setDepth(10)
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
this.entityGraphics = this.add.graphics().setDepth(35)
this.drawState(this.deps.getState())
}
@@ -80,22 +93,29 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
private drawEntities(state: Iwt2ArenaState) {
const bossUnderlay = this.bossUnderlayGraphics!
const graphics = this.entityGraphics!
bossUnderlay.clear()
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
const liveIds = new Set<string>(entities.map((entity) => entity.id))
const liveLabelIds = new Set<string>(entities.filter((entity) => entity.kind !== 'boss').map((entity) => entity.id))
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
for (const entity of entities.sort(entitySort)) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
const color = entityColor(entity)
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
if (entity.kind === 'boss') {
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
drawBossUnderlay(bossUnderlay, entity, color, alpha)
this.drawBossSprite(entity, alpha)
} else {
drawBossFallback(graphics, entity, color, alpha)
}
} else if (entity.kind === 'hostileAdd') {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(2, 0x0a0c10, 1)
const wing = entity.radius * 1.55
graphics.fillTriangle(
entity.position.x + entity.facing.x * wing,
@@ -114,6 +134,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
entity.position.y + entity.radius,
)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
if (entity.id === this.deps.getSelectedPartyId()) {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
@@ -121,12 +143,19 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
this.drawLabel(entity)
if (entity.kind !== 'boss') this.drawLabel(entity)
this.drawHealthBar(graphics, entity)
}
for (const [id, sprite] of this.bossSprites) {
if (!liveBossIds.has(id)) {
sprite.destroy()
this.bossSprites.delete(id)
}
}
for (const [id, label] of this.labels) {
if (!liveIds.has(id)) {
if (!liveLabelIds.has(id)) {
label.destroy()
this.labels.delete(id)
}
@@ -144,6 +173,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
private drawBossSprite(entity: Iwt2BossEntityState, alpha: number) {
const key = bossSpriteKey(entity.bossId)
const metadata = IWT2_BOSS_METADATA[entity.bossId]
let sprite = this.bossSprites.get(entity.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5).setDepth(25)
this.bossSprites.set(entity.id, sprite)
}
const isSideView = metadata.spriteView === 'side'
const facingLength = Math.hypot(entity.facing.x, entity.facing.y)
const rotation = !isSideView && facingLength > 0.001 ? Math.atan2(entity.facing.y, entity.facing.x) : 0
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
sprite
.setTexture(key)
.setPosition(entity.position.x, entity.position.y + yOffset)
.setDisplaySize(width, height)
.setRotation(rotation)
.setFlipX(isSideView && entity.facing.x < -0.05)
.setAlpha(alpha)
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entityIcon(entity)
@@ -166,7 +219,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
const width = entity.kind === 'boss' ? 92 : 34
const height = entity.kind === 'boss' ? 8 : 7
const y = entity.position.y + entity.radius + 8
const y = entity.position.y + (entity.kind === 'boss' ? entity.radius * 1.55 : entity.radius) + 8
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
graphics.fillStyle(0x050608, 0.9)
graphics.fillRect(entity.position.x - width / 2, y, width, height)
@@ -177,6 +230,20 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.fillStyle(0x58a8ff, 0.92)
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
}
if (entity.kind === 'boss' && entity.maxArmor > 0) {
const armorRatio = Math.max(0, Math.min(1, entity.armor / entity.maxArmor))
graphics.fillStyle(0x21180e, 0.9)
graphics.fillRect(entity.position.x - width / 2, y + height + 3, width, 4)
graphics.fillStyle(0xd6bd80, 0.95)
graphics.fillRect(entity.position.x - width / 2, y + height + 3, width * armorRatio, 4)
}
if (entity.kind === 'boss' && entity.mechanicEnergyMax > 0) {
const energyRatio = Math.max(0, Math.min(1, entity.mechanicEnergy / entity.mechanicEnergyMax))
graphics.fillStyle(0x07171b, 0.9)
graphics.fillRect(entity.position.x - width / 2, y + height + 8, width, 4)
graphics.fillStyle(0xa8f3ff, 0.95)
graphics.fillRect(entity.position.x - width / 2, y + height + 8, width * energyRatio, 4)
}
}
}
@@ -197,11 +264,42 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon
}
function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}`
}
function drawBossUnderlay(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2BossEntityState,
color: string,
alpha: number,
) {
const colorValue = Number.parseInt(color.slice(1), 16)
graphics.fillStyle(0x050608, 0.42 * alpha)
graphics.fillEllipse(entity.position.x, entity.position.y + entity.radius * 0.68, entity.radius * 2.45, entity.radius * 0.74)
graphics.lineStyle(2, colorValue, 0.45 * alpha)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
function drawBossFallback(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2BossEntityState,
color: string,
alpha: number,
) {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(2, 0xfff0b8, 1)
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
}
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
graphics.fillStyle(0xff7a2f, alpha)
graphics.lineStyle(2, 0xffc15a, 0.55)
const color = hazard.hazardKind === 'poisonPuddle' ? 0x7bd84f : hazard.hazardKind === 'mudPuddle' ? 0x9a6a3a : 0xff7a2f
const lineColor = hazard.hazardKind === 'poisonPuddle' ? 0xc9ff8f : hazard.hazardKind === 'mudPuddle' ? 0xd6bd80 : 0xffc15a
graphics.fillStyle(color, alpha)
graphics.lineStyle(2, lineColor, 0.55)
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
}
@@ -237,12 +335,55 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
return
}
if (indicator.kind === 'arc') {
const points = arcDangerPolygon(
indicator.position,
indicator.direction,
indicator.innerRadius,
indicator.outerRadius,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
}
function arcDangerPolygon(
position: Iwt2Vec2,
direction: Iwt2Vec2,
innerRadius: number,
outerRadius: number,
angleRadians: number,
) {
const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2
const segmentCount = 18
const points: Phaser.Math.Vector2[] = []
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
position.x + Math.cos(angle) * outerRadius,
position.y + Math.sin(angle) * outerRadius,
))
}
for (let index = segmentCount; index >= 0; index -= 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
position.x + Math.cos(angle) * innerRadius,
position.y + Math.sin(angle) * innerRadius,
))
}
return points
}
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
const dx = end.x - start.x
const dy = end.y - start.y
+51 -10
View File
@@ -1,4 +1,6 @@
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
import { IWT2_BOSS_PET_DROP_RATE, iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import type { Iwt2BossId } from '../content/bosses'
export type Iwt2InventoryItem = {
id: string
@@ -11,6 +13,7 @@ export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moo
export type Iwt2CollectionLog = {
bossKills: Record<string, number>
bossPets: Record<string, number>
dropsFound: Record<string, number>
}
@@ -29,6 +32,20 @@ export type Iwt2CloudSlot = {
collectionLog: Iwt2CollectionLog
}
export type Iwt2BossPetAward = {
bossId: Iwt2BossId
petId: string
petName: string
quantity: number
duplicate: boolean
quantityAfter: number
}
export type Iwt2BossKillReward = {
save: Iwt2Save
petAwarded: Iwt2BossPetAward | null
}
export type Iwt2Save = {
version: 1
updatedAt: number
@@ -54,6 +71,7 @@ export function createDefaultIwt2Save(): Iwt2Save {
inventory: [],
collectionLog: {
bossKills: {},
bossPets: {},
dropsFound: {},
},
}
@@ -76,6 +94,7 @@ function normalizeSave(value: unknown): Iwt2Save {
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
bossPets: candidate.collectionLog?.bossPets ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
},
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
@@ -97,9 +116,16 @@ export function writeIwt2Save(save: Iwt2Save) {
}))
}
export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
const drop = bossDropFor(bossId)
return {
export function recordIwt2BossKill(save: Iwt2Save, bossId: Iwt2BossId): Iwt2Save {
return recordIwt2BossKillReward(save, bossId).save
}
export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iwt2BossKillReward {
const drop = iwt2BossMaterialRewardFor(bossId)
const pet = iwt2BossPetRewardFor(bossId)
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0
const updatedSave: Iwt2Save = {
...save,
updatedAt: Date.now(),
character: {
@@ -122,12 +148,31 @@ export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
...save.collectionLog.bossKills,
[bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1,
},
bossPets: awardedPet
? {
...save.collectionLog.bossPets,
[pet.id]: (save.collectionLog.bossPets[pet.id] ?? 0) + 1,
}
: save.collectionLog.bossPets,
dropsFound: {
...save.collectionLog.dropsFound,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
},
},
}
return {
save: updatedSave,
petAwarded: awardedPet
? {
bossId,
duplicate: previousPetQuantity > 0,
petId: pet.id,
petName: pet.name,
quantity: 1,
quantityAfter: previousPetQuantity + 1,
}
: null,
}
}
export function updateIwt2CharacterSettings(
@@ -157,6 +202,7 @@ export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
inventory: save.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.collectionLog.bossKills },
bossPets: { ...save.collectionLog.bossPets },
dropsFound: { ...save.collectionLog.dropsFound },
},
},
@@ -172,18 +218,12 @@ export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
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'
@@ -214,6 +254,7 @@ function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog.bossKills ?? {},
bossPets: candidate.collectionLog.bossPets ?? {},
dropsFound: candidate.collectionLog.dropsFound ?? {},
},
}
+13 -2
View File
@@ -16,7 +16,8 @@ import type { Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKill,
recordIwt2BossKillReward,
type Iwt2BossPetAward,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
@@ -74,6 +75,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status)
@@ -271,12 +273,16 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
const newPetAwards: Iwt2BossPetAward[] = []
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId)
updatedSave = reward.save
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
onSaveUpdated(updatedSave)
}
@@ -460,6 +466,11 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
<span>+{arenaState.bosses.length * 125} XP</span>
<span>{arenaState.bosses.length} carves</span>
<span>Log +{arenaState.bosses.length}</span>
{petAwards.map((award) => (
<span key={`${award.petId}:${award.quantityAfter}`}>
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
</span>
))}
</div>
)}
<div className="iwt2-overlay-actions">
+592 -71
View File
@@ -1,6 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import {
useGameAction,
useInput,
type ControllerIconStyle,
type InputAction,
} from '../../../input'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
restoreIwt2CloudSlot,
@@ -9,6 +15,7 @@ import {
type Iwt2Save,
} from '../save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import {
abilitiesForHealer,
IWT2_HEALER_METADATA,
@@ -62,6 +69,39 @@ type Iwt2UpgradeNavEntry =
| { kind: 'upgradeContinue', row: number, column: number, disabled?: boolean }
| { kind: 'upgradeLeave', row: number, column: number }
type Iwt2HunterProfileTab = 'stats' | 'collection'
type Iwt2HunterProfileNavEntry =
| { kind: 'back', key: string, row: number, column: number }
| { kind: 'tab', key: string, row: number, column: number, tab: Iwt2HunterProfileTab }
| { kind: 'stat', key: string, row: number, column: number, index: number }
| { kind: 'boss', key: string, row: number, column: number, bossId: Iwt2BossId }
| { kind: 'item', key: string, row: number, column: number, itemKey: string }
type Iwt2HunterProfileBossEntry = {
bossId: Iwt2BossId
dropId: string
dropName: string
encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
kills: number
petId: string
petName: string
pets: number
drops: number
}
type Iwt2HunterCollectionItem = {
key: string
glyph: string
name: string
chance: string
quantity: number
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
source: string
}
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] {
return [
{
@@ -122,10 +162,83 @@ const MODE_ACTIONS: Record<Iwt2Mode, Array<{
detail: 'Shared arena entry with raid-role notes: watch lightning markers, rotate through safe bands, and recover stunned allies fast.',
value: 'Start',
},
{
bossId: 'rathian',
label: 'Rathian Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge tail arcs, keep poison puddles out of the party path, and recover after sweeps.',
value: 'Start',
},
{
bossId: 'barroth',
label: 'Barroth Raid Assignment',
detail: 'Shared arena entry with raid-role notes: break mud armor, sidestep mud cones, and reposition through slow zones.',
value: 'Start',
},
{
bossId: 'tobi-kadachi',
label: 'Tobi Kadachi Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track static charge, bait line pounces, and spread before chain shock.',
value: 'Start',
},
{
bossId: 'rimebastion',
label: 'Rimebastion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: read ice wall lanes, dodge shard cones, and keep the tank away from corners.',
value: 'Start',
},
{
bossId: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track sidesteps, cut through slash lanes, and spread before crossed blades.',
value: 'Start',
},
{
bossId: 'cinderback-ricochet',
label: 'Cinderback Ricochet Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait ricochet lanes, avoid lava trails, and recover after armor slam.',
value: 'Start',
},
{
bossId: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Raid Assignment',
detail: 'Shared arena entry with raid-role notes: bait charge lines, avoid quake fractures, and break armor safely.',
value: 'Start',
},
{
bossId: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Raid Assignment',
detail: 'Shared arena entry with raid-role notes: spread for chain lightning and rotate through charged ring gaps.',
value: 'Start',
},
{
bossId: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Raid Assignment',
detail: 'Shared arena entry with raid-role notes: dodge staggered poison cones and keep root lanes off the party.',
value: 'Start',
},
{
bossId: 'sandglass-scorpion',
label: 'Sandglass Scorpion Raid Assignment',
detail: 'Shared arena entry with raid-role notes: track burrows, wait out stinger eruptions, and avoid sinking sand.',
value: 'Start',
},
{
bossId: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Raid Assignment',
detail: 'Shared arena entry with raid-role notes: space for sonic rings, watch mirror lanes, and dodge swoops.',
value: 'Start',
},
{
bossId: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Raid Assignment',
detail: 'Shared arena entry with raid-role notes: avoid cursed hoof zones and sidestep ghost-antler charges.',
value: 'Start',
},
],
}
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
const IWT2_DUNGEON_BOSS_PAGE_SIZE = 5
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
xbox: 'Xbox',
@@ -133,6 +246,121 @@ const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
nintendo: 'Nintendo',
}
const IWT2_DUNGEON_ACTIONS: Array<Omit<Iwt2NavAction, 'onConfirm'> & { bossId: Iwt2BossId }> = [
{
bossId: 'bulldrome',
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
},
{
bossId: 'yian-kut-ku',
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
},
{
bossId: 'great-jaggi',
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
},
{
bossId: 'khezu',
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
},
{
bossId: 'rathian',
key: 'rathian',
label: 'Rathian Arena',
detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.',
value: 'Ready',
},
{
bossId: 'barroth',
key: 'barroth',
label: 'Barroth Arena',
detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.',
value: 'Ready',
},
{
bossId: 'tobi-kadachi',
key: 'tobi-kadachi',
label: 'Tobi Kadachi Arena',
detail: 'Static builds into line pounces while chain shock punishes grouped allies.',
value: 'Ready',
},
{
bossId: 'rimebastion',
key: 'rimebastion',
label: 'Rimebastion Arena',
detail: 'Ice walls split the room, then shard lanes and cones punish trapped routes.',
value: 'Ready',
},
{
bossId: 'ember-mantis-duelist',
key: 'ember-mantis-duelist',
label: 'Ember Mantis Duelist Arena',
detail: 'Fast sidesteps lead into line slashes and crossed lane attacks.',
value: 'Ready',
},
{
bossId: 'cinderback-ricochet',
key: 'cinderback-ricochet',
label: 'Cinderback Ricochet Arena',
detail: 'Wall-bounce rolls leave lava trails before a heavy armor slam.',
value: 'Ready',
},
{
bossId: 'obsidian-ram-golem',
key: 'obsidian-ram-golem',
label: 'Obsidian Ram Golem Arena',
detail: 'Volcanic charge lines crack armor, then quake fractures split the floor.',
value: 'Ready',
},
{
bossId: 'stormcoil-wyrm',
key: 'stormcoil-wyrm',
label: 'Stormcoil Wyrm Arena',
detail: 'Charged rings and chain lightning punish clustered party movement.',
value: 'Ready',
},
{
bossId: 'venom-orchid-hydra',
key: 'venom-orchid-hydra',
label: 'Venom Orchid Hydra Arena',
detail: 'Three heads sweep poison cones while roots and pods pressure escape paths.',
value: 'Ready',
},
{
bossId: 'sandglass-scorpion',
key: 'sandglass-scorpion',
label: 'Sandglass Scorpion Arena',
detail: 'Burrows, delayed stinger eruptions, and shifting sand zones control space.',
value: 'Ready',
},
{
bossId: 'crystal-bat-matriarch',
key: 'crystal-bat-matriarch',
label: 'Crystal Bat Matriarch Arena',
detail: 'Sonic rings, mirror shard lanes, and swoop dives punish bad spacing.',
value: 'Ready',
},
{
bossId: 'hollowcrown-revenant',
key: 'hollowcrown-revenant',
label: 'Hollowcrown Revenant Arena',
detail: 'Cursed hoof zones and ghost-antler dash trails cut across the arena.',
value: 'Ready',
},
]
export function Iwt2ScreenShell({
children,
eyebrow = 'I Want To Heal 2',
@@ -222,40 +450,39 @@ export function Iwt2DungeonsScreen({
onBack: () => void
onOpenBoss: (bossId: Iwt2BossId) => void
}) {
const [page, setPage] = useState(0)
const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE))
const currentPage = Math.min(page, pageCount - 1)
const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE
const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE)
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
onConfirm: () => onOpenBoss('bulldrome'),
key: 'dungeons-page-prev',
label: 'Previous Page',
detail: `Bosses ${pageStart + 1}-${pageEnd} of ${IWT2_DUNGEON_ACTIONS.length}`,
disabled: currentPage === 0,
value: 'Prev',
onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)),
},
{
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
onConfirm: () => onOpenBoss('yian-kut-ku'),
key: 'dungeons-page-next',
label: 'Next Page',
detail: `Page ${currentPage + 1}/${pageCount}`,
disabled: currentPage >= pageCount - 1,
value: 'Next',
onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)),
},
{
key: 'great-jaggi',
label: 'Great Jaggi Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
onConfirm: () => onOpenBoss('great-jaggi'),
},
{
key: 'khezu',
label: 'Khezu Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
onConfirm: () => onOpenBoss('khezu'),
},
], onBack), [onBack, onOpenBoss])
...IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd).map((entry) => ({
...entry,
onConfirm: () => onOpenBoss(entry.bossId),
})),
], onBack), [currentPage, onBack, onOpenBoss, pageCount, pageEnd, pageStart])
return (
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
<Iwt2ActionList actions={actions} />
<div className="iwt2-dungeon-list">
<Iwt2ActionList actions={actions} />
</div>
</Iwt2ScreenShell>
)
}
@@ -778,63 +1005,352 @@ export function Iwt2HunterProfileScreen({
onBack: () => void
save: Iwt2Save
}) {
const bossRows = (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
bossId,
drops: save.collectionLog.dropsFound[bossDropIdForProfile(bossId)] ?? 0,
kills: save.collectionLog.bossKills[bossId] ?? 0,
name: IWT2_BOSS_METADATA[bossId].name,
}))
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
const [activeTab, setActiveTab] = useState<Iwt2HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<Iwt2HunterProfileBossEntry[]>(() => (
(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => {
const drop = iwt2BossMaterialRewardFor(bossId)
const pet = iwt2BossPetRewardFor(bossId)
return {
bossId,
dropId: drop.id,
dropName: drop.name,
drops: save.collectionLog.dropsFound[drop.id] ?? 0,
encounter: IWT2_BOSS_METADATA[bossId],
kills: save.collectionLog.bossKills[bossId] ?? 0,
petId: pet.id,
petName: pet.name,
pets: save.collectionLog.bossPets[pet.id] ?? 0,
}
})
), [save.collectionLog.bossKills, save.collectionLog.bossPets, save.collectionLog.dropsFound])
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>(() => bosses[0]?.bossId ?? 'bulldrome')
const selectedBoss = bosses.find((boss) => boss.bossId === selectedBossId) ?? bosses[0]
const totalBossKills = bosses.reduce((total, boss) => total + boss.kills, 0)
const totalDrops = bosses.reduce((total, boss) => total + boss.drops, 0)
const totalPets = bosses.reduce((total, boss) => total + boss.pets, 0)
const mostKilledBoss = bosses.reduce<Iwt2HunterProfileBossEntry | null>((best, boss) => {
if (!best) return boss
return boss.kills > best.kills ? boss : best
}, null)
const collectionItems = useMemo<Iwt2HunterCollectionItem[]>(() => {
if (!selectedBoss) return []
return [
{
chance: 'Defeats',
glyph: 'K',
key: `kills:${selectedBoss.bossId}`,
name: 'Boss Kills',
quantity: selectedBoss.kills,
rarity: 'stat',
source: selectedBoss.encounter.name,
},
{
chance: 'Guaranteed',
glyph: selectedBoss.encounter.icon,
key: `drop:${selectedBoss.dropId}`,
name: selectedBoss.dropName,
quantity: selectedBoss.drops,
rarity: 'common',
source: selectedBoss.encounter.name,
},
{
chance: '1 in 500',
glyph: '*',
key: `pet:${selectedBoss.petId}`,
name: selectedBoss.petName,
quantity: selectedBoss.pets,
rarity: 'legendary',
source: selectedBoss.encounter.name,
},
]
}, [selectedBoss])
const statTiles = useMemo(() => [
{
key: 'name',
label: 'Name',
value: save.character.name,
onConfirm: () => {},
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'level',
label: 'Level',
value: String(save.character.level),
onConfirm: () => {},
detail: totalBossKills > 0 && mostKilledBoss ? `${mostKilledBoss.kills} kills` : '0 kills',
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.name : 'None',
},
{
key: 'experience',
key: 'stat:hunter-level',
label: 'Hunter Level',
value: save.character.level,
},
{
key: 'stat:experience',
label: 'Experience',
value: `${save.character.experience} XP`,
onConfirm: () => {},
},
{
key: 'class',
label: 'Healing Class',
value: IWT2_HEALER_METADATA[save.character.healerStyle].name,
onConfirm: () => {},
key: 'stat:collection-drops',
label: 'Collection Drops',
value: totalDrops + totalPets,
detail: `${totalPets} pets, ${save.inventory.length} inventory slots`,
},
...bossRows.map((row) => ({
key: `boss-${row.bossId}`,
label: row.name,
value: `${row.kills} kills / ${row.drops} drops`,
onConfirm: () => {},
})),
{
key: 'inventory',
label: 'IWT2 Inventory',
value: `${save.inventory.length} slots`,
onConfirm: () => {},
},
], onBack), [bossRows, onBack, save.character.experience, save.character.healerStyle, save.character.level, save.character.name, save.inventory.length])
], [mostKilledBoss, save.character.experience, save.character.level, save.inventory.length, totalBossKills, totalDrops, totalPets])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<Iwt2HunterProfileNavEntry[]>(() => {
const entries: Iwt2HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
column: index,
index,
key: tile.key,
kind: 'stat' as const,
row: 1,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
bossId: boss.bossId,
column: 0,
key: `boss:${boss.bossId}`,
kind: 'boss' as const,
row: index + 1,
})),
...collectionItems.map((item, index) => ({
column: (index % IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
key: `item:${item.key}`,
kind: 'item' as const,
row: Math.floor(index / IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
items: [
{
detail: focusedItem.source,
glyph: focusedItem.glyph,
meta: focusedItem.chance,
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
title: 'Drop Rate',
},
{
detail: 'I Want To Heal 2',
meta: `${selectedBoss.kills}`,
status: `${selectedBoss.drops} drops`,
title: 'Boss Kills',
},
],
mode: 'collection',
subtitle: selectedBoss.encounter.name,
summary: `Owned x${focusedItem.quantity}`,
title: focusedItem.name,
}
}, [activeTab, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: Iwt2HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: Iwt2BossId) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
aria-selected={activeTab === 'stats'}
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
role="tab"
type="button"
>
Stats
</button>
<button
aria-selected={activeTab === 'collection'}
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
role="tab"
type="button"
>
Collection Log
</button>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
{activeTab === 'collection' && (
<div className="collection-log-layout">
<div className="collection-boss-list" aria-label="Bosses">
{bosses.map((boss) => (
<button
className={`collection-boss-button ${selectedBoss?.bossId === boss.bossId ? 'selected' : ''} ${selected(`boss:${boss.bossId}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.bossId}`) ? 'true' : undefined}
key={boss.bossId}
onClick={() => selectBoss(boss.bossId)}
onPointerDown={() => selectKey(`boss:${boss.bossId}`)}
type="button"
>
<span>{bossInitialsForProfile(boss.encounter.name)}</span>
<strong>{boss.encounter.name}</strong>
</button>
))}
</div>
{selectedBoss && (
<article className="collection-boss-detail">
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
<strong>{item.name}</strong>
<small>{item.chance}</small>
<b>x{item.quantity}</b>
</button>
))}
</div>
</article>
)}
</div>
)}
</section>
)
}
function bossDropIdForProfile(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
function bossInitialsForProfile(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
export function Iwt2CustomizeCharacterScreen({
@@ -938,8 +1454,13 @@ function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1)
if (!actions[bounded]?.disabled) return bounded
const firstEnabled = actions.findIndex((action) => !action.disabled)
return firstEnabled >= 0 ? firstEnabled : 0
for (let offset = 1; offset < actions.length; offset += 1) {
const previous = bounded - offset
const next = bounded + offset
if (previous >= 0 && !actions[previous]?.disabled) return previous
if (next < actions.length && !actions[next]?.disabled) return next
}
return 0
}
function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) {
+198 -13
View File
@@ -14,6 +14,7 @@ import type {
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
@@ -22,6 +23,7 @@ import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
@@ -35,7 +37,7 @@ const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
type InitialPartyMember = {
id: Iwt2PartyEntityId
@@ -102,6 +104,10 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
radius: bossMetadata.radius,
health: maxHealth,
maxHealth,
armor: bossMetadata.mudArmor ?? 0,
maxArmor: bossMetadata.mudArmor ?? 0,
mechanicEnergy: 0,
mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
@@ -118,6 +124,8 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
mechanicArcs: [],
mechanicLinks: [],
}
}
@@ -132,12 +140,31 @@ function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
if (bossId === 'khezu') return 3
if (bossId === 'rathian') return 2.2
if (bossId === 'barroth') return 2.1
if (bossId === 'tobi-kadachi') return 2.6
if (bossId === 'rimebastion') return 2.8
if (bossId === 'ember-mantis-duelist') return 1.4
if (bossId === 'cinderback-ricochet') return 2.5
if (bossId === 'obsidian-ram-golem') return 2.2
if (bossId === 'stormcoil-wyrm') return 2.6
if (bossId === 'venom-orchid-hydra') return 2.4
if (bossId === 'sandglass-scorpion') return 2.1
if (bossId === 'crystal-bat-matriarch') return 2.3
if (bossId === 'hollowcrown-revenant') return 1.8
return 0
}
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
if (bossId === 'yian-kut-ku') return 1.2
if (bossId === 'khezu') return 1.6
if (bossId === 'rathian') return 3
if (bossId === 'tobi-kadachi') return 2.4
if (bossId === 'ember-mantis-duelist') return 3
if (bossId === 'stormcoil-wyrm') return 3.1
if (bossId === 'venom-orchid-hydra') return 2.8
if (bossId === 'sandglass-scorpion') return 3.2
if (bossId === 'crystal-bat-matriarch') return 2.9
return 0
}
@@ -157,12 +184,23 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
}
const separatedState = {
...baseState,
party: separatePartyFromBosses(baseState.party, baseState),
party: separatePartyFromBosses(
separatePartyFromParty(separatePartyFromBosses(baseState.party, baseState), baseState),
baseState,
),
}
const bossResult = tickBosses(separatedState, step)
const postBossState = { ...separatedState, bosses: bossResult.bosses }
const postBossParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(bossResult.party, postBossState),
postBossState,
),
postBossState,
)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
postBossParty,
bossResult.bosses,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
@@ -186,7 +224,15 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const regeneratedParty = regeneratePartyMana(hotResult.party, step)
const finalCollisionState = { ...separatedState, bosses: damageResult.bosses }
const finalParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(regeneratedParty, finalCollisionState),
finalCollisionState,
),
finalCollisionState,
)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
@@ -296,6 +342,7 @@ function createEmptyStatus(): Iwt2StatusState {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
slowedSeconds: 0,
}
}
@@ -340,9 +387,10 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
indicators.push(...result.indicators)
}
const separatedBosses = separateBosses(bosses, state)
return {
bosses,
boss: getPrimaryBoss(bosses),
bosses: separatedBosses,
boss: getPrimaryBoss(separatedBosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
@@ -355,22 +403,129 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
}
}
function separateBosses(bosses: Iwt2BossEntityState[], state: Iwt2ArenaState): Iwt2BossEntityState[] {
const next = bosses.map((boss) => ({ ...boss, position: { ...boss.position } }))
for (let pass = 0; pass < 3; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 4
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.4 : 0.4,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.55
const firstPosition = clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds)
const secondPosition = clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds)
next[firstIndex] = {
...first,
position: firstPosition,
velocity: scaleVec2(subtractVec2(firstPosition, first.position), 30),
}
next[secondIndex] = {
...second,
position: secondPosition,
velocity: scaleVec2(subtractVec2(secondPosition, second.position), 30),
}
}
}
}
return next
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
position = separateCircles(
const separated = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
position = wallClearance(separated, member, state) <= 2
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
? separateFromBossTowardCenter(member, boss, state)
: separated
}
return { ...member, position }
})
}
function circleOverlapAmount(
moving: { position: Iwt2Vec2, radius: number },
fixed: { position: Iwt2Vec2, radius: number },
): number {
return Math.max(0, moving.radius + fixed.radius - distanceVec2(moving.position, fixed.position))
}
function separateFromBossTowardCenter(
member: Iwt2PartyEntityState,
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5 }
const direction = normalizeVec2(subtractVec2(center, boss.position))
const minDistance = boss.radius + member.radius + 3
return clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, minDistance)),
member.radius,
state.bounds,
)
}
function wallClearance(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
return Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
}
function separatePartyFromParty(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
const next = party.map((member) => ({ ...member, position: { ...member.position } }))
for (let pass = 0; pass < 2; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 2
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.35 : 0.35,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.5
next[firstIndex] = {
...first,
position: clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds),
}
next[secondIndex] = {
...second,
position: clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds),
}
}
}
}
return next
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
@@ -461,9 +616,10 @@ function advanceProjectiles(
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
))
if (hitBoss) {
const damage = Math.min(projectile.damage, hitBoss.health)
const damageResult = applyDamageToBoss(hitBoss, projectile.damage)
const damage = damageResult.healthDamage
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
? { ...boss, health: Math.max(0, boss.health - damage) }
? damageResult.boss
: boss)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
@@ -474,7 +630,7 @@ function advanceProjectiles(
targetId: hitBoss.id,
value: damage,
})
if (hitBoss.health - damage <= 0) {
if (damageResult.boss.health <= 0) {
events.push({
id: 0,
time,
@@ -673,10 +829,14 @@ function applyPartyAttacks(
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
let damage = Math.min(metadata.attackDamage, target.health)
let defeated = target.health - damage <= 0
if (target.kind === 'boss') {
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
damage = damageResult.healthDamage
defeated = damageResult.boss.health <= 0
nextBosses = nextBosses.map((boss) => boss.id === target.id
? { ...boss, health: Math.max(0, boss.health - damage) }
? damageResult.boss
: boss)
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
@@ -691,7 +851,7 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
if (defeated) {
events.push({
id: 0,
time,
@@ -754,6 +914,31 @@ function addDamageDone(
: member)
}
function applyDamageToBoss(
boss: Iwt2BossEntityState,
rawDamage: number,
): { boss: Iwt2BossEntityState, healthDamage: number } {
if (rawDamage <= 0 || boss.health <= 0) return { boss, healthDamage: 0 }
if (boss.armor <= 0) {
const healthDamage = Math.min(rawDamage, boss.health)
return {
boss: { ...boss, health: Math.max(0, boss.health - healthDamage) },
healthDamage,
}
}
const armorDamage = Math.min(boss.armor, rawDamage * 0.75)
const healthDamage = Math.min(boss.health, rawDamage - armorDamage * 0.6)
return {
boss: {
...boss,
armor: Math.max(0, boss.armor - armorDamage),
health: Math.max(0, boss.health - healthDamage),
},
healthDamage,
}
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+271
View File
@@ -0,0 +1,271 @@
import { BARROTH_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createConeIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickBarroth(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
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 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyMudSpray(party, boss, state.time + dt)
const mudResult = addMudSprayPuddles(hazards, boss, nextHazardId, state.time + dt, state.bounds)
party = result.party
hazards = mudResult.hazards
nextHazardId = mudResult.nextHazardId
events.push(...result.events)
boss = {
...boss,
attackPhase: 'mudSprayRecover',
phaseSecondsRemaining: 0.46,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 1.18 : 1.4)
boss = moveBossTowardCenter(boss, state, speed, dt)
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'mudSprayWindup',
chargeCooldownRemaining: BARROTH_BOSS_METADATA.mudSprayCooldown!,
phaseSecondsRemaining: BARROTH_BOSS_METADATA.mudSprayWindup!,
velocity: { x: 0, y: 0 },
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 0.9 : 1.08)
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, speed * 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 withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function applyMudSpray(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'cone',
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
direction: boss.facing,
origin: boss.position,
range: BARROTH_BOSS_METADATA.mudSprayRange!,
}, {
damage: BARROTH_BOSS_METADATA.mudSprayDamage!,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: BARROTH_BOSS_METADATA.mudSprayStunSeconds!,
time,
})
return { party: result.party, events: result.events }
}
function addMudSprayPuddles(
hazards: Iwt2GroundHazardState[],
boss: Iwt2BossEntityState,
nextHazardId: number,
time: number,
bounds: Iwt2ArenaState['bounds'],
): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const offsets = [-0.34, 0, 0.34]
for (let index = 0; index < offsets.length; index += 1) {
const point = clampVec2ToArena(
conePoint(boss.position, boss.facing, BARROTH_BOSS_METADATA.mudSprayRange! * (0.46 + index * 0.17), offsets[index]),
BARROTH_BOSS_METADATA.mudPuddleRadius!,
bounds,
)
const puddle = addMudPuddle({
duration: BARROTH_BOSS_METADATA.mudPuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextId,
position: point,
radius: BARROTH_BOSS_METADATA.mudPuddleRadius!,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function conePoint(origin: Iwt2Vec2, direction: Iwt2Vec2, distance: number, angleOffset: number): Iwt2Vec2 {
const base = Math.atan2(direction.y, direction.x) + angleOffset
return addVec2(origin, {
x: Math.cos(base) * distance,
y: Math.sin(base) * distance,
})
}
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) > BARROTH_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: BARROTH_BOSS_METADATA.meleeRange,
}, {
damage: BARROTH_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: BARROTH_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withBarrothIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createBarrothIndicators(result.boss),
}
}
function createBarrothIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
if (boss.attackPhase !== 'mudSprayWindup' && boss.attackPhase !== 'mudSprayRecover') return []
return [createConeIndicator({
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
color: '#b38a52',
direction: boss.facing,
id: `${boss.id}:mud-spray`,
mechanicId: 'barroth-mud-spray',
origin: boss.position,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'mudSprayWindup',
boss.attackPhase === 'mudSprayRecover',
),
range: BARROTH_BOSS_METADATA.mudSprayRange!,
sourceId: boss.id,
})]
}
+24
View File
@@ -13,6 +13,18 @@ import type {
} from './types'
import { tickGreatJaggi } from './greatJaggiAi'
import { tickKhezu } from './khezuAi'
import { tickBarroth } from './barrothAi'
import { tickCinderbackRicochet } from './cinderbackAi'
import { tickCrystalBatMatriarch } from './crystalBatMatriarchAi'
import { tickEmberMantisDuelist } from './emberMantisAi'
import { tickHollowcrownRevenant } from './hollowcrownRevenantAi'
import { tickObsidianRamGolem } from './obsidianRamGolemAi'
import { tickRathian } from './rathianAi'
import { tickRimebastion } from './rimebastionAi'
import { tickSandglassScorpion } from './sandglassScorpionAi'
import { tickStormcoilWyrm } from './stormcoilWyrmAi'
import { tickTobiKadachi } from './tobiKadachiAi'
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
@@ -48,6 +60,18 @@ 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)
if (state.boss.bossId === 'rathian') return tickRathian(state, dt)
if (state.boss.bossId === 'barroth') return tickBarroth(state, dt)
if (state.boss.bossId === 'tobi-kadachi') return tickTobiKadachi(state, dt)
if (state.boss.bossId === 'rimebastion') return tickRimebastion(state, dt)
if (state.boss.bossId === 'ember-mantis-duelist') return tickEmberMantisDuelist(state, dt)
if (state.boss.bossId === 'cinderback-ricochet') return tickCinderbackRicochet(state, dt)
if (state.boss.bossId === 'obsidian-ram-golem') return tickObsidianRamGolem(state, dt)
if (state.boss.bossId === 'stormcoil-wyrm') return tickStormcoilWyrm(state, dt)
if (state.boss.bossId === 'venom-orchid-hydra') return tickVenomOrchidHydra(state, dt)
if (state.boss.bossId === 'sandglass-scorpion') return tickSandglassScorpion(state, dt)
if (state.boss.bossId === 'crystal-bat-matriarch') return tickCrystalBatMatriarch(state, dt)
if (state.boss.bossId === 'hollowcrown-revenant') return tickHollowcrownRevenant(state, dt)
return tickBulldrome(state, dt)
}
+605
View File
@@ -0,0 +1,605 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addFirePuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CINDERBACK_PHASE = {
armorSlamRecover: 'cinderbackArmorSlamRecover',
armorSlamWindup: 'cinderbackArmorSlamWindup',
ricochetWindup: 'cinderbackRicochetWindup',
ricocheting: 'cinderbackRicocheting',
} as const
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 20,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 5,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 18,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
ricochetStunSeconds: 0.42,
ricochetWidth: 46,
ricochetWindup: 0.72,
wallContactLimit: 0.48,
wallMargin: 64,
}
export function tickCinderbackRicochet(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = cinderbackPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === CINDERBACK_PHASE.ricocheting
|| incomingPhase === CINDERBACK_PHASE.armorSlamWindup
|| incomingPhase === CINDERBACK_PHASE.armorSlamRecover
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = cinderbackPhase(boss)
if (phase === CINDERBACK_PHASE.ricochetWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricocheting),
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: 0,
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetDuration,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.ricocheting) {
const roll = moveRicochet(boss, state, dt)
const hitResult = applyRicochetHits(party, boss, boss.position, roll.position, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const trailResult = maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position: roll.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
const bounceCount = boss.mechanicEnergy + roll.bounces
const wallContactSeconds = roll.bounces > 0 || roll.cornerBounce
? 0
: isBossNearWall({ ...boss, position: roll.position }, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = {
...boss,
chargeEnd: nextWallImpact(roll.position, roll.facing, boss.radius, state),
chargeHitEntityIds: hitResult.hitEntityIds,
chargeStart: { ...roll.position },
facing: roll.facing,
fireballCooldownRemaining: trailResult.nextTrailInSeconds,
mechanicEnergy: bounceCount,
position: roll.position,
velocity: roll.velocity,
wallContactSeconds,
}
if (
boss.phaseSecondsRemaining <= 0
|| bounceCount >= CINDERBACK_TUNING.ricochetMaxBounces
|| wallContactSeconds >= CINDERBACK_TUNING.wallContactLimit
|| roll.cornerBounce
) {
boss = beginArmorSlam(boss, state)
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applyArmorSlam(party, boss, state.time + dt)
const puddleResult = addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time: state.time + dt,
})
party = slamResult.party
hazards = puddleResult.hazards
nextHazardId = puddleResult.nextHazardId
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamRecover,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamRecover) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.4),
phaseSecondsRemaining: 0,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.68) {
boss = moveBossTowardCenter(boss, state, CINDERBACK_TUNING.centerDisengageSpeed, dt)
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricochetWindup),
chargeCooldownRemaining: CINDERBACK_TUNING.ricochetCooldown,
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetWindup,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, target.position, CINDERBACK_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginArmorSlam(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
const position = clampVec2ToArena(disengageFromWall(boss.position, boss.radius, state), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamWindup),
chargeEnd: { ...position },
chargeStart: { ...position },
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamWindup,
position,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
function moveRicochet(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { bounces: number, cornerBounce: boolean, facing: Iwt2Vec2, position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const minX = boss.radius
const maxX = state.bounds.width - boss.radius
const minY = boss.radius
const maxY = state.bounds.height - boss.radius
let facing = withFallbackFacing(boss.facing, { x: 1, y: 0 })
let position = addVec2(boss.position, scaleVec2(facing, CINDERBACK_TUNING.ricochetSpeed * dt))
let xBounce = false
let yBounce = false
if (position.x < minX) {
position = { ...position, x: minX + (minX - position.x) }
facing = { ...facing, x: Math.abs(facing.x) }
xBounce = true
} else if (position.x > maxX) {
position = { ...position, x: maxX - (position.x - maxX) }
facing = { ...facing, x: -Math.abs(facing.x) }
xBounce = true
}
if (position.y < minY) {
position = { ...position, y: minY + (minY - position.y) }
facing = { ...facing, y: Math.abs(facing.y) }
yBounce = true
} else if (position.y > maxY) {
position = { ...position, y: maxY - (position.y - maxY) }
facing = { ...facing, y: -Math.abs(facing.y) }
yBounce = true
}
position = clampVec2ToArena(position, boss.radius, state.bounds)
const cornerBounce = xBounce && yBounce
if (cornerBounce) {
facing = withFallbackFacing(subtractVec2(arenaCenter(state), position), facing)
position = disengageFromWall(position, boss.radius, state)
}
facing = normalizeVec2(facing)
return {
bounces: Number(xBounce) + Number(yBounce),
cornerBounce,
facing,
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.fireballCooldownRemaining > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.fireballCooldownRemaining }
}
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards,
nextHazardId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: CINDERBACK_TUNING.lavaTrailInterval,
}
}
function addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
state: Iwt2ArenaState
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const angles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
for (const angle of angles) {
const position = clampVec2ToArena({
x: boss.position.x + Math.cos(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
y: boss.position.y + Math.sin(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
}, CINDERBACK_TUNING.firePuddleRadius, state.bounds)
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function applyRicochetHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CINDERBACK_TUNING.ricochetWidth,
}, {
damage: CINDERBACK_TUNING.ricochetDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyArmorSlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
}, {
damage: CINDERBACK_TUNING.armorSlamDamage,
knockdownSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
time,
})
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, CINDERBACK_TUNING.armorSlamRadius),
...result.events,
],
}
}
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) > CINDERBACK_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.meleeRange,
}, {
damage: CINDERBACK_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CINDERBACK_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, center, speed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardKeepingDistance(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function nextWallImpact(
position: Iwt2Vec2,
direction: Iwt2Vec2,
radius: number,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const facing = withFallbackFacing(direction, { x: 1, y: 0 })
const times: number[] = []
if (Math.abs(facing.x) > 0.0001) {
times.push(((facing.x > 0 ? state.bounds.width - radius : radius) - position.x) / facing.x)
}
if (Math.abs(facing.y) > 0.0001) {
times.push(((facing.y > 0 ? state.bounds.height - radius : radius) - position.y) / facing.y)
}
const impactDistance = Math.max(0, Math.min(...times.filter((value) => value > 0)))
return clampVec2ToArena(addVec2(position, scaleVec2(facing, impactDistance)), radius, state.bounds)
}
function wallSafeRollDirection(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2Vec2 {
if (!isBossNearWall(boss, state)) return withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.25), scaleVec2(targetBias, 0.55)), centerBias)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + CINDERBACK_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function disengageFromWall(position: Iwt2Vec2, radius: number, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const margin = radius + CINDERBACK_TUNING.wallMargin * 0.55
const clamped = {
x: Math.min(state.bounds.width - margin, Math.max(margin, position.x)),
y: Math.min(state.bounds.height - margin, Math.max(margin, position.y)),
}
if (clamped.x !== position.x || clamped.y !== position.y) return clamped
return addVec2(position, scaleVec2(normalizeVec2(subtractVec2(center, position)), 18))
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
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 withCinderbackIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCinderbackIndicators(result.boss),
}
}
function createCinderbackIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = cinderbackPhase(boss)
if (
phase === CINDERBACK_PHASE.ricochetWindup
|| phase === CINDERBACK_PHASE.ricocheting
) {
indicators.push(createLaneIndicator({
color: phase === CINDERBACK_PHASE.ricocheting ? '#ff6a2a' : '#ffd166',
end: boss.chargeEnd,
id: `${boss.id}:cinderback-ricochet-lane`,
mechanicId: 'cinderback-ricochet',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.ricochetWindup,
phase === CINDERBACK_PHASE.ricocheting,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CINDERBACK_TUNING.ricochetWidth,
}))
}
if (
phase === CINDERBACK_PHASE.armorSlamWindup
|| phase === CINDERBACK_PHASE.armorSlamRecover
) {
indicators.push(createCircleIndicator({
color: '#ff7a2f',
id: `${boss.id}:cinderback-armor-slam`,
mechanicId: 'cinderback-armor-slam',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.armorSlamWindup,
phase === CINDERBACK_PHASE.armorSlamRecover,
),
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
sourceId: boss.id,
}))
}
return indicators
}
function cinderbackPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CinderbackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+682
View File
@@ -0,0 +1,682 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CRYSTAL_BAT_PHASE = {
shardRecover: 'crystalBatShardRecover',
shardWindup: 'crystalBatShardWindup',
sonicRecover: 'crystalBatSonicRecover',
sonicWindup: 'crystalBatSonicWindup',
swoopRecover: 'crystalBatSwoopRecover',
swoopWindup: 'crystalBatSwoopWindup',
swooping: 'crystalBatSwooping',
} as const
type CrystalBatPhase = typeof CRYSTAL_BAT_PHASE[keyof typeof CRYSTAL_BAT_PHASE]
const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 4,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 13,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 10,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 17,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
swoopStunSeconds: 0.48,
swoopWindup: 0.56,
wallContactLimit: 0.7,
wallMargin: 62,
} as const
const SONIC_COLOR = '#d9b7ff'
const SHARD_COLOR = '#9de9ff'
const SWOOP_COLOR = '#f6d36b'
export function tickCrystalBatMatriarch(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const activePhase = crystalBatPhase(boss)
boss = {
...boss,
wallContactSeconds: activePhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withCrystalBatIndicators({ boss, events, party })
const phase = crystalBatPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, CRYSTAL_BAT_TUNING.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applySonicRings(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicLanes: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swooping),
chargeHitEntityIds: [],
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swooping) {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.chargeEnd, CRYSTAL_BAT_TUNING.swoopSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applySwoopHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopRecover,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
const cluster = findBestCluster(party)
if (boss.wallContactSeconds >= CRYSTAL_BAT_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (cluster.count >= 2 && boss.fireballCooldownRemaining <= 0) {
boss = beginSwoop(boss, cluster.center, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginSonicRings(boss, party, state)
: beginShardLanes(boss, party, state)
return withCrystalBatIndicators({ boss, events, party })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCrystalBatIndicators({ boss, events, party })
boss = moveBossToHoverPoint(boss, state, target, dt)
return withCrystalBatIndicators({ boss, events, party })
}
function beginSonicRings(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const circles = party
.filter((member) => member.health > 0)
.map((member) => ({
id: `crystal-sonic-${member.id}`,
position: clampVec2ToArena(member.position, CRYSTAL_BAT_TUNING.sonicRingRadius, state.bounds),
radius: CRYSTAL_BAT_TUNING.sonicRingRadius,
}))
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.sonicCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: circles,
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicWindup,
velocity: { x: 0, y: 0 },
}
}
function beginShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const lanes = createReflectedShardLanes(boss, party, state)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.shardCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: lanes,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardWindup,
velocity: { x: 0, y: 0 },
}
}
function beginSwoop(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const end = clampVec2ToArena(addVec2(targetPosition, scaleVec2(facing, 170)), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopWindup),
chargeEnd: end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: CRYSTAL_BAT_TUNING.swoopCooldown,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createReflectedShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2MechanicLaneState[] {
const living = party.filter((member) => member.health > 0)
const cluster = findBestCluster(party)
const priorityTargets = [
cluster.center,
living.find((member) => member.aiRole === 'ranged')?.position ?? getBossTarget(party)?.position ?? arenaCenter(state),
]
const mirrors = mirrorPointsForPattern(boss, state)
const lanes: Iwt2MechanicLaneState[] = []
for (let index = 0; index < mirrors.length; index += 1) {
const mirror = mirrors[index]
const target = priorityTargets[index] ?? cluster.center
const reflectedDirection = withFallbackFacing(subtractVec2(target, mirror), boss.facing)
const reflectedEnd = clampVec2ToArena(
addVec2(mirror, scaleVec2(reflectedDirection, 380)),
CRYSTAL_BAT_TUNING.shardLaneWidth,
state.bounds,
)
lanes.push({
end: mirror,
id: `crystal-shard-${index}-in`,
start: boss.position,
width: CRYSTAL_BAT_TUNING.shardLaneWidth * 0.78,
})
lanes.push({
end: reflectedEnd,
id: `crystal-shard-${index}-out`,
start: mirror,
width: CRYSTAL_BAT_TUNING.shardLaneWidth,
})
}
return lanes
}
function mirrorPointsForPattern(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2[] {
const center = arenaCenter(state)
const verticalFirst = boss.chargeCount % 4 < 2
if (verticalFirst) {
return [
{ x: clamp(center.x + 210, 80, state.bounds.width - 80), y: 42 },
{ x: clamp(center.x - 180, 80, state.bounds.width - 80), y: state.bounds.height - 42 },
]
}
return [
{ x: 44, y: clamp(center.y - 120, 74, state.bounds.height - 74) },
{ x: state.bounds.width - 44, y: clamp(center.y + 110, 74, state.bounds.height - 74) },
]
}
function applySonicRings(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const ownerId = sonicRingOwnerId(circle)
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: CRYSTAL_BAT_TUNING.sonicDamage,
excludedEntityIds: ownerId ? [ownerId, ...hitEntityIds] : hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.sonicStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applyShardLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: CRYSTAL_BAT_TUNING.shardDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applySwoopHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}, {
damage: CRYSTAL_BAT_TUNING.swoopDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
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) > CRYSTAL_BAT_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CRYSTAL_BAT_TUNING.meleeRange,
}, {
damage: CRYSTAL_BAT_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CRYSTAL_BAT_TUNING.meleeCooldown },
events: result.events,
party: result.party,
}
}
function moveBossToHoverPoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
const awayFromTarget = withFallbackFacing(subtractVec2(center, target.position), { x: -1, y: 0 })
const desired = targetNearWall
? addVec2(target.position, scaleVec2(awayFromTarget, CRYSTAL_BAT_TUNING.hoverDistance))
: addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(boss.position, target.position)), CRYSTAL_BAT_TUNING.hoverDistance))
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, desired, CRYSTAL_BAT_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.relocateTarget, CRYSTAL_BAT_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardLimited(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function findBestCluster(party: Iwt2PartyEntityState[]): { center: Iwt2Vec2, count: number } {
const living = party.filter((member) => member.health > 0)
if (living.length <= 0) return { center: { x: 0, y: 0 }, count: 0 }
let bestMembers = [living[0]]
for (const member of living) {
const nearby = living.filter((other) => distanceVec2(member.position, other.position) <= CRYSTAL_BAT_TUNING.clusterRadius)
if (nearby.length > bestMembers.length) bestMembers = nearby
}
return { center: partyCenter(bestMembers), count: bestMembers.length }
}
function partyCenter(party: Iwt2PartyEntityState[]): Iwt2Vec2 {
if (party.length <= 0) return { x: 0, y: 0 }
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 88)), boss.radius, state.bounds)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
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 sonicRingOwnerId(circle: Iwt2MechanicCircleState): Iwt2EntityId | undefined {
const prefix = 'crystal-sonic-'
return circle.id.startsWith(prefix) ? circle.id.slice(prefix.length) : undefined
}
function withCrystalBatIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCrystalBatIndicators(result.boss),
}
}
function createCrystalBatIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = crystalBatPhase(boss)
const indicators: Iwt2ArenaIndicator[] = []
if (phase === CRYSTAL_BAT_PHASE.sonicWindup || phase === CRYSTAL_BAT_PHASE.sonicRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.sonicWindup,
phase === CRYSTAL_BAT_PHASE.sonicRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createDonutIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:ring`,
innerRadius: Math.max(12, circle.radius * 0.34),
mechanicId: 'crystal-bat-sonic-spacing-ring',
outerRadius: circle.radius,
phase: indicatorPhase,
position: circle.position,
sourceId: boss.id,
}))
indicators.push(createCircleIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:center`,
mechanicId: 'crystal-bat-sonic-overlap-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius * 0.22,
sourceId: boss.id,
}))
}
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup || phase === CRYSTAL_BAT_PHASE.shardRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.shardWindup,
phase === CRYSTAL_BAT_PHASE.shardRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: lane.id.endsWith('-out') ? 'crystal-bat-reflected-shard' : 'crystal-bat-mirror-shard',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (
phase === CRYSTAL_BAT_PHASE.swoopWindup
|| phase === CRYSTAL_BAT_PHASE.swooping
|| phase === CRYSTAL_BAT_PHASE.swoopRecover
) {
indicators.push(createLaneIndicator({
color: SWOOP_COLOR,
end: boss.chargeEnd,
id: `${boss.id}:crystal-bat-swoop`,
mechanicId: 'crystal-bat-cluster-swoop',
phase: indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.swoopWindup,
phase === CRYSTAL_BAT_PHASE.swooping,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}))
}
return indicators
}
function crystalBatPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CrystalBatPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+459
View File
@@ -0,0 +1,459 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type EmberMantisAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
sidestepInterval: 1.9,
sidestepSeconds: 0.32,
sidestepSpeed: 330,
slashLaneWidth: 34,
wallMargin: 86,
}
export function tickEmberMantisDuelist(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as EmberMantisAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(EMBER_MANTIS.sidestepInterval, state.boss.mechanicEnergy + dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withEmberMantisIndicators({ boss, party, events })
const phase = boss.attackPhase as EmberMantisAttackPhase
if (phase === 'bladeSidestep') {
boss = moveBossTowardRelocateTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 6) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicEnergy: 0,
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.lineSlashDamage,
EMBER_MANTIS.lineSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('lineSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'crossSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.crossSlashDamage,
EMBER_MANTIS.crossSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('crossSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds + 0.12,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashRecover' || phase === 'crossSlashRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= 0.58) {
boss = beginSidestep(boss, state, target, true)
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('crossSlashWindup'),
fireballCooldownRemaining: EMBER_MANTIS.crossSlashCooldown,
mechanicLanes: createCrossSlashLanes(state, boss, target),
phaseSecondsRemaining: EMBER_MANTIS.crossSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('lineSlashWindup'),
chargeCooldownRemaining: EMBER_MANTIS.lineSlashCooldown,
mechanicLanes: [createLineSlashLane(state, boss, target)],
phaseSecondsRemaining: EMBER_MANTIS.lineSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= EMBER_MANTIS.sidestepInterval && distanceVec2(boss.position, target.position) <= 220) {
boss = beginSidestep(boss, state, target, false)
return withEmberMantisIndicators({ 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 withEmberMantisIndicators({ boss, party, events })
boss = moveDuelist(boss, state, target, dt)
return withEmberMantisIndicators({ boss, party, events })
}
function asBossPhase(phase: EmberMantisAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginSidestep(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase('bladeSidestep'),
mechanicEnergy: 0,
phaseSecondsRemaining: forcedFromWall ? EMBER_MANTIS.sidestepSeconds * 1.6 : EMBER_MANTIS.sidestepSeconds,
relocateTarget: chooseSidestepTarget(boss, state, target, forcedFromWall),
velocity: { x: 0, y: 0 },
}
}
function moveBossTowardRelocateTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, EMBER_MANTIS.sidestepSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveDuelist(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const desiredDistance = distance < EMBER_MANTIS.duelistRange * 0.72
? EMBER_MANTIS.duelistRange
: EMBER_MANTIS.duelistRange * 0.82
const direction = distance < EMBER_MANTIS.duelistRange * 0.72
? withFallbackFacing(offset, boss.facing)
: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const targetPosition = distance < desiredDistance
? addVec2(target.position, scaleVec2(direction, EMBER_MANTIS.duelistRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, targetPosition, EMBER_MANTIS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function chooseSidestepTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2Vec2 {
if (forcedFromWall) {
return clampVec2ToArena(arenaCenter(state), boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
const towardTarget = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const sideSign = boss.chargeCount % 2 === 0 ? 1 : -1
const lateral = { x: -towardTarget.y * sideSign, y: towardTarget.x * sideSign }
const center = arenaCenter(state)
const centerBias = normalizeVec2(subtractVec2(center, boss.position))
const sideWeight = forcedFromWall ? 0.35 : 1
const centerWeight = forcedFromWall ? 1.15 : 0.35
const direction = withFallbackFacing(addVec2(scaleVec2(lateral, sideWeight), scaleVec2(centerBias, centerWeight)), centerBias)
const distance = forcedFromWall ? 152 : 118
const rawTarget = addVec2(boss.position, scaleVec2(direction, distance))
return clampVec2ToArena(rawTarget, boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
function createLineSlashLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
return createArenaLane('line-slash', target.position, direction, EMBER_MANTIS.slashLaneWidth, state)
}
function createCrossSlashLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const center = targetClusterCenter(state.party, target.position)
const baseAngle = Math.atan2(target.position.y - boss.position.y, target.position.x - boss.position.x)
const angles = [baseAngle + Math.PI * 0.25, baseAngle - Math.PI * 0.25]
return angles.map((angle, index) => createArenaLane(
`cross-slash-${index}`,
center,
{ x: Math.cos(angle), y: Math.sin(angle) },
EMBER_MANTIS.slashLaneWidth,
state,
))
}
function createArenaLane(
id: string,
center: Iwt2Vec2,
direction: Iwt2Vec2,
width: number,
state: Iwt2ArenaState,
): Iwt2MechanicLaneState {
const normalized = withFallbackFacing(direction, { x: 1, y: 0 })
const halfLength = Math.hypot(state.bounds.width, state.bounds.height) * 0.62 + 48
return {
end: clampLanePoint(addVec2(center, scaleVec2(normalized, halfLength)), state),
id,
start: clampLanePoint(addVec2(center, scaleVec2(normalized, -halfLength)), state),
width,
}
}
function clampLanePoint(point: Iwt2Vec2, state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: clamp(point.x, -24, state.bounds.width + 24),
y: clamp(point.y, -24, state.bounds.height + 24),
}
}
function targetClusterCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return fallback
const total = living.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / living.length)
}
function applySlashLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
damage: number,
stunSeconds: number,
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',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return boss
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 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) > EMBER_MANTIS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: EMBER_MANTIS.meleeRange,
}, {
damage: EMBER_MANTIS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: EMBER_MANTIS.meleeCooldown },
events: result.events,
party: result.party,
}
}
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 isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + EMBER_MANTIS.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withEmberMantisIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createEmberMantisIndicators(result.boss),
}
}
function createEmberMantisIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as EmberMantisAttackPhase
const isSlashPhase = phase === 'lineSlashWindup'
|| phase === 'lineSlashRecover'
|| phase === 'crossSlashWindup'
|| phase === 'crossSlashRecover'
if (!isSlashPhase) return []
const isWindup = phase === 'lineSlashWindup' || phase === 'crossSlashWindup'
const isRecover = phase === 'lineSlashRecover' || phase === 'crossSlashRecover'
return boss.mechanicLanes.map((lane) => createLaneIndicator({
color: isRecover ? '#ff6a2a' : EMBER_MANTIS.accentColor,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: phase === 'crossSlashWindup' || phase === 'crossSlashRecover'
? 'ember-mantis-cross-slash'
: 'ember-mantis-line-slash',
phase: indicatorPhaseFromAttack(isWindup, isRecover),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
+166 -26
View File
@@ -15,6 +15,10 @@ const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
const MAX_POISON_PUDDLES = 6
const MAX_MUD_PUDDLES = 7
const MUD_PUDDLE_TICK_SECONDS = 0.22
const MUD_SLOW_SECONDS = 0.5
export function addFirePuddle({
damage,
@@ -34,9 +38,115 @@ export function addFirePuddle({
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
hazardKind: 'firePuddle',
hazards,
maxActive: MAX_FIRE_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addPoisonPuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: 1,
hazardKind: 'poisonPuddle',
hazards,
maxActive: MAX_POISON_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addMudPuddle({
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage: 0,
duration,
fadeSeconds: 0.9,
hazardKind: 'mudPuddle',
hazards,
maxActive: MAX_MUD_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
function addGroundPuddle({
damage,
duration,
fadeSeconds,
hazardKind,
hazards,
maxActive,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
fadeSeconds: number
hazardKind: Iwt2GroundHazardState['hazardKind']
hazards: Iwt2GroundHazardState[]
maxActive: number
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
hazard.hazardKind === hazardKind
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
@@ -59,19 +169,19 @@ export function addFirePuddle({
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
id: `${hazardKind}-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
hazardKind,
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
fadeSeconds,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
hazards: capPuddles([...hazards, nextHazard], hazardKind, maxActive),
nextHazardId: nextHazardId + 1,
}
}
@@ -101,22 +211,27 @@ export function tickGroundHazards({
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
if (hazard.hazardKind === 'mudPuddle') {
nextParty = applyMudSlow(nextParty, hazard)
nextDamageAt = time + MUD_PUDDLE_TICK_SECONDS
} else {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
}
nextHazards.push({
@@ -135,9 +250,9 @@ export function tickGroundHazards({
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
color: hazardColor(hazard),
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
mechanicId: hazard.hazardKind,
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
@@ -145,16 +260,41 @@ export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2Ar
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
function applyMudSlow(party: Iwt2PartyEntityState[], hazard: Iwt2GroundHazardState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const distance = Math.hypot(member.position.x - hazard.position.x, member.position.y - hazard.position.y)
if (distance > hazard.radius + member.radius) return member
return {
...member,
status: {
...member.status,
slowedSeconds: Math.max(member.status.slowedSeconds, MUD_SLOW_SECONDS),
},
}
})
}
function hazardColor(hazard: Iwt2GroundHazardState): string {
if (hazard.hazardKind === 'poisonPuddle') return '#7bd84f'
if (hazard.hazardKind === 'mudPuddle') return '#9a6a3a'
return '#ff7a2f'
}
function capPuddles(
hazards: Iwt2GroundHazardState[],
hazardKind: Iwt2GroundHazardState['hazardKind'],
maxActive: number,
): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
if (hazard.hazardKind !== hazardKind || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
if (activePuddles <= maxActive || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
+712
View File
@@ -0,0 +1,712 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const HOLLOWCROWN_PHASE = {
antlerChargeRecover: 'hollowcrownAntlerChargeRecover',
antlerChargeWindup: 'hollowcrownAntlerChargeWindup',
hoofCurseRecover: 'hollowcrownHoofCurseRecover',
hoofCurseWindup: 'hollowcrownHoofCurseWindup',
trailDashing: 'hollowcrownTrailDashing',
trailDashWindup: 'hollowcrownTrailDashWindup',
trailFade: 'hollowcrownTrailFade',
} as const
type HollowcrownSpecialPhase = typeof HOLLOWCROWN_PHASE[keyof typeof HOLLOWCROWN_PHASE]
type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSpecialPhase
const HOLLOWCROWN_TUNING = {
antlerCooldown: 7.4,
antlerDamage: 17,
antlerLaneLength: 440,
antlerLaneOffset: 44,
antlerLineWidth: 30,
antlerRecover: 0.52,
antlerStunSeconds: 0.48,
antlerWindup: 0.82,
dashCooldown: 5.2,
dashDamage: 13,
dashLength: 330,
dashRecover: 0.42,
dashSpeed: 430,
dashStunSeconds: 0.32,
dashTrailDamage: 9,
dashTrailSeconds: 0.5,
dashTrailWidth: 40,
hoofCooldown: 6.2,
hoofDamage: 12,
hoofRadius: 46,
hoofRecover: 0.46,
hoofStunSeconds: 0.34,
hoofWindup: 0.74,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 116,
relocateSeconds: 1.25,
relocateSpeed: 168,
wallContactLimit: 0.68,
wallMargin: 76,
} as const
const CURSE_COLOR = '#9b74ff'
const TRAIL_COLOR = '#7ee7ff'
const ANTLER_COLOR = '#e8dcff'
export function tickHollowcrownRevenant(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as HollowcrownAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withHollowcrownIndicators({ boss, party, events, state })
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
fireballCooldownRemaining: Math.max(boss.fireballCooldownRemaining, 1.0),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup) {
boss = faceTarget(boss, target)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyHoofCurse(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashing),
chargeHitEntityIds: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashing) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, HOLLOWCROWN_TUNING.dashSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyTrailDashHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds + dt : 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
const trailResult = applyFadingTrail(party, boss, state.time + dt)
party = trailResult.party
events.push(...trailResult.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailFade),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.dashTrailSeconds,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailFade) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup) {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyAntlerLines(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginRangedPattern(boss, state, party, target)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginTrailDash(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withHollowcrownIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withHollowcrownIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withHollowcrownIndicators({ boss, party, events, state })
}
function asBossPhase(phase: HollowcrownAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginRangedPattern(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const useAntlers = boss.chargeCount % 2 === 1
if (useAntlers) {
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.antlerCooldown,
mechanicCircles: [],
mechanicLanes: createAntlerLines(state, boss, target),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerWindup,
velocity: { x: 0, y: 0 },
}
}
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.hoofCooldown,
mechanicCircles: createHoofZones(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofWindup,
velocity: { x: 0, y: 0 },
}
}
function beginTrailDash(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const lane = createDashTrailLane(state, boss, target)
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashWindup),
chargeCooldownRemaining: HOLLOWCROWN_TUNING.dashCooldown,
chargeEnd: lane.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: withFallbackFacing(subtractVec2(lane.end, boss.position), boss.facing),
mechanicCircles: [],
mechanicLanes: [lane],
phaseSecondsRemaining: 0.58,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createHoofZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const priority = [
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player'),
...living.filter((member) => member.aiRole !== 'ranged' && member.aiRole !== 'flanker' && member.aiRole !== 'player'),
]
return priority.slice(0, 4).map((member, index) => {
const drift = normalizeVec2(addVec2(
subtractVec2(member.position, boss.position),
scaleVec2(subtractVec2(arenaCenter(state), member.position), 0.35),
))
return {
id: `hollowcrown-hoof-zone-${index}`,
position: clampVec2ToArena(
addVec2(member.position, scaleVec2(drift, 18 + index * 4)),
HOLLOWCROWN_TUNING.hoofRadius,
state.bounds,
),
radius: HOLLOWCROWN_TUNING.hoofRadius,
}
})
}
function createDashTrailLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const wallBias = isBossNearWall(boss, state)
? normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
: { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.9)), boss.facing)
const rawEnd = addVec2(boss.position, scaleVec2(direction, HOLLOWCROWN_TUNING.dashLength))
return {
end: clampVec2ToArena(rawEnd, boss.radius + 8, state.bounds),
id: 'hollowcrown-dash-trail',
start: { ...boss.position },
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}
}
function createAntlerLines(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const perpendicular = { x: -direction.y, y: direction.x }
return [-1, 0, 1].map((index) => {
const offset = scaleVec2(perpendicular, index * HOLLOWCROWN_TUNING.antlerLaneOffset)
const start = clampVec2ToArena(addVec2(boss.position, offset), 4, state.bounds)
const end = clampVec2ToArena(
addVec2(start, scaleVec2(direction, HOLLOWCROWN_TUNING.antlerLaneLength)),
4,
state.bounds,
)
return {
end,
id: `hollowcrown-antler-line-${index + 1}`,
start,
width: index === 0 ? HOLLOWCROWN_TUNING.antlerLineWidth + 8 : HOLLOWCROWN_TUNING.antlerLineWidth,
}
})
}
function applyHoofCurse(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: HOLLOWCROWN_TUNING.hoofDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.hoofStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyTrailDashHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}, {
damage: HOLLOWCROWN_TUNING.dashDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFadingTrail(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const lane = boss.mechanicLanes[0]
if (!lane) return { party, events: [] }
const result = applyPartyDamageInShape(party, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width * 1.15,
}, {
damage: HOLLOWCROWN_TUNING.dashTrailDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds * 0.65,
time,
})
return { party: result.party, events: result.events }
}
function applyAntlerLines(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: HOLLOWCROWN_TUNING.antlerDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
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) > HOLLOWCROWN_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: HOLLOWCROWN_TUNING.meleeRange,
}, {
damage: HOLLOWCROWN_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: HOLLOWCROWN_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, HOLLOWCROWN_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + HOLLOWCROWN_TUNING.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), HOLLOWCROWN_TUNING.meleeRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, HOLLOWCROWN_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function faceTarget(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2BossEntityState {
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return { ...boss, velocity: { x: 0, y: 0 } }
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2(addVec2(
subtractVec2(center, boss.position),
{
x: boss.position.x < center.x ? 1 : -1,
y: boss.position.y < center.y ? 1 : -1,
},
))
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 72)), boss.radius, state.bounds)
}
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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + HOLLOWCROWN_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withHollowcrownIndicators(result: {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
return {
boss: result.boss,
events: result.events,
indicators: createHollowcrownIndicators(result.boss),
party: result.party,
}
}
function createHollowcrownIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup || phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.hoofCurseWindup,
phase === HOLLOWCROWN_PHASE.hoofCurseRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: CURSE_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'hollowcrown-cursed-hoof-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (
phase === HOLLOWCROWN_PHASE.trailDashWindup
|| phase === HOLLOWCROWN_PHASE.trailDashing
|| phase === HOLLOWCROWN_PHASE.trailFade
) {
const indicatorPhase: Iwt2ArenaIndicatorPhase = phase === HOLLOWCROWN_PHASE.trailDashWindup
? 'windup'
: phase === HOLLOWCROWN_PHASE.trailDashing
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: TRAIL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-fading-dash-trail',
phase: indicatorPhase,
sourceId: boss.id,
start: phase === HOLLOWCROWN_PHASE.trailDashing ? boss.position : lane.start,
width: phase === HOLLOWCROWN_PHASE.trailFade ? lane.width * 1.15 : lane.width,
}))
}
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup || phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.antlerChargeWindup,
phase === HOLLOWCROWN_PHASE.antlerChargeRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ANTLER_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-ghost-antler-line-charge',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
return indicators
}
+82 -2
View File
@@ -1,5 +1,6 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaArcIndicator,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
@@ -51,6 +52,14 @@ export type Iwt2CircleHitShape = {
radius: number
}
export type Iwt2ConeHitShape = {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2DonutHitShape = {
kind: 'donut'
position: Iwt2Vec2
@@ -58,7 +67,21 @@ export type Iwt2DonutHitShape = {
outerRadius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
export type Iwt2ArcHitShape = {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2HitShape =
| Iwt2LaneHitShape
| Iwt2CircleHitShape
| Iwt2ConeHitShape
| Iwt2DonutHitShape
| Iwt2ArcHitShape
export function createArenaEvent(
id: number,
@@ -162,9 +185,26 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
shape.width,
)
}
if (shape.kind === 'cone') {
const distance = distanceVec2(member.position, shape.origin)
if (distance > shape.range + member.radius) return false
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.origin))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
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)
if (shape.kind === 'donut') {
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
}
if (distance > shape.outerRadius + member.radius || distance < Math.max(0, shape.innerRadius - member.radius)) {
return false
}
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.position))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
export function createLaneIndicator({
@@ -303,6 +343,46 @@ export function createDonutIndicator({
}
}
export function createArcIndicator({
angleRadians,
color,
direction,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaArcIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.16 : 0.09,
id,
innerRadius,
kind: 'arc',
lineAlpha: phase === 'active' ? 0.78 : 0.52,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
+718
View File
@@ -0,0 +1,718 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const OBSIDIAN_RAM_PHASE = {
armorShatterRecover: 'obsidianRamArmorShatterRecover',
armorShatterWindup: 'obsidianRamArmorShatterWindup',
fractureQuakeRecover: 'obsidianRamFractureQuakeRecover',
fractureQuakeWindup: 'obsidianRamFractureQuakeWindup',
plateChargeRecover: 'obsidianRamPlateChargeRecover',
plateChargeWindup: 'obsidianRamPlateChargeWindup',
plateCharging: 'obsidianRamPlateCharging',
} as const
type ObsidianRamPhase = typeof OBSIDIAN_RAM_PHASE[keyof typeof OBSIDIAN_RAM_PHASE]
const OBSIDIAN_RAM = {
armorCracksBeforeShatter: 3,
centerDisengageSpeed: 118,
chargeCooldown: 4.4,
chargeDamage: 8,
chargeLength: 340,
chargeRecover: 0.5,
chargeSpeed: 390,
chargeStunSeconds: 0.55,
chargeWidth: 54,
chargeWindup: 0.68,
fractureLaneCount: 5,
fractureLaneDamage: 6,
fractureLaneLength: 300,
fractureLaneWidth: 30,
meleeCooldown: 1.3,
meleeDamage: 2,
meleeRange: 62,
moveSpeed: 82,
quakeCooldown: 7.4,
quakeRadius: 126,
quakeRecover: 0.48,
quakeStunSeconds: 0.35,
quakeWindup: 0.78,
slabDamage: 10,
slabDuration: 5.8,
slabRadius: 42,
slabSlowRadius: 46,
shatterRecover: 0.8,
shatterWindup: 0.9,
wallContactLimit: 0.72,
wallMargin: 72,
} as const
export function tickObsidianRamGolem(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = obsidianRamPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: isLockedPhase(incomingPhase)
? 0
: isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = obsidianRamPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateCharging),
chargeHitEntityIds: [],
phaseSecondsRemaining: 1.25,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateCharging) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, OBSIDIAN_RAM.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.phaseSecondsRemaining <= 0) {
const crackedArmor = clamp(boss.mechanicEnergy + 1, 0, OBSIDIAN_RAM.armorCracksBeforeShatter)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeRecover),
mechanicEnergy: crackedArmor,
phaseSecondsRemaining: OBSIDIAN_RAM.chargeRecover,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter
? beginArmorShatter(boss, state, party)
: {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyFractureQuake(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeRecover),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slabResult = dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = slabResult.party
hazards = slabResult.hazards
nextHazardId = slabResult.nextHazardId
events.push(...slabResult.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: OBSIDIAN_RAM.shatterRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= OBSIDIAN_RAM.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.3,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter) {
boss = beginArmorShatter(boss, state, party)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeWindup),
fireballCooldownRemaining: OBSIDIAN_RAM.quakeCooldown,
mechanicCircles: [{ id: 'obsidian-ram-quake-core', position: { ...boss.position }, radius: OBSIDIAN_RAM.quakeRadius }],
mechanicLanes: createFractureWaveLanes(state, boss, target.position),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeWindup,
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginPlateCharge(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginPlateCharge(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const direction = wallSafeChargeDirection(boss, state, target.position)
const chargeEnd = clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.chargeLength)),
boss.radius,
state.bounds,
)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeWindup),
chargeCooldownRemaining: OBSIDIAN_RAM.chargeCooldown,
chargeEnd,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: direction,
mechanicLanes: [{
end: chargeEnd,
id: 'obsidian-ram-plate-charge',
start: { ...boss.position },
width: OBSIDIAN_RAM.chargeWidth,
}],
phaseSecondsRemaining: OBSIDIAN_RAM.chargeWindup,
velocity: { x: 0, y: 0 },
}
}
function beginArmorShatter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const slabCircles = createObsidianSlabCircles(state, boss, party)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterWindup),
mechanicCircles: slabCircles,
mechanicLanes: [],
phaseSecondsRemaining: OBSIDIAN_RAM.shatterWindup,
velocity: { x: 0, y: 0 },
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: OBSIDIAN_RAM.chargeWidth,
}, {
damage: OBSIDIAN_RAM.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: OBSIDIAN_RAM.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.chargeStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFractureQuake(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, OBSIDIAN_RAM.quakeRadius),
]
const quakeResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.quakeRadius,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = quakeResult.party
events.push(...quakeResult.events)
for (const lane of boss.mechanicLanes) {
const laneResult = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = laneResult.party
events.push(...laneResult.events)
}
return { events, party: nextParty }
}
function dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
} {
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const slab of boss.mechanicCircles) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: slab.position,
radius: slab.radius,
}, {
damage: OBSIDIAN_RAM.slabDamage,
knockdownSeconds: 0.22,
sourceId: boss.id,
stunSeconds: 0.22,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
const hazardResult = addMudPuddle({
duration: OBSIDIAN_RAM.slabDuration,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(slab.position, OBSIDIAN_RAM.slabSlowRadius, state.bounds),
radius: OBSIDIAN_RAM.slabSlowRadius,
sourceId: boss.id,
time,
})
nextHazards = hazardResult.hazards
nextId = hazardResult.nextHazardId
}
return { events, hazards: nextHazards, nextHazardId: nextId, party: nextParty }
}
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) > OBSIDIAN_RAM.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.meleeRange,
}, {
damage: OBSIDIAN_RAM.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: OBSIDIAN_RAM.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function createFractureWaveLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
): Iwt2MechanicLaneState[] {
const baseDirection = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const baseAngle = Math.atan2(baseDirection.y, baseDirection.x)
const spread = Math.PI * 0.82
const lanes: Iwt2MechanicLaneState[] = []
const laneCount: number = OBSIDIAN_RAM.fractureLaneCount
for (let index = 0; index < laneCount; index += 1) {
const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1)
const angle = baseAngle - spread * 0.5 + spread * ratio
const direction = { x: Math.cos(angle), y: Math.sin(angle) }
lanes.push({
end: clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.fractureLaneLength)),
OBSIDIAN_RAM.fractureLaneWidth,
state.bounds,
),
id: `obsidian-ram-fracture-${index}`,
start: { ...boss.position },
width: OBSIDIAN_RAM.fractureLaneWidth,
})
}
return lanes
}
function createObsidianSlabCircles(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const targetPositions = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, 3)
.map((member) => member.position)
const fallbackAngles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
while (targetPositions.length < 3) {
const angle = fallbackAngles[targetPositions.length]
targetPositions.push(addVec2(boss.position, {
x: Math.cos(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
y: Math.sin(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
}))
}
return targetPositions.map((position, index) => ({
id: `obsidian-ram-slab-${index}`,
position: clampVec2ToArena(position, OBSIDIAN_RAM.slabRadius, state.bounds),
radius: OBSIDIAN_RAM.slabRadius,
}))
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const wallBias = isBossNearWall(boss, state) ? normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) : { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.8)), targetBias)
const desiredPosition = addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.moveSpeed * dt))
const nextPosition = clampVec2ToArena(desiredPosition, boss.radius, state.bounds)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, OBSIDIAN_RAM.centerDisengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function wallSafeChargeDirection(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
targetPosition: Iwt2Vec2,
): Iwt2Vec2 {
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
if (!isBossNearWall(boss, state)) return withFallbackFacing(targetBias, boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.35), scaleVec2(targetBias, 0.55)), centerBias)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const inward = normalizeVec2(subtractVec2(center, boss.position))
const offset = addVec2(scaleVec2(inward, 138), {
x: boss.position.y < center.y ? 42 : -42,
y: boss.position.x < center.x ? -34 : 34,
})
return clampVec2ToArena(addVec2(boss.position, offset), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + OBSIDIAN_RAM.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withObsidianRamIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createObsidianRamIndicators(result.boss),
}
}
function createObsidianRamIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = obsidianRamPhase(boss)
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup || phase === OBSIDIAN_RAM_PHASE.plateCharging) {
indicators.push(createLaneIndicator({
color: phase === OBSIDIAN_RAM_PHASE.plateCharging ? '#ff7a2f' : '#f0b35a',
end: boss.chargeEnd,
id: `${boss.id}:obsidian-ram-plate-charge`,
mechanicId: 'obsidian-ram-plate-charge',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.plateChargeWindup,
phase === OBSIDIAN_RAM_PHASE.plateCharging,
),
sourceId: boss.id,
start: boss.chargeStart,
width: OBSIDIAN_RAM.chargeWidth,
}))
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup || phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup,
phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#ff9f1c',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-quake-core',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: '#ffbf69',
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'obsidian-ram-fracture-wave',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup || phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#b8b6c7',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-broken-slab',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.armorShatterWindup,
phase === OBSIDIAN_RAM_PHASE.armorShatterRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function isLockedPhase(phase: string): boolean {
return phase === OBSIDIAN_RAM_PHASE.plateChargeWindup
|| phase === OBSIDIAN_RAM_PHASE.plateCharging
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover
|| phase === OBSIDIAN_RAM_PHASE.armorShatterWindup
|| phase === OBSIDIAN_RAM_PHASE.armorShatterRecover
}
function obsidianRamPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: ObsidianRamPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+209 -12
View File
@@ -1,6 +1,7 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
@@ -20,6 +21,12 @@ const CENTER_LEASH_WALL_MARGIN = 96
const CENTER_LEASH_EXTRA_DISTANCE = 36
const PARTY_ARRIVAL_RADIUS = 3.5
const PARTY_SAFE_DESTINATION_PADDING = 34
const PARTY_STEER_ANGLES = [0, 0.42, -0.42, 0.82, -0.82, 1.22, -1.22, 1.7, -1.7, 2.25, -2.25, Math.PI]
const HAZARD_ROUTE_BUFFER = 54
const INDICATOR_ROUTE_PENALTY = 24
const BOSS_ROUTE_BUFFER = 22
const PARTY_ROUTE_BUFFER = 8
const DANGER_CENTER_WEIGHT = 0.32
export function tickPartyMember(
member: Iwt2PartyEntityState,
@@ -32,6 +39,7 @@ export function tickPartyMember(
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
slowedSeconds: Math.max(0, member.status.slowedSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
@@ -44,7 +52,7 @@ export function tickPartyMember(
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
@@ -88,11 +96,11 @@ export function tickPartyMember(
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * dt
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const distanceToDesired = distanceVec2(member.position, desired)
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
? choosePartyStepPosition(member, state, desired, maxDistance)
: member.position
const velocity = movingToDesired
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
@@ -112,6 +120,112 @@ export function tickPartyMember(
}
}
function choosePartyStepPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
maxDistance: number,
): Iwt2Vec2 {
if (maxDistance <= 0) return member.position
const direct = withFallbackFacing(subtractVec2(desired, member.position), member.facing)
const currentDanger = routeDangerPenalty(member.position, member, state)
let best = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
let bestScore = scoreRoutePosition(best, member, state, desired, currentDanger)
for (const angle of PARTY_STEER_ANGLES) {
const direction = rotateVec2(direct, angle)
const candidate = clampVec2ToArena(addVec2(member.position, scaleVec2(direction, maxDistance)), member.radius, state.bounds)
const score = scoreRoutePosition(candidate, member, state, desired, currentDanger)
+ (angle === 0 ? 0 : Math.abs(angle) * 1.8)
if (score < bestScore) {
best = candidate
bestScore = score
}
}
return best
}
function scoreRoutePosition(
position: Iwt2Vec2,
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
currentDanger: number,
): number {
const progress = distanceVec2(position, desired)
const centerPull = currentDanger > 0 ? distanceVec2(position, arenaCenter(state)) * DANGER_CENTER_WEIGHT : 0
const progressWeight = currentDanger > 0 ? 0.28 : 1
const danger = routeDangerPenalty(position, member, state)
const crowd = routeCrowdPenalty(position, member, state)
const wall = routeWallPenalty(position, member, state)
const stayingInDanger = currentDanger > 0 && distanceVec2(position, member.position) <= 0.75 ? 90 : 0
return progress * progressWeight + centerPull + danger * 100 + crowd * 42 + wall + stayingInDanger
}
function routeDangerPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const hazard of state.hazards) {
const distance = distanceVec2(position, hazard.position)
const damageRadius = hazard.radius + member.radius
const avoidRadius = damageRadius + HAZARD_ROUTE_BUFFER
if (distance <= damageRadius) {
penalty += 18 + (damageRadius - distance) * 0.7
} else if (distance < avoidRadius) {
const ratio = (avoidRadius - distance) / HAZARD_ROUTE_BUFFER
penalty += ratio * ratio * 8
}
}
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape({ ...member, position }, shape)) continue
penalty += indicator.phase === 'active' ? INDICATOR_ROUTE_PENALTY * 1.4 : INDICATOR_ROUTE_PENALTY
}
return penalty
}
function routeCrowdPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const distance = distanceVec2(position, boss.position)
const avoidRadius = boss.radius + member.radius + BOSS_ROUTE_BUFFER
if (distance < avoidRadius) penalty += (avoidRadius - distance) * 1.8
}
for (const other of state.party) {
if (other.id === member.id || other.health <= 0) continue
const distance = distanceVec2(position, other.position)
const avoidRadius = other.radius + member.radius + PARTY_ROUTE_BUFFER
if (distance < avoidRadius) penalty += avoidRadius - distance
}
return penalty
}
function routeWallPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
const clearance = Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
if (clearance >= PARTY_SAFE_DESTINATION_PADDING) return 0
return (PARTY_SAFE_DESTINATION_PADDING - clearance) * (clearance <= 4 ? 12 : 4.8)
}
function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return {
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
@@ -158,10 +272,10 @@ function getTankCenterLeashPosition(
y: boss.position.y < center.y ? 0.35 : -0.35,
})
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampVec2ToArena(
return clampPartyDestination(
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
member.radius,
state.bounds,
member,
state,
)
}
@@ -198,6 +312,11 @@ function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): bo
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const indicatorEscape = getIndicatorAvoidancePosition(member, state)
if (indicatorEscape) {
return indicatorEscape
}
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
@@ -210,7 +329,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
return clampPartyDestination(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member, state)
}
}
@@ -220,7 +339,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
return clampPartyDestination(escape, member, state)
}
}
}
@@ -228,6 +347,65 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
return null
}
function getIndicatorAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let pressure = 0
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape(member, shape)) continue
const origin = indicator.kind === 'lane'
? closestPointOnSegment(member.position, indicator.start, indicator.end)
: indicator.kind === 'cone'
? indicator.origin
: indicator.position
const away = withFallbackFacing(subtractVec2(member.position, origin), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
escape = addVec2(escape, scaleVec2(away, indicator.phase === 'active' ? 96 : 72))
pressure += indicator.phase === 'active' ? 1.35 : 1
}
if (pressure <= 0) return null
return clampPartyDestination(addVec2(member.position, escape), member, state)
}
function indicatorAvoidanceShape(indicator: Iwt2ArenaState['indicators'][number]): Iwt2HitShape {
if (indicator.kind === 'lane') {
return { kind: 'lane', start: indicator.start, end: indicator.end, width: indicator.width + 22 }
}
if (indicator.kind === 'circle') {
return { kind: 'circle', position: indicator.position, radius: indicator.radius + 18 }
}
if (indicator.kind === 'donut') {
return {
kind: 'donut',
innerRadius: indicator.innerRadius,
outerRadius: indicator.outerRadius + 18,
position: indicator.position,
}
}
if (indicator.kind === 'arc') {
return {
kind: 'arc',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
innerRadius: Math.max(0, indicator.innerRadius - 8),
outerRadius: indicator.outerRadius + 24,
position: indicator.position,
}
}
return {
kind: 'cone',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
origin: indicator.origin,
range: indicator.range + 24,
}
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
@@ -254,10 +432,18 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
const destination = clampPartyDestination(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 96)),
member,
state,
)
if (distanceVec2(destination, member.position) > PARTY_ARRIVAL_RADIUS) return destination
const centerDirection = withFallbackFacing(subtractVec2(arenaCenter(state), member.position), direction)
return clampPartyDestination(
addVec2(member.position, scaleVec2(centerDirection, maxNeededDistance + 120)),
member,
state,
)
}
@@ -295,6 +481,17 @@ function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
}
}
function closestPointOnSegment(position: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): Iwt2Vec2 {
const segment = subtractVec2(end, start)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const t = Math.min(1, Math.max(0, dotVec2(subtractVec2(position, start), segment) / lengthSq))
return addVec2(start, scaleVec2(segment, t))
}
function movementSpeedMultiplier(slowedSeconds: number): number {
return slowedSeconds > 0 ? 0.58 : 1
}
function getPrimaryBoss(state: Iwt2ArenaState) {
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
}
+317
View File
@@ -0,0 +1,317 @@
import { RATHIAN_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicArcState,
Iwt2MechanicCircleState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArcIndicator,
createCircleIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickRathian(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
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 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyTailSweep(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'tailSweepRecover',
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepRecover!,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'poisonSpitWindup') {
if (boss.phaseSecondsRemaining <= 0) {
for (const circle of boss.mechanicCircles) {
const puddle = addPoisonPuddle({
damage: RATHIAN_BOSS_METADATA.poisonPuddleDamage!,
duration: RATHIAN_BOSS_METADATA.poisonPuddleSeconds!,
hazards,
nextHazardId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time: state.time + dt,
})
hazards = puddle.hazards
nextHazardId = puddle.nextHazardId
}
boss = {
...boss,
attackPhase: 'poisonSpitRecover',
phaseSecondsRemaining: 0.42,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepRecover' || boss.attackPhase === 'poisonSpitRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
boss = moveBossTowardCenter(boss, state, RATHIAN_BOSS_METADATA.moveSpeed * 1.35, dt)
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'tailSweepWindup',
chargeCooldownRemaining: RATHIAN_BOSS_METADATA.poisonTailCooldown!,
mechanicArcs: [createTailArc(boss, target)],
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'poisonSpitWindup',
fireballCooldownRemaining: RATHIAN_BOSS_METADATA.poisonSpitCooldown!,
mechanicCircles: createPoisonTargets(party),
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.poisonSpitWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, RATHIAN_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 withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function createTailArc(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2MechanicArcState {
return {
id: 'tail-sweep',
angleRadians: RATHIAN_BOSS_METADATA.tailSweepAngleRadians!,
direction: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
innerRadius: RATHIAN_BOSS_METADATA.tailSweepInnerRadius!,
outerRadius: RATHIAN_BOSS_METADATA.tailSweepOuterRadius!,
position: { ...boss.position },
}
}
function createPoisonTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
return [...party]
.filter((member) => member.health > 0)
.sort((a, b) => b.health / b.maxHealth - a.health / a.maxHealth)
.slice(0, 2)
.map((member, index) => ({
id: `poison-spit-${index}`,
position: { ...member.position },
radius: RATHIAN_BOSS_METADATA.poisonPuddleRadius!,
}))
}
function applyTailSweep(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'arc',
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: RATHIAN_BOSS_METADATA.tailSweepDamage!,
knockdownSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
sourceId: boss.id,
stunSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
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) > RATHIAN_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RATHIAN_BOSS_METADATA.meleeRange,
}, {
damage: RATHIAN_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RATHIAN_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withRathianIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createRathianIndicators(result.boss),
}
}
function createRathianIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'tailSweepWindup' || boss.attackPhase === 'tailSweepRecover') {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: '#a7e768',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'rathian-tail-sweep',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'tailSweepWindup',
boss.attackPhase === 'tailSweepRecover',
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'poisonSpitWindup' || boss.attackPhase === 'poisonSpitRecover') {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#7bd84f',
id: `${boss.id}:${circle.id}`,
mechanicId: 'rathian-poison-spit',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'poisonSpitWindup',
boss.attackPhase === 'poisonSpitRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
+578
View File
@@ -0,0 +1,578 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type RimebastionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
type RimebastionBossState = Omit<Iwt2BossEntityState, 'attackPhase'> & {
attackPhase: RimebastionAttackPhase
}
type ShardCone = {
id: string
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 6,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
iceCircleRadius: 58,
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 4,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 9,
shatterConeRange: 158,
shatterLaneDamage: 11,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
shatterWindup: 0.72,
wallDisengageSeconds: 0.7,
wallMargin: 68,
}
const WALL_COLOR = '#a7ecff'
const SHARD_COLOR = '#e8fbff'
export function tickRimebastion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss: RimebastionBossState = {
...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 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withRimebastionIndicators({ boss, party, events, state })
if (boss.attackPhase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, RIMEBASTION_BALANCE.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'iceWallWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyIceWalls(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterWindup',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterWindup,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardShatter(party, boss, state, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterRecover',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterRecover,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= RIMEBASTION_BALANCE.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: RIMEBASTION_BALANCE.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
const pattern = createIceWallPattern(state, party, boss)
boss = {
...boss,
attackPhase: 'iceWallWindup',
chargeCooldownRemaining: RIMEBASTION_BALANCE.iceWallCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: pattern.circles,
mechanicLanes: pattern.lanes,
phaseSecondsRemaining: RIMEBASTION_BALANCE.iceWallWindup,
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRimebastionIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withRimebastionIndicators({ boss, party, events, state })
}
function createIceWallPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
): { lanes: Iwt2MechanicLaneState[], circles: Iwt2MechanicCircleState[] } {
const living = party.filter((member) => member.health > 0)
const center = partyCenter(living, arenaCenter(state))
const vertical = boss.chargeCount % 2 === 0
const lanes: Iwt2MechanicLaneState[] = []
const offsets = [-96, 0, 96]
for (let index = 0; index < RIMEBASTION_BALANCE.iceWallLaneCount; index += 1) {
const offset = offsets[index] ?? 0
if (vertical) {
const x = clamp(center.x + offset, 82, state.bounds.width - 82)
lanes.push({
end: { x, y: state.bounds.height - 38 },
id: `rime-wall-lane-${index}`,
start: { x, y: 38 },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
} else {
const y = clamp(center.y + offset * 0.72, 74, state.bounds.height - 74)
lanes.push({
end: { x: state.bounds.width - 38, y },
id: `rime-wall-lane-${index}`,
start: { x: 38, y },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
}
}
const circles = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, RIMEBASTION_BALANCE.iceCircleCount)
.map((member, index) => ({
id: `rime-wall-circle-${index}`,
position: clampVec2ToArena(member.position, RIMEBASTION_BALANCE.iceCircleRadius, state.bounds),
radius: RIMEBASTION_BALANCE.iceCircleRadius,
}))
return { circles, lanes }
}
function applyIceWalls(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
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',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyShardShatter(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
state: Iwt2ArenaState,
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',
end: lane.end,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}, {
damage: RIMEBASTION_BALANCE.shatterLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const cone of createShardCones(boss, state)) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'cone',
angleRadians: cone.angleRadians,
direction: cone.direction,
origin: cone.origin,
range: cone.range,
}, {
damage: RIMEBASTION_BALANCE.shatterConeDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createShardCones(boss: RimebastionBossState, state: Iwt2ArenaState): ShardCone[] {
const cones: ShardCone[] = []
const center = arenaCenter(state)
for (const lane of boss.mechanicLanes) {
const midpoint = {
x: (lane.start.x + lane.end.x) * 0.5,
y: (lane.start.y + lane.end.y) * 0.5,
}
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(center, midpoint), boss.facing),
id: `${lane.id}-shard-cone`,
origin: midpoint,
range: RIMEBASTION_BALANCE.shatterConeRange,
})
}
for (const circle of boss.mechanicCircles) {
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(circle.position, boss.position), boss.facing),
id: `${circle.id}-shard-cone`,
origin: circle.position,
range: RIMEBASTION_BALANCE.shatterConeRange * 0.86,
})
}
return cones
}
function moveBossTowardRelocationTarget(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, RIMEBASTION_BALANCE.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + RIMEBASTION_BALANCE.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), RIMEBASTION_BALANCE.meleeRange * 0.9))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, RIMEBASTION_BALANCE.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 58)), boss.radius, state.bounds)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
target: Iwt2PartyEntityState,
time: number,
): { boss: RimebastionBossState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RIMEBASTION_BALANCE.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RIMEBASTION_BALANCE.meleeRange,
}, {
damage: RIMEBASTION_BALANCE.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RIMEBASTION_BALANCE.meleeCooldown },
events: result.events,
party: result.party,
}
}
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 partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + RIMEBASTION_BALANCE.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withRimebastionIndicators(result: {
boss: RimebastionBossState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
const { state, ...rest } = result
return {
...rest,
boss: result.boss as Iwt2BossEntityState,
indicators: createRimebastionIndicators(result.boss, state),
}
}
function createRimebastionIndicators(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const showWalls = (
boss.attackPhase === 'iceWallWindup'
|| boss.attackPhase === 'shardShatterWindup'
|| boss.attackPhase === 'shardShatterRecover'
)
if (showWalls) {
const wallPhase: Iwt2ArenaIndicatorPhase = boss.attackPhase === 'iceWallWindup'
? 'windup'
: boss.attackPhase === 'shardShatterWindup'
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: WALL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'rimebastion-ice-wall-lane',
phase: wallPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: WALL_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'rimebastion-ice-wall-circle',
phase: wallPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'shardShatterWindup' || boss.attackPhase === 'shardShatterRecover') {
const shardPhase = indicatorPhaseFromAttack(
boss.attackPhase === 'shardShatterWindup',
boss.attackPhase === 'shardShatterRecover',
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}:shatter`,
mechanicId: 'rimebastion-shard-lane',
phase: shardPhase,
sourceId: boss.id,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}))
}
for (const cone of createShardCones(boss, state)) {
indicators.push(createConeIndicator({
angleRadians: cone.angleRadians,
color: SHARD_COLOR,
direction: cone.direction,
id: `${boss.id}:${cone.id}`,
mechanicId: 'rimebastion-shard-cone',
origin: cone.origin,
phase: shardPhase,
range: cone.range,
sourceId: boss.id,
}))
}
}
return indicators
}
+749
View File
@@ -0,0 +1,749 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type SandglassScorpionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
const SANDGLASS = {
burrowCooldown: 6.6,
burrowSeconds: 1.05,
burrowSpeed: 285,
burrowTrailInterval: 0.18,
burrowTrailRadius: 28,
burrowTrailSeconds: 4.2,
burrowTrailWidth: 44,
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 15,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 6,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
zoneCount: 3,
}
const BURROW_COLOR = '#c8944e'
const ERUPTION_COLOR = '#f2c15b'
const HOURGLASS_SAFE_COLOR = '#68d8ff'
const HOURGLASS_HAZARD_COLOR = '#d49a42'
export function tickSandglassScorpion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = state.boss.attackPhase as SandglassScorpionAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === 'sandglassBurrowing'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = boss.attackPhase as SandglassScorpionAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.relocateTarget, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowing'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.burrowSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowing') {
const burrow = moveBurrowingBoss(boss, state, dt)
const trailResult = maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position: burrow.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
boss = {
...boss,
mechanicEnergy: trailResult.nextTrailInSeconds,
position: burrow.position,
velocity: burrow.velocity,
wallContactSeconds: 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 7) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionWindup'),
mechanicCircles: createStingerEruptions(state, party, boss),
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: SANDGLASS.eruptionWindup,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStingerEruptions(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionRecover'),
phaseSecondsRemaining: SANDGLASS.eruptionRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassSafe') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassHazard'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassHazardSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassHazard') {
const tickResult = maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
hazards = tickResult.hazards
nextHazardId = tickResult.nextHazardId
party = tickResult.party
events.push(...tickResult.events)
boss = {
...boss,
mechanicEnergy: tickResult.nextTickInSeconds,
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassRecover'),
phaseSecondsRemaining: SANDGLASS.hourglassRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= SANDGLASS.disengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.25,
relocateTarget: centerRelocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassSafe'),
fireballCooldownRemaining: SANDGLASS.hourglassCooldown,
mechanicCircles: createHourglassZones(state, party, boss),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassSafeSeconds,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const exit = chooseBurrowExit(state, party, boss, target)
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowWindup'),
chargeCooldownRemaining: SANDGLASS.burrowCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: { ...exit },
chargeStart: { ...boss.position },
mechanicCircles: [],
mechanicLanes: [createBurrowLane(boss.position, exit)],
phaseSecondsRemaining: SANDGLASS.burrowWindup,
relocateTarget: exit,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
function asBossPhase(phase: SandglassScorpionAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function moveBurrowingBoss(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.burrowSpeed * dt),
boss.radius,
state.bounds,
)
return {
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.mechanicEnergy > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.mechanicEnergy }
}
const result = addMudPuddle({
duration: SANDGLASS.burrowTrailSeconds,
hazards,
nextHazardId,
position,
radius: SANDGLASS.burrowTrailRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: SANDGLASS.burrowTrailInterval,
}
}
function maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextTickInSeconds: number
party: Iwt2PartyEntityState[]
} {
if (boss.mechanicEnergy > 0) {
return { events: [], hazards, nextHazardId, nextTickInSeconds: boss.mechanicEnergy, party }
}
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const circle of boss.mechanicCircles) {
const damage = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.hourglassDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0.18,
time,
})
nextParty = damage.party
events.push(...damage.events)
const puddle = addMudPuddle({
duration: SANDGLASS.hourglassTickSeconds + 0.45,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(circle.position, circle.radius, state.bounds),
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return {
events,
hazards: nextHazards,
nextHazardId: nextId,
nextTickInSeconds: SANDGLASS.hourglassTickSeconds,
party: nextParty,
}
}
function createStingerEruptions(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const marked = [
getBossTarget(living),
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker'),
...living,
].filter((member): member is Iwt2PartyEntityState => Boolean(member))
const circles: Iwt2MechanicCircleState[] = []
const seen = new Set<string>()
for (const member of marked) {
if (seen.has(member.id)) continue
seen.add(member.id)
const offset = offsetFromBoss(member.position, boss.position, 28 + circles.length * 10)
circles.push({
id: `sandglass-stinger-${circles.length}`,
position: clampVec2ToArena(addVec2(member.position, offset), SANDGLASS.eruptionRadius, state.bounds),
radius: SANDGLASS.eruptionRadius,
})
if (circles.length >= SANDGLASS.zoneCount) break
}
return circles
}
function createHourglassZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const center = arenaCenter(state)
const partyCenterPoint = partyCenter(party.filter((member) => member.health > 0), center)
const angleBase = Math.atan2(partyCenterPoint.y - center.y, partyCenterPoint.x - center.x)
const circles: Iwt2MechanicCircleState[] = []
for (let index = 0; index < SANDGLASS.zoneCount; index += 1) {
const angle = angleBase + (Math.PI * 2 * index) / SANDGLASS.zoneCount + boss.chargeCount * 0.28
const radius = index === 0 ? 54 : 128
circles.push({
id: `sandglass-hourglass-${index}`,
position: clampVec2ToArena({
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius * 0.68,
}, SANDGLASS.hourglassRadius, state.bounds),
radius: SANDGLASS.hourglassRadius,
})
}
return circles
}
function applyStingerEruptions(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.eruptionDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0.2,
sourceId: boss.id,
stunSeconds: SANDGLASS.eruptionStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
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) > SANDGLASS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: SANDGLASS.meleeRange,
}, {
damage: SANDGLASS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: SANDGLASS.meleeCooldown },
party: result.party,
events: result.events,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const desiredDistance = 70
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const toward = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const away = withFallbackFacing(offset, scaleVec2(toward, -1))
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const destination = distance < desiredDistance
? addVec2(target.position, scaleVec2(away, desiredDistance))
: addVec2(target.position, scaleVec2(centerBias, 18))
const position = clampVec2ToArena(
moveToward(boss.position, destination, SANDGLASS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.disengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position }, state) ? boss.wallContactSeconds : 0,
}
}
function chooseBurrowExit(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2Vec2 {
const center = arenaCenter(state)
const living = party.filter((member) => member.health > 0)
const partyCenterPoint = partyCenter(living, target.position)
const baseDirection = withFallbackFacing(subtractVec2(partyCenterPoint, boss.position), boss.facing)
const flank = { x: -baseDirection.y, y: baseDirection.x }
const side = boss.chargeCount % 2 === 0 ? 1 : -1
const candidates = [
addVec2(target.position, scaleVec2(flank, 118 * side)),
addVec2(partyCenterPoint, scaleVec2(flank, -124 * side)),
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), -76)),
center,
]
let best = clampVec2ToArena(candidates[0], boss.radius + SANDGLASS.wallMargin, state.bounds)
let bestScore = -Infinity
for (const candidate of candidates) {
const clamped = clampVec2ToArena(candidate, boss.radius + SANDGLASS.wallMargin, state.bounds)
const wall = wallClearance(clamped, state, boss.radius)
const targetDistance = distanceVec2(clamped, target.position)
const centerDistance = distanceVec2(clamped, center)
const bossDistance = distanceVec2(clamped, boss.position)
const score = wall * 1.6
+ clamp(targetDistance, 72, 210) * 0.55
- centerDistance * 0.18
+ clamp(bossDistance, 90, 280) * 0.25
if (score > bestScore) {
best = clamped
bestScore = score
}
}
return best
}
function centerRelocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(
addVec2(center, scaleVec2(awayFromWall, 54)),
boss.radius + SANDGLASS.wallMargin,
state.bounds,
)
}
function createBurrowLane(start: Iwt2Vec2, end: Iwt2Vec2): Iwt2MechanicLaneState {
return {
end: { ...end },
id: 'sandglass-burrow-trail',
start: { ...start },
width: SANDGLASS.burrowTrailWidth,
}
}
function offsetFromBoss(position: Iwt2Vec2, bossPosition: Iwt2Vec2, distance: number): Iwt2Vec2 {
const direction = withFallbackFacing(subtractVec2(position, bossPosition), { x: 1, y: 0 })
return scaleVec2(direction, distance)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function wallClearance(position: Iwt2Vec2, state: Iwt2ArenaState, radius: number): number {
return Math.min(
position.x - radius,
state.bounds.width - radius - position.x,
position.y - radius,
state.bounds.height - radius - position.y,
)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return wallClearance(boss.position, state, boss.radius) <= SANDGLASS.wallMargin
}
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 withSandglassIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createSandglassIndicators(result.boss),
}
}
function createSandglassIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as SandglassScorpionAttackPhase
const indicators: Iwt2ArenaIndicator[] = []
if (phase === 'sandglassBurrowWindup' || phase === 'sandglassBurrowing') {
indicators.push(...boss.mechanicLanes.map((lane) => createLaneIndicator({
color: BURROW_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'sandglass-burrow-trail',
phase: indicatorPhaseFromAttack(
phase === 'sandglassBurrowWindup',
phase === 'sandglassBurrowing',
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
})))
}
if (phase === 'sandglassEruptionWindup' || phase === 'sandglassEruptionRecover') {
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color: ERUPTION_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-stinger-eruption',
phase: indicatorPhaseFromAttack(
phase === 'sandglassEruptionWindup',
phase === 'sandglassEruptionRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
if (
phase === 'sandglassHourglassSafe'
|| phase === 'sandglassHourglassHazard'
|| phase === 'sandglassHourglassRecover'
) {
const color = phase === 'sandglassHourglassSafe' ? HOURGLASS_SAFE_COLOR : HOURGLASS_HAZARD_COLOR
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-hourglass-zone',
phase: indicatorPhaseFromAttack(
phase === 'sandglassHourglassSafe',
phase === 'sandglassHourglassHazard',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
return indicators
}
+543
View File
@@ -0,0 +1,543 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicArcState,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArcIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const STORMCOIL_PHASE = {
arcRecover: 'stormcoilArcRecover',
arcWindup: 'stormcoilArcWindup',
chainRecover: 'stormcoilChainRecover',
chainWindup: 'stormcoilChainWindup',
donutRecover: 'stormcoilDonutRecover',
donutWindup: 'stormcoilDonutWindup',
} as const
type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
const STORMCOIL_TUNING = {
arcAngleRadians: Math.PI * 0.34,
arcCooldown: 5.6,
arcDamage: 6,
arcInnerRadius: 78,
arcOuterRadius: 196,
arcRecover: 0.46,
arcStunSeconds: 0.24,
arcWindup: 0.74,
chainCooldown: 7.4,
chainDamage: 5,
chainLinkRadius: 150,
chainMaxLinks: 7,
chainRecover: 0.5,
chainStunSeconds: 0.38,
chainWidth: 16,
chainWindup: 0.68,
disengageSpeed: 168,
donutCooldown: 6.2,
donutDamage: 7,
donutInnerRadius: 64,
donutOuterRadius: 156,
donutRecover: 0.48,
donutStunSeconds: 0.44,
donutWindup: 0.82,
meleeCooldown: 1.12,
meleeDamage: 2,
meleeRange: 58,
moveSpeed: 124,
wallContactLimit: 0.72,
wallMargin: 68,
}
export function tickStormcoilWyrm(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = stormcoilPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withStormcoilIndicators({ boss, party, events })
const phase = stormcoilPhase(boss)
if (phase === 'relocating') {
boss = moveBossToward(boss, boss.relocateTarget, state, STORMCOIL_TUNING.disengageSpeed, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.donutWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormDonut(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutRecover),
mechanicCircles: [],
phaseSecondsRemaining: STORMCOIL_TUNING.donutRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.arcWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormArcs(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.arcRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.chainWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainLightning(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.chainRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (
phase === STORMCOIL_PHASE.donutRecover
|| phase === STORMCOIL_PHASE.arcRecover
|| phase === STORMCOIL_PHASE.chainRecover
) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= STORMCOIL_TUNING.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 1.25,
relocateTarget: wallDisengageTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(boss, party)
if (links.length > 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainWindup),
fireballCooldownRemaining: STORMCOIL_TUNING.chainCooldown,
mechanicLinks: links,
phaseSecondsRemaining: STORMCOIL_TUNING.chainWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
boss = { ...boss, fireballCooldownRemaining: 1.0 }
}
if (boss.mechanicEnergy <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcWindup),
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
mechanicArcs: createStormArcs(boss, target.position),
mechanicEnergy: STORMCOIL_TUNING.arcCooldown,
phaseSecondsRemaining: STORMCOIL_TUNING.arcWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutWindup),
chargeCooldownRemaining: STORMCOIL_TUNING.donutCooldown,
mechanicCircles: [{
id: 'stormcoil-donut-center',
position: { ...boss.position },
radius: STORMCOIL_TUNING.donutOuterRadius,
}],
phaseSecondsRemaining: STORMCOIL_TUNING.donutWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ 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 withStormcoilIndicators({ boss, party, events })
const desired = isBossNearWall(boss, state)
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(arenaCenter(state), boss.position)), 84))
: target.position
boss = moveBossToward(boss, desired, state, STORMCOIL_TUNING.moveSpeed, dt)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
return withStormcoilIndicators({ boss, party, events })
}
function applyStormDonut(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
kind: 'donut',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
position: boss.position,
}, {
damage: STORMCOIL_TUNING.donutDamage,
knockdownSeconds: STORMCOIL_TUNING.donutStunSeconds,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.donutStunSeconds,
time,
})
return { party: result.party, events: result.events }
}
function applyStormArcs(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
kind: 'arc',
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: STORMCOIL_TUNING.arcDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.arcStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyChainLightning(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
end: link.end,
kind: 'lane',
start: link.start,
width: link.width,
}, {
damage: STORMCOIL_TUNING.chainDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.chainStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createStormArcs(boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2): Iwt2MechanicArcState[] {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const directions = [
rotateVec2(facing, -0.66),
facing,
rotateVec2(facing, 0.66),
]
return directions.map((direction, index) => ({
angleRadians: STORMCOIL_TUNING.arcAngleRadians,
direction,
id: `stormcoil-arc-${index}`,
innerRadius: STORMCOIL_TUNING.arcInnerRadius,
outerRadius: STORMCOIL_TUNING.arcOuterRadius,
position: { ...boss.position },
}))
}
function createChainLinks(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
const nearest = living
.map((member) => ({ member, distance: distanceVec2(member.position, boss.position) }))
.sort((a, b) => a.distance - b.distance)[0]?.member
if (nearest) {
links.push({
end: { ...nearest.position },
id: `stormcoil-chain-${boss.id}-${nearest.id}`,
start: { ...boss.position },
width: STORMCOIL_TUNING.chainWidth,
})
}
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > STORMCOIL_TUNING.chainLinkRadius) continue
links.push({
end: { ...living[b].position },
id: `stormcoil-chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
width: STORMCOIL_TUNING.chainWidth,
})
if (links.length >= STORMCOIL_TUNING.chainMaxLinks) return links
}
}
return links.length > 1 ? links : []
}
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) > STORMCOIL_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: STORMCOIL_TUNING.meleeRange,
}, {
damage: STORMCOIL_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: STORMCOIL_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossToward(
boss: Iwt2BossEntityState,
target: Iwt2Vec2,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(target, boss.position)
const distance = Math.hypot(offset.x, offset.y)
const nextPosition = clampVec2ToArena(
distance <= speed * dt || distance <= 0.0001
? target
: addVec2(boss.position, scaleVec2(offset, (speed * dt) / distance)),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function wallDisengageTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const toCenter = normalizeVec2(subtractVec2(center, boss.position))
return clampVec2ToArena(addVec2(boss.position, scaleVec2(toCenter, 180)), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + STORMCOIL_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const cos = Math.cos(radians)
const sin = Math.sin(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
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 withStormcoilIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createStormcoilIndicators(result.boss),
}
}
function createStormcoilIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = stormcoilPhase(boss)
if (phase === STORMCOIL_PHASE.donutWindup || phase === STORMCOIL_PHASE.donutRecover) {
indicators.push(createDonutIndicator({
color: '#8ff2ff',
id: `${boss.id}:stormcoil-donut`,
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
mechanicId: 'stormcoil-charged-ring',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.donutWindup,
phase === STORMCOIL_PHASE.donutRecover,
),
position: boss.position,
sourceId: boss.id,
}))
}
if (phase === STORMCOIL_PHASE.arcWindup || phase === STORMCOIL_PHASE.arcRecover) {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: phase === STORMCOIL_PHASE.arcRecover ? '#d8fbff' : '#60cfff',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'stormcoil-arc-trail',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.arcWindup,
phase === STORMCOIL_PHASE.arcRecover,
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (phase === STORMCOIL_PHASE.chainWindup || phase === STORMCOIL_PHASE.chainRecover) {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#b8f7ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'stormcoil-chain-lightning',
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.chainWindup,
phase === STORMCOIL_PHASE.chainRecover,
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
function stormcoilPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: StormcoilPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+321
View File
@@ -0,0 +1,321 @@
import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickTobiKadachi(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),
mechanicEnergy: Math.min(
state.boss.mechanicEnergyMax,
state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt,
),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events })
if (boss.attackPhase === 'staticPounceWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' }
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPouncing') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt),
boss.radius,
state.bounds,
)
const hitResult = applyPounceHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
boss = {
...boss,
attackPhase: 'staticPounceRecover',
phaseSecondsRemaining: 0.42,
velocity: { x: 0, y: 0 },
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chainShockWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainShock(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'chainShockRecover',
phaseSecondsRemaining: 0.42,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) {
const pounce = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'staticPounceWindup',
chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown,
chargeEnd: pounce.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: pounce.direction,
mechanicEnergy: 0,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(party)
boss = {
...boss,
attackPhase: 'chainShockWindup',
fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!,
mechanicLinks: links,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ 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 withTobiIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, TOBI_KADACHI_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 withTobiIndicators({ boss, party, events })
}
function applyPounceHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2BossEntityState['position'],
end: Iwt2BossEntityState['position'],
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'lane',
start,
end,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue
links.push({
id: `chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
end: { ...living[b].position },
width: 5,
})
}
}
return links.slice(0, 6)
}
function applyChainShock(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
start: link.start,
end: link.end,
width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!,
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) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: TOBI_KADACHI_BOSS_METADATA.meleeRange,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: {
...boss,
mechanicEnergy: Math.min(
boss.mechanicEnergyMax,
boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!,
),
meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown,
},
party: result.party,
events: result.events,
}
}
function withTobiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createTobiIndicators(result.boss),
}
}
function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'staticPounceWindup'
|| boss.attackPhase === 'staticPouncing'
|| boss.attackPhase === 'staticPounceRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff',
end: boss.chargeEnd,
id: `${boss.id}:static-pounce`,
mechanicId: 'tobi-static-pounce',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'staticPounceWindup',
boss.attackPhase === 'staticPouncing',
),
sourceId: boss.id,
start: boss.chargeStart,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}))
}
if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#a8f3ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'tobi-chain-shock',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chainShockWindup',
boss.attackPhase === 'chainShockRecover',
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
+97 -1
View File
@@ -31,6 +31,7 @@ export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
slowedSeconds: number
}
export type Iwt2HotEffectState = {
@@ -100,6 +101,69 @@ export type Iwt2BossAttackPhase =
| 'thunderRingRecover'
| 'lightningStrikeWindup'
| 'lightningStrikeRecover'
| 'tailSweepWindup'
| 'tailSweepRecover'
| 'poisonSpitWindup'
| 'poisonSpitRecover'
| 'mudSprayWindup'
| 'mudSprayRecover'
| 'staticPounceWindup'
| 'staticPouncing'
| 'staticPounceRecover'
| 'chainShockWindup'
| 'chainShockRecover'
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
| 'cinderbackRicochetWindup'
| 'cinderbackRicocheting'
| 'cinderbackArmorSlamWindup'
| 'cinderbackArmorSlamRecover'
| 'obsidianRamPlateChargeWindup'
| 'obsidianRamPlateCharging'
| 'obsidianRamPlateChargeRecover'
| 'obsidianRamFractureQuakeWindup'
| 'obsidianRamFractureQuakeRecover'
| 'obsidianRamArmorShatterWindup'
| 'obsidianRamArmorShatterRecover'
| 'stormcoilDonutWindup'
| 'stormcoilDonutRecover'
| 'stormcoilArcWindup'
| 'stormcoilArcRecover'
| 'stormcoilChainWindup'
| 'stormcoilChainRecover'
| 'crystalBatSonicWindup'
| 'crystalBatSonicRecover'
| 'crystalBatShardWindup'
| 'crystalBatShardRecover'
| 'crystalBatSwoopWindup'
| 'crystalBatSwooping'
| 'crystalBatSwoopRecover'
| 'venomOrchidHydraConeWindup'
| 'venomOrchidHydraConeRecover'
| 'venomOrchidHydraRootSnareWindup'
| 'venomOrchidHydraRootSnareRecover'
| 'venomOrchidHydraBloomPodWindup'
| 'venomOrchidHydraBloomPodRecover'
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
| 'hollowcrownHoofCurseWindup'
| 'hollowcrownHoofCurseRecover'
| 'hollowcrownTrailDashWindup'
| 'hollowcrownTrailDashing'
| 'hollowcrownTrailFade'
| 'hollowcrownAntlerChargeWindup'
| 'hollowcrownAntlerChargeRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
@@ -117,6 +181,10 @@ export type Iwt2BossEntityState = {
radius: number
health: number
maxHealth: number
armor: number
maxArmor: number
mechanicEnergy: number
mechanicEnergyMax: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
@@ -133,6 +201,8 @@ export type Iwt2BossEntityState = {
birdWaveThresholdsTriggered: number[]
mechanicLanes: Iwt2MechanicLaneState[]
mechanicCircles: Iwt2MechanicCircleState[]
mechanicArcs: Iwt2MechanicArcState[]
mechanicLinks: Iwt2MechanicLinkState[]
}
export type Iwt2MechanicLaneState = {
@@ -148,6 +218,22 @@ export type Iwt2MechanicCircleState = {
radius: number
}
export type Iwt2MechanicArcState = {
id: string
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2MechanicLinkState = {
id: string
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
@@ -172,7 +258,7 @@ export type Iwt2HostileAddState = {
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
hazardKind: 'firePuddle' | 'poisonPuddle' | 'mudPuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
@@ -241,11 +327,21 @@ export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
outerRadius: number
}
export type Iwt2ArenaArcIndicator = Iwt2ArenaIndicatorBase & {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
| Iwt2ArenaArcIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
+736
View File
@@ -0,0 +1,736 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const VENOM_ORCHID_HYDRA_PHASE = {
bloomPodRecover: 'venomOrchidHydraBloomPodRecover',
bloomPodWindup: 'venomOrchidHydraBloomPodWindup',
coneRecover: 'venomOrchidHydraConeRecover',
coneWindup: 'venomOrchidHydraConeWindup',
rootSnareRecover: 'venomOrchidHydraRootSnareRecover',
rootSnareWindup: 'venomOrchidHydraRootSnareWindup',
} as const
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 7,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 3,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 10,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 4,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 8,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
rootSnareSeconds: 0.85,
rootWindup: 0.82,
wallDisengageSeconds: 0.72,
wallMargin: 70,
}
const VENOM_COLOR = '#b7ff4f'
const ROOT_COLOR = '#d68b49'
const BLOOM_COLOR = '#ff70d7'
type PoisonConePlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
origin: Iwt2Vec2
}
export function tickVenomOrchidHydra(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const phase = venomOrchidHydraPhase(boss)
boss = {
...boss,
wallContactSeconds: phase === 'relocating'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyPoisonCone(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
const nextHeadIndex = Math.floor(boss.mechanicEnergy) + 1
if (nextHeadIndex < 3) {
const cone = createPoisonConePlan(boss, party, state, nextHeadIndex)
boss = {
...boss,
chargeEnd: cone.end,
chargeHitEntityIds: result.hitEntityIds,
chargeStart: cone.origin,
facing: cone.direction,
mechanicEnergy: nextHeadIndex,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
}
} else {
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneRecover),
chargeHitEntityIds: [],
mechanicEnergy: 0,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneRecover,
}
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyRootSnareLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const burst = applyBloomBursts(party, boss, state.time + dt)
const pods = addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time: state.time + dt,
})
party = burst.party
hazards = pods.hazards
nextHazardId = pods.nextHazardId
events.push(...burst.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= VENOM_ORCHID_HYDRA.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginBloomPods(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginStaggeredPoisonCones(boss, state, party)
: beginRootSnareLanes(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveHydraTowardEngagePoint(boss, state, target, dt)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginStaggeredPoisonCones(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const cone = createPoisonConePlan(boss, party, state, 0)
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: cone.end,
chargeHitEntityIds: [],
chargeStart: cone.origin,
facing: cone.direction,
mechanicCircles: [],
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRootSnareLanes(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: createRootSnarePattern(state, party),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootWindup,
velocity: { x: 0, y: 0 },
}
}
function beginBloomPods(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup),
fireballCooldownRemaining: VENOM_ORCHID_HYDRA.bloomCooldown,
mechanicCircles: createBloomPodPattern(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomWindup,
velocity: { x: 0, y: 0 },
}
}
function createPoisonConePlan(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
headIndex: number,
): PoisonConePlan {
const living = livingParty(party)
const fallbackTarget = getBossTarget(party)?.position ?? arenaCenter(state)
const target = living[(headIndex + boss.chargeCount) % Math.max(1, living.length)]?.position ?? fallbackTarget
const headOffset = (headIndex - 1) * VENOM_ORCHID_HYDRA.headOffset
const baseDirection = withFallbackFacing(subtractVec2(target, boss.position), boss.facing)
const direction = rotateVec2(baseDirection, (headIndex - 1) * 0.28)
const perpendicular = { x: -baseDirection.y, y: baseDirection.x }
const origin = clampVec2ToArena(
addVec2(boss.position, scaleVec2(perpendicular, headOffset)),
8,
state.bounds,
)
return {
direction,
end: addVec2(origin, scaleVec2(direction, VENOM_ORCHID_HYDRA.coneRange)),
origin,
}
}
function createRootSnarePattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLaneState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const selected = [
getPriorityMember(living, 'player')?.position ?? center,
getPriorityMember(living, 'ranged')?.position ?? center,
getPriorityMember(living, 'flanker')?.position ?? center,
]
const directions = [
{ x: 1, y: 0 },
{ x: 0, y: 1 },
normalizeVec2({ x: state.bounds.width >= state.bounds.height ? 1 : 0.8, y: 0.72 }),
]
return selected.map((position, index) => {
const lane = createArenaLaneThroughPoint(position, directions[index], state)
return {
...lane,
id: `venom-orchid-root-snare-${index}`,
width: VENOM_ORCHID_HYDRA.rootLaneWidth,
}
})
}
function createBloomPodPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const chosen = [
getPriorityMember(living, 'ranged')?.position,
getPriorityMember(living, 'player')?.position,
getPriorityMember(living, 'flanker')?.position,
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), 54)),
].filter((position): position is Iwt2Vec2 => Boolean(position))
return chosen.slice(0, VENOM_ORCHID_HYDRA.bloomPodCount).map((position, index) => ({
id: `venom-orchid-bloom-pod-${index}`,
position: clampVec2ToArena(position, VENOM_ORCHID_HYDRA.bloomPodRadius, state.bounds),
radius: VENOM_ORCHID_HYDRA.bloomPodRadius,
}))
}
function applyPoisonCone(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
direction: boss.facing,
kind: 'cone',
origin: boss.chargeStart,
range: VENOM_ORCHID_HYDRA.coneRange,
}, {
damage: VENOM_ORCHID_HYDRA.coneDamage,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.coneStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyRootSnareLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: VENOM_ORCHID_HYDRA.rootLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.rootSnareSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyBloomBursts(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: VENOM_ORCHID_HYDRA.bloomBurstDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
for (const circle of boss.mechanicCircles) {
const result = addPoisonPuddle({
damage: VENOM_ORCHID_HYDRA.bloomPodDamage,
duration: VENOM_ORCHID_HYDRA.bloomPodSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
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) > VENOM_ORCHID_HYDRA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: VENOM_ORCHID_HYDRA.meleeRange,
}, {
damage: VENOM_ORCHID_HYDRA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: VENOM_ORCHID_HYDRA.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, VENOM_ORCHID_HYDRA.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveHydraTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), 106))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, VENOM_ORCHID_HYDRA.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 66)), boss.radius, state.bounds)
}
function createArenaLaneThroughPoint(
point: Iwt2Vec2,
direction: Iwt2Vec2,
state: Iwt2ArenaState,
): { start: Iwt2Vec2, end: Iwt2Vec2 } {
const dir = withFallbackFacing(direction, { x: 1, y: 0 })
const inset = VENOM_ORCHID_HYDRA.rootLaneLengthInset
const minX = inset
const maxX = state.bounds.width - inset
const minY = inset
const maxY = state.bounds.height - inset
const candidates: number[] = []
if (Math.abs(dir.x) > 0.0001) {
candidates.push((minX - point.x) / dir.x, (maxX - point.x) / dir.x)
}
if (Math.abs(dir.y) > 0.0001) {
candidates.push((minY - point.y) / dir.y, (maxY - point.y) / dir.y)
}
const valid = candidates.filter((t) => {
const position = addVec2(point, scaleVec2(dir, t))
return position.x >= minX - 0.1 && position.x <= maxX + 0.1 && position.y >= minY - 0.1 && position.y <= maxY + 0.1
})
const startT = Math.min(...valid)
const endT = Math.max(...valid)
return {
end: addVec2(point, scaleVec2(dir, endT)),
start: addVec2(point, scaleVec2(dir, startT)),
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const sin = Math.sin(radians)
const cos = Math.cos(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function livingParty(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState[] {
return party.filter((member) => member.health > 0)
}
function getPriorityMember(
party: Iwt2PartyEntityState[],
role: Iwt2PartyEntityState['aiRole'],
): Iwt2PartyEntityState | undefined {
return party.find((member) => member.aiRole === role)
}
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 partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withVenomOrchidHydraIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createVenomOrchidHydraIndicators(result.boss),
}
}
function createVenomOrchidHydraIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = venomOrchidHydraPhase(boss)
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup || phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
indicators.push(createConeIndicator({
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
color: VENOM_COLOR,
direction: boss.facing,
id: `${boss.id}:venom-orchid-hydra-cone-${Math.floor(boss.mechanicEnergy)}`,
mechanicId: 'venom-orchid-hydra-poison-cone',
origin: boss.chargeStart,
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover,
),
range: VENOM_ORCHID_HYDRA.coneRange,
sourceId: boss.id,
}))
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup || phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ROOT_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'venom-orchid-hydra-root-snare',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover,
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup || phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: BLOOM_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'venom-orchid-hydra-bloom-pod',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function venomOrchidHydraPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: VenomOrchidHydraPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}