413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
|
|
import type {
|
|
Iwt2ArenaEvent,
|
|
Iwt2ArenaIndicator,
|
|
Iwt2ArenaState,
|
|
Iwt2BossEntityState,
|
|
Iwt2EntityId,
|
|
Iwt2GroundHazardState,
|
|
Iwt2HostileAddState,
|
|
Iwt2PartyEntityState,
|
|
Iwt2ProjectileEntityState,
|
|
Iwt2Vec2,
|
|
} from './types'
|
|
import { tickGreatJaggi } from './greatJaggiAi'
|
|
import { tickKhezu } from './khezuAi'
|
|
import { tickBarroth } from './barrothAi'
|
|
import { tickCinderbackRicochet } from './cinderbackAi'
|
|
import { tickCrystalBatMatriarch } from './crystalBatMatriarchAi'
|
|
import { tickEmberMantisDuelist } from './emberMantisAi'
|
|
import { tickHollowcrownRevenant } from './hollowcrownRevenantAi'
|
|
import { tickObsidianRamGolem } from './obsidianRamGolemAi'
|
|
import { tickRathian } from './rathianAi'
|
|
import { tickRimebastion } from './rimebastionAi'
|
|
import { tickSandglassScorpion } from './sandglassScorpionAi'
|
|
import { tickStormcoilWyrm } from './stormcoilWyrmAi'
|
|
import { tickTobiKadachi } from './tobiKadachiAi'
|
|
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
|
|
import { tickYianKutKu } from './yianKutKuAi'
|
|
import {
|
|
applyPartyDamageToMember,
|
|
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)
|
|
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
|
|
if (state.boss.bossId === 'khezu') return tickKhezu(state, dt)
|
|
if (state.boss.bossId === 'rathian') return tickRathian(state, dt)
|
|
if (state.boss.bossId === 'barroth') return tickBarroth(state, dt)
|
|
if (state.boss.bossId === 'tobi-kadachi') return tickTobiKadachi(state, dt)
|
|
if (state.boss.bossId === 'rimebastion') return tickRimebastion(state, dt)
|
|
if (state.boss.bossId === 'ember-mantis-duelist') return tickEmberMantisDuelist(state, dt)
|
|
if (state.boss.bossId === 'cinderback-ricochet') return tickCinderbackRicochet(state, dt)
|
|
if (state.boss.bossId === 'obsidian-ram-golem') return tickObsidianRamGolem(state, dt)
|
|
if (state.boss.bossId === 'stormcoil-wyrm') return tickStormcoilWyrm(state, dt)
|
|
if (state.boss.bossId === 'venom-orchid-hydra') return tickVenomOrchidHydra(state, dt)
|
|
if (state.boss.bossId === 'sandglass-scorpion') return tickSandglassScorpion(state, dt)
|
|
if (state.boss.bossId === 'crystal-bat-matriarch') return tickCrystalBatMatriarch(state, dt)
|
|
if (state.boss.bossId === 'hollowcrown-revenant') return tickHollowcrownRevenant(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<Iwt2BossTickResult, 'indicators'> {
|
|
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
|
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
|
|
return { boss, party, events: [] }
|
|
}
|
|
let appliedDamage = 0
|
|
let nextTargetHealth = target.health
|
|
const nextParty = party.map((member) => {
|
|
if (member.id !== target.id) return member
|
|
const result = applyPartyDamageToMember(member, BULLDROME_BOSS_METADATA.meleeDamage)
|
|
appliedDamage = result.damage
|
|
nextTargetHealth = result.member.health
|
|
return result.member
|
|
})
|
|
const events = [
|
|
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, appliedDamage),
|
|
]
|
|
if (target.health > 0 && nextTargetHealth <= 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, 'indicators'>,
|
|
): 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
|
|
}
|