Android build v1.1.22
This commit is contained in:
+25
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
Reference in New Issue
Block a user