Android build v1.1.22
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
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: 4,
|
||||
meleeRange: 54,
|
||||
moveSpeed: 138,
|
||||
relocateCooldownFloor: 1.2,
|
||||
relocateSeconds: 1.25,
|
||||
relocateSpeed: 184,
|
||||
shardCooldown: 5.8,
|
||||
shardDamage: 13,
|
||||
shardLaneWidth: 30,
|
||||
shardRecover: 0.58,
|
||||
shardStunSeconds: 0.35,
|
||||
shardWindup: 0.82,
|
||||
sonicCooldown: 6.4,
|
||||
sonicDamage: 10,
|
||||
sonicRecover: 0.42,
|
||||
sonicRingRadius: 58,
|
||||
sonicStunSeconds: 0.32,
|
||||
sonicWindup: 0.78,
|
||||
swoopCooldown: 4.9,
|
||||
swoopDamage: 17,
|
||||
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']
|
||||
}
|
||||
Reference in New Issue
Block a user