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: 70, eruptionRadius: 50, eruptionRecover: 0.52, eruptionStunSeconds: 0.42, eruptionWindup: 0.76, hourglassCooldown: 8.1, hourglassDamage: 35, hourglassHazardSeconds: 2.2, hourglassRadius: 66, hourglassRecover: 0.48, hourglassSafeSeconds: 1.05, hourglassTickSeconds: 0.5, meleeCooldown: 1.25, meleeDamage: 22, 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() 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 { 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 }