Android build v1.1.22

This commit is contained in:
Warren H
2026-07-05 12:08:11 -04:00
parent 547044892d
commit 956c9e32f9
69 changed files with 9259 additions and 196 deletions
+198 -13
View File
@@ -14,6 +14,7 @@ import type {
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
@@ -22,6 +23,7 @@ import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
@@ -35,7 +37,7 @@ const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
type InitialPartyMember = {
id: Iwt2PartyEntityId
@@ -102,6 +104,10 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
radius: bossMetadata.radius,
health: maxHealth,
maxHealth,
armor: bossMetadata.mudArmor ?? 0,
maxArmor: bossMetadata.mudArmor ?? 0,
mechanicEnergy: 0,
mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
@@ -118,6 +124,8 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
mechanicArcs: [],
mechanicLinks: [],
}
}
@@ -132,12 +140,31 @@ function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
if (bossId === 'khezu') return 3
if (bossId === 'rathian') return 2.2
if (bossId === 'barroth') return 2.1
if (bossId === 'tobi-kadachi') return 2.6
if (bossId === 'rimebastion') return 2.8
if (bossId === 'ember-mantis-duelist') return 1.4
if (bossId === 'cinderback-ricochet') return 2.5
if (bossId === 'obsidian-ram-golem') return 2.2
if (bossId === 'stormcoil-wyrm') return 2.6
if (bossId === 'venom-orchid-hydra') return 2.4
if (bossId === 'sandglass-scorpion') return 2.1
if (bossId === 'crystal-bat-matriarch') return 2.3
if (bossId === 'hollowcrown-revenant') return 1.8
return 0
}
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
if (bossId === 'yian-kut-ku') return 1.2
if (bossId === 'khezu') return 1.6
if (bossId === 'rathian') return 3
if (bossId === 'tobi-kadachi') return 2.4
if (bossId === 'ember-mantis-duelist') return 3
if (bossId === 'stormcoil-wyrm') return 3.1
if (bossId === 'venom-orchid-hydra') return 2.8
if (bossId === 'sandglass-scorpion') return 3.2
if (bossId === 'crystal-bat-matriarch') return 2.9
return 0
}
@@ -157,12 +184,23 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
}
const separatedState = {
...baseState,
party: separatePartyFromBosses(baseState.party, baseState),
party: separatePartyFromBosses(
separatePartyFromParty(separatePartyFromBosses(baseState.party, baseState), baseState),
baseState,
),
}
const bossResult = tickBosses(separatedState, step)
const postBossState = { ...separatedState, bosses: bossResult.bosses }
const postBossParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(bossResult.party, postBossState),
postBossState,
),
postBossState,
)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
postBossParty,
bossResult.bosses,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
@@ -186,7 +224,15 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const regeneratedParty = regeneratePartyMana(hotResult.party, step)
const finalCollisionState = { ...separatedState, bosses: damageResult.bosses }
const finalParty = separatePartyFromBosses(
separatePartyFromParty(
separatePartyFromBosses(regeneratedParty, finalCollisionState),
finalCollisionState,
),
finalCollisionState,
)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
@@ -296,6 +342,7 @@ function createEmptyStatus(): Iwt2StatusState {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
slowedSeconds: 0,
}
}
@@ -340,9 +387,10 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
indicators.push(...result.indicators)
}
const separatedBosses = separateBosses(bosses, state)
return {
bosses,
boss: getPrimaryBoss(bosses),
bosses: separatedBosses,
boss: getPrimaryBoss(separatedBosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
@@ -355,22 +403,129 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
}
}
function separateBosses(bosses: Iwt2BossEntityState[], state: Iwt2ArenaState): Iwt2BossEntityState[] {
const next = bosses.map((boss) => ({ ...boss, position: { ...boss.position } }))
for (let pass = 0; pass < 3; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 4
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.4 : 0.4,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.55
const firstPosition = clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds)
const secondPosition = clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds)
next[firstIndex] = {
...first,
position: firstPosition,
velocity: scaleVec2(subtractVec2(firstPosition, first.position), 30),
}
next[secondIndex] = {
...second,
position: secondPosition,
velocity: scaleVec2(subtractVec2(secondPosition, second.position), 30),
}
}
}
}
return next
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
position = separateCircles(
const separated = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
position = wallClearance(separated, member, state) <= 2
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
? separateFromBossTowardCenter(member, boss, state)
: separated
}
return { ...member, position }
})
}
function circleOverlapAmount(
moving: { position: Iwt2Vec2, radius: number },
fixed: { position: Iwt2Vec2, radius: number },
): number {
return Math.max(0, moving.radius + fixed.radius - distanceVec2(moving.position, fixed.position))
}
function separateFromBossTowardCenter(
member: Iwt2PartyEntityState,
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5 }
const direction = normalizeVec2(subtractVec2(center, boss.position))
const minDistance = boss.radius + member.radius + 3
return clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, minDistance)),
member.radius,
state.bounds,
)
}
function wallClearance(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
return Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
}
function separatePartyFromParty(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
const next = party.map((member) => ({ ...member, position: { ...member.position } }))
for (let pass = 0; pass < 2; pass += 1) {
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
const first = next[firstIndex]
if (!first || first.health <= 0) continue
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
const second = next[secondIndex]
if (!second || second.health <= 0) continue
const minDistance = first.radius + second.radius + 2
const distance = distanceVec2(first.position, second.position)
if (distance >= minDistance) continue
const fallback = {
x: first.position.x <= second.position.x ? -1 : 1,
y: first.position.y <= second.position.y ? -0.35 : 0.35,
}
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
const push = (minDistance - distance) * 0.5
next[firstIndex] = {
...first,
position: clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds),
}
next[secondIndex] = {
...second,
position: clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds),
}
}
}
}
return next
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
@@ -461,9 +616,10 @@ function advanceProjectiles(
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
))
if (hitBoss) {
const damage = Math.min(projectile.damage, hitBoss.health)
const damageResult = applyDamageToBoss(hitBoss, projectile.damage)
const damage = damageResult.healthDamage
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
? { ...boss, health: Math.max(0, boss.health - damage) }
? damageResult.boss
: boss)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
@@ -474,7 +630,7 @@ function advanceProjectiles(
targetId: hitBoss.id,
value: damage,
})
if (hitBoss.health - damage <= 0) {
if (damageResult.boss.health <= 0) {
events.push({
id: 0,
time,
@@ -673,10 +829,14 @@ function applyPartyAttacks(
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
let damage = Math.min(metadata.attackDamage, target.health)
let defeated = target.health - damage <= 0
if (target.kind === 'boss') {
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
damage = damageResult.healthDamage
defeated = damageResult.boss.health <= 0
nextBosses = nextBosses.map((boss) => boss.id === target.id
? { ...boss, health: Math.max(0, boss.health - damage) }
? damageResult.boss
: boss)
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
@@ -691,7 +851,7 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
if (defeated) {
events.push({
id: 0,
time,
@@ -754,6 +914,31 @@ function addDamageDone(
: member)
}
function applyDamageToBoss(
boss: Iwt2BossEntityState,
rawDamage: number,
): { boss: Iwt2BossEntityState, healthDamage: number } {
if (rawDamage <= 0 || boss.health <= 0) return { boss, healthDamage: 0 }
if (boss.armor <= 0) {
const healthDamage = Math.min(rawDamage, boss.health)
return {
boss: { ...boss, health: Math.max(0, boss.health - healthDamage) },
healthDamage,
}
}
const armorDamage = Math.min(boss.armor, rawDamage * 0.75)
const healthDamage = Math.min(boss.health, rawDamage - armorDamage * 0.6)
return {
boss: {
...boss,
armor: Math.max(0, boss.armor - armorDamage),
health: Math.max(0, boss.health - healthDamage),
},
healthDamage,
}
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+271
View File
@@ -0,0 +1,271 @@
import { BARROTH_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createConeIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickBarroth(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyMudSpray(party, boss, state.time + dt)
const mudResult = addMudSprayPuddles(hazards, boss, nextHazardId, state.time + dt, state.bounds)
party = result.party
hazards = mudResult.hazards
nextHazardId = mudResult.nextHazardId
events.push(...result.events)
boss = {
...boss,
attackPhase: 'mudSprayRecover',
phaseSecondsRemaining: 0.46,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'mudSprayRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 1.18 : 1.4)
boss = moveBossTowardCenter(boss, state, speed, dt)
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'mudSprayWindup',
chargeCooldownRemaining: BARROTH_BOSS_METADATA.mudSprayCooldown!,
phaseSecondsRemaining: BARROTH_BOSS_METADATA.mudSprayWindup!,
velocity: { x: 0, y: 0 },
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 0.9 : 1.08)
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, speed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function applyMudSpray(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'cone',
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
direction: boss.facing,
origin: boss.position,
range: BARROTH_BOSS_METADATA.mudSprayRange!,
}, {
damage: BARROTH_BOSS_METADATA.mudSprayDamage!,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: BARROTH_BOSS_METADATA.mudSprayStunSeconds!,
time,
})
return { party: result.party, events: result.events }
}
function addMudSprayPuddles(
hazards: Iwt2GroundHazardState[],
boss: Iwt2BossEntityState,
nextHazardId: number,
time: number,
bounds: Iwt2ArenaState['bounds'],
): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const offsets = [-0.34, 0, 0.34]
for (let index = 0; index < offsets.length; index += 1) {
const point = clampVec2ToArena(
conePoint(boss.position, boss.facing, BARROTH_BOSS_METADATA.mudSprayRange! * (0.46 + index * 0.17), offsets[index]),
BARROTH_BOSS_METADATA.mudPuddleRadius!,
bounds,
)
const puddle = addMudPuddle({
duration: BARROTH_BOSS_METADATA.mudPuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextId,
position: point,
radius: BARROTH_BOSS_METADATA.mudPuddleRadius!,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function conePoint(origin: Iwt2Vec2, direction: Iwt2Vec2, distance: number, angleOffset: number): Iwt2Vec2 {
const base = Math.atan2(direction.y, direction.x) + angleOffset
return addVec2(origin, {
x: Math.cos(base) * distance,
y: Math.sin(base) * distance,
})
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > BARROTH_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: BARROTH_BOSS_METADATA.meleeRange,
}, {
damage: BARROTH_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: BARROTH_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withBarrothIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createBarrothIndicators(result.boss),
}
}
function createBarrothIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
if (boss.attackPhase !== 'mudSprayWindup' && boss.attackPhase !== 'mudSprayRecover') return []
return [createConeIndicator({
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
color: '#b38a52',
direction: boss.facing,
id: `${boss.id}:mud-spray`,
mechanicId: 'barroth-mud-spray',
origin: boss.position,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'mudSprayWindup',
boss.attackPhase === 'mudSprayRecover',
),
range: BARROTH_BOSS_METADATA.mudSprayRange!,
sourceId: boss.id,
})]
}
+24
View File
@@ -13,6 +13,18 @@ import type {
} from './types'
import { tickGreatJaggi } from './greatJaggiAi'
import { tickKhezu } from './khezuAi'
import { tickBarroth } from './barrothAi'
import { tickCinderbackRicochet } from './cinderbackAi'
import { tickCrystalBatMatriarch } from './crystalBatMatriarchAi'
import { tickEmberMantisDuelist } from './emberMantisAi'
import { tickHollowcrownRevenant } from './hollowcrownRevenantAi'
import { tickObsidianRamGolem } from './obsidianRamGolemAi'
import { tickRathian } from './rathianAi'
import { tickRimebastion } from './rimebastionAi'
import { tickSandglassScorpion } from './sandglassScorpionAi'
import { tickStormcoilWyrm } from './stormcoilWyrmAi'
import { tickTobiKadachi } from './tobiKadachiAi'
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
@@ -48,6 +60,18 @@ export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
if (state.boss.bossId === 'khezu') return tickKhezu(state, dt)
if (state.boss.bossId === 'rathian') return tickRathian(state, dt)
if (state.boss.bossId === 'barroth') return tickBarroth(state, dt)
if (state.boss.bossId === 'tobi-kadachi') return tickTobiKadachi(state, dt)
if (state.boss.bossId === 'rimebastion') return tickRimebastion(state, dt)
if (state.boss.bossId === 'ember-mantis-duelist') return tickEmberMantisDuelist(state, dt)
if (state.boss.bossId === 'cinderback-ricochet') return tickCinderbackRicochet(state, dt)
if (state.boss.bossId === 'obsidian-ram-golem') return tickObsidianRamGolem(state, dt)
if (state.boss.bossId === 'stormcoil-wyrm') return tickStormcoilWyrm(state, dt)
if (state.boss.bossId === 'venom-orchid-hydra') return tickVenomOrchidHydra(state, dt)
if (state.boss.bossId === 'sandglass-scorpion') return tickSandglassScorpion(state, dt)
if (state.boss.bossId === 'crystal-bat-matriarch') return tickCrystalBatMatriarch(state, dt)
if (state.boss.bossId === 'hollowcrown-revenant') return tickHollowcrownRevenant(state, dt)
return tickBulldrome(state, dt)
}
+605
View File
@@ -0,0 +1,605 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addFirePuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CINDERBACK_PHASE = {
armorSlamRecover: 'cinderbackArmorSlamRecover',
armorSlamWindup: 'cinderbackArmorSlamWindup',
ricochetWindup: 'cinderbackRicochetWindup',
ricocheting: 'cinderbackRicocheting',
} as const
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 20,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 5,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 18,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
ricochetStunSeconds: 0.42,
ricochetWidth: 46,
ricochetWindup: 0.72,
wallContactLimit: 0.48,
wallMargin: 64,
}
export function tickCinderbackRicochet(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = cinderbackPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === CINDERBACK_PHASE.ricocheting
|| incomingPhase === CINDERBACK_PHASE.armorSlamWindup
|| incomingPhase === CINDERBACK_PHASE.armorSlamRecover
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = cinderbackPhase(boss)
if (phase === CINDERBACK_PHASE.ricochetWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricocheting),
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: 0,
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetDuration,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.ricocheting) {
const roll = moveRicochet(boss, state, dt)
const hitResult = applyRicochetHits(party, boss, boss.position, roll.position, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const trailResult = maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position: roll.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
const bounceCount = boss.mechanicEnergy + roll.bounces
const wallContactSeconds = roll.bounces > 0 || roll.cornerBounce
? 0
: isBossNearWall({ ...boss, position: roll.position }, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = {
...boss,
chargeEnd: nextWallImpact(roll.position, roll.facing, boss.radius, state),
chargeHitEntityIds: hitResult.hitEntityIds,
chargeStart: { ...roll.position },
facing: roll.facing,
fireballCooldownRemaining: trailResult.nextTrailInSeconds,
mechanicEnergy: bounceCount,
position: roll.position,
velocity: roll.velocity,
wallContactSeconds,
}
if (
boss.phaseSecondsRemaining <= 0
|| bounceCount >= CINDERBACK_TUNING.ricochetMaxBounces
|| wallContactSeconds >= CINDERBACK_TUNING.wallContactLimit
|| roll.cornerBounce
) {
boss = beginArmorSlam(boss, state)
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applyArmorSlam(party, boss, state.time + dt)
const puddleResult = addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time: state.time + dt,
})
party = slamResult.party
hazards = puddleResult.hazards
nextHazardId = puddleResult.nextHazardId
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamRecover,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamRecover) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.4),
phaseSecondsRemaining: 0,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.68) {
boss = moveBossTowardCenter(boss, state, CINDERBACK_TUNING.centerDisengageSpeed, dt)
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricochetWindup),
chargeCooldownRemaining: CINDERBACK_TUNING.ricochetCooldown,
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetWindup,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, target.position, CINDERBACK_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginArmorSlam(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
const position = clampVec2ToArena(disengageFromWall(boss.position, boss.radius, state), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamWindup),
chargeEnd: { ...position },
chargeStart: { ...position },
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamWindup,
position,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
function moveRicochet(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { bounces: number, cornerBounce: boolean, facing: Iwt2Vec2, position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const minX = boss.radius
const maxX = state.bounds.width - boss.radius
const minY = boss.radius
const maxY = state.bounds.height - boss.radius
let facing = withFallbackFacing(boss.facing, { x: 1, y: 0 })
let position = addVec2(boss.position, scaleVec2(facing, CINDERBACK_TUNING.ricochetSpeed * dt))
let xBounce = false
let yBounce = false
if (position.x < minX) {
position = { ...position, x: minX + (minX - position.x) }
facing = { ...facing, x: Math.abs(facing.x) }
xBounce = true
} else if (position.x > maxX) {
position = { ...position, x: maxX - (position.x - maxX) }
facing = { ...facing, x: -Math.abs(facing.x) }
xBounce = true
}
if (position.y < minY) {
position = { ...position, y: minY + (minY - position.y) }
facing = { ...facing, y: Math.abs(facing.y) }
yBounce = true
} else if (position.y > maxY) {
position = { ...position, y: maxY - (position.y - maxY) }
facing = { ...facing, y: -Math.abs(facing.y) }
yBounce = true
}
position = clampVec2ToArena(position, boss.radius, state.bounds)
const cornerBounce = xBounce && yBounce
if (cornerBounce) {
facing = withFallbackFacing(subtractVec2(arenaCenter(state), position), facing)
position = disengageFromWall(position, boss.radius, state)
}
facing = normalizeVec2(facing)
return {
bounces: Number(xBounce) + Number(yBounce),
cornerBounce,
facing,
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.fireballCooldownRemaining > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.fireballCooldownRemaining }
}
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards,
nextHazardId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: CINDERBACK_TUNING.lavaTrailInterval,
}
}
function addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
state: Iwt2ArenaState
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const angles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
for (const angle of angles) {
const position = clampVec2ToArena({
x: boss.position.x + Math.cos(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
y: boss.position.y + Math.sin(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
}, CINDERBACK_TUNING.firePuddleRadius, state.bounds)
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function applyRicochetHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CINDERBACK_TUNING.ricochetWidth,
}, {
damage: CINDERBACK_TUNING.ricochetDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyArmorSlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
}, {
damage: CINDERBACK_TUNING.armorSlamDamage,
knockdownSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
time,
})
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, CINDERBACK_TUNING.armorSlamRadius),
...result.events,
],
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > CINDERBACK_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.meleeRange,
}, {
damage: CINDERBACK_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CINDERBACK_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, center, speed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardKeepingDistance(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function nextWallImpact(
position: Iwt2Vec2,
direction: Iwt2Vec2,
radius: number,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const facing = withFallbackFacing(direction, { x: 1, y: 0 })
const times: number[] = []
if (Math.abs(facing.x) > 0.0001) {
times.push(((facing.x > 0 ? state.bounds.width - radius : radius) - position.x) / facing.x)
}
if (Math.abs(facing.y) > 0.0001) {
times.push(((facing.y > 0 ? state.bounds.height - radius : radius) - position.y) / facing.y)
}
const impactDistance = Math.max(0, Math.min(...times.filter((value) => value > 0)))
return clampVec2ToArena(addVec2(position, scaleVec2(facing, impactDistance)), radius, state.bounds)
}
function wallSafeRollDirection(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2Vec2 {
if (!isBossNearWall(boss, state)) return withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.25), scaleVec2(targetBias, 0.55)), centerBias)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + CINDERBACK_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function disengageFromWall(position: Iwt2Vec2, radius: number, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const margin = radius + CINDERBACK_TUNING.wallMargin * 0.55
const clamped = {
x: Math.min(state.bounds.width - margin, Math.max(margin, position.x)),
y: Math.min(state.bounds.height - margin, Math.max(margin, position.y)),
}
if (clamped.x !== position.x || clamped.y !== position.y) return clamped
return addVec2(position, scaleVec2(normalizeVec2(subtractVec2(center, position)), 18))
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withCinderbackIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCinderbackIndicators(result.boss),
}
}
function createCinderbackIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = cinderbackPhase(boss)
if (
phase === CINDERBACK_PHASE.ricochetWindup
|| phase === CINDERBACK_PHASE.ricocheting
) {
indicators.push(createLaneIndicator({
color: phase === CINDERBACK_PHASE.ricocheting ? '#ff6a2a' : '#ffd166',
end: boss.chargeEnd,
id: `${boss.id}:cinderback-ricochet-lane`,
mechanicId: 'cinderback-ricochet',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.ricochetWindup,
phase === CINDERBACK_PHASE.ricocheting,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CINDERBACK_TUNING.ricochetWidth,
}))
}
if (
phase === CINDERBACK_PHASE.armorSlamWindup
|| phase === CINDERBACK_PHASE.armorSlamRecover
) {
indicators.push(createCircleIndicator({
color: '#ff7a2f',
id: `${boss.id}:cinderback-armor-slam`,
mechanicId: 'cinderback-armor-slam',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.armorSlamWindup,
phase === CINDERBACK_PHASE.armorSlamRecover,
),
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
sourceId: boss.id,
}))
}
return indicators
}
function cinderbackPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CinderbackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+682
View File
@@ -0,0 +1,682 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CRYSTAL_BAT_PHASE = {
shardRecover: 'crystalBatShardRecover',
shardWindup: 'crystalBatShardWindup',
sonicRecover: 'crystalBatSonicRecover',
sonicWindup: 'crystalBatSonicWindup',
swoopRecover: 'crystalBatSwoopRecover',
swoopWindup: 'crystalBatSwoopWindup',
swooping: 'crystalBatSwooping',
} as const
type CrystalBatPhase = typeof CRYSTAL_BAT_PHASE[keyof typeof CRYSTAL_BAT_PHASE]
const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 4,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 13,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 10,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 17,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
swoopStunSeconds: 0.48,
swoopWindup: 0.56,
wallContactLimit: 0.7,
wallMargin: 62,
} as const
const SONIC_COLOR = '#d9b7ff'
const SHARD_COLOR = '#9de9ff'
const SWOOP_COLOR = '#f6d36b'
export function tickCrystalBatMatriarch(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const activePhase = crystalBatPhase(boss)
boss = {
...boss,
wallContactSeconds: activePhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withCrystalBatIndicators({ boss, events, party })
const phase = crystalBatPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, CRYSTAL_BAT_TUNING.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applySonicRings(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicLanes: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swooping),
chargeHitEntityIds: [],
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swooping) {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.chargeEnd, CRYSTAL_BAT_TUNING.swoopSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applySwoopHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopRecover,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
const cluster = findBestCluster(party)
if (boss.wallContactSeconds >= CRYSTAL_BAT_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (cluster.count >= 2 && boss.fireballCooldownRemaining <= 0) {
boss = beginSwoop(boss, cluster.center, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginSonicRings(boss, party, state)
: beginShardLanes(boss, party, state)
return withCrystalBatIndicators({ boss, events, party })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCrystalBatIndicators({ boss, events, party })
boss = moveBossToHoverPoint(boss, state, target, dt)
return withCrystalBatIndicators({ boss, events, party })
}
function beginSonicRings(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const circles = party
.filter((member) => member.health > 0)
.map((member) => ({
id: `crystal-sonic-${member.id}`,
position: clampVec2ToArena(member.position, CRYSTAL_BAT_TUNING.sonicRingRadius, state.bounds),
radius: CRYSTAL_BAT_TUNING.sonicRingRadius,
}))
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.sonicCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: circles,
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicWindup,
velocity: { x: 0, y: 0 },
}
}
function beginShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const lanes = createReflectedShardLanes(boss, party, state)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.shardCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: lanes,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardWindup,
velocity: { x: 0, y: 0 },
}
}
function beginSwoop(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const end = clampVec2ToArena(addVec2(targetPosition, scaleVec2(facing, 170)), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopWindup),
chargeEnd: end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: CRYSTAL_BAT_TUNING.swoopCooldown,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createReflectedShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2MechanicLaneState[] {
const living = party.filter((member) => member.health > 0)
const cluster = findBestCluster(party)
const priorityTargets = [
cluster.center,
living.find((member) => member.aiRole === 'ranged')?.position ?? getBossTarget(party)?.position ?? arenaCenter(state),
]
const mirrors = mirrorPointsForPattern(boss, state)
const lanes: Iwt2MechanicLaneState[] = []
for (let index = 0; index < mirrors.length; index += 1) {
const mirror = mirrors[index]
const target = priorityTargets[index] ?? cluster.center
const reflectedDirection = withFallbackFacing(subtractVec2(target, mirror), boss.facing)
const reflectedEnd = clampVec2ToArena(
addVec2(mirror, scaleVec2(reflectedDirection, 380)),
CRYSTAL_BAT_TUNING.shardLaneWidth,
state.bounds,
)
lanes.push({
end: mirror,
id: `crystal-shard-${index}-in`,
start: boss.position,
width: CRYSTAL_BAT_TUNING.shardLaneWidth * 0.78,
})
lanes.push({
end: reflectedEnd,
id: `crystal-shard-${index}-out`,
start: mirror,
width: CRYSTAL_BAT_TUNING.shardLaneWidth,
})
}
return lanes
}
function mirrorPointsForPattern(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2[] {
const center = arenaCenter(state)
const verticalFirst = boss.chargeCount % 4 < 2
if (verticalFirst) {
return [
{ x: clamp(center.x + 210, 80, state.bounds.width - 80), y: 42 },
{ x: clamp(center.x - 180, 80, state.bounds.width - 80), y: state.bounds.height - 42 },
]
}
return [
{ x: 44, y: clamp(center.y - 120, 74, state.bounds.height - 74) },
{ x: state.bounds.width - 44, y: clamp(center.y + 110, 74, state.bounds.height - 74) },
]
}
function applySonicRings(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const ownerId = sonicRingOwnerId(circle)
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: CRYSTAL_BAT_TUNING.sonicDamage,
excludedEntityIds: ownerId ? [ownerId, ...hitEntityIds] : hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.sonicStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applyShardLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: CRYSTAL_BAT_TUNING.shardDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applySwoopHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}, {
damage: CRYSTAL_BAT_TUNING.swoopDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > CRYSTAL_BAT_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CRYSTAL_BAT_TUNING.meleeRange,
}, {
damage: CRYSTAL_BAT_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CRYSTAL_BAT_TUNING.meleeCooldown },
events: result.events,
party: result.party,
}
}
function moveBossToHoverPoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
const awayFromTarget = withFallbackFacing(subtractVec2(center, target.position), { x: -1, y: 0 })
const desired = targetNearWall
? addVec2(target.position, scaleVec2(awayFromTarget, CRYSTAL_BAT_TUNING.hoverDistance))
: addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(boss.position, target.position)), CRYSTAL_BAT_TUNING.hoverDistance))
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, desired, CRYSTAL_BAT_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.relocateTarget, CRYSTAL_BAT_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardLimited(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function findBestCluster(party: Iwt2PartyEntityState[]): { center: Iwt2Vec2, count: number } {
const living = party.filter((member) => member.health > 0)
if (living.length <= 0) return { center: { x: 0, y: 0 }, count: 0 }
let bestMembers = [living[0]]
for (const member of living) {
const nearby = living.filter((other) => distanceVec2(member.position, other.position) <= CRYSTAL_BAT_TUNING.clusterRadius)
if (nearby.length > bestMembers.length) bestMembers = nearby
}
return { center: partyCenter(bestMembers), count: bestMembers.length }
}
function partyCenter(party: Iwt2PartyEntityState[]): Iwt2Vec2 {
if (party.length <= 0) return { x: 0, y: 0 }
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 88)), boss.radius, state.bounds)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function sonicRingOwnerId(circle: Iwt2MechanicCircleState): Iwt2EntityId | undefined {
const prefix = 'crystal-sonic-'
return circle.id.startsWith(prefix) ? circle.id.slice(prefix.length) : undefined
}
function withCrystalBatIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCrystalBatIndicators(result.boss),
}
}
function createCrystalBatIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = crystalBatPhase(boss)
const indicators: Iwt2ArenaIndicator[] = []
if (phase === CRYSTAL_BAT_PHASE.sonicWindup || phase === CRYSTAL_BAT_PHASE.sonicRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.sonicWindup,
phase === CRYSTAL_BAT_PHASE.sonicRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createDonutIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:ring`,
innerRadius: Math.max(12, circle.radius * 0.34),
mechanicId: 'crystal-bat-sonic-spacing-ring',
outerRadius: circle.radius,
phase: indicatorPhase,
position: circle.position,
sourceId: boss.id,
}))
indicators.push(createCircleIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:center`,
mechanicId: 'crystal-bat-sonic-overlap-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius * 0.22,
sourceId: boss.id,
}))
}
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup || phase === CRYSTAL_BAT_PHASE.shardRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.shardWindup,
phase === CRYSTAL_BAT_PHASE.shardRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: lane.id.endsWith('-out') ? 'crystal-bat-reflected-shard' : 'crystal-bat-mirror-shard',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (
phase === CRYSTAL_BAT_PHASE.swoopWindup
|| phase === CRYSTAL_BAT_PHASE.swooping
|| phase === CRYSTAL_BAT_PHASE.swoopRecover
) {
indicators.push(createLaneIndicator({
color: SWOOP_COLOR,
end: boss.chargeEnd,
id: `${boss.id}:crystal-bat-swoop`,
mechanicId: 'crystal-bat-cluster-swoop',
phase: indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.swoopWindup,
phase === CRYSTAL_BAT_PHASE.swooping,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}))
}
return indicators
}
function crystalBatPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CrystalBatPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+459
View File
@@ -0,0 +1,459 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type EmberMantisAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
sidestepInterval: 1.9,
sidestepSeconds: 0.32,
sidestepSpeed: 330,
slashLaneWidth: 34,
wallMargin: 86,
}
export function tickEmberMantisDuelist(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as EmberMantisAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(EMBER_MANTIS.sidestepInterval, state.boss.mechanicEnergy + dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withEmberMantisIndicators({ boss, party, events })
const phase = boss.attackPhase as EmberMantisAttackPhase
if (phase === 'bladeSidestep') {
boss = moveBossTowardRelocateTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 6) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicEnergy: 0,
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.lineSlashDamage,
EMBER_MANTIS.lineSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('lineSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'crossSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.crossSlashDamage,
EMBER_MANTIS.crossSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('crossSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds + 0.12,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashRecover' || phase === 'crossSlashRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= 0.58) {
boss = beginSidestep(boss, state, target, true)
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('crossSlashWindup'),
fireballCooldownRemaining: EMBER_MANTIS.crossSlashCooldown,
mechanicLanes: createCrossSlashLanes(state, boss, target),
phaseSecondsRemaining: EMBER_MANTIS.crossSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('lineSlashWindup'),
chargeCooldownRemaining: EMBER_MANTIS.lineSlashCooldown,
mechanicLanes: [createLineSlashLane(state, boss, target)],
phaseSecondsRemaining: EMBER_MANTIS.lineSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= EMBER_MANTIS.sidestepInterval && distanceVec2(boss.position, target.position) <= 220) {
boss = beginSidestep(boss, state, target, false)
return withEmberMantisIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withEmberMantisIndicators({ boss, party, events })
boss = moveDuelist(boss, state, target, dt)
return withEmberMantisIndicators({ boss, party, events })
}
function asBossPhase(phase: EmberMantisAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginSidestep(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase('bladeSidestep'),
mechanicEnergy: 0,
phaseSecondsRemaining: forcedFromWall ? EMBER_MANTIS.sidestepSeconds * 1.6 : EMBER_MANTIS.sidestepSeconds,
relocateTarget: chooseSidestepTarget(boss, state, target, forcedFromWall),
velocity: { x: 0, y: 0 },
}
}
function moveBossTowardRelocateTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, EMBER_MANTIS.sidestepSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveDuelist(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const desiredDistance = distance < EMBER_MANTIS.duelistRange * 0.72
? EMBER_MANTIS.duelistRange
: EMBER_MANTIS.duelistRange * 0.82
const direction = distance < EMBER_MANTIS.duelistRange * 0.72
? withFallbackFacing(offset, boss.facing)
: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const targetPosition = distance < desiredDistance
? addVec2(target.position, scaleVec2(direction, EMBER_MANTIS.duelistRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, targetPosition, EMBER_MANTIS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function chooseSidestepTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2Vec2 {
if (forcedFromWall) {
return clampVec2ToArena(arenaCenter(state), boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
const towardTarget = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const sideSign = boss.chargeCount % 2 === 0 ? 1 : -1
const lateral = { x: -towardTarget.y * sideSign, y: towardTarget.x * sideSign }
const center = arenaCenter(state)
const centerBias = normalizeVec2(subtractVec2(center, boss.position))
const sideWeight = forcedFromWall ? 0.35 : 1
const centerWeight = forcedFromWall ? 1.15 : 0.35
const direction = withFallbackFacing(addVec2(scaleVec2(lateral, sideWeight), scaleVec2(centerBias, centerWeight)), centerBias)
const distance = forcedFromWall ? 152 : 118
const rawTarget = addVec2(boss.position, scaleVec2(direction, distance))
return clampVec2ToArena(rawTarget, boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
function createLineSlashLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
return createArenaLane('line-slash', target.position, direction, EMBER_MANTIS.slashLaneWidth, state)
}
function createCrossSlashLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const center = targetClusterCenter(state.party, target.position)
const baseAngle = Math.atan2(target.position.y - boss.position.y, target.position.x - boss.position.x)
const angles = [baseAngle + Math.PI * 0.25, baseAngle - Math.PI * 0.25]
return angles.map((angle, index) => createArenaLane(
`cross-slash-${index}`,
center,
{ x: Math.cos(angle), y: Math.sin(angle) },
EMBER_MANTIS.slashLaneWidth,
state,
))
}
function createArenaLane(
id: string,
center: Iwt2Vec2,
direction: Iwt2Vec2,
width: number,
state: Iwt2ArenaState,
): Iwt2MechanicLaneState {
const normalized = withFallbackFacing(direction, { x: 1, y: 0 })
const halfLength = Math.hypot(state.bounds.width, state.bounds.height) * 0.62 + 48
return {
end: clampLanePoint(addVec2(center, scaleVec2(normalized, halfLength)), state),
id,
start: clampLanePoint(addVec2(center, scaleVec2(normalized, -halfLength)), state),
width,
}
}
function clampLanePoint(point: Iwt2Vec2, state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: clamp(point.x, -24, state.bounds.width + 24),
y: clamp(point.y, -24, state.bounds.height + 24),
}
}
function targetClusterCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return fallback
const total = living.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / living.length)
}
function applySlashLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
damage: number,
stunSeconds: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return boss
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > EMBER_MANTIS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: EMBER_MANTIS.meleeRange,
}, {
damage: EMBER_MANTIS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: EMBER_MANTIS.meleeCooldown },
events: result.events,
party: result.party,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + EMBER_MANTIS.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withEmberMantisIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createEmberMantisIndicators(result.boss),
}
}
function createEmberMantisIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as EmberMantisAttackPhase
const isSlashPhase = phase === 'lineSlashWindup'
|| phase === 'lineSlashRecover'
|| phase === 'crossSlashWindup'
|| phase === 'crossSlashRecover'
if (!isSlashPhase) return []
const isWindup = phase === 'lineSlashWindup' || phase === 'crossSlashWindup'
const isRecover = phase === 'lineSlashRecover' || phase === 'crossSlashRecover'
return boss.mechanicLanes.map((lane) => createLaneIndicator({
color: isRecover ? '#ff6a2a' : EMBER_MANTIS.accentColor,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: phase === 'crossSlashWindup' || phase === 'crossSlashRecover'
? 'ember-mantis-cross-slash'
: 'ember-mantis-line-slash',
phase: indicatorPhaseFromAttack(isWindup, isRecover),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
+166 -26
View File
@@ -15,6 +15,10 @@ const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
const MAX_POISON_PUDDLES = 6
const MAX_MUD_PUDDLES = 7
const MUD_PUDDLE_TICK_SECONDS = 0.22
const MUD_SLOW_SECONDS = 0.5
export function addFirePuddle({
damage,
@@ -34,9 +38,115 @@ export function addFirePuddle({
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
hazardKind: 'firePuddle',
hazards,
maxActive: MAX_FIRE_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addPoisonPuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage,
duration,
fadeSeconds: 1,
hazardKind: 'poisonPuddle',
hazards,
maxActive: MAX_POISON_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
export function addMudPuddle({
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
return addGroundPuddle({
damage: 0,
duration,
fadeSeconds: 0.9,
hazardKind: 'mudPuddle',
hazards,
maxActive: MAX_MUD_PUDDLES,
nextHazardId,
position,
radius,
sourceId,
time,
})
}
function addGroundPuddle({
damage,
duration,
fadeSeconds,
hazardKind,
hazards,
maxActive,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
fadeSeconds: number
hazardKind: Iwt2GroundHazardState['hazardKind']
hazards: Iwt2GroundHazardState[]
maxActive: number
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
hazard.hazardKind === hazardKind
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
@@ -59,19 +169,19 @@ export function addFirePuddle({
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
id: `${hazardKind}-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
hazardKind,
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
fadeSeconds,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
hazards: capPuddles([...hazards, nextHazard], hazardKind, maxActive),
nextHazardId: nextHazardId + 1,
}
}
@@ -101,22 +211,27 @@ export function tickGroundHazards({
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
if (hazard.hazardKind === 'mudPuddle') {
nextParty = applyMudSlow(nextParty, hazard)
nextDamageAt = time + MUD_PUDDLE_TICK_SECONDS
} else {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
}
nextHazards.push({
@@ -135,9 +250,9 @@ export function tickGroundHazards({
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
color: hazardColor(hazard),
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
mechanicId: hazard.hazardKind,
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
@@ -145,16 +260,41 @@ export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2Ar
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
function applyMudSlow(party: Iwt2PartyEntityState[], hazard: Iwt2GroundHazardState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const distance = Math.hypot(member.position.x - hazard.position.x, member.position.y - hazard.position.y)
if (distance > hazard.radius + member.radius) return member
return {
...member,
status: {
...member.status,
slowedSeconds: Math.max(member.status.slowedSeconds, MUD_SLOW_SECONDS),
},
}
})
}
function hazardColor(hazard: Iwt2GroundHazardState): string {
if (hazard.hazardKind === 'poisonPuddle') return '#7bd84f'
if (hazard.hazardKind === 'mudPuddle') return '#9a6a3a'
return '#ff7a2f'
}
function capPuddles(
hazards: Iwt2GroundHazardState[],
hazardKind: Iwt2GroundHazardState['hazardKind'],
maxActive: number,
): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
if (hazard.hazardKind !== hazardKind || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
if (activePuddles <= maxActive || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
+712
View File
@@ -0,0 +1,712 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const HOLLOWCROWN_PHASE = {
antlerChargeRecover: 'hollowcrownAntlerChargeRecover',
antlerChargeWindup: 'hollowcrownAntlerChargeWindup',
hoofCurseRecover: 'hollowcrownHoofCurseRecover',
hoofCurseWindup: 'hollowcrownHoofCurseWindup',
trailDashing: 'hollowcrownTrailDashing',
trailDashWindup: 'hollowcrownTrailDashWindup',
trailFade: 'hollowcrownTrailFade',
} as const
type HollowcrownSpecialPhase = typeof HOLLOWCROWN_PHASE[keyof typeof HOLLOWCROWN_PHASE]
type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSpecialPhase
const HOLLOWCROWN_TUNING = {
antlerCooldown: 7.4,
antlerDamage: 17,
antlerLaneLength: 440,
antlerLaneOffset: 44,
antlerLineWidth: 30,
antlerRecover: 0.52,
antlerStunSeconds: 0.48,
antlerWindup: 0.82,
dashCooldown: 5.2,
dashDamage: 13,
dashLength: 330,
dashRecover: 0.42,
dashSpeed: 430,
dashStunSeconds: 0.32,
dashTrailDamage: 9,
dashTrailSeconds: 0.5,
dashTrailWidth: 40,
hoofCooldown: 6.2,
hoofDamage: 12,
hoofRadius: 46,
hoofRecover: 0.46,
hoofStunSeconds: 0.34,
hoofWindup: 0.74,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 116,
relocateSeconds: 1.25,
relocateSpeed: 168,
wallContactLimit: 0.68,
wallMargin: 76,
} as const
const CURSE_COLOR = '#9b74ff'
const TRAIL_COLOR = '#7ee7ff'
const ANTLER_COLOR = '#e8dcff'
export function tickHollowcrownRevenant(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as HollowcrownAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withHollowcrownIndicators({ boss, party, events, state })
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
fireballCooldownRemaining: Math.max(boss.fireballCooldownRemaining, 1.0),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup) {
boss = faceTarget(boss, target)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyHoofCurse(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashing),
chargeHitEntityIds: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailDashing) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, HOLLOWCROWN_TUNING.dashSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyTrailDashHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds + dt : 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
const trailResult = applyFadingTrail(party, boss, state.time + dt)
party = trailResult.party
events.push(...trailResult.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailFade),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.dashTrailSeconds,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.trailFade) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup) {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applyAntlerLines(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeRecover),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerRecover,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginRangedPattern(boss, state, party, target)
return withHollowcrownIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginTrailDash(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withHollowcrownIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withHollowcrownIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withHollowcrownIndicators({ boss, party, events, state })
}
function asBossPhase(phase: HollowcrownAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginRangedPattern(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const useAntlers = boss.chargeCount % 2 === 1
if (useAntlers) {
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.antlerCooldown,
mechanicCircles: [],
mechanicLanes: createAntlerLines(state, boss, target),
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerWindup,
velocity: { x: 0, y: 0 },
}
}
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseWindup),
chargeCount: boss.chargeCount + 1,
fireballCooldownRemaining: HOLLOWCROWN_TUNING.hoofCooldown,
mechanicCircles: createHoofZones(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofWindup,
velocity: { x: 0, y: 0 },
}
}
function beginTrailDash(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const lane = createDashTrailLane(state, boss, target)
return {
...boss,
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashWindup),
chargeCooldownRemaining: HOLLOWCROWN_TUNING.dashCooldown,
chargeEnd: lane.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: withFallbackFacing(subtractVec2(lane.end, boss.position), boss.facing),
mechanicCircles: [],
mechanicLanes: [lane],
phaseSecondsRemaining: 0.58,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: HOLLOWCROWN_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createHoofZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const priority = [
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player'),
...living.filter((member) => member.aiRole !== 'ranged' && member.aiRole !== 'flanker' && member.aiRole !== 'player'),
]
return priority.slice(0, 4).map((member, index) => {
const drift = normalizeVec2(addVec2(
subtractVec2(member.position, boss.position),
scaleVec2(subtractVec2(arenaCenter(state), member.position), 0.35),
))
return {
id: `hollowcrown-hoof-zone-${index}`,
position: clampVec2ToArena(
addVec2(member.position, scaleVec2(drift, 18 + index * 4)),
HOLLOWCROWN_TUNING.hoofRadius,
state.bounds,
),
radius: HOLLOWCROWN_TUNING.hoofRadius,
}
})
}
function createDashTrailLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const wallBias = isBossNearWall(boss, state)
? normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
: { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.9)), boss.facing)
const rawEnd = addVec2(boss.position, scaleVec2(direction, HOLLOWCROWN_TUNING.dashLength))
return {
end: clampVec2ToArena(rawEnd, boss.radius + 8, state.bounds),
id: 'hollowcrown-dash-trail',
start: { ...boss.position },
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}
}
function createAntlerLines(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const perpendicular = { x: -direction.y, y: direction.x }
return [-1, 0, 1].map((index) => {
const offset = scaleVec2(perpendicular, index * HOLLOWCROWN_TUNING.antlerLaneOffset)
const start = clampVec2ToArena(addVec2(boss.position, offset), 4, state.bounds)
const end = clampVec2ToArena(
addVec2(start, scaleVec2(direction, HOLLOWCROWN_TUNING.antlerLaneLength)),
4,
state.bounds,
)
return {
end,
id: `hollowcrown-antler-line-${index + 1}`,
start,
width: index === 0 ? HOLLOWCROWN_TUNING.antlerLineWidth + 8 : HOLLOWCROWN_TUNING.antlerLineWidth,
}
})
}
function applyHoofCurse(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: HOLLOWCROWN_TUNING.hoofDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.hoofStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyTrailDashHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: HOLLOWCROWN_TUNING.dashTrailWidth,
}, {
damage: HOLLOWCROWN_TUNING.dashDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFadingTrail(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const lane = boss.mechanicLanes[0]
if (!lane) return { party, events: [] }
const result = applyPartyDamageInShape(party, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width * 1.15,
}, {
damage: HOLLOWCROWN_TUNING.dashTrailDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds * 0.65,
time,
})
return { party: result.party, events: result.events }
}
function applyAntlerLines(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: HOLLOWCROWN_TUNING.antlerDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
sourceId: boss.id,
stunSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > HOLLOWCROWN_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: HOLLOWCROWN_TUNING.meleeRange,
}, {
damage: HOLLOWCROWN_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: HOLLOWCROWN_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, HOLLOWCROWN_TUNING.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + HOLLOWCROWN_TUNING.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), HOLLOWCROWN_TUNING.meleeRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, HOLLOWCROWN_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function faceTarget(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2BossEntityState {
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return { ...boss, velocity: { x: 0, y: 0 } }
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 0 },
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2(addVec2(
subtractVec2(center, boss.position),
{
x: boss.position.x < center.x ? 1 : -1,
y: boss.position.y < center.y ? 1 : -1,
},
))
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 72)), boss.radius, state.bounds)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + HOLLOWCROWN_TUNING.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withHollowcrownIndicators(result: {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
return {
boss: result.boss,
events: result.events,
indicators: createHollowcrownIndicators(result.boss),
party: result.party,
}
}
function createHollowcrownIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = boss.attackPhase as HollowcrownAttackPhase
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup || phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.hoofCurseWindup,
phase === HOLLOWCROWN_PHASE.hoofCurseRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: CURSE_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'hollowcrown-cursed-hoof-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (
phase === HOLLOWCROWN_PHASE.trailDashWindup
|| phase === HOLLOWCROWN_PHASE.trailDashing
|| phase === HOLLOWCROWN_PHASE.trailFade
) {
const indicatorPhase: Iwt2ArenaIndicatorPhase = phase === HOLLOWCROWN_PHASE.trailDashWindup
? 'windup'
: phase === HOLLOWCROWN_PHASE.trailDashing
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: TRAIL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-fading-dash-trail',
phase: indicatorPhase,
sourceId: boss.id,
start: phase === HOLLOWCROWN_PHASE.trailDashing ? boss.position : lane.start,
width: phase === HOLLOWCROWN_PHASE.trailFade ? lane.width * 1.15 : lane.width,
}))
}
}
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup || phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === HOLLOWCROWN_PHASE.antlerChargeWindup,
phase === HOLLOWCROWN_PHASE.antlerChargeRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ANTLER_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'hollowcrown-ghost-antler-line-charge',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
return indicators
}
+82 -2
View File
@@ -1,5 +1,6 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaArcIndicator,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
@@ -51,6 +52,14 @@ export type Iwt2CircleHitShape = {
radius: number
}
export type Iwt2ConeHitShape = {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2DonutHitShape = {
kind: 'donut'
position: Iwt2Vec2
@@ -58,7 +67,21 @@ export type Iwt2DonutHitShape = {
outerRadius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
export type Iwt2ArcHitShape = {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2HitShape =
| Iwt2LaneHitShape
| Iwt2CircleHitShape
| Iwt2ConeHitShape
| Iwt2DonutHitShape
| Iwt2ArcHitShape
export function createArenaEvent(
id: number,
@@ -162,9 +185,26 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
shape.width,
)
}
if (shape.kind === 'cone') {
const distance = distanceVec2(member.position, shape.origin)
if (distance > shape.range + member.radius) return false
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.origin))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
const distance = distanceVec2(member.position, shape.position)
if (shape.kind === 'circle') return distance <= shape.radius + member.radius
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
if (shape.kind === 'donut') {
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
}
if (distance > shape.outerRadius + member.radius || distance < Math.max(0, shape.innerRadius - member.radius)) {
return false
}
const direction = normalizeVec2(shape.direction)
const offset = normalizeVec2(subtractVec2(member.position, shape.position))
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
return Math.acos(dot) <= shape.angleRadians / 2
}
export function createLaneIndicator({
@@ -303,6 +343,46 @@ export function createDonutIndicator({
}
}
export function createArcIndicator({
angleRadians,
color,
direction,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaArcIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.16 : 0.09,
id,
innerRadius,
kind: 'arc',
lineAlpha: phase === 'active' ? 0.78 : 0.52,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
+718
View File
@@ -0,0 +1,718 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const OBSIDIAN_RAM_PHASE = {
armorShatterRecover: 'obsidianRamArmorShatterRecover',
armorShatterWindup: 'obsidianRamArmorShatterWindup',
fractureQuakeRecover: 'obsidianRamFractureQuakeRecover',
fractureQuakeWindup: 'obsidianRamFractureQuakeWindup',
plateChargeRecover: 'obsidianRamPlateChargeRecover',
plateChargeWindup: 'obsidianRamPlateChargeWindup',
plateCharging: 'obsidianRamPlateCharging',
} as const
type ObsidianRamPhase = typeof OBSIDIAN_RAM_PHASE[keyof typeof OBSIDIAN_RAM_PHASE]
const OBSIDIAN_RAM = {
armorCracksBeforeShatter: 3,
centerDisengageSpeed: 118,
chargeCooldown: 4.4,
chargeDamage: 8,
chargeLength: 340,
chargeRecover: 0.5,
chargeSpeed: 390,
chargeStunSeconds: 0.55,
chargeWidth: 54,
chargeWindup: 0.68,
fractureLaneCount: 5,
fractureLaneDamage: 6,
fractureLaneLength: 300,
fractureLaneWidth: 30,
meleeCooldown: 1.3,
meleeDamage: 2,
meleeRange: 62,
moveSpeed: 82,
quakeCooldown: 7.4,
quakeRadius: 126,
quakeRecover: 0.48,
quakeStunSeconds: 0.35,
quakeWindup: 0.78,
slabDamage: 10,
slabDuration: 5.8,
slabRadius: 42,
slabSlowRadius: 46,
shatterRecover: 0.8,
shatterWindup: 0.9,
wallContactLimit: 0.72,
wallMargin: 72,
} as const
export function tickObsidianRamGolem(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = obsidianRamPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: isLockedPhase(incomingPhase)
? 0
: isBossNearWall(state.boss, state)
? state.boss.wallContactSeconds + dt
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = obsidianRamPhase(boss)
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateCharging),
chargeHitEntityIds: [],
phaseSecondsRemaining: 1.25,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateCharging) {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, OBSIDIAN_RAM.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.phaseSecondsRemaining <= 0) {
const crackedArmor = clamp(boss.mechanicEnergy + 1, 0, OBSIDIAN_RAM.armorCracksBeforeShatter)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeRecover),
mechanicEnergy: crackedArmor,
phaseSecondsRemaining: OBSIDIAN_RAM.chargeRecover,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.plateChargeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter
? beginArmorShatter(boss, state, party)
: {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyFractureQuake(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeRecover),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slabResult = dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = slabResult.party
hazards = slabResult.hazards
nextHazardId = slabResult.nextHazardId
events.push(...slabResult.events)
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: OBSIDIAN_RAM.shatterRecover,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= OBSIDIAN_RAM.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.3,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter) {
boss = beginArmorShatter(boss, state, party)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeWindup),
fireballCooldownRemaining: OBSIDIAN_RAM.quakeCooldown,
mechanicCircles: [{ id: 'obsidian-ram-quake-core', position: { ...boss.position }, radius: OBSIDIAN_RAM.quakeRadius }],
mechanicLanes: createFractureWaveLanes(state, boss, target.position),
phaseSecondsRemaining: OBSIDIAN_RAM.quakeWindup,
velocity: { x: 0, y: 0 },
}
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = beginPlateCharge(boss, state, target)
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginPlateCharge(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
): Iwt2BossEntityState {
const direction = wallSafeChargeDirection(boss, state, target.position)
const chargeEnd = clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.chargeLength)),
boss.radius,
state.bounds,
)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeWindup),
chargeCooldownRemaining: OBSIDIAN_RAM.chargeCooldown,
chargeEnd,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: direction,
mechanicLanes: [{
end: chargeEnd,
id: 'obsidian-ram-plate-charge',
start: { ...boss.position },
width: OBSIDIAN_RAM.chargeWidth,
}],
phaseSecondsRemaining: OBSIDIAN_RAM.chargeWindup,
velocity: { x: 0, y: 0 },
}
}
function beginArmorShatter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const slabCircles = createObsidianSlabCircles(state, boss, party)
return {
...boss,
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterWindup),
mechanicCircles: slabCircles,
mechanicLanes: [],
phaseSecondsRemaining: OBSIDIAN_RAM.shatterWindup,
velocity: { x: 0, y: 0 },
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: OBSIDIAN_RAM.chargeWidth,
}, {
damage: OBSIDIAN_RAM.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: OBSIDIAN_RAM.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.chargeStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyFractureQuake(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, OBSIDIAN_RAM.quakeRadius),
]
const quakeResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.quakeRadius,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = quakeResult.party
events.push(...quakeResult.events)
for (const lane of boss.mechanicLanes) {
const laneResult = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: OBSIDIAN_RAM.fractureLaneDamage,
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
sourceId: boss.id,
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
time,
})
nextParty = laneResult.party
events.push(...laneResult.events)
}
return { events, party: nextParty }
}
function dropObsidianSlabs({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
} {
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const slab of boss.mechanicCircles) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: slab.position,
radius: slab.radius,
}, {
damage: OBSIDIAN_RAM.slabDamage,
knockdownSeconds: 0.22,
sourceId: boss.id,
stunSeconds: 0.22,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
const hazardResult = addMudPuddle({
duration: OBSIDIAN_RAM.slabDuration,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(slab.position, OBSIDIAN_RAM.slabSlowRadius, state.bounds),
radius: OBSIDIAN_RAM.slabSlowRadius,
sourceId: boss.id,
time,
})
nextHazards = hazardResult.hazards
nextId = hazardResult.nextHazardId
}
return { events, hazards: nextHazards, nextHazardId: nextId, party: nextParty }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > OBSIDIAN_RAM.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: OBSIDIAN_RAM.meleeRange,
}, {
damage: OBSIDIAN_RAM.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: OBSIDIAN_RAM.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function createFractureWaveLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
): Iwt2MechanicLaneState[] {
const baseDirection = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const baseAngle = Math.atan2(baseDirection.y, baseDirection.x)
const spread = Math.PI * 0.82
const lanes: Iwt2MechanicLaneState[] = []
const laneCount: number = OBSIDIAN_RAM.fractureLaneCount
for (let index = 0; index < laneCount; index += 1) {
const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1)
const angle = baseAngle - spread * 0.5 + spread * ratio
const direction = { x: Math.cos(angle), y: Math.sin(angle) }
lanes.push({
end: clampVec2ToArena(
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.fractureLaneLength)),
OBSIDIAN_RAM.fractureLaneWidth,
state.bounds,
),
id: `obsidian-ram-fracture-${index}`,
start: { ...boss.position },
width: OBSIDIAN_RAM.fractureLaneWidth,
})
}
return lanes
}
function createObsidianSlabCircles(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const targetPositions = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, 3)
.map((member) => member.position)
const fallbackAngles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
while (targetPositions.length < 3) {
const angle = fallbackAngles[targetPositions.length]
targetPositions.push(addVec2(boss.position, {
x: Math.cos(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
y: Math.sin(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
}))
}
return targetPositions.map((position, index) => ({
id: `obsidian-ram-slab-${index}`,
position: clampVec2ToArena(position, OBSIDIAN_RAM.slabRadius, state.bounds),
radius: OBSIDIAN_RAM.slabRadius,
}))
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const wallBias = isBossNearWall(boss, state) ? normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) : { x: 0, y: 0 }
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.8)), targetBias)
const desiredPosition = addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.moveSpeed * dt))
const nextPosition = clampVec2ToArena(desiredPosition, boss.radius, state.bounds)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, OBSIDIAN_RAM.centerDisengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function wallSafeChargeDirection(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
targetPosition: Iwt2Vec2,
): Iwt2Vec2 {
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
if (!isBossNearWall(boss, state)) return withFallbackFacing(targetBias, boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.35), scaleVec2(targetBias, 0.55)), centerBias)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const inward = normalizeVec2(subtractVec2(center, boss.position))
const offset = addVec2(scaleVec2(inward, 138), {
x: boss.position.y < center.y ? 42 : -42,
y: boss.position.x < center.x ? -34 : 34,
})
return clampVec2ToArena(addVec2(boss.position, offset), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + OBSIDIAN_RAM.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withObsidianRamIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createObsidianRamIndicators(result.boss),
}
}
function createObsidianRamIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = obsidianRamPhase(boss)
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup || phase === OBSIDIAN_RAM_PHASE.plateCharging) {
indicators.push(createLaneIndicator({
color: phase === OBSIDIAN_RAM_PHASE.plateCharging ? '#ff7a2f' : '#f0b35a',
end: boss.chargeEnd,
id: `${boss.id}:obsidian-ram-plate-charge`,
mechanicId: 'obsidian-ram-plate-charge',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.plateChargeWindup,
phase === OBSIDIAN_RAM_PHASE.plateCharging,
),
sourceId: boss.id,
start: boss.chargeStart,
width: OBSIDIAN_RAM.chargeWidth,
}))
}
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup || phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup,
phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#ff9f1c',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-quake-core',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: '#ffbf69',
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'obsidian-ram-fracture-wave',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup || phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#b8b6c7',
id: `${boss.id}:${circle.id}`,
mechanicId: 'obsidian-ram-broken-slab',
phase: indicatorPhaseFromAttack(
phase === OBSIDIAN_RAM_PHASE.armorShatterWindup,
phase === OBSIDIAN_RAM_PHASE.armorShatterRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function isLockedPhase(phase: string): boolean {
return phase === OBSIDIAN_RAM_PHASE.plateChargeWindup
|| phase === OBSIDIAN_RAM_PHASE.plateCharging
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover
|| phase === OBSIDIAN_RAM_PHASE.armorShatterWindup
|| phase === OBSIDIAN_RAM_PHASE.armorShatterRecover
}
function obsidianRamPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: ObsidianRamPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+209 -12
View File
@@ -1,6 +1,7 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
import {
addVec2,
clampVec2ToArena,
@@ -20,6 +21,12 @@ const CENTER_LEASH_WALL_MARGIN = 96
const CENTER_LEASH_EXTRA_DISTANCE = 36
const PARTY_ARRIVAL_RADIUS = 3.5
const PARTY_SAFE_DESTINATION_PADDING = 34
const PARTY_STEER_ANGLES = [0, 0.42, -0.42, 0.82, -0.82, 1.22, -1.22, 1.7, -1.7, 2.25, -2.25, Math.PI]
const HAZARD_ROUTE_BUFFER = 54
const INDICATOR_ROUTE_PENALTY = 24
const BOSS_ROUTE_BUFFER = 22
const PARTY_ROUTE_BUFFER = 8
const DANGER_CENTER_WEIGHT = 0.32
export function tickPartyMember(
member: Iwt2PartyEntityState,
@@ -32,6 +39,7 @@ export function tickPartyMember(
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
slowedSeconds: Math.max(0, member.status.slowedSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
@@ -44,7 +52,7 @@ export function tickPartyMember(
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
@@ -88,11 +96,11 @@ export function tickPartyMember(
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * dt
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const distanceToDesired = distanceVec2(member.position, desired)
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
? choosePartyStepPosition(member, state, desired, maxDistance)
: member.position
const velocity = movingToDesired
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
@@ -112,6 +120,112 @@ export function tickPartyMember(
}
}
function choosePartyStepPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
maxDistance: number,
): Iwt2Vec2 {
if (maxDistance <= 0) return member.position
const direct = withFallbackFacing(subtractVec2(desired, member.position), member.facing)
const currentDanger = routeDangerPenalty(member.position, member, state)
let best = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
let bestScore = scoreRoutePosition(best, member, state, desired, currentDanger)
for (const angle of PARTY_STEER_ANGLES) {
const direction = rotateVec2(direct, angle)
const candidate = clampVec2ToArena(addVec2(member.position, scaleVec2(direction, maxDistance)), member.radius, state.bounds)
const score = scoreRoutePosition(candidate, member, state, desired, currentDanger)
+ (angle === 0 ? 0 : Math.abs(angle) * 1.8)
if (score < bestScore) {
best = candidate
bestScore = score
}
}
return best
}
function scoreRoutePosition(
position: Iwt2Vec2,
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
desired: Iwt2Vec2,
currentDanger: number,
): number {
const progress = distanceVec2(position, desired)
const centerPull = currentDanger > 0 ? distanceVec2(position, arenaCenter(state)) * DANGER_CENTER_WEIGHT : 0
const progressWeight = currentDanger > 0 ? 0.28 : 1
const danger = routeDangerPenalty(position, member, state)
const crowd = routeCrowdPenalty(position, member, state)
const wall = routeWallPenalty(position, member, state)
const stayingInDanger = currentDanger > 0 && distanceVec2(position, member.position) <= 0.75 ? 90 : 0
return progress * progressWeight + centerPull + danger * 100 + crowd * 42 + wall + stayingInDanger
}
function routeDangerPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const hazard of state.hazards) {
const distance = distanceVec2(position, hazard.position)
const damageRadius = hazard.radius + member.radius
const avoidRadius = damageRadius + HAZARD_ROUTE_BUFFER
if (distance <= damageRadius) {
penalty += 18 + (damageRadius - distance) * 0.7
} else if (distance < avoidRadius) {
const ratio = (avoidRadius - distance) / HAZARD_ROUTE_BUFFER
penalty += ratio * ratio * 8
}
}
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape({ ...member, position }, shape)) continue
penalty += indicator.phase === 'active' ? INDICATOR_ROUTE_PENALTY * 1.4 : INDICATOR_ROUTE_PENALTY
}
return penalty
}
function routeCrowdPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
let penalty = 0
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const distance = distanceVec2(position, boss.position)
const avoidRadius = boss.radius + member.radius + BOSS_ROUTE_BUFFER
if (distance < avoidRadius) penalty += (avoidRadius - distance) * 1.8
}
for (const other of state.party) {
if (other.id === member.id || other.health <= 0) continue
const distance = distanceVec2(position, other.position)
const avoidRadius = other.radius + member.radius + PARTY_ROUTE_BUFFER
if (distance < avoidRadius) penalty += avoidRadius - distance
}
return penalty
}
function routeWallPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
const clearance = Math.min(
position.x - member.radius,
state.bounds.width - member.radius - position.x,
position.y - member.radius,
state.bounds.height - member.radius - position.y,
)
if (clearance >= PARTY_SAFE_DESTINATION_PADDING) return 0
return (PARTY_SAFE_DESTINATION_PADDING - clearance) * (clearance <= 4 ? 12 : 4.8)
}
function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return {
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
@@ -158,10 +272,10 @@ function getTankCenterLeashPosition(
y: boss.position.y < center.y ? 0.35 : -0.35,
})
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
return clampVec2ToArena(
return clampPartyDestination(
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
member.radius,
state.bounds,
member,
state,
)
}
@@ -198,6 +312,11 @@ function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): bo
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const indicatorEscape = getIndicatorAvoidancePosition(member, state)
if (indicatorEscape) {
return indicatorEscape
}
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
@@ -210,7 +329,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
return clampPartyDestination(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member, state)
}
}
@@ -220,7 +339,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
return clampPartyDestination(escape, member, state)
}
}
}
@@ -228,6 +347,65 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
return null
}
function getIndicatorAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let pressure = 0
for (const indicator of state.indicators) {
if (indicator.phase === 'recover') continue
const shape = indicatorAvoidanceShape(indicator)
if (!partyMemberIntersectsShape(member, shape)) continue
const origin = indicator.kind === 'lane'
? closestPointOnSegment(member.position, indicator.start, indicator.end)
: indicator.kind === 'cone'
? indicator.origin
: indicator.position
const away = withFallbackFacing(subtractVec2(member.position, origin), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
escape = addVec2(escape, scaleVec2(away, indicator.phase === 'active' ? 96 : 72))
pressure += indicator.phase === 'active' ? 1.35 : 1
}
if (pressure <= 0) return null
return clampPartyDestination(addVec2(member.position, escape), member, state)
}
function indicatorAvoidanceShape(indicator: Iwt2ArenaState['indicators'][number]): Iwt2HitShape {
if (indicator.kind === 'lane') {
return { kind: 'lane', start: indicator.start, end: indicator.end, width: indicator.width + 22 }
}
if (indicator.kind === 'circle') {
return { kind: 'circle', position: indicator.position, radius: indicator.radius + 18 }
}
if (indicator.kind === 'donut') {
return {
kind: 'donut',
innerRadius: indicator.innerRadius,
outerRadius: indicator.outerRadius + 18,
position: indicator.position,
}
}
if (indicator.kind === 'arc') {
return {
kind: 'arc',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
innerRadius: Math.max(0, indicator.innerRadius - 8),
outerRadius: indicator.outerRadius + 24,
position: indicator.position,
}
}
return {
kind: 'cone',
angleRadians: indicator.angleRadians,
direction: indicator.direction,
origin: indicator.origin,
range: indicator.range + 24,
}
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
@@ -254,10 +432,18 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
const destination = clampPartyDestination(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 96)),
member,
state,
)
if (distanceVec2(destination, member.position) > PARTY_ARRIVAL_RADIUS) return destination
const centerDirection = withFallbackFacing(subtractVec2(arenaCenter(state), member.position), direction)
return clampPartyDestination(
addVec2(member.position, scaleVec2(centerDirection, maxNeededDistance + 120)),
member,
state,
)
}
@@ -295,6 +481,17 @@ function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
}
}
function closestPointOnSegment(position: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): Iwt2Vec2 {
const segment = subtractVec2(end, start)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const t = Math.min(1, Math.max(0, dotVec2(subtractVec2(position, start), segment) / lengthSq))
return addVec2(start, scaleVec2(segment, t))
}
function movementSpeedMultiplier(slowedSeconds: number): number {
return slowedSeconds > 0 ? 0.58 : 1
}
function getPrimaryBoss(state: Iwt2ArenaState) {
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
}
+317
View File
@@ -0,0 +1,317 @@
import { RATHIAN_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicArcState,
Iwt2MechanicCircleState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createArcIndicator,
createCircleIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickRathian(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyTailSweep(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'tailSweepRecover',
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepRecover!,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'poisonSpitWindup') {
if (boss.phaseSecondsRemaining <= 0) {
for (const circle of boss.mechanicCircles) {
const puddle = addPoisonPuddle({
damage: RATHIAN_BOSS_METADATA.poisonPuddleDamage!,
duration: RATHIAN_BOSS_METADATA.poisonPuddleSeconds!,
hazards,
nextHazardId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time: state.time + dt,
})
hazards = puddle.hazards
nextHazardId = puddle.nextHazardId
}
boss = {
...boss,
attackPhase: 'poisonSpitRecover',
phaseSecondsRemaining: 0.42,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.attackPhase === 'tailSweepRecover' || boss.attackPhase === 'poisonSpitRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.65) {
boss = moveBossTowardCenter(boss, state, RATHIAN_BOSS_METADATA.moveSpeed * 1.35, dt)
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'tailSweepWindup',
chargeCooldownRemaining: RATHIAN_BOSS_METADATA.poisonTailCooldown!,
mechanicArcs: [createTailArc(boss, target)],
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'poisonSpitWindup',
fireballCooldownRemaining: RATHIAN_BOSS_METADATA.poisonSpitCooldown!,
mechanicCircles: createPoisonTargets(party),
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.poisonSpitWindup!,
velocity: { x: 0, y: 0 },
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, RATHIAN_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
return {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + 58
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function createTailArc(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2MechanicArcState {
return {
id: 'tail-sweep',
angleRadians: RATHIAN_BOSS_METADATA.tailSweepAngleRadians!,
direction: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
innerRadius: RATHIAN_BOSS_METADATA.tailSweepInnerRadius!,
outerRadius: RATHIAN_BOSS_METADATA.tailSweepOuterRadius!,
position: { ...boss.position },
}
}
function createPoisonTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
return [...party]
.filter((member) => member.health > 0)
.sort((a, b) => b.health / b.maxHealth - a.health / a.maxHealth)
.slice(0, 2)
.map((member, index) => ({
id: `poison-spit-${index}`,
position: { ...member.position },
radius: RATHIAN_BOSS_METADATA.poisonPuddleRadius!,
}))
}
function applyTailSweep(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'arc',
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: RATHIAN_BOSS_METADATA.tailSweepDamage!,
knockdownSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
sourceId: boss.id,
stunSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
time,
})
nextParty = result.party
events.push(...result.events)
}
return { party: nextParty, events }
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RATHIAN_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RATHIAN_BOSS_METADATA.meleeRange,
}, {
damage: RATHIAN_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RATHIAN_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withRathianIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createRathianIndicators(result.boss),
}
}
function createRathianIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'tailSweepWindup' || boss.attackPhase === 'tailSweepRecover') {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: '#a7e768',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'rathian-tail-sweep',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'tailSweepWindup',
boss.attackPhase === 'tailSweepRecover',
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'poisonSpitWindup' || boss.attackPhase === 'poisonSpitRecover') {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#7bd84f',
id: `${boss.id}:${circle.id}`,
mechanicId: 'rathian-poison-spit',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'poisonSpitWindup',
boss.attackPhase === 'poisonSpitRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
+578
View File
@@ -0,0 +1,578 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type RimebastionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
type RimebastionBossState = Omit<Iwt2BossEntityState, 'attackPhase'> & {
attackPhase: RimebastionAttackPhase
}
type ShardCone = {
id: string
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 6,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
iceCircleRadius: 58,
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 4,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 9,
shatterConeRange: 158,
shatterLaneDamage: 11,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
shatterWindup: 0.72,
wallDisengageSeconds: 0.7,
wallMargin: 68,
}
const WALL_COLOR = '#a7ecff'
const SHARD_COLOR = '#e8fbff'
export function tickRimebastion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss: RimebastionBossState = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withRimebastionIndicators({ boss, party, events, state })
if (boss.attackPhase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, RIMEBASTION_BALANCE.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'iceWallWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyIceWalls(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterWindup',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterWindup,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardShatter(party, boss, state, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterRecover',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterRecover,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= RIMEBASTION_BALANCE.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: RIMEBASTION_BALANCE.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
const pattern = createIceWallPattern(state, party, boss)
boss = {
...boss,
attackPhase: 'iceWallWindup',
chargeCooldownRemaining: RIMEBASTION_BALANCE.iceWallCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: pattern.circles,
mechanicLanes: pattern.lanes,
phaseSecondsRemaining: RIMEBASTION_BALANCE.iceWallWindup,
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRimebastionIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withRimebastionIndicators({ boss, party, events, state })
}
function createIceWallPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
): { lanes: Iwt2MechanicLaneState[], circles: Iwt2MechanicCircleState[] } {
const living = party.filter((member) => member.health > 0)
const center = partyCenter(living, arenaCenter(state))
const vertical = boss.chargeCount % 2 === 0
const lanes: Iwt2MechanicLaneState[] = []
const offsets = [-96, 0, 96]
for (let index = 0; index < RIMEBASTION_BALANCE.iceWallLaneCount; index += 1) {
const offset = offsets[index] ?? 0
if (vertical) {
const x = clamp(center.x + offset, 82, state.bounds.width - 82)
lanes.push({
end: { x, y: state.bounds.height - 38 },
id: `rime-wall-lane-${index}`,
start: { x, y: 38 },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
} else {
const y = clamp(center.y + offset * 0.72, 74, state.bounds.height - 74)
lanes.push({
end: { x: state.bounds.width - 38, y },
id: `rime-wall-lane-${index}`,
start: { x: 38, y },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
}
}
const circles = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, RIMEBASTION_BALANCE.iceCircleCount)
.map((member, index) => ({
id: `rime-wall-circle-${index}`,
position: clampVec2ToArena(member.position, RIMEBASTION_BALANCE.iceCircleRadius, state.bounds),
radius: RIMEBASTION_BALANCE.iceCircleRadius,
}))
return { circles, lanes }
}
function applyIceWalls(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyShardShatter(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
state: Iwt2ArenaState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}, {
damage: RIMEBASTION_BALANCE.shatterLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const cone of createShardCones(boss, state)) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'cone',
angleRadians: cone.angleRadians,
direction: cone.direction,
origin: cone.origin,
range: cone.range,
}, {
damage: RIMEBASTION_BALANCE.shatterConeDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createShardCones(boss: RimebastionBossState, state: Iwt2ArenaState): ShardCone[] {
const cones: ShardCone[] = []
const center = arenaCenter(state)
for (const lane of boss.mechanicLanes) {
const midpoint = {
x: (lane.start.x + lane.end.x) * 0.5,
y: (lane.start.y + lane.end.y) * 0.5,
}
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(center, midpoint), boss.facing),
id: `${lane.id}-shard-cone`,
origin: midpoint,
range: RIMEBASTION_BALANCE.shatterConeRange,
})
}
for (const circle of boss.mechanicCircles) {
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(circle.position, boss.position), boss.facing),
id: `${circle.id}-shard-cone`,
origin: circle.position,
range: RIMEBASTION_BALANCE.shatterConeRange * 0.86,
})
}
return cones
}
function moveBossTowardRelocationTarget(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, RIMEBASTION_BALANCE.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + RIMEBASTION_BALANCE.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), RIMEBASTION_BALANCE.meleeRange * 0.9))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, RIMEBASTION_BALANCE.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 58)), boss.radius, state.bounds)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
target: Iwt2PartyEntityState,
time: number,
): { boss: RimebastionBossState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RIMEBASTION_BALANCE.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RIMEBASTION_BALANCE.meleeRange,
}, {
damage: RIMEBASTION_BALANCE.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RIMEBASTION_BALANCE.meleeCooldown },
events: result.events,
party: result.party,
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + RIMEBASTION_BALANCE.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withRimebastionIndicators(result: {
boss: RimebastionBossState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
const { state, ...rest } = result
return {
...rest,
boss: result.boss as Iwt2BossEntityState,
indicators: createRimebastionIndicators(result.boss, state),
}
}
function createRimebastionIndicators(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const showWalls = (
boss.attackPhase === 'iceWallWindup'
|| boss.attackPhase === 'shardShatterWindup'
|| boss.attackPhase === 'shardShatterRecover'
)
if (showWalls) {
const wallPhase: Iwt2ArenaIndicatorPhase = boss.attackPhase === 'iceWallWindup'
? 'windup'
: boss.attackPhase === 'shardShatterWindup'
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: WALL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'rimebastion-ice-wall-lane',
phase: wallPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: WALL_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'rimebastion-ice-wall-circle',
phase: wallPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'shardShatterWindup' || boss.attackPhase === 'shardShatterRecover') {
const shardPhase = indicatorPhaseFromAttack(
boss.attackPhase === 'shardShatterWindup',
boss.attackPhase === 'shardShatterRecover',
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}:shatter`,
mechanicId: 'rimebastion-shard-lane',
phase: shardPhase,
sourceId: boss.id,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}))
}
for (const cone of createShardCones(boss, state)) {
indicators.push(createConeIndicator({
angleRadians: cone.angleRadians,
color: SHARD_COLOR,
direction: cone.direction,
id: `${boss.id}:${cone.id}`,
mechanicId: 'rimebastion-shard-cone',
origin: cone.origin,
phase: shardPhase,
range: cone.range,
sourceId: boss.id,
}))
}
}
return indicators
}
+749
View File
@@ -0,0 +1,749 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type SandglassScorpionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
const SANDGLASS = {
burrowCooldown: 6.6,
burrowSeconds: 1.05,
burrowSpeed: 285,
burrowTrailInterval: 0.18,
burrowTrailRadius: 28,
burrowTrailSeconds: 4.2,
burrowTrailWidth: 44,
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 15,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 6,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
zoneCount: 3,
}
const BURROW_COLOR = '#c8944e'
const ERUPTION_COLOR = '#f2c15b'
const HOURGLASS_SAFE_COLOR = '#68d8ff'
const HOURGLASS_HAZARD_COLOR = '#d49a42'
export function tickSandglassScorpion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = state.boss.attackPhase as SandglassScorpionAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === 'sandglassBurrowing'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = boss.attackPhase as SandglassScorpionAttackPhase
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.relocateTarget, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowing'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.burrowSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowing') {
const burrow = moveBurrowingBoss(boss, state, dt)
const trailResult = maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position: burrow.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
boss = {
...boss,
mechanicEnergy: trailResult.nextTrailInSeconds,
position: burrow.position,
velocity: burrow.velocity,
wallContactSeconds: 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 7) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionWindup'),
mechanicCircles: createStingerEruptions(state, party, boss),
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: SANDGLASS.eruptionWindup,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStingerEruptions(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionRecover'),
phaseSecondsRemaining: SANDGLASS.eruptionRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassSafe') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassHazard'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassHazardSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassHazard') {
const tickResult = maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
hazards = tickResult.hazards
nextHazardId = tickResult.nextHazardId
party = tickResult.party
events.push(...tickResult.events)
boss = {
...boss,
mechanicEnergy: tickResult.nextTickInSeconds,
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassRecover'),
phaseSecondsRemaining: SANDGLASS.hourglassRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= SANDGLASS.disengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.25,
relocateTarget: centerRelocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassSafe'),
fireballCooldownRemaining: SANDGLASS.hourglassCooldown,
mechanicCircles: createHourglassZones(state, party, boss),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassSafeSeconds,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const exit = chooseBurrowExit(state, party, boss, target)
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowWindup'),
chargeCooldownRemaining: SANDGLASS.burrowCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: { ...exit },
chargeStart: { ...boss.position },
mechanicCircles: [],
mechanicLanes: [createBurrowLane(boss.position, exit)],
phaseSecondsRemaining: SANDGLASS.burrowWindup,
relocateTarget: exit,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
function asBossPhase(phase: SandglassScorpionAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function moveBurrowingBoss(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.burrowSpeed * dt),
boss.radius,
state.bounds,
)
return {
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.mechanicEnergy > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.mechanicEnergy }
}
const result = addMudPuddle({
duration: SANDGLASS.burrowTrailSeconds,
hazards,
nextHazardId,
position,
radius: SANDGLASS.burrowTrailRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: SANDGLASS.burrowTrailInterval,
}
}
function maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextTickInSeconds: number
party: Iwt2PartyEntityState[]
} {
if (boss.mechanicEnergy > 0) {
return { events: [], hazards, nextHazardId, nextTickInSeconds: boss.mechanicEnergy, party }
}
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const circle of boss.mechanicCircles) {
const damage = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.hourglassDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0.18,
time,
})
nextParty = damage.party
events.push(...damage.events)
const puddle = addMudPuddle({
duration: SANDGLASS.hourglassTickSeconds + 0.45,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(circle.position, circle.radius, state.bounds),
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return {
events,
hazards: nextHazards,
nextHazardId: nextId,
nextTickInSeconds: SANDGLASS.hourglassTickSeconds,
party: nextParty,
}
}
function createStingerEruptions(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const marked = [
getBossTarget(living),
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker'),
...living,
].filter((member): member is Iwt2PartyEntityState => Boolean(member))
const circles: Iwt2MechanicCircleState[] = []
const seen = new Set<string>()
for (const member of marked) {
if (seen.has(member.id)) continue
seen.add(member.id)
const offset = offsetFromBoss(member.position, boss.position, 28 + circles.length * 10)
circles.push({
id: `sandglass-stinger-${circles.length}`,
position: clampVec2ToArena(addVec2(member.position, offset), SANDGLASS.eruptionRadius, state.bounds),
radius: SANDGLASS.eruptionRadius,
})
if (circles.length >= SANDGLASS.zoneCount) break
}
return circles
}
function createHourglassZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const center = arenaCenter(state)
const partyCenterPoint = partyCenter(party.filter((member) => member.health > 0), center)
const angleBase = Math.atan2(partyCenterPoint.y - center.y, partyCenterPoint.x - center.x)
const circles: Iwt2MechanicCircleState[] = []
for (let index = 0; index < SANDGLASS.zoneCount; index += 1) {
const angle = angleBase + (Math.PI * 2 * index) / SANDGLASS.zoneCount + boss.chargeCount * 0.28
const radius = index === 0 ? 54 : 128
circles.push({
id: `sandglass-hourglass-${index}`,
position: clampVec2ToArena({
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius * 0.68,
}, SANDGLASS.hourglassRadius, state.bounds),
radius: SANDGLASS.hourglassRadius,
})
}
return circles
}
function applyStingerEruptions(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.eruptionDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0.2,
sourceId: boss.id,
stunSeconds: SANDGLASS.eruptionStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > SANDGLASS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: SANDGLASS.meleeRange,
}, {
damage: SANDGLASS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: SANDGLASS.meleeCooldown },
party: result.party,
events: result.events,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const desiredDistance = 70
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const toward = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const away = withFallbackFacing(offset, scaleVec2(toward, -1))
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const destination = distance < desiredDistance
? addVec2(target.position, scaleVec2(away, desiredDistance))
: addVec2(target.position, scaleVec2(centerBias, 18))
const position = clampVec2ToArena(
moveToward(boss.position, destination, SANDGLASS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.disengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position }, state) ? boss.wallContactSeconds : 0,
}
}
function chooseBurrowExit(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2Vec2 {
const center = arenaCenter(state)
const living = party.filter((member) => member.health > 0)
const partyCenterPoint = partyCenter(living, target.position)
const baseDirection = withFallbackFacing(subtractVec2(partyCenterPoint, boss.position), boss.facing)
const flank = { x: -baseDirection.y, y: baseDirection.x }
const side = boss.chargeCount % 2 === 0 ? 1 : -1
const candidates = [
addVec2(target.position, scaleVec2(flank, 118 * side)),
addVec2(partyCenterPoint, scaleVec2(flank, -124 * side)),
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), -76)),
center,
]
let best = clampVec2ToArena(candidates[0], boss.radius + SANDGLASS.wallMargin, state.bounds)
let bestScore = -Infinity
for (const candidate of candidates) {
const clamped = clampVec2ToArena(candidate, boss.radius + SANDGLASS.wallMargin, state.bounds)
const wall = wallClearance(clamped, state, boss.radius)
const targetDistance = distanceVec2(clamped, target.position)
const centerDistance = distanceVec2(clamped, center)
const bossDistance = distanceVec2(clamped, boss.position)
const score = wall * 1.6
+ clamp(targetDistance, 72, 210) * 0.55
- centerDistance * 0.18
+ clamp(bossDistance, 90, 280) * 0.25
if (score > bestScore) {
best = clamped
bestScore = score
}
}
return best
}
function centerRelocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(
addVec2(center, scaleVec2(awayFromWall, 54)),
boss.radius + SANDGLASS.wallMargin,
state.bounds,
)
}
function createBurrowLane(start: Iwt2Vec2, end: Iwt2Vec2): Iwt2MechanicLaneState {
return {
end: { ...end },
id: 'sandglass-burrow-trail',
start: { ...start },
width: SANDGLASS.burrowTrailWidth,
}
}
function offsetFromBoss(position: Iwt2Vec2, bossPosition: Iwt2Vec2, distance: number): Iwt2Vec2 {
const direction = withFallbackFacing(subtractVec2(position, bossPosition), { x: 1, y: 0 })
return scaleVec2(direction, distance)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function wallClearance(position: Iwt2Vec2, state: Iwt2ArenaState, radius: number): number {
return Math.min(
position.x - radius,
state.bounds.width - radius - position.x,
position.y - radius,
state.bounds.height - radius - position.y,
)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return wallClearance(boss.position, state, boss.radius) <= SANDGLASS.wallMargin
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withSandglassIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createSandglassIndicators(result.boss),
}
}
function createSandglassIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as SandglassScorpionAttackPhase
const indicators: Iwt2ArenaIndicator[] = []
if (phase === 'sandglassBurrowWindup' || phase === 'sandglassBurrowing') {
indicators.push(...boss.mechanicLanes.map((lane) => createLaneIndicator({
color: BURROW_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'sandglass-burrow-trail',
phase: indicatorPhaseFromAttack(
phase === 'sandglassBurrowWindup',
phase === 'sandglassBurrowing',
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
})))
}
if (phase === 'sandglassEruptionWindup' || phase === 'sandglassEruptionRecover') {
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color: ERUPTION_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-stinger-eruption',
phase: indicatorPhaseFromAttack(
phase === 'sandglassEruptionWindup',
phase === 'sandglassEruptionRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
if (
phase === 'sandglassHourglassSafe'
|| phase === 'sandglassHourglassHazard'
|| phase === 'sandglassHourglassRecover'
) {
const color = phase === 'sandglassHourglassSafe' ? HOURGLASS_SAFE_COLOR : HOURGLASS_HAZARD_COLOR
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-hourglass-zone',
phase: indicatorPhaseFromAttack(
phase === 'sandglassHourglassSafe',
phase === 'sandglassHourglassHazard',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
return indicators
}
+543
View File
@@ -0,0 +1,543 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicArcState,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createArcIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const STORMCOIL_PHASE = {
arcRecover: 'stormcoilArcRecover',
arcWindup: 'stormcoilArcWindup',
chainRecover: 'stormcoilChainRecover',
chainWindup: 'stormcoilChainWindup',
donutRecover: 'stormcoilDonutRecover',
donutWindup: 'stormcoilDonutWindup',
} as const
type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
const STORMCOIL_TUNING = {
arcAngleRadians: Math.PI * 0.34,
arcCooldown: 5.6,
arcDamage: 6,
arcInnerRadius: 78,
arcOuterRadius: 196,
arcRecover: 0.46,
arcStunSeconds: 0.24,
arcWindup: 0.74,
chainCooldown: 7.4,
chainDamage: 5,
chainLinkRadius: 150,
chainMaxLinks: 7,
chainRecover: 0.5,
chainStunSeconds: 0.38,
chainWidth: 16,
chainWindup: 0.68,
disengageSpeed: 168,
donutCooldown: 6.2,
donutDamage: 7,
donutInnerRadius: 64,
donutOuterRadius: 156,
donutRecover: 0.48,
donutStunSeconds: 0.44,
donutWindup: 0.82,
meleeCooldown: 1.12,
meleeDamage: 2,
meleeRange: 58,
moveSpeed: 124,
wallContactLimit: 0.72,
wallMargin: 68,
}
export function tickStormcoilWyrm(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = stormcoilPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withStormcoilIndicators({ boss, party, events })
const phase = stormcoilPhase(boss)
if (phase === 'relocating') {
boss = moveBossToward(boss, boss.relocateTarget, state, STORMCOIL_TUNING.disengageSpeed, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.donutWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormDonut(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutRecover),
mechanicCircles: [],
phaseSecondsRemaining: STORMCOIL_TUNING.donutRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.arcWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStormArcs(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.arcRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (phase === STORMCOIL_PHASE.chainWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainLightning(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainRecover),
phaseSecondsRemaining: STORMCOIL_TUNING.chainRecover,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (
phase === STORMCOIL_PHASE.donutRecover
|| phase === STORMCOIL_PHASE.arcRecover
|| phase === STORMCOIL_PHASE.chainRecover
) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= STORMCOIL_TUNING.wallContactLimit) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicArcs: [],
mechanicCircles: [],
mechanicLinks: [],
phaseSecondsRemaining: 1.25,
relocateTarget: wallDisengageTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(boss, party)
if (links.length > 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.chainWindup),
fireballCooldownRemaining: STORMCOIL_TUNING.chainCooldown,
mechanicLinks: links,
phaseSecondsRemaining: STORMCOIL_TUNING.chainWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
boss = { ...boss, fireballCooldownRemaining: 1.0 }
}
if (boss.mechanicEnergy <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.arcWindup),
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
mechanicArcs: createStormArcs(boss, target.position),
mechanicEnergy: STORMCOIL_TUNING.arcCooldown,
phaseSecondsRemaining: STORMCOIL_TUNING.arcWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(STORMCOIL_PHASE.donutWindup),
chargeCooldownRemaining: STORMCOIL_TUNING.donutCooldown,
mechanicCircles: [{
id: 'stormcoil-donut-center',
position: { ...boss.position },
radius: STORMCOIL_TUNING.donutOuterRadius,
}],
phaseSecondsRemaining: STORMCOIL_TUNING.donutWindup,
velocity: { x: 0, y: 0 },
}
return withStormcoilIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withStormcoilIndicators({ boss, party, events })
const desired = isBossNearWall(boss, state)
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(arenaCenter(state), boss.position)), 84))
: target.position
boss = moveBossToward(boss, desired, state, STORMCOIL_TUNING.moveSpeed, dt)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
return withStormcoilIndicators({ boss, party, events })
}
function applyStormDonut(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
kind: 'donut',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
position: boss.position,
}, {
damage: STORMCOIL_TUNING.donutDamage,
knockdownSeconds: STORMCOIL_TUNING.donutStunSeconds,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.donutStunSeconds,
time,
})
return { party: result.party, events: result.events }
}
function applyStormArcs(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const arc of boss.mechanicArcs) {
const result = applyPartyDamageInShape(nextParty, {
angleRadians: arc.angleRadians,
direction: arc.direction,
innerRadius: arc.innerRadius,
kind: 'arc',
outerRadius: arc.outerRadius,
position: arc.position,
}, {
damage: STORMCOIL_TUNING.arcDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.arcStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyChainLightning(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
end: link.end,
kind: 'lane',
start: link.start,
width: link.width,
}, {
damage: STORMCOIL_TUNING.chainDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: STORMCOIL_TUNING.chainStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createStormArcs(boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2): Iwt2MechanicArcState[] {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const directions = [
rotateVec2(facing, -0.66),
facing,
rotateVec2(facing, 0.66),
]
return directions.map((direction, index) => ({
angleRadians: STORMCOIL_TUNING.arcAngleRadians,
direction,
id: `stormcoil-arc-${index}`,
innerRadius: STORMCOIL_TUNING.arcInnerRadius,
outerRadius: STORMCOIL_TUNING.arcOuterRadius,
position: { ...boss.position },
}))
}
function createChainLinks(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
const nearest = living
.map((member) => ({ member, distance: distanceVec2(member.position, boss.position) }))
.sort((a, b) => a.distance - b.distance)[0]?.member
if (nearest) {
links.push({
end: { ...nearest.position },
id: `stormcoil-chain-${boss.id}-${nearest.id}`,
start: { ...boss.position },
width: STORMCOIL_TUNING.chainWidth,
})
}
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > STORMCOIL_TUNING.chainLinkRadius) continue
links.push({
end: { ...living[b].position },
id: `stormcoil-chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
width: STORMCOIL_TUNING.chainWidth,
})
if (links.length >= STORMCOIL_TUNING.chainMaxLinks) return links
}
}
return links.length > 1 ? links : []
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > STORMCOIL_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: STORMCOIL_TUNING.meleeRange,
}, {
damage: STORMCOIL_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: STORMCOIL_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossToward(
boss: Iwt2BossEntityState,
target: Iwt2Vec2,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(target, boss.position)
const distance = Math.hypot(offset.x, offset.y)
const nextPosition = clampVec2ToArena(
distance <= speed * dt || distance <= 0.0001
? target
: addVec2(boss.position, scaleVec2(offset, (speed * dt) / distance)),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function wallDisengageTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const toCenter = normalizeVec2(subtractVec2(center, boss.position))
return clampVec2ToArena(addVec2(boss.position, scaleVec2(toCenter, 180)), boss.radius, state.bounds)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + STORMCOIL_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const cos = Math.cos(radians)
const sin = Math.sin(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function withStormcoilIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createStormcoilIndicators(result.boss),
}
}
function createStormcoilIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = stormcoilPhase(boss)
if (phase === STORMCOIL_PHASE.donutWindup || phase === STORMCOIL_PHASE.donutRecover) {
indicators.push(createDonutIndicator({
color: '#8ff2ff',
id: `${boss.id}:stormcoil-donut`,
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
mechanicId: 'stormcoil-charged-ring',
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.donutWindup,
phase === STORMCOIL_PHASE.donutRecover,
),
position: boss.position,
sourceId: boss.id,
}))
}
if (phase === STORMCOIL_PHASE.arcWindup || phase === STORMCOIL_PHASE.arcRecover) {
for (const arc of boss.mechanicArcs) {
indicators.push(createArcIndicator({
angleRadians: arc.angleRadians,
color: phase === STORMCOIL_PHASE.arcRecover ? '#d8fbff' : '#60cfff',
direction: arc.direction,
id: `${boss.id}:${arc.id}`,
innerRadius: arc.innerRadius,
mechanicId: 'stormcoil-arc-trail',
outerRadius: arc.outerRadius,
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.arcWindup,
phase === STORMCOIL_PHASE.arcRecover,
),
position: arc.position,
sourceId: boss.id,
}))
}
}
if (phase === STORMCOIL_PHASE.chainWindup || phase === STORMCOIL_PHASE.chainRecover) {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#b8f7ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'stormcoil-chain-lightning',
phase: indicatorPhaseFromAttack(
phase === STORMCOIL_PHASE.chainWindup,
phase === STORMCOIL_PHASE.chainRecover,
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
function stormcoilPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: StormcoilPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}
+321
View File
@@ -0,0 +1,321 @@
import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicLinkState,
Iwt2PartyEntityState,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickTobiKadachi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(
state.boss.mechanicEnergyMax,
state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt,
),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events })
if (boss.attackPhase === 'staticPounceWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' }
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPouncing') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt),
boss.radius,
state.bounds,
)
const hitResult = applyPounceHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
boss = {
...boss,
attackPhase: 'staticPounceRecover',
phaseSecondsRemaining: 0.42,
velocity: { x: 0, y: 0 },
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chainShockWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyChainShock(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'chainShockRecover',
phaseSecondsRemaining: 0.42,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLinks: [],
phaseSecondsRemaining: 0,
}
}
return withTobiIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) {
const pounce = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'staticPounceWindup',
chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown,
chargeEnd: pounce.end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing: pounce.direction,
mechanicEnergy: 0,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
const links = createChainLinks(party)
boss = {
...boss,
attackPhase: 'chainShockWindup',
fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!,
mechanicLinks: links,
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!,
velocity: { x: 0, y: 0 },
}
return withTobiIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withTobiIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, TOBI_KADACHI_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withTobiIndicators({ boss, party, events })
}
function applyPounceHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2BossEntityState['position'],
end: Iwt2BossEntityState['position'],
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'lane',
start,
end,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] {
const living = party.filter((member) => member.health > 0)
const links: Iwt2MechanicLinkState[] = []
for (let a = 0; a < living.length; a += 1) {
for (let b = a + 1; b < living.length; b += 1) {
if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue
links.push({
id: `chain-${living[a].id}-${living[b].id}`,
start: { ...living[a].position },
end: { ...living[b].position },
width: 5,
})
}
}
return links.slice(0, 6)
}
function applyChainShock(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const link of boss.mechanicLinks) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
start: link.start,
end: link.end,
width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: TOBI_KADACHI_BOSS_METADATA.meleeRange,
}, {
damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: {
...boss,
mechanicEnergy: Math.min(
boss.mechanicEnergyMax,
boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!,
),
meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown,
},
party: result.party,
events: result.events,
}
}
function withTobiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createTobiIndicators(result.boss),
}
}
function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'staticPounceWindup'
|| boss.attackPhase === 'staticPouncing'
|| boss.attackPhase === 'staticPounceRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff',
end: boss.chargeEnd,
id: `${boss.id}:static-pounce`,
mechanicId: 'tobi-static-pounce',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'staticPounceWindup',
boss.attackPhase === 'staticPouncing',
),
sourceId: boss.id,
start: boss.chargeStart,
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
}))
}
if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') {
for (const link of boss.mechanicLinks) {
indicators.push(createLaneIndicator({
color: '#a8f3ff',
end: link.end,
id: `${boss.id}:${link.id}`,
mechanicId: 'tobi-chain-shock',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chainShockWindup',
boss.attackPhase === 'chainShockRecover',
),
sourceId: boss.id,
start: link.start,
width: link.width,
}))
}
}
return indicators
}
+97 -1
View File
@@ -31,6 +31,7 @@ export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
slowedSeconds: number
}
export type Iwt2HotEffectState = {
@@ -100,6 +101,69 @@ export type Iwt2BossAttackPhase =
| 'thunderRingRecover'
| 'lightningStrikeWindup'
| 'lightningStrikeRecover'
| 'tailSweepWindup'
| 'tailSweepRecover'
| 'poisonSpitWindup'
| 'poisonSpitRecover'
| 'mudSprayWindup'
| 'mudSprayRecover'
| 'staticPounceWindup'
| 'staticPouncing'
| 'staticPounceRecover'
| 'chainShockWindup'
| 'chainShockRecover'
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
| 'cinderbackRicochetWindup'
| 'cinderbackRicocheting'
| 'cinderbackArmorSlamWindup'
| 'cinderbackArmorSlamRecover'
| 'obsidianRamPlateChargeWindup'
| 'obsidianRamPlateCharging'
| 'obsidianRamPlateChargeRecover'
| 'obsidianRamFractureQuakeWindup'
| 'obsidianRamFractureQuakeRecover'
| 'obsidianRamArmorShatterWindup'
| 'obsidianRamArmorShatterRecover'
| 'stormcoilDonutWindup'
| 'stormcoilDonutRecover'
| 'stormcoilArcWindup'
| 'stormcoilArcRecover'
| 'stormcoilChainWindup'
| 'stormcoilChainRecover'
| 'crystalBatSonicWindup'
| 'crystalBatSonicRecover'
| 'crystalBatShardWindup'
| 'crystalBatShardRecover'
| 'crystalBatSwoopWindup'
| 'crystalBatSwooping'
| 'crystalBatSwoopRecover'
| 'venomOrchidHydraConeWindup'
| 'venomOrchidHydraConeRecover'
| 'venomOrchidHydraRootSnareWindup'
| 'venomOrchidHydraRootSnareRecover'
| 'venomOrchidHydraBloomPodWindup'
| 'venomOrchidHydraBloomPodRecover'
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
| 'hollowcrownHoofCurseWindup'
| 'hollowcrownHoofCurseRecover'
| 'hollowcrownTrailDashWindup'
| 'hollowcrownTrailDashing'
| 'hollowcrownTrailFade'
| 'hollowcrownAntlerChargeWindup'
| 'hollowcrownAntlerChargeRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
@@ -117,6 +181,10 @@ export type Iwt2BossEntityState = {
radius: number
health: number
maxHealth: number
armor: number
maxArmor: number
mechanicEnergy: number
mechanicEnergyMax: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
@@ -133,6 +201,8 @@ export type Iwt2BossEntityState = {
birdWaveThresholdsTriggered: number[]
mechanicLanes: Iwt2MechanicLaneState[]
mechanicCircles: Iwt2MechanicCircleState[]
mechanicArcs: Iwt2MechanicArcState[]
mechanicLinks: Iwt2MechanicLinkState[]
}
export type Iwt2MechanicLaneState = {
@@ -148,6 +218,22 @@ export type Iwt2MechanicCircleState = {
radius: number
}
export type Iwt2MechanicArcState = {
id: string
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2MechanicLinkState = {
id: string
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
@@ -172,7 +258,7 @@ export type Iwt2HostileAddState = {
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
hazardKind: 'firePuddle' | 'poisonPuddle' | 'mudPuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
@@ -241,11 +327,21 @@ export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
outerRadius: number
}
export type Iwt2ArenaArcIndicator = Iwt2ArenaIndicatorBase & {
kind: 'arc'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
direction: Iwt2Vec2
angleRadians: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
| Iwt2ArenaArcIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
+736
View File
@@ -0,0 +1,736 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const VENOM_ORCHID_HYDRA_PHASE = {
bloomPodRecover: 'venomOrchidHydraBloomPodRecover',
bloomPodWindup: 'venomOrchidHydraBloomPodWindup',
coneRecover: 'venomOrchidHydraConeRecover',
coneWindup: 'venomOrchidHydraConeWindup',
rootSnareRecover: 'venomOrchidHydraRootSnareRecover',
rootSnareWindup: 'venomOrchidHydraRootSnareWindup',
} as const
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 7,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 3,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 10,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 4,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 8,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
rootSnareSeconds: 0.85,
rootWindup: 0.82,
wallDisengageSeconds: 0.72,
wallMargin: 70,
}
const VENOM_COLOR = '#b7ff4f'
const ROOT_COLOR = '#d68b49'
const BLOOM_COLOR = '#ff70d7'
type PoisonConePlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
origin: Iwt2Vec2
}
export function tickVenomOrchidHydra(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const phase = venomOrchidHydraPhase(boss)
boss = {
...boss,
wallContactSeconds: phase === 'relocating'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyPoisonCone(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
const nextHeadIndex = Math.floor(boss.mechanicEnergy) + 1
if (nextHeadIndex < 3) {
const cone = createPoisonConePlan(boss, party, state, nextHeadIndex)
boss = {
...boss,
chargeEnd: cone.end,
chargeHitEntityIds: result.hitEntityIds,
chargeStart: cone.origin,
facing: cone.direction,
mechanicEnergy: nextHeadIndex,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
}
} else {
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneRecover),
chargeHitEntityIds: [],
mechanicEnergy: 0,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneRecover,
}
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyRootSnareLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const burst = applyBloomBursts(party, boss, state.time + dt)
const pods = addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time: state.time + dt,
})
party = burst.party
hazards = pods.hazards
nextHazardId = pods.nextHazardId
events.push(...burst.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= VENOM_ORCHID_HYDRA.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginBloomPods(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginStaggeredPoisonCones(boss, state, party)
: beginRootSnareLanes(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveHydraTowardEngagePoint(boss, state, target, dt)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginStaggeredPoisonCones(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const cone = createPoisonConePlan(boss, party, state, 0)
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: cone.end,
chargeHitEntityIds: [],
chargeStart: cone.origin,
facing: cone.direction,
mechanicCircles: [],
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRootSnareLanes(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: createRootSnarePattern(state, party),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootWindup,
velocity: { x: 0, y: 0 },
}
}
function beginBloomPods(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup),
fireballCooldownRemaining: VENOM_ORCHID_HYDRA.bloomCooldown,
mechanicCircles: createBloomPodPattern(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomWindup,
velocity: { x: 0, y: 0 },
}
}
function createPoisonConePlan(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
headIndex: number,
): PoisonConePlan {
const living = livingParty(party)
const fallbackTarget = getBossTarget(party)?.position ?? arenaCenter(state)
const target = living[(headIndex + boss.chargeCount) % Math.max(1, living.length)]?.position ?? fallbackTarget
const headOffset = (headIndex - 1) * VENOM_ORCHID_HYDRA.headOffset
const baseDirection = withFallbackFacing(subtractVec2(target, boss.position), boss.facing)
const direction = rotateVec2(baseDirection, (headIndex - 1) * 0.28)
const perpendicular = { x: -baseDirection.y, y: baseDirection.x }
const origin = clampVec2ToArena(
addVec2(boss.position, scaleVec2(perpendicular, headOffset)),
8,
state.bounds,
)
return {
direction,
end: addVec2(origin, scaleVec2(direction, VENOM_ORCHID_HYDRA.coneRange)),
origin,
}
}
function createRootSnarePattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLaneState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const selected = [
getPriorityMember(living, 'player')?.position ?? center,
getPriorityMember(living, 'ranged')?.position ?? center,
getPriorityMember(living, 'flanker')?.position ?? center,
]
const directions = [
{ x: 1, y: 0 },
{ x: 0, y: 1 },
normalizeVec2({ x: state.bounds.width >= state.bounds.height ? 1 : 0.8, y: 0.72 }),
]
return selected.map((position, index) => {
const lane = createArenaLaneThroughPoint(position, directions[index], state)
return {
...lane,
id: `venom-orchid-root-snare-${index}`,
width: VENOM_ORCHID_HYDRA.rootLaneWidth,
}
})
}
function createBloomPodPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const chosen = [
getPriorityMember(living, 'ranged')?.position,
getPriorityMember(living, 'player')?.position,
getPriorityMember(living, 'flanker')?.position,
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), 54)),
].filter((position): position is Iwt2Vec2 => Boolean(position))
return chosen.slice(0, VENOM_ORCHID_HYDRA.bloomPodCount).map((position, index) => ({
id: `venom-orchid-bloom-pod-${index}`,
position: clampVec2ToArena(position, VENOM_ORCHID_HYDRA.bloomPodRadius, state.bounds),
radius: VENOM_ORCHID_HYDRA.bloomPodRadius,
}))
}
function applyPoisonCone(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
direction: boss.facing,
kind: 'cone',
origin: boss.chargeStart,
range: VENOM_ORCHID_HYDRA.coneRange,
}, {
damage: VENOM_ORCHID_HYDRA.coneDamage,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.coneStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyRootSnareLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: VENOM_ORCHID_HYDRA.rootLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.rootSnareSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyBloomBursts(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: VENOM_ORCHID_HYDRA.bloomBurstDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
for (const circle of boss.mechanicCircles) {
const result = addPoisonPuddle({
damage: VENOM_ORCHID_HYDRA.bloomPodDamage,
duration: VENOM_ORCHID_HYDRA.bloomPodSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > VENOM_ORCHID_HYDRA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: VENOM_ORCHID_HYDRA.meleeRange,
}, {
damage: VENOM_ORCHID_HYDRA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: VENOM_ORCHID_HYDRA.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, VENOM_ORCHID_HYDRA.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveHydraTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), 106))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, VENOM_ORCHID_HYDRA.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 66)), boss.radius, state.bounds)
}
function createArenaLaneThroughPoint(
point: Iwt2Vec2,
direction: Iwt2Vec2,
state: Iwt2ArenaState,
): { start: Iwt2Vec2, end: Iwt2Vec2 } {
const dir = withFallbackFacing(direction, { x: 1, y: 0 })
const inset = VENOM_ORCHID_HYDRA.rootLaneLengthInset
const minX = inset
const maxX = state.bounds.width - inset
const minY = inset
const maxY = state.bounds.height - inset
const candidates: number[] = []
if (Math.abs(dir.x) > 0.0001) {
candidates.push((minX - point.x) / dir.x, (maxX - point.x) / dir.x)
}
if (Math.abs(dir.y) > 0.0001) {
candidates.push((minY - point.y) / dir.y, (maxY - point.y) / dir.y)
}
const valid = candidates.filter((t) => {
const position = addVec2(point, scaleVec2(dir, t))
return position.x >= minX - 0.1 && position.x <= maxX + 0.1 && position.y >= minY - 0.1 && position.y <= maxY + 0.1
})
const startT = Math.min(...valid)
const endT = Math.max(...valid)
return {
end: addVec2(point, scaleVec2(dir, endT)),
start: addVec2(point, scaleVec2(dir, startT)),
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const sin = Math.sin(radians)
const cos = Math.cos(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function livingParty(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState[] {
return party.filter((member) => member.health > 0)
}
function getPriorityMember(
party: Iwt2PartyEntityState[],
role: Iwt2PartyEntityState['aiRole'],
): Iwt2PartyEntityState | undefined {
return party.find((member) => member.aiRole === role)
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withVenomOrchidHydraIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createVenomOrchidHydraIndicators(result.boss),
}
}
function createVenomOrchidHydraIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = venomOrchidHydraPhase(boss)
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup || phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
indicators.push(createConeIndicator({
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
color: VENOM_COLOR,
direction: boss.facing,
id: `${boss.id}:venom-orchid-hydra-cone-${Math.floor(boss.mechanicEnergy)}`,
mechanicId: 'venom-orchid-hydra-poison-cone',
origin: boss.chargeStart,
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover,
),
range: VENOM_ORCHID_HYDRA.coneRange,
sourceId: boss.id,
}))
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup || phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ROOT_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'venom-orchid-hydra-root-snare',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover,
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup || phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: BLOOM_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'venom-orchid-hydra-bloom-pod',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function venomOrchidHydraPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: VenomOrchidHydraPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}