606 lines
19 KiB
TypeScript
606 lines
19 KiB
TypeScript
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, 'indicators'>): 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']
|
|
}
|