import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses' import type { Iwt2BossTickResult } from './bossAi' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2EntityId, Iwt2MechanicLinkState, Iwt2PartyEntityState, } from './types' import { applyPartyDamageInShape, createLaneIndicator, createLinearMovementPlan, indicatorPhaseFromAttack, } from './mechanics' import { clampVec2ToArena, distanceVec2, moveToward, scaleVec2, subtractVec2, withFallbackFacing, } from './vector' export function tickTobiKadachi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { const events: Iwt2ArenaEvent[] = [] let party = state.party let boss = { ...state.boss, meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt), mechanicEnergy: Math.min( state.boss.mechanicEnergyMax, state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt, ), phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), velocity: { x: 0, y: 0 }, } const target = getBossTarget(party) if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events }) if (boss.attackPhase === 'staticPounceWindup') { boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing), } if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' } return withTobiIndicators({ boss, party, events }) } if (boss.attackPhase === 'staticPouncing') { const nextPosition = clampVec2ToArena( moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt), boss.radius, state.bounds, ) const hitResult = applyPounceHits(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), } if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) { boss = { ...boss, attackPhase: 'staticPounceRecover', phaseSecondsRemaining: 0.42, velocity: { x: 0, y: 0 }, } } return withTobiIndicators({ boss, party, events }) } if (boss.attackPhase === 'chainShockWindup') { if (boss.phaseSecondsRemaining <= 0) { const result = applyChainShock(party, boss, state.time + dt) party = result.party events.push(...result.events) boss = { ...boss, attackPhase: 'chainShockRecover', phaseSecondsRemaining: 0.42, } } return withTobiIndicators({ boss, party, events }) } if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') { if (boss.phaseSecondsRemaining <= 0) { boss = { ...boss, attackPhase: 'idle', mechanicLinks: [], phaseSecondsRemaining: 0, } } return withTobiIndicators({ boss, party, events }) } if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) { const pounce = createLinearMovementPlan({ bounds: state.bounds, from: boss.position, length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!, radius: boss.radius, target: target.position, }) boss = { ...boss, attackPhase: 'staticPounceWindup', chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown, chargeEnd: pounce.end, chargeHitEntityIds: [], chargeStart: { ...boss.position }, facing: pounce.direction, mechanicEnergy: 0, phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!, velocity: { x: 0, y: 0 }, } return withTobiIndicators({ boss, party, events }) } if (boss.fireballCooldownRemaining <= 0) { const links = createChainLinks(party) boss = { ...boss, attackPhase: 'chainShockWindup', fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!, mechanicLinks: links, phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!, velocity: { x: 0, y: 0 }, } return withTobiIndicators({ boss, party, events }) } const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) party = meleeResult.party events.push(...meleeResult.events) boss = meleeResult.boss if (events.length > 0) return withTobiIndicators({ boss, party, events }) const nextPosition = clampVec2ToArena( moveToward(boss.position, target.position, TOBI_KADACHI_BOSS_METADATA.moveSpeed * dt), boss.radius, state.bounds, ) boss = { ...boss, position: nextPosition, velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), } return withTobiIndicators({ boss, party, events }) } function applyPounceHits( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, start: Iwt2BossEntityState['position'], end: Iwt2BossEntityState['position'], time: number, ): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } { const result = applyPartyDamageInShape(party, { kind: 'lane', start, end, width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!, }, { damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!, excludedEntityIds: boss.chargeHitEntityIds, knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!, sourceId: boss.id, stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!, time, }) return { ...result, hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds], } } function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] { const living = party.filter((member) => member.health > 0) const links: Iwt2MechanicLinkState[] = [] for (let a = 0; a < living.length; a += 1) { for (let b = a + 1; b < living.length; b += 1) { if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue links.push({ id: `chain-${living[a].id}-${living[b].id}`, start: { ...living[a].position }, end: { ...living[b].position }, width: 5, }) } } return links.slice(0, 6) } function applyChainShock( party: Iwt2PartyEntityState[], boss: Iwt2BossEntityState, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { let nextParty = party const events: Iwt2ArenaEvent[] = [] const hitEntityIds: Iwt2EntityId[] = [] for (const link of boss.mechanicLinks) { const result = applyPartyDamageInShape(nextParty, { kind: 'lane', start: link.start, end: link.end, width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12, }, { damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!, excludedEntityIds: hitEntityIds, knockdownSeconds: 0, sourceId: boss.id, stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!, time, }) nextParty = result.party hitEntityIds.push(...result.hitEntityIds) events.push(...result.events) } return { party: nextParty, events } } 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, ): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } if (distanceVec2(boss.position, target.position) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) { return { boss, party, events: [] } } const result = applyPartyDamageInShape(party, { kind: 'circle', position: boss.position, radius: TOBI_KADACHI_BOSS_METADATA.meleeRange, }, { damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage, sourceId: boss.id, time, }) return { boss: { ...boss, mechanicEnergy: Math.min( boss.mechanicEnergyMax, boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!, ), meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown, }, party: result.party, events: result.events, } } function withTobiIndicators(result: Omit): Iwt2BossTickResult { return { ...result, indicators: createTobiIndicators(result.boss), } } function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { const indicators: Iwt2ArenaIndicator[] = [] if ( boss.attackPhase === 'staticPounceWindup' || boss.attackPhase === 'staticPouncing' || boss.attackPhase === 'staticPounceRecover' ) { indicators.push(createLaneIndicator({ color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff', end: boss.chargeEnd, id: `${boss.id}:static-pounce`, mechanicId: 'tobi-static-pounce', phase: indicatorPhaseFromAttack( boss.attackPhase === 'staticPounceWindup', boss.attackPhase === 'staticPouncing', ), sourceId: boss.id, start: boss.chargeStart, width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!, })) } if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') { for (const link of boss.mechanicLinks) { indicators.push(createLaneIndicator({ color: '#a8f3ff', end: link.end, id: `${boss.id}:${link.id}`, mechanicId: 'tobi-chain-shock', phase: indicatorPhaseFromAttack( boss.attackPhase === 'chainShockWindup', boss.attackPhase === 'chainShockRecover', ), sourceId: boss.id, start: link.start, width: link.width, })) } } return indicators }