import { BULLDROME_BOSS_METADATA } from '../content/bosses' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2EntityId, Iwt2GroundHazardState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2ProjectileEntityState, Iwt2Vec2, } from './types' import { tickYianKutKu } from './yianKutKuAi' import { applyPartyDamageInShape, createArenaEvent, createCircleIndicator, createLaneIndicator, createLinearMovementPlan, indicatorPhaseFromAttack, } from './mechanics' import { clampVec2ToArena, distanceVec2, moveToward, scaleVec2, subtractVec2, withFallbackFacing, } from './vector' export type Iwt2BossTickResult = { boss: Iwt2BossEntityState party: Iwt2PartyEntityState[] hostileAdds?: Iwt2HostileAddState[] hazards?: Iwt2GroundHazardState[] projectiles?: Iwt2ProjectileEntityState[] events: Iwt2ArenaEvent[] indicators: Iwt2ArenaIndicator[] nextAddId?: number nextHazardId?: number nextProjectileId?: number } export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt) return tickBulldrome(state, dt) } export function tickBulldrome(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { const events: Iwt2ArenaEvent[] = [] const target = getBossTarget(state.party) let party = state.party 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), } const wallContactSeconds = isBossNearWall(boss, state) ? boss.wallContactSeconds + dt : Math.max(0, boss.wallContactSeconds - dt * 2) boss = { ...boss, wallContactSeconds } if (boss.health <= 0 || !target) { return withBulldromeIndicators({ boss: { ...boss, velocity: { x: 0, y: 0 } }, party, events }) } if (boss.attackPhase === 'relocating') { const nextPosition = clampVec2ToArena( moveToward(boss.position, boss.relocateTarget, BULLDROME_BOSS_METADATA.moveSpeed * 1.45 * dt), boss.radius, state.bounds, ) const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) boss = { ...boss, position: nextPosition, velocity, facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0, } if (boss.phaseSecondsRemaining <= 0 || distanceVec2(nextPosition, boss.relocateTarget) <= 8) { boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0, chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.2), velocity: { x: 0, y: 0 }, } } return withBulldromeIndicators({ boss, party, events }) } if (boss.attackPhase === 'chargeWindup') { boss = { ...boss, velocity: { x: 0, y: 0 }, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing), } if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'charging' } return withBulldromeIndicators({ boss, party, events }) } if (boss.attackPhase === 'charging') { const nextPosition = clampVec2ToArena( moveToward(boss.position, boss.chargeEnd, BULLDROME_BOSS_METADATA.chargeSpeed * dt), boss.radius, state.bounds, ) const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt) party = hitResult.party events.push(...hitResult.events) const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) boss = { ...boss, position: nextPosition, velocity, facing: withFallbackFacing(velocity, boss.facing), chargeHitEntityIds: hitResult.hitEntityIds, } if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) { const needsSlam = boss.chargeCount % 3 === 0 boss = { ...boss, attackPhase: needsSlam ? 'slamWindup' : 'chargeRecover', phaseSecondsRemaining: needsSlam ? BULLDROME_BOSS_METADATA.slamWindup : 0.45, slamApplied: false, velocity: { x: 0, y: 0 }, } } return withBulldromeIndicators({ boss, party, events }) } if (boss.attackPhase === 'slamWindup') { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { const slamResult = applySlam(party, boss, state.time + dt) party = slamResult.party events.push(...slamResult.events) boss = { ...boss, attackPhase: 'slamRecover', phaseSecondsRemaining: 0.5, slamApplied: true, } } return withBulldromeIndicators({ boss, party, events }) } if (boss.attackPhase === 'chargeRecover' || boss.attackPhase === 'slamRecover') { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 } } return withBulldromeIndicators({ boss, party, events }) } if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= 1.1) { boss = { ...boss, attackPhase: 'relocating', phaseSecondsRemaining: 1.8, relocateTarget: relocationTarget(boss, state), velocity: { x: 0, y: 0 }, } return withBulldromeIndicators({ boss, party, events }) } if (boss.chargeCooldownRemaining <= 0) { const charge = createLinearMovementPlan({ bounds: state.bounds, from: boss.position, length: BULLDROME_BOSS_METADATA.chargeLength, radius: boss.radius, target: target.position, }) boss = { ...boss, attackPhase: 'chargeWindup', phaseSecondsRemaining: BULLDROME_BOSS_METADATA.chargeWindup, chargeCooldownRemaining: BULLDROME_BOSS_METADATA.chargeCooldown, chargeCount: boss.chargeCount + 1, chargeStart: { ...boss.position }, chargeEnd: charge.end, chargeHitEntityIds: [], slamApplied: false, facing: charge.direction, velocity: { x: 0, y: 0 }, } events.push(createArenaEvent(state.nextEventId + events.length, state.time + dt, 'bossChargeStart', boss.id, target.id)) return withBulldromeIndicators({ boss, party, events }) } const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt, state.nextEventId + events.length) party = meleeResult.party events.push(...meleeResult.events) boss = meleeResult.boss if (meleeResult.events.length > 0) return withBulldromeIndicators({ boss, party, events }) const nextPosition = clampVec2ToArena( moveToward(boss.position, target.position, BULLDROME_BOSS_METADATA.moveSpeed * dt), boss.radius, state.bounds, ) const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) boss = { ...boss, position: nextPosition, velocity, facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), } return withBulldromeIndicators({ boss, party, events }) } function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState) { const margin = boss.radius + 10 return ( boss.position.x <= margin || boss.position.x >= state.bounds.width - margin || boss.position.y <= margin || boss.position.y >= state.bounds.height - margin ) } function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 { const center = { x: state.bounds.width * 0.58, y: state.bounds.height * 0.5 } const awayFromWall = { x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1, y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1, } return { x: Math.min(state.bounds.width - boss.radius, Math.max(boss.radius, center.x + awayFromWall.x * 70)), y: Math.min(state.bounds.height - boss.radius, Math.max(boss.radius, center.y + awayFromWall.y * 54)), } } 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, firstEventId: number, ): Omit { if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) { return { boss, party, events: [] } } const nextParty = party.map((member) => { if (member.id !== target.id) return member return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) } }) const events = [ createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage), ] if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) { events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id)) } return { boss: { ...boss, meleeCooldownRemaining: BULLDROME_BOSS_METADATA.meleeCooldown, velocity: { x: 0, y: 0 } }, party: nextParty, events, } } function applyChargeHits( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, start: Iwt2Vec2, end: Iwt2Vec2, time: number, ): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape( party, { kind: 'lane', start, end, width: boss.radius, }, { damage: BULLDROME_BOSS_METADATA.chargeDamage, damageEventType: 'bossChargeHit', excludedEntityIds: boss.chargeHitEntityIds, knockdownSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds, sourceId: boss.id, stunSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds, time, }, ) return { ...result, hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds], } } function applySlam( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape( party, { kind: 'circle', position: boss.position, radius: BULLDROME_BOSS_METADATA.slamRadius, }, { damage: BULLDROME_BOSS_METADATA.slamDamage, knockdownSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds, sourceId: boss.id, stunSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds, time, }, ) return { party: result.party, events: [ createArenaEvent(0, time, 'bossSlam', boss.id, undefined, BULLDROME_BOSS_METADATA.slamRadius), ...result.events, ], } } function withBulldromeIndicators( result: Omit, ): Iwt2BossTickResult { return { ...result, indicators: createBulldromeIndicators(result.boss), } } function createBulldromeIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { const indicators: Iwt2ArenaIndicator[] = [] if ( boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging' || boss.attackPhase === 'chargeRecover' ) { indicators.push(createLaneIndicator({ color: boss.attackPhase === 'charging' ? '#f05b4f' : '#f1c663', end: boss.chargeEnd, id: `${boss.id}:charge-lane`, mechanicId: 'bulldrome-charge', phase: indicatorPhaseFromAttack( boss.attackPhase === 'chargeWindup', boss.attackPhase === 'charging', ), sourceId: boss.id, start: boss.chargeStart, width: boss.radius, })) } if (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover') { indicators.push(createCircleIndicator({ color: '#ff7f50', id: `${boss.id}:slam-circle`, mechanicId: 'bulldrome-slam', phase: indicatorPhaseFromAttack( boss.attackPhase === 'slamWindup', boss.attackPhase === 'slamRecover', ), position: boss.position, radius: BULLDROME_BOSS_METADATA.slamRadius, sourceId: boss.id, })) } return indicators }