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: 10, meleeRange: 54, moveSpeed: 138, relocateCooldownFloor: 1.2, relocateSeconds: 1.25, relocateSpeed: 184, shardCooldown: 5.8, shardDamage: 34, shardLaneWidth: 30, shardRecover: 0.58, shardStunSeconds: 0.35, shardWindup: 0.82, sonicCooldown: 6.4, sonicDamage: 30, sonicRecover: 0.42, sonicRingRadius: 58, sonicStunSeconds: 0.32, sonicWindup: 0.78, swoopCooldown: 4.9, swoopDamage: 42, 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 { 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'] }