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 & { attackPhase: RimebastionAttackPhase } type ShardCone = { id: string origin: Iwt2Vec2 direction: Iwt2Vec2 range: number angleRadians: number } const RIMEBASTION_BALANCE = { iceWallCooldown: 7.2, iceWallDamage: 18, iceWallStunSeconds: 0.25, iceWallWindup: 1, iceCircleCount: 2, iceCircleRadius: 58, iceWallLaneCount: 3, iceWallWidth: 22, meleeCooldown: 1.45, meleeDamage: 10, meleeRange: 66, moveSpeed: 64, relocateCooldownFloor: 1.3, relocateSpeed: 112, relocateSeconds: 1.6, shatterConeAngleRadians: Math.PI * 0.34, shatterConeDamage: 30, shatterConeRange: 158, shatterLaneDamage: 34, 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, 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 }