import { KHEZU_BOSS_METADATA } from '../content/bosses' import type { Iwt2BossTickResult } from './bossAi' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2MechanicCircleState, Iwt2PartyEntityState, } from './types' import { applyPartyDamageInShape, createCircleIndicator, createDonutIndicator, indicatorPhaseFromAttack, } from './mechanics' import { clampVec2ToArena, distanceVec2, moveToward, scaleVec2, subtractVec2, withFallbackFacing, } from './vector' export function tickKhezu(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { const events: Iwt2ArenaEvent[] = [] let party = state.party let boss = { ...state.boss, meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt), phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), velocity: { x: 0, y: 0 }, } const target = getBossTarget(party) if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events }) if (boss.attackPhase === 'thunderRingWindup') { boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing), } if (boss.phaseSecondsRemaining <= 0) { const result = applyThunderRing(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: 'thunderRingRecover', phaseSecondsRemaining: 0.45, } } return withKhezuIndicators({ boss, party, events }) } if (boss.attackPhase === 'lightningStrikeWindup') { if (boss.phaseSecondsRemaining <= 0) { const result = applyLightningStrikes(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: 'lightningStrikeRecover', phaseSecondsRemaining: 0.38, } } return withKhezuIndicators({ boss, party, events }) } if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') { if (boss.phaseSecondsRemaining <= 0) { boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0, } } return withKhezuIndicators({ boss, party, events }) } if (boss.chargeCooldownRemaining <= 0) { boss = { ...boss, attackPhase: 'thunderRingWindup', chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!, phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!, velocity: { x: 0, y: 0 }, } return withKhezuIndicators({ boss, party, events }) } if (boss.fireballCooldownRemaining <= 0) { boss = { ...boss, attackPhase: 'lightningStrikeWindup', fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!, mechanicCircles: createLightningTargets(party), phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!, velocity: { x: 0, y: 0 }, } return withKhezuIndicators({ boss, party, events }) } const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) party = meleeResult.party events.push(...meleeResult.events) boss = meleeResult.boss if (events.length > 0) return withKhezuIndicators({ boss, party, events }) const nextPosition = clampVec2ToArena( moveToward(boss.position, target.position, KHEZU_BOSS_METADATA.moveSpeed * dt), boss.radius, state.bounds, ) boss = { ...boss, position: nextPosition, velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), } return withKhezuIndicators({ boss, party, events }) } function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] { return [...party] .filter((member) => member.health > 0) .sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth)) .slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!) .map((member, index) => ({ id: `lightning-${index}`, position: { ...member.position }, radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!, })) } function applyThunderRing( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape(party, { kind: 'donut', position: boss.position, innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!, outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!, }, { damage: KHEZU_BOSS_METADATA.thunderRingDamage!, knockdownSeconds: 0, sourceId: boss.id, stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!, time, }) return { party: result.party, events: result.events } } function applyLightningStrikes( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { let nextParty = party const events: Iwt2ArenaEvent[] = [] for (const circle of boss.mechanicCircles) { const result = applyPartyDamageInShape(nextParty, { kind: 'circle', position: circle.position, radius: circle.radius, }, { damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!, knockdownSeconds: 0, sourceId: boss.id, stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!, time, }) nextParty = result.party events.push(...result.events) } return { party: nextParty, events } } function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) if (livingTank) return livingTank return party.find((member) => member.health > 0) } function maybeApplyMelee( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, target: Iwt2PartyEntityState, time: number, ): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } if (distanceVec2(boss.position, target.position) > KHEZU_BOSS_METADATA.meleeRange + target.radius) { return { boss, party, events: [] } } const result = applyPartyDamageInShape(party, { kind: 'circle', position: boss.position, radius: KHEZU_BOSS_METADATA.meleeRange, }, { damage: KHEZU_BOSS_METADATA.meleeDamage, sourceId: boss.id, time, }) return { boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown }, party: result.party, events: result.events, } } function withKhezuIndicators(result: Omit): Iwt2BossTickResult { return { ...result, indicators: createKhezuIndicators(result.boss), } } function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { const indicators: Iwt2ArenaIndicator[] = [] if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') { indicators.push(createDonutIndicator({ color: '#77d9ff', id: `${boss.id}:thunder-ring`, innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!, mechanicId: 'khezu-thunder-ring', outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!, phase: indicatorPhaseFromAttack( boss.attackPhase === 'thunderRingWindup', boss.attackPhase === 'thunderRingRecover', ), position: boss.position, sourceId: boss.id, })) } if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') { for (const circle of boss.mechanicCircles) { indicators.push(createCircleIndicator({ color: '#9be8ff', id: `${boss.id}:${circle.id}`, mechanicId: 'khezu-lightning-strike', phase: indicatorPhaseFromAttack( boss.attackPhase === 'lightningStrikeWindup', boss.attackPhase === 'lightningStrikeRecover', ), position: circle.position, radius: circle.radius, sourceId: boss.id, })) } } return indicators }