import type { Iwt2BossTickResult } from './bossAi' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2EntityId, Iwt2GroundHazardState, Iwt2PartyEntityState, Iwt2Vec2, } from './types' import { addFirePuddle } from './hazards' import { applyPartyDamageInShape, createArenaEvent, createCircleIndicator, createLaneIndicator, indicatorPhaseFromAttack, } from './mechanics' import { addVec2, clampVec2ToArena, distanceVec2, normalizeVec2, scaleVec2, subtractVec2, withFallbackFacing, } from './vector' const CINDERBACK_PHASE = { armorSlamRecover: 'cinderbackArmorSlamRecover', armorSlamWindup: 'cinderbackArmorSlamWindup', ricochetWindup: 'cinderbackRicochetWindup', ricocheting: 'cinderbackRicocheting', } as const type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE] const CINDERBACK_TUNING = { armorSlamDamage: 44, armorSlamRadius: 118, armorSlamRecover: 1.05, armorSlamStunSeconds: 0.7, armorSlamWindup: 0.62, centerDisengageSpeed: 152, firePuddleDamage: 15, firePuddleRadius: 34, firePuddleSeconds: 6.4, lavaTrailInterval: 0.26, meleeCooldown: 1.12, meleeDamage: 11, meleeRange: 58, moveSpeed: 104, ricochetCooldown: 7.2, ricochetDamage: 40, ricochetDuration: 3.1, ricochetMaxBounces: 4, ricochetSpeed: 470, ricochetStunSeconds: 0.42, ricochetWidth: 46, ricochetWindup: 0.72, wallContactLimit: 0.48, wallMargin: 64, } export function tickCinderbackRicochet(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { const events: Iwt2ArenaEvent[] = [] let hazards = state.hazards let nextHazardId = state.nextHazardId let party = state.party const incomingPhase = cinderbackPhase(state.boss) 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 }, } boss = { ...boss, wallContactSeconds: incomingPhase === CINDERBACK_PHASE.ricocheting || incomingPhase === CINDERBACK_PHASE.armorSlamWindup || incomingPhase === CINDERBACK_PHASE.armorSlamRecover ? 0 : isBossNearWall(boss, state) ? boss.wallContactSeconds + dt : Math.max(0, boss.wallContactSeconds - dt * 2), } const target = getBossTarget(party) if (boss.health <= 0 || !target) { return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } const phase = cinderbackPhase(boss) if (phase === CINDERBACK_PHASE.ricochetWindup) { boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing), velocity: { x: 0, y: 0 }, } if (boss.phaseSecondsRemaining <= 0) { const facing = wallSafeRollDirection(boss, target.position, state) boss = { ...boss, attackPhase: asBossPhase(CINDERBACK_PHASE.ricocheting), chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state), chargeHitEntityIds: [], chargeStart: { ...boss.position }, facing, fireballCooldownRemaining: 0, mechanicEnergy: 0, phaseSecondsRemaining: CINDERBACK_TUNING.ricochetDuration, } } return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } if (phase === CINDERBACK_PHASE.ricocheting) { const roll = moveRicochet(boss, state, dt) const hitResult = applyRicochetHits(party, boss, boss.position, roll.position, state.time + dt) party = hitResult.party events.push(...hitResult.events) const trailResult = maybeAddLavaTrail({ boss, hazards, nextHazardId, position: roll.position, time: state.time + dt, }) hazards = trailResult.hazards nextHazardId = trailResult.nextHazardId const bounceCount = boss.mechanicEnergy + roll.bounces const wallContactSeconds = roll.bounces > 0 || roll.cornerBounce ? 0 : isBossNearWall({ ...boss, position: roll.position }, state) ? boss.wallContactSeconds + dt : Math.max(0, boss.wallContactSeconds - dt * 2) boss = { ...boss, chargeEnd: nextWallImpact(roll.position, roll.facing, boss.radius, state), chargeHitEntityIds: hitResult.hitEntityIds, chargeStart: { ...roll.position }, facing: roll.facing, fireballCooldownRemaining: trailResult.nextTrailInSeconds, mechanicEnergy: bounceCount, position: roll.position, velocity: roll.velocity, wallContactSeconds, } if ( boss.phaseSecondsRemaining <= 0 || bounceCount >= CINDERBACK_TUNING.ricochetMaxBounces || wallContactSeconds >= CINDERBACK_TUNING.wallContactLimit || roll.cornerBounce ) { boss = beginArmorSlam(boss, state) } return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } if (phase === CINDERBACK_PHASE.armorSlamWindup) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { const slamResult = applyArmorSlam(party, boss, state.time + dt) const puddleResult = addArmorSlamPuddles({ boss, hazards, nextHazardId, state, time: state.time + dt, }) party = slamResult.party hazards = puddleResult.hazards nextHazardId = puddleResult.nextHazardId events.push(...slamResult.events) boss = { ...boss, attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamRecover), mechanicEnergy: 0, phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamRecover, } } return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } if (phase === CINDERBACK_PHASE.armorSlamRecover) { boss = { ...boss, velocity: { x: 0, y: 0 } } if (boss.phaseSecondsRemaining <= 0) { boss = { ...boss, attackPhase: 'idle', chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.4), phaseSecondsRemaining: 0, } } return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } if (boss.wallContactSeconds >= 0.68) { boss = moveBossTowardCenter(boss, state, CINDERBACK_TUNING.centerDisengageSpeed, dt) return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } if (boss.chargeCooldownRemaining <= 0) { const facing = wallSafeRollDirection(boss, target.position, state) boss = { ...boss, attackPhase: asBossPhase(CINDERBACK_PHASE.ricochetWindup), chargeCooldownRemaining: CINDERBACK_TUNING.ricochetCooldown, chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state), chargeHitEntityIds: [], chargeStart: { ...boss.position }, facing, phaseSecondsRemaining: CINDERBACK_TUNING.ricochetWindup, velocity: { x: 0, y: 0 }, } events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id)) return withCinderbackIndicators({ 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 withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) const nextPosition = clampVec2ToArena( moveTowardKeepingDistance(boss.position, target.position, CINDERBACK_TUNING.moveSpeed * dt), boss.radius, state.bounds, ) boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), position: nextPosition, velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), } return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId }) } function beginArmorSlam(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState { const position = clampVec2ToArena(disengageFromWall(boss.position, boss.radius, state), boss.radius, state.bounds) return { ...boss, attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamWindup), chargeEnd: { ...position }, chargeStart: { ...position }, mechanicEnergy: 0, phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamWindup, position, velocity: { x: 0, y: 0 }, wallContactSeconds: 0, } } function moveRicochet( boss: Iwt2BossEntityState, state: Iwt2ArenaState, dt: number, ): { bounces: number, cornerBounce: boolean, facing: Iwt2Vec2, position: Iwt2Vec2, velocity: Iwt2Vec2 } { const minX = boss.radius const maxX = state.bounds.width - boss.radius const minY = boss.radius const maxY = state.bounds.height - boss.radius let facing = withFallbackFacing(boss.facing, { x: 1, y: 0 }) let position = addVec2(boss.position, scaleVec2(facing, CINDERBACK_TUNING.ricochetSpeed * dt)) let xBounce = false let yBounce = false if (position.x < minX) { position = { ...position, x: minX + (minX - position.x) } facing = { ...facing, x: Math.abs(facing.x) } xBounce = true } else if (position.x > maxX) { position = { ...position, x: maxX - (position.x - maxX) } facing = { ...facing, x: -Math.abs(facing.x) } xBounce = true } if (position.y < minY) { position = { ...position, y: minY + (minY - position.y) } facing = { ...facing, y: Math.abs(facing.y) } yBounce = true } else if (position.y > maxY) { position = { ...position, y: maxY - (position.y - maxY) } facing = { ...facing, y: -Math.abs(facing.y) } yBounce = true } position = clampVec2ToArena(position, boss.radius, state.bounds) const cornerBounce = xBounce && yBounce if (cornerBounce) { facing = withFallbackFacing(subtractVec2(arenaCenter(state), position), facing) position = disengageFromWall(position, boss.radius, state) } facing = normalizeVec2(facing) return { bounces: Number(xBounce) + Number(yBounce), cornerBounce, facing, position, velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0), } } function maybeAddLavaTrail({ boss, hazards, nextHazardId, position, time, }: { boss: Iwt2BossEntityState hazards: Iwt2GroundHazardState[] nextHazardId: number position: Iwt2Vec2 time: number }): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } { if (boss.fireballCooldownRemaining > 0) { return { hazards, nextHazardId, nextTrailInSeconds: boss.fireballCooldownRemaining } } const result = addFirePuddle({ damage: CINDERBACK_TUNING.firePuddleDamage, duration: CINDERBACK_TUNING.firePuddleSeconds, hazards, nextHazardId, position, radius: CINDERBACK_TUNING.firePuddleRadius, sourceId: boss.id, time, }) return { hazards: result.hazards, nextHazardId: result.nextHazardId, nextTrailInSeconds: CINDERBACK_TUNING.lavaTrailInterval, } } function addArmorSlamPuddles({ boss, hazards, nextHazardId, state, time, }: { boss: Iwt2BossEntityState hazards: Iwt2GroundHazardState[] nextHazardId: number state: Iwt2ArenaState time: number }): { hazards: Iwt2GroundHazardState[], nextHazardId: number } { let nextHazards = hazards let nextId = nextHazardId const angles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3] for (const angle of angles) { const position = clampVec2ToArena({ x: boss.position.x + Math.cos(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72, y: boss.position.y + Math.sin(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72, }, CINDERBACK_TUNING.firePuddleRadius, state.bounds) const result = addFirePuddle({ damage: CINDERBACK_TUNING.firePuddleDamage, duration: CINDERBACK_TUNING.firePuddleSeconds, hazards: nextHazards, nextHazardId: nextId, position, radius: CINDERBACK_TUNING.firePuddleRadius, sourceId: boss.id, time, }) nextHazards = result.hazards nextId = result.nextHazardId } return { hazards: nextHazards, nextHazardId: nextId } } function applyRicochetHits( 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: CINDERBACK_TUNING.ricochetWidth, }, { damage: CINDERBACK_TUNING.ricochetDamage, damageEventType: 'bossChargeHit', excludedEntityIds: boss.chargeHitEntityIds, knockdownSeconds: CINDERBACK_TUNING.ricochetStunSeconds, sourceId: boss.id, stunSeconds: CINDERBACK_TUNING.ricochetStunSeconds, time, }) return { ...result, hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds], } } function applyArmorSlam( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape(party, { kind: 'circle', position: boss.position, radius: CINDERBACK_TUNING.armorSlamRadius, }, { damage: CINDERBACK_TUNING.armorSlamDamage, knockdownSeconds: CINDERBACK_TUNING.armorSlamStunSeconds, sourceId: boss.id, stunSeconds: CINDERBACK_TUNING.armorSlamStunSeconds, time, }) return { party: result.party, events: [ createArenaEvent(0, time, 'bossSlam', boss.id, undefined, CINDERBACK_TUNING.armorSlamRadius), ...result.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) > CINDERBACK_TUNING.meleeRange + target.radius) { return { boss, party, events: [] } } const result = applyPartyDamageInShape(party, { kind: 'circle', position: boss.position, radius: CINDERBACK_TUNING.meleeRange, }, { damage: CINDERBACK_TUNING.meleeDamage, sourceId: boss.id, time, }) return { boss: { ...boss, meleeCooldownRemaining: CINDERBACK_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } }, events: result.events, party: result.party, } } function moveBossTowardCenter( boss: Iwt2BossEntityState, state: Iwt2ArenaState, speed: number, dt: number, ): Iwt2BossEntityState { const center = arenaCenter(state) const nextPosition = clampVec2ToArena( moveTowardKeepingDistance(boss.position, center, speed * dt), boss.radius, state.bounds, ) return { ...boss, facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing), position: nextPosition, velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), wallContactSeconds: 0, } } function moveTowardKeepingDistance(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 nextWallImpact( position: Iwt2Vec2, direction: Iwt2Vec2, radius: number, state: Iwt2ArenaState, ): Iwt2Vec2 { const facing = withFallbackFacing(direction, { x: 1, y: 0 }) const times: number[] = [] if (Math.abs(facing.x) > 0.0001) { times.push(((facing.x > 0 ? state.bounds.width - radius : radius) - position.x) / facing.x) } if (Math.abs(facing.y) > 0.0001) { times.push(((facing.y > 0 ? state.bounds.height - radius : radius) - position.y) / facing.y) } const impactDistance = Math.max(0, Math.min(...times.filter((value) => value > 0))) return clampVec2ToArena(addVec2(position, scaleVec2(facing, impactDistance)), radius, state.bounds) } function wallSafeRollDirection( boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2, state: Iwt2ArenaState, ): Iwt2Vec2 { if (!isBossNearWall(boss, state)) return withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing) const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position)) return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.25), scaleVec2(targetBias, 0.55)), centerBias) } function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean { const margin = boss.radius + CINDERBACK_TUNING.wallMargin return ( boss.position.x <= margin || boss.position.x >= state.bounds.width - margin || boss.position.y <= margin || boss.position.y >= state.bounds.height - margin ) } function disengageFromWall(position: Iwt2Vec2, radius: number, state: Iwt2ArenaState): Iwt2Vec2 { const center = arenaCenter(state) const margin = radius + CINDERBACK_TUNING.wallMargin * 0.55 const clamped = { x: Math.min(state.bounds.width - margin, Math.max(margin, position.x)), y: Math.min(state.bounds.height - margin, Math.max(margin, position.y)), } if (clamped.x !== position.x || clamped.y !== position.y) return clamped return addVec2(position, scaleVec2(normalizeVec2(subtractVec2(center, position)), 18)) } function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 { return { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5, } } 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 withCinderbackIndicators(result: Omit): Iwt2BossTickResult { return { ...result, indicators: createCinderbackIndicators(result.boss), } } function createCinderbackIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { const indicators: Iwt2ArenaIndicator[] = [] const phase = cinderbackPhase(boss) if ( phase === CINDERBACK_PHASE.ricochetWindup || phase === CINDERBACK_PHASE.ricocheting ) { indicators.push(createLaneIndicator({ color: phase === CINDERBACK_PHASE.ricocheting ? '#ff6a2a' : '#ffd166', end: boss.chargeEnd, id: `${boss.id}:cinderback-ricochet-lane`, mechanicId: 'cinderback-ricochet', phase: indicatorPhaseFromAttack( phase === CINDERBACK_PHASE.ricochetWindup, phase === CINDERBACK_PHASE.ricocheting, ), sourceId: boss.id, start: boss.chargeStart, width: CINDERBACK_TUNING.ricochetWidth, })) } if ( phase === CINDERBACK_PHASE.armorSlamWindup || phase === CINDERBACK_PHASE.armorSlamRecover ) { indicators.push(createCircleIndicator({ color: '#ff7a2f', id: `${boss.id}:cinderback-armor-slam`, mechanicId: 'cinderback-armor-slam', phase: indicatorPhaseFromAttack( phase === CINDERBACK_PHASE.armorSlamWindup, phase === CINDERBACK_PHASE.armorSlamRecover, ), position: boss.position, radius: CINDERBACK_TUNING.armorSlamRadius, sourceId: boss.id, })) } return indicators } function cinderbackPhase(boss: Iwt2BossEntityState): string { return boss.attackPhase as string } function asBossPhase(phase: CinderbackPhase): Iwt2BossEntityState['attackPhase'] { return phase as unknown as Iwt2BossEntityState['attackPhase'] }