Android build v1.1.22
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addMudPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
type SandglassScorpionAttackPhase =
|
||||
| Iwt2BossEntityState['attackPhase']
|
||||
| 'sandglassBurrowWindup'
|
||||
| 'sandglassBurrowing'
|
||||
| 'sandglassEruptionWindup'
|
||||
| 'sandglassEruptionRecover'
|
||||
| 'sandglassHourglassSafe'
|
||||
| 'sandglassHourglassHazard'
|
||||
| 'sandglassHourglassRecover'
|
||||
|
||||
const SANDGLASS = {
|
||||
burrowCooldown: 6.6,
|
||||
burrowSeconds: 1.05,
|
||||
burrowSpeed: 285,
|
||||
burrowTrailInterval: 0.18,
|
||||
burrowTrailRadius: 28,
|
||||
burrowTrailSeconds: 4.2,
|
||||
burrowTrailWidth: 44,
|
||||
burrowWindup: 0.48,
|
||||
disengageSeconds: 0.72,
|
||||
disengageSpeed: 148,
|
||||
eruptionDamage: 15,
|
||||
eruptionRadius: 50,
|
||||
eruptionRecover: 0.52,
|
||||
eruptionStunSeconds: 0.42,
|
||||
eruptionWindup: 0.76,
|
||||
hourglassCooldown: 8.1,
|
||||
hourglassDamage: 6,
|
||||
hourglassHazardSeconds: 2.2,
|
||||
hourglassRadius: 66,
|
||||
hourglassRecover: 0.48,
|
||||
hourglassSafeSeconds: 1.05,
|
||||
hourglassTickSeconds: 0.5,
|
||||
meleeCooldown: 1.25,
|
||||
meleeDamage: 5,
|
||||
meleeRange: 58,
|
||||
moveSpeed: 94,
|
||||
wallMargin: 70,
|
||||
zoneCount: 3,
|
||||
}
|
||||
|
||||
const BURROW_COLOR = '#c8944e'
|
||||
const ERUPTION_COLOR = '#f2c15b'
|
||||
const HOURGLASS_SAFE_COLOR = '#68d8ff'
|
||||
const HOURGLASS_HAZARD_COLOR = '#d49a42'
|
||||
|
||||
export function tickSandglassScorpion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
let party = state.party
|
||||
const incomingPhase = state.boss.attackPhase as SandglassScorpionAttackPhase
|
||||
let boss = {
|
||||
...state.boss,
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - 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 === 'sandglassBurrowing'
|
||||
? 0
|
||||
: isBossNearWall(boss, state)
|
||||
? boss.wallContactSeconds + dt
|
||||
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
|
||||
const target = getBossTarget(party)
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
const phase = boss.attackPhase as SandglassScorpionAttackPhase
|
||||
|
||||
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, 1.1),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassBurrowWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.relocateTarget, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassBurrowing'),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: SANDGLASS.burrowSeconds,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassBurrowing') {
|
||||
const burrow = moveBurrowingBoss(boss, state, dt)
|
||||
const trailResult = maybeAddBurrowTrail({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position: burrow.position,
|
||||
time: state.time + dt,
|
||||
})
|
||||
hazards = trailResult.hazards
|
||||
nextHazardId = trailResult.nextHazardId
|
||||
boss = {
|
||||
...boss,
|
||||
mechanicEnergy: trailResult.nextTrailInSeconds,
|
||||
position: burrow.position,
|
||||
velocity: burrow.velocity,
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 7) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassEruptionWindup'),
|
||||
mechanicCircles: createStingerEruptions(state, party, boss),
|
||||
mechanicEnergy: 0,
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: SANDGLASS.eruptionWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassEruptionWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyStingerEruptions(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassEruptionRecover'),
|
||||
phaseSecondsRemaining: SANDGLASS.eruptionRecover,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassEruptionRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassHourglassSafe') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassHourglassHazard'),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: SANDGLASS.hourglassHazardSeconds,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassHourglassHazard') {
|
||||
const tickResult = maybeApplyHourglassZones({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time: state.time + dt,
|
||||
})
|
||||
hazards = tickResult.hazards
|
||||
nextHazardId = tickResult.nextHazardId
|
||||
party = tickResult.party
|
||||
events.push(...tickResult.events)
|
||||
boss = {
|
||||
...boss,
|
||||
mechanicEnergy: tickResult.nextTickInSeconds,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassHourglassRecover'),
|
||||
phaseSecondsRemaining: SANDGLASS.hourglassRecover,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'sandglassHourglassRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= SANDGLASS.disengageSeconds) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 1.25,
|
||||
relocateTarget: centerRelocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassHourglassSafe'),
|
||||
fireballCooldownRemaining: SANDGLASS.hourglassCooldown,
|
||||
mechanicCircles: createHourglassZones(state, party, boss),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: SANDGLASS.hourglassSafeSeconds,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
const exit = chooseBurrowExit(state, party, boss, target)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('sandglassBurrowWindup'),
|
||||
chargeCooldownRemaining: SANDGLASS.burrowCooldown,
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
chargeEnd: { ...exit },
|
||||
chargeStart: { ...boss.position },
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [createBurrowLane(boss.position, exit)],
|
||||
phaseSecondsRemaining: SANDGLASS.burrowWindup,
|
||||
relocateTarget: exit,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withSandglassIndicators({ 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 withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
boss = moveBossTowardEngagePoint(boss, state, target, dt)
|
||||
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function asBossPhase(phase: SandglassScorpionAttackPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
|
||||
function moveBurrowingBoss(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
dt: number,
|
||||
): { position: Iwt2Vec2, velocity: Iwt2Vec2 } {
|
||||
const position = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, SANDGLASS.burrowSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
position,
|
||||
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
}
|
||||
}
|
||||
|
||||
function maybeAddBurrowTrail({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position,
|
||||
time,
|
||||
}: {
|
||||
boss: Iwt2BossEntityState
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
position: Iwt2Vec2
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
|
||||
if (boss.mechanicEnergy > 0) {
|
||||
return { hazards, nextHazardId, nextTrailInSeconds: boss.mechanicEnergy }
|
||||
}
|
||||
const result = addMudPuddle({
|
||||
duration: SANDGLASS.burrowTrailSeconds,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius: SANDGLASS.burrowTrailRadius,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
hazards: result.hazards,
|
||||
nextHazardId: result.nextHazardId,
|
||||
nextTrailInSeconds: SANDGLASS.burrowTrailInterval,
|
||||
}
|
||||
}
|
||||
|
||||
function maybeApplyHourglassZones({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time,
|
||||
}: {
|
||||
boss: Iwt2BossEntityState
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
state: Iwt2ArenaState
|
||||
time: number
|
||||
}): {
|
||||
events: Iwt2ArenaEvent[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
nextTickInSeconds: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
} {
|
||||
if (boss.mechanicEnergy > 0) {
|
||||
return { events: [], hazards, nextHazardId, nextTickInSeconds: boss.mechanicEnergy, party }
|
||||
}
|
||||
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextId = nextHazardId
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const damage = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: SANDGLASS.hourglassDamage,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: 0.18,
|
||||
time,
|
||||
})
|
||||
nextParty = damage.party
|
||||
events.push(...damage.events)
|
||||
|
||||
const puddle = addMudPuddle({
|
||||
duration: SANDGLASS.hourglassTickSeconds + 0.45,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
position: clampVec2ToArena(circle.position, circle.radius, state.bounds),
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = puddle.hazards
|
||||
nextId = puddle.nextHazardId
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
nextTickInSeconds: SANDGLASS.hourglassTickSeconds,
|
||||
party: nextParty,
|
||||
}
|
||||
}
|
||||
|
||||
function createStingerEruptions(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const marked = [
|
||||
getBossTarget(living),
|
||||
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker'),
|
||||
...living,
|
||||
].filter((member): member is Iwt2PartyEntityState => Boolean(member))
|
||||
|
||||
const circles: Iwt2MechanicCircleState[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const member of marked) {
|
||||
if (seen.has(member.id)) continue
|
||||
seen.add(member.id)
|
||||
const offset = offsetFromBoss(member.position, boss.position, 28 + circles.length * 10)
|
||||
circles.push({
|
||||
id: `sandglass-stinger-${circles.length}`,
|
||||
position: clampVec2ToArena(addVec2(member.position, offset), SANDGLASS.eruptionRadius, state.bounds),
|
||||
radius: SANDGLASS.eruptionRadius,
|
||||
})
|
||||
if (circles.length >= SANDGLASS.zoneCount) break
|
||||
}
|
||||
return circles
|
||||
}
|
||||
|
||||
function createHourglassZones(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const center = arenaCenter(state)
|
||||
const partyCenterPoint = partyCenter(party.filter((member) => member.health > 0), center)
|
||||
const angleBase = Math.atan2(partyCenterPoint.y - center.y, partyCenterPoint.x - center.x)
|
||||
const circles: Iwt2MechanicCircleState[] = []
|
||||
for (let index = 0; index < SANDGLASS.zoneCount; index += 1) {
|
||||
const angle = angleBase + (Math.PI * 2 * index) / SANDGLASS.zoneCount + boss.chargeCount * 0.28
|
||||
const radius = index === 0 ? 54 : 128
|
||||
circles.push({
|
||||
id: `sandglass-hourglass-${index}`,
|
||||
position: clampVec2ToArena({
|
||||
x: center.x + Math.cos(angle) * radius,
|
||||
y: center.y + Math.sin(angle) * radius * 0.68,
|
||||
}, SANDGLASS.hourglassRadius, state.bounds),
|
||||
radius: SANDGLASS.hourglassRadius,
|
||||
})
|
||||
}
|
||||
return circles
|
||||
}
|
||||
|
||||
function applyStingerEruptions(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: string[] = []
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: SANDGLASS.eruptionDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0.2,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: SANDGLASS.eruptionStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, 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) > SANDGLASS.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: SANDGLASS.meleeRange,
|
||||
}, {
|
||||
damage: SANDGLASS.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: SANDGLASS.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardEngagePoint(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const desiredDistance = 70
|
||||
const offset = subtractVec2(boss.position, target.position)
|
||||
const distance = distanceVec2(boss.position, target.position)
|
||||
const toward = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
|
||||
const away = withFallbackFacing(offset, scaleVec2(toward, -1))
|
||||
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
|
||||
const destination = distance < desiredDistance
|
||||
? addVec2(target.position, scaleVec2(away, desiredDistance))
|
||||
: addVec2(target.position, scaleVec2(centerBias, 18))
|
||||
const position = clampVec2ToArena(
|
||||
moveToward(boss.position, destination, SANDGLASS.moveSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
|
||||
position,
|
||||
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardRelocationTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const position = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, SANDGLASS.disengageSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
|
||||
position,
|
||||
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
wallContactSeconds: isBossNearWall({ ...boss, position }, state) ? boss.wallContactSeconds : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function chooseBurrowExit(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2Vec2 {
|
||||
const center = arenaCenter(state)
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const partyCenterPoint = partyCenter(living, target.position)
|
||||
const baseDirection = withFallbackFacing(subtractVec2(partyCenterPoint, boss.position), boss.facing)
|
||||
const flank = { x: -baseDirection.y, y: baseDirection.x }
|
||||
const side = boss.chargeCount % 2 === 0 ? 1 : -1
|
||||
const candidates = [
|
||||
addVec2(target.position, scaleVec2(flank, 118 * side)),
|
||||
addVec2(partyCenterPoint, scaleVec2(flank, -124 * side)),
|
||||
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), -76)),
|
||||
center,
|
||||
]
|
||||
|
||||
let best = clampVec2ToArena(candidates[0], boss.radius + SANDGLASS.wallMargin, state.bounds)
|
||||
let bestScore = -Infinity
|
||||
for (const candidate of candidates) {
|
||||
const clamped = clampVec2ToArena(candidate, boss.radius + SANDGLASS.wallMargin, state.bounds)
|
||||
const wall = wallClearance(clamped, state, boss.radius)
|
||||
const targetDistance = distanceVec2(clamped, target.position)
|
||||
const centerDistance = distanceVec2(clamped, center)
|
||||
const bossDistance = distanceVec2(clamped, boss.position)
|
||||
const score = wall * 1.6
|
||||
+ clamp(targetDistance, 72, 210) * 0.55
|
||||
- centerDistance * 0.18
|
||||
+ clamp(bossDistance, 90, 280) * 0.25
|
||||
if (score > bestScore) {
|
||||
best = clamped
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function centerRelocationTarget(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, 54)),
|
||||
boss.radius + SANDGLASS.wallMargin,
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
|
||||
function createBurrowLane(start: Iwt2Vec2, end: Iwt2Vec2): Iwt2MechanicLaneState {
|
||||
return {
|
||||
end: { ...end },
|
||||
id: 'sandglass-burrow-trail',
|
||||
start: { ...start },
|
||||
width: SANDGLASS.burrowTrailWidth,
|
||||
}
|
||||
}
|
||||
|
||||
function offsetFromBoss(position: Iwt2Vec2, bossPosition: Iwt2Vec2, distance: number): Iwt2Vec2 {
|
||||
const direction = withFallbackFacing(subtractVec2(position, bossPosition), { x: 1, y: 0 })
|
||||
return scaleVec2(direction, distance)
|
||||
}
|
||||
|
||||
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
|
||||
if (party.length <= 0) return fallback
|
||||
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
|
||||
return scaleVec2(total, 1 / party.length)
|
||||
}
|
||||
|
||||
function wallClearance(position: Iwt2Vec2, state: Iwt2ArenaState, radius: number): number {
|
||||
return Math.min(
|
||||
position.x - radius,
|
||||
state.bounds.width - radius - position.x,
|
||||
position.y - radius,
|
||||
state.bounds.height - radius - position.y,
|
||||
)
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
return wallClearance(boss.position, state, boss.radius) <= SANDGLASS.wallMargin
|
||||
}
|
||||
|
||||
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 withSandglassIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createSandglassIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createSandglassIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const phase = boss.attackPhase as SandglassScorpionAttackPhase
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
|
||||
if (phase === 'sandglassBurrowWindup' || phase === 'sandglassBurrowing') {
|
||||
indicators.push(...boss.mechanicLanes.map((lane) => createLaneIndicator({
|
||||
color: BURROW_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'sandglass-burrow-trail',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === 'sandglassBurrowWindup',
|
||||
phase === 'sandglassBurrowing',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
})))
|
||||
}
|
||||
|
||||
if (phase === 'sandglassEruptionWindup' || phase === 'sandglassEruptionRecover') {
|
||||
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
|
||||
color: ERUPTION_COLOR,
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'sandglass-stinger-eruption',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === 'sandglassEruptionWindup',
|
||||
phase === 'sandglassEruptionRecover',
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
})))
|
||||
}
|
||||
|
||||
if (
|
||||
phase === 'sandglassHourglassSafe'
|
||||
|| phase === 'sandglassHourglassHazard'
|
||||
|| phase === 'sandglassHourglassRecover'
|
||||
) {
|
||||
const color = phase === 'sandglassHourglassSafe' ? HOURGLASS_SAFE_COLOR : HOURGLASS_HAZARD_COLOR
|
||||
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
|
||||
color,
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'sandglass-hourglass-zone',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === 'sandglassHourglassSafe',
|
||||
phase === 'sandglassHourglassHazard',
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
})))
|
||||
}
|
||||
|
||||
return indicators
|
||||
}
|
||||
Reference in New Issue
Block a user