683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
import type { Iwt2BossTickResult } from './bossAi'
|
|
import type {
|
|
Iwt2ArenaEvent,
|
|
Iwt2ArenaIndicator,
|
|
Iwt2ArenaState,
|
|
Iwt2BossEntityState,
|
|
Iwt2EntityId,
|
|
Iwt2MechanicCircleState,
|
|
Iwt2MechanicLaneState,
|
|
Iwt2PartyEntityState,
|
|
Iwt2Vec2,
|
|
} from './types'
|
|
import {
|
|
applyPartyDamageInShape,
|
|
createCircleIndicator,
|
|
createDonutIndicator,
|
|
createLaneIndicator,
|
|
indicatorPhaseFromAttack,
|
|
} from './mechanics'
|
|
import {
|
|
addVec2,
|
|
clamp,
|
|
clampVec2ToArena,
|
|
distanceVec2,
|
|
normalizeVec2,
|
|
scaleVec2,
|
|
subtractVec2,
|
|
withFallbackFacing,
|
|
} from './vector'
|
|
|
|
const CRYSTAL_BAT_PHASE = {
|
|
shardRecover: 'crystalBatShardRecover',
|
|
shardWindup: 'crystalBatShardWindup',
|
|
sonicRecover: 'crystalBatSonicRecover',
|
|
sonicWindup: 'crystalBatSonicWindup',
|
|
swoopRecover: 'crystalBatSwoopRecover',
|
|
swoopWindup: 'crystalBatSwoopWindup',
|
|
swooping: 'crystalBatSwooping',
|
|
} as const
|
|
|
|
type CrystalBatPhase = typeof CRYSTAL_BAT_PHASE[keyof typeof CRYSTAL_BAT_PHASE]
|
|
|
|
const CRYSTAL_BAT_TUNING = {
|
|
clusterRadius: 84,
|
|
hoverDistance: 138,
|
|
meleeCooldown: 1.18,
|
|
meleeDamage: 10,
|
|
meleeRange: 54,
|
|
moveSpeed: 138,
|
|
relocateCooldownFloor: 1.2,
|
|
relocateSeconds: 1.25,
|
|
relocateSpeed: 184,
|
|
shardCooldown: 5.8,
|
|
shardDamage: 34,
|
|
shardLaneWidth: 30,
|
|
shardRecover: 0.58,
|
|
shardStunSeconds: 0.35,
|
|
shardWindup: 0.82,
|
|
sonicCooldown: 6.4,
|
|
sonicDamage: 30,
|
|
sonicRecover: 0.42,
|
|
sonicRingRadius: 58,
|
|
sonicStunSeconds: 0.32,
|
|
sonicWindup: 0.78,
|
|
swoopCooldown: 4.9,
|
|
swoopDamage: 42,
|
|
swoopLaneWidth: 44,
|
|
swoopRecover: 0.62,
|
|
swoopSpeed: 430,
|
|
swoopStunSeconds: 0.48,
|
|
swoopWindup: 0.56,
|
|
wallContactLimit: 0.7,
|
|
wallMargin: 62,
|
|
} as const
|
|
|
|
const SONIC_COLOR = '#d9b7ff'
|
|
const SHARD_COLOR = '#9de9ff'
|
|
const SWOOP_COLOR = '#f6d36b'
|
|
|
|
export function tickCrystalBatMatriarch(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
|
const events: Iwt2ArenaEvent[] = []
|
|
let party = state.party
|
|
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 },
|
|
}
|
|
const activePhase = crystalBatPhase(boss)
|
|
boss = {
|
|
...boss,
|
|
wallContactSeconds: activePhase !== 'idle'
|
|
? 0
|
|
: isBossNearWall(boss, state)
|
|
? boss.wallContactSeconds + dt
|
|
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
|
}
|
|
|
|
const target = getBossTarget(party)
|
|
if (boss.health <= 0 || !target) return withCrystalBatIndicators({ boss, events, party })
|
|
|
|
const phase = crystalBatPhase(boss)
|
|
if (phase === '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, CRYSTAL_BAT_TUNING.relocateCooldownFloor),
|
|
mechanicCircles: [],
|
|
mechanicLanes: [],
|
|
phaseSecondsRemaining: 0,
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.sonicWindup) {
|
|
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
const result = applySonicRings(party, boss, state.time + dt)
|
|
party = result.party
|
|
events.push(...result.events)
|
|
boss = {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicRecover),
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicRecover,
|
|
}
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.sonicRecover) {
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0 }
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.shardWindup) {
|
|
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
const result = applyShardLanes(party, boss, state.time + dt)
|
|
party = result.party
|
|
events.push(...result.events)
|
|
boss = {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardRecover),
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardRecover,
|
|
}
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.shardRecover) {
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
boss = { ...boss, attackPhase: 'idle', mechanicLanes: [], phaseSecondsRemaining: 0 }
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.swoopWindup) {
|
|
boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing) }
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
boss = {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swooping),
|
|
chargeHitEntityIds: [],
|
|
}
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.swooping) {
|
|
const nextPosition = clampVec2ToArena(
|
|
moveTowardLimited(boss.position, boss.chargeEnd, CRYSTAL_BAT_TUNING.swoopSpeed * dt),
|
|
boss.radius,
|
|
state.bounds,
|
|
)
|
|
const hitResult = applySwoopHits(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),
|
|
wallContactSeconds: 0,
|
|
}
|
|
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2) {
|
|
boss = {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopRecover),
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopRecover,
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.swoopRecover) {
|
|
if (boss.phaseSecondsRemaining <= 0) {
|
|
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
|
|
}
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
const cluster = findBestCluster(party)
|
|
if (boss.wallContactSeconds >= CRYSTAL_BAT_TUNING.wallContactLimit) {
|
|
boss = beginRelocation(boss, state)
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (cluster.count >= 2 && boss.fireballCooldownRemaining <= 0) {
|
|
boss = beginSwoop(boss, cluster.center, state)
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
if (boss.chargeCooldownRemaining <= 0) {
|
|
boss = boss.chargeCount % 2 === 0
|
|
? beginSonicRings(boss, party, state)
|
|
: beginShardLanes(boss, party, state)
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
|
party = meleeResult.party
|
|
events.push(...meleeResult.events)
|
|
boss = meleeResult.boss
|
|
if (events.length > 0) return withCrystalBatIndicators({ boss, events, party })
|
|
|
|
boss = moveBossToHoverPoint(boss, state, target, dt)
|
|
return withCrystalBatIndicators({ boss, events, party })
|
|
}
|
|
|
|
function beginSonicRings(
|
|
boss: Iwt2BossEntityState,
|
|
party: Iwt2PartyEntityState[],
|
|
state: Iwt2ArenaState,
|
|
): Iwt2BossEntityState {
|
|
const circles = party
|
|
.filter((member) => member.health > 0)
|
|
.map((member) => ({
|
|
id: `crystal-sonic-${member.id}`,
|
|
position: clampVec2ToArena(member.position, CRYSTAL_BAT_TUNING.sonicRingRadius, state.bounds),
|
|
radius: CRYSTAL_BAT_TUNING.sonicRingRadius,
|
|
}))
|
|
return {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicWindup),
|
|
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.sonicCooldown,
|
|
chargeCount: boss.chargeCount + 1,
|
|
mechanicCircles: circles,
|
|
mechanicLanes: [],
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicWindup,
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
|
|
function beginShardLanes(
|
|
boss: Iwt2BossEntityState,
|
|
party: Iwt2PartyEntityState[],
|
|
state: Iwt2ArenaState,
|
|
): Iwt2BossEntityState {
|
|
const lanes = createReflectedShardLanes(boss, party, state)
|
|
return {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardWindup),
|
|
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.shardCooldown,
|
|
chargeCount: boss.chargeCount + 1,
|
|
mechanicCircles: [],
|
|
mechanicLanes: lanes,
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardWindup,
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
|
|
function beginSwoop(
|
|
boss: Iwt2BossEntityState,
|
|
targetPosition: Iwt2Vec2,
|
|
state: Iwt2ArenaState,
|
|
): Iwt2BossEntityState {
|
|
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
|
|
const end = clampVec2ToArena(addVec2(targetPosition, scaleVec2(facing, 170)), boss.radius, state.bounds)
|
|
return {
|
|
...boss,
|
|
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopWindup),
|
|
chargeEnd: end,
|
|
chargeHitEntityIds: [],
|
|
chargeStart: { ...boss.position },
|
|
facing,
|
|
fireballCooldownRemaining: CRYSTAL_BAT_TUNING.swoopCooldown,
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopWindup,
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
|
|
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
|
|
return {
|
|
...boss,
|
|
attackPhase: 'relocating',
|
|
mechanicCircles: [],
|
|
mechanicLanes: [],
|
|
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.relocateSeconds,
|
|
relocateTarget: relocationTarget(boss, state),
|
|
velocity: { x: 0, y: 0 },
|
|
}
|
|
}
|
|
|
|
function createReflectedShardLanes(
|
|
boss: Iwt2BossEntityState,
|
|
party: Iwt2PartyEntityState[],
|
|
state: Iwt2ArenaState,
|
|
): Iwt2MechanicLaneState[] {
|
|
const living = party.filter((member) => member.health > 0)
|
|
const cluster = findBestCluster(party)
|
|
const priorityTargets = [
|
|
cluster.center,
|
|
living.find((member) => member.aiRole === 'ranged')?.position ?? getBossTarget(party)?.position ?? arenaCenter(state),
|
|
]
|
|
const mirrors = mirrorPointsForPattern(boss, state)
|
|
const lanes: Iwt2MechanicLaneState[] = []
|
|
|
|
for (let index = 0; index < mirrors.length; index += 1) {
|
|
const mirror = mirrors[index]
|
|
const target = priorityTargets[index] ?? cluster.center
|
|
const reflectedDirection = withFallbackFacing(subtractVec2(target, mirror), boss.facing)
|
|
const reflectedEnd = clampVec2ToArena(
|
|
addVec2(mirror, scaleVec2(reflectedDirection, 380)),
|
|
CRYSTAL_BAT_TUNING.shardLaneWidth,
|
|
state.bounds,
|
|
)
|
|
lanes.push({
|
|
end: mirror,
|
|
id: `crystal-shard-${index}-in`,
|
|
start: boss.position,
|
|
width: CRYSTAL_BAT_TUNING.shardLaneWidth * 0.78,
|
|
})
|
|
lanes.push({
|
|
end: reflectedEnd,
|
|
id: `crystal-shard-${index}-out`,
|
|
start: mirror,
|
|
width: CRYSTAL_BAT_TUNING.shardLaneWidth,
|
|
})
|
|
}
|
|
|
|
return lanes
|
|
}
|
|
|
|
function mirrorPointsForPattern(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2[] {
|
|
const center = arenaCenter(state)
|
|
const verticalFirst = boss.chargeCount % 4 < 2
|
|
if (verticalFirst) {
|
|
return [
|
|
{ x: clamp(center.x + 210, 80, state.bounds.width - 80), y: 42 },
|
|
{ x: clamp(center.x - 180, 80, state.bounds.width - 80), y: state.bounds.height - 42 },
|
|
]
|
|
}
|
|
return [
|
|
{ x: 44, y: clamp(center.y - 120, 74, state.bounds.height - 74) },
|
|
{ x: state.bounds.width - 44, y: clamp(center.y + 110, 74, state.bounds.height - 74) },
|
|
]
|
|
}
|
|
|
|
function applySonicRings(
|
|
party: Iwt2PartyEntityState[],
|
|
boss: Iwt2BossEntityState,
|
|
time: number,
|
|
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
|
let nextParty = party
|
|
const events: Iwt2ArenaEvent[] = []
|
|
const hitEntityIds: Iwt2EntityId[] = []
|
|
|
|
for (const circle of boss.mechanicCircles) {
|
|
const ownerId = sonicRingOwnerId(circle)
|
|
const result = applyPartyDamageInShape(nextParty, {
|
|
kind: 'circle',
|
|
position: circle.position,
|
|
radius: circle.radius,
|
|
}, {
|
|
damage: CRYSTAL_BAT_TUNING.sonicDamage,
|
|
excludedEntityIds: ownerId ? [ownerId, ...hitEntityIds] : hitEntityIds,
|
|
knockdownSeconds: 0,
|
|
sourceId: boss.id,
|
|
stunSeconds: CRYSTAL_BAT_TUNING.sonicStunSeconds,
|
|
time,
|
|
})
|
|
nextParty = result.party
|
|
hitEntityIds.push(...result.hitEntityIds)
|
|
events.push(...result.events)
|
|
}
|
|
|
|
return { events, party: nextParty }
|
|
}
|
|
|
|
function applyShardLanes(
|
|
party: Iwt2PartyEntityState[],
|
|
boss: Iwt2BossEntityState,
|
|
time: number,
|
|
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
|
let nextParty = party
|
|
const events: Iwt2ArenaEvent[] = []
|
|
const hitEntityIds: Iwt2EntityId[] = []
|
|
|
|
for (const lane of boss.mechanicLanes) {
|
|
const result = applyPartyDamageInShape(nextParty, {
|
|
end: lane.end,
|
|
kind: 'lane',
|
|
start: lane.start,
|
|
width: lane.width,
|
|
}, {
|
|
damage: CRYSTAL_BAT_TUNING.shardDamage,
|
|
excludedEntityIds: hitEntityIds,
|
|
knockdownSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
|
|
sourceId: boss.id,
|
|
stunSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
|
|
time,
|
|
})
|
|
nextParty = result.party
|
|
hitEntityIds.push(...result.hitEntityIds)
|
|
events.push(...result.events)
|
|
}
|
|
|
|
return { events, party: nextParty }
|
|
}
|
|
|
|
function applySwoopHits(
|
|
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: CRYSTAL_BAT_TUNING.swoopLaneWidth,
|
|
}, {
|
|
damage: CRYSTAL_BAT_TUNING.swoopDamage,
|
|
damageEventType: 'bossChargeHit',
|
|
excludedEntityIds: boss.chargeHitEntityIds,
|
|
knockdownSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
|
|
sourceId: boss.id,
|
|
stunSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
|
|
time,
|
|
})
|
|
return {
|
|
...result,
|
|
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
|
}
|
|
}
|
|
|
|
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) > CRYSTAL_BAT_TUNING.meleeRange + target.radius) {
|
|
return { boss, party, events: [] }
|
|
}
|
|
const result = applyPartyDamageInShape(party, {
|
|
kind: 'circle',
|
|
position: boss.position,
|
|
radius: CRYSTAL_BAT_TUNING.meleeRange,
|
|
}, {
|
|
damage: CRYSTAL_BAT_TUNING.meleeDamage,
|
|
sourceId: boss.id,
|
|
time,
|
|
})
|
|
return {
|
|
boss: { ...boss, meleeCooldownRemaining: CRYSTAL_BAT_TUNING.meleeCooldown },
|
|
events: result.events,
|
|
party: result.party,
|
|
}
|
|
}
|
|
|
|
function moveBossToHoverPoint(
|
|
boss: Iwt2BossEntityState,
|
|
state: Iwt2ArenaState,
|
|
target: Iwt2PartyEntityState,
|
|
dt: number,
|
|
): Iwt2BossEntityState {
|
|
const center = arenaCenter(state)
|
|
const targetNearWall = isPositionNearWall(target.position, target.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
|
|
const awayFromTarget = withFallbackFacing(subtractVec2(center, target.position), { x: -1, y: 0 })
|
|
const desired = targetNearWall
|
|
? addVec2(target.position, scaleVec2(awayFromTarget, CRYSTAL_BAT_TUNING.hoverDistance))
|
|
: addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(boss.position, target.position)), CRYSTAL_BAT_TUNING.hoverDistance))
|
|
const nextPosition = clampVec2ToArena(
|
|
moveTowardLimited(boss.position, desired, CRYSTAL_BAT_TUNING.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 moveBossTowardRelocationTarget(
|
|
boss: Iwt2BossEntityState,
|
|
state: Iwt2ArenaState,
|
|
target: Iwt2PartyEntityState,
|
|
dt: number,
|
|
): Iwt2BossEntityState {
|
|
const nextPosition = clampVec2ToArena(
|
|
moveTowardLimited(boss.position, boss.relocateTarget, CRYSTAL_BAT_TUNING.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 moveTowardLimited(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 findBestCluster(party: Iwt2PartyEntityState[]): { center: Iwt2Vec2, count: number } {
|
|
const living = party.filter((member) => member.health > 0)
|
|
if (living.length <= 0) return { center: { x: 0, y: 0 }, count: 0 }
|
|
|
|
let bestMembers = [living[0]]
|
|
for (const member of living) {
|
|
const nearby = living.filter((other) => distanceVec2(member.position, other.position) <= CRYSTAL_BAT_TUNING.clusterRadius)
|
|
if (nearby.length > bestMembers.length) bestMembers = nearby
|
|
}
|
|
|
|
return { center: partyCenter(bestMembers), count: bestMembers.length }
|
|
}
|
|
|
|
function partyCenter(party: Iwt2PartyEntityState[]): Iwt2Vec2 {
|
|
if (party.length <= 0) return { x: 0, y: 0 }
|
|
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
|
|
return scaleVec2(total, 1 / party.length)
|
|
}
|
|
|
|
function relocationTarget(boss: Iwt2BossEntityState, 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, 88)), boss.radius, state.bounds)
|
|
}
|
|
|
|
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
|
return {
|
|
x: state.bounds.width * 0.5,
|
|
y: state.bounds.height * 0.5,
|
|
}
|
|
}
|
|
|
|
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
|
return isPositionNearWall(boss.position, boss.radius + CRYSTAL_BAT_TUNING.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 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 sonicRingOwnerId(circle: Iwt2MechanicCircleState): Iwt2EntityId | undefined {
|
|
const prefix = 'crystal-sonic-'
|
|
return circle.id.startsWith(prefix) ? circle.id.slice(prefix.length) : undefined
|
|
}
|
|
|
|
function withCrystalBatIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
|
return {
|
|
...result,
|
|
indicators: createCrystalBatIndicators(result.boss),
|
|
}
|
|
}
|
|
|
|
function createCrystalBatIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
|
const phase = crystalBatPhase(boss)
|
|
const indicators: Iwt2ArenaIndicator[] = []
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.sonicWindup || phase === CRYSTAL_BAT_PHASE.sonicRecover) {
|
|
const indicatorPhase = indicatorPhaseFromAttack(
|
|
phase === CRYSTAL_BAT_PHASE.sonicWindup,
|
|
phase === CRYSTAL_BAT_PHASE.sonicRecover,
|
|
)
|
|
for (const circle of boss.mechanicCircles) {
|
|
indicators.push(createDonutIndicator({
|
|
color: SONIC_COLOR,
|
|
id: `${boss.id}:${circle.id}:ring`,
|
|
innerRadius: Math.max(12, circle.radius * 0.34),
|
|
mechanicId: 'crystal-bat-sonic-spacing-ring',
|
|
outerRadius: circle.radius,
|
|
phase: indicatorPhase,
|
|
position: circle.position,
|
|
sourceId: boss.id,
|
|
}))
|
|
indicators.push(createCircleIndicator({
|
|
color: SONIC_COLOR,
|
|
id: `${boss.id}:${circle.id}:center`,
|
|
mechanicId: 'crystal-bat-sonic-overlap-zone',
|
|
phase: indicatorPhase,
|
|
position: circle.position,
|
|
radius: circle.radius * 0.22,
|
|
sourceId: boss.id,
|
|
}))
|
|
}
|
|
}
|
|
|
|
if (phase === CRYSTAL_BAT_PHASE.shardWindup || phase === CRYSTAL_BAT_PHASE.shardRecover) {
|
|
const indicatorPhase = indicatorPhaseFromAttack(
|
|
phase === CRYSTAL_BAT_PHASE.shardWindup,
|
|
phase === CRYSTAL_BAT_PHASE.shardRecover,
|
|
)
|
|
for (const lane of boss.mechanicLanes) {
|
|
indicators.push(createLaneIndicator({
|
|
color: SHARD_COLOR,
|
|
end: lane.end,
|
|
id: `${boss.id}:${lane.id}`,
|
|
mechanicId: lane.id.endsWith('-out') ? 'crystal-bat-reflected-shard' : 'crystal-bat-mirror-shard',
|
|
phase: indicatorPhase,
|
|
sourceId: boss.id,
|
|
start: lane.start,
|
|
width: lane.width,
|
|
}))
|
|
}
|
|
}
|
|
|
|
if (
|
|
phase === CRYSTAL_BAT_PHASE.swoopWindup
|
|
|| phase === CRYSTAL_BAT_PHASE.swooping
|
|
|| phase === CRYSTAL_BAT_PHASE.swoopRecover
|
|
) {
|
|
indicators.push(createLaneIndicator({
|
|
color: SWOOP_COLOR,
|
|
end: boss.chargeEnd,
|
|
id: `${boss.id}:crystal-bat-swoop`,
|
|
mechanicId: 'crystal-bat-cluster-swoop',
|
|
phase: indicatorPhaseFromAttack(
|
|
phase === CRYSTAL_BAT_PHASE.swoopWindup,
|
|
phase === CRYSTAL_BAT_PHASE.swooping,
|
|
),
|
|
sourceId: boss.id,
|
|
start: boss.chargeStart,
|
|
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
|
|
}))
|
|
}
|
|
|
|
return indicators
|
|
}
|
|
|
|
function crystalBatPhase(boss: Iwt2BossEntityState): string {
|
|
return boss.attackPhase as string
|
|
}
|
|
|
|
function asBossPhase(phase: CrystalBatPhase): Iwt2BossEntityState['attackPhase'] {
|
|
return phase as unknown as Iwt2BossEntityState['attackPhase']
|
|
}
|