Android build v1.1.22
This commit is contained in:
+198
-13
@@ -14,6 +14,7 @@ import type {
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2StatusState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||
import { tickBoss } from './bossAi'
|
||||
@@ -22,6 +23,7 @@ import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
||||
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
||||
import { applyPartyDamageInShape } from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
normalizeVec2,
|
||||
@@ -35,7 +37,7 @@ const MAX_DT = 1 / 15
|
||||
const MAX_EVENTS = 80
|
||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||
const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||
|
||||
type InitialPartyMember = {
|
||||
id: Iwt2PartyEntityId
|
||||
@@ -102,6 +104,10 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
||||
radius: bossMetadata.radius,
|
||||
health: maxHealth,
|
||||
maxHealth,
|
||||
armor: bossMetadata.mudArmor ?? 0,
|
||||
maxArmor: bossMetadata.mudArmor ?? 0,
|
||||
mechanicEnergy: 0,
|
||||
mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0,
|
||||
meleeCooldownRemaining: 0.6 + index * 0.25,
|
||||
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
|
||||
chargeCount: 0,
|
||||
@@ -118,6 +124,8 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
||||
birdWaveThresholdsTriggered: [],
|
||||
mechanicLanes: [],
|
||||
mechanicCircles: [],
|
||||
mechanicArcs: [],
|
||||
mechanicLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +140,31 @@ function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||
if (bossId === 'bulldrome') return 2
|
||||
if (bossId === 'great-jaggi') return 2.4
|
||||
if (bossId === 'khezu') return 3
|
||||
if (bossId === 'rathian') return 2.2
|
||||
if (bossId === 'barroth') return 2.1
|
||||
if (bossId === 'tobi-kadachi') return 2.6
|
||||
if (bossId === 'rimebastion') return 2.8
|
||||
if (bossId === 'ember-mantis-duelist') return 1.4
|
||||
if (bossId === 'cinderback-ricochet') return 2.5
|
||||
if (bossId === 'obsidian-ram-golem') return 2.2
|
||||
if (bossId === 'stormcoil-wyrm') return 2.6
|
||||
if (bossId === 'venom-orchid-hydra') return 2.4
|
||||
if (bossId === 'sandglass-scorpion') return 2.1
|
||||
if (bossId === 'crystal-bat-matriarch') return 2.3
|
||||
if (bossId === 'hollowcrown-revenant') return 1.8
|
||||
return 0
|
||||
}
|
||||
|
||||
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
||||
if (bossId === 'yian-kut-ku') return 1.2
|
||||
if (bossId === 'khezu') return 1.6
|
||||
if (bossId === 'rathian') return 3
|
||||
if (bossId === 'tobi-kadachi') return 2.4
|
||||
if (bossId === 'ember-mantis-duelist') return 3
|
||||
if (bossId === 'stormcoil-wyrm') return 3.1
|
||||
if (bossId === 'venom-orchid-hydra') return 2.8
|
||||
if (bossId === 'sandglass-scorpion') return 3.2
|
||||
if (bossId === 'crystal-bat-matriarch') return 2.9
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -157,12 +184,23 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
||||
}
|
||||
const separatedState = {
|
||||
...baseState,
|
||||
party: separatePartyFromBosses(baseState.party, baseState),
|
||||
party: separatePartyFromBosses(
|
||||
separatePartyFromParty(separatePartyFromBosses(baseState.party, baseState), baseState),
|
||||
baseState,
|
||||
),
|
||||
}
|
||||
const bossResult = tickBosses(separatedState, step)
|
||||
const postBossState = { ...separatedState, bosses: bossResult.bosses }
|
||||
const postBossParty = separatePartyFromBosses(
|
||||
separatePartyFromParty(
|
||||
separatePartyFromBosses(bossResult.party, postBossState),
|
||||
postBossState,
|
||||
),
|
||||
postBossState,
|
||||
)
|
||||
const projectileResult = advanceProjectiles(
|
||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||
bossResult.party,
|
||||
postBossParty,
|
||||
bossResult.bosses,
|
||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||
bossResult.hazards ?? separatedState.hazards,
|
||||
@@ -186,7 +224,15 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
||||
projectileResult.nextProjectileId,
|
||||
)
|
||||
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
|
||||
const finalParty = regeneratePartyMana(hotResult.party, step)
|
||||
const regeneratedParty = regeneratePartyMana(hotResult.party, step)
|
||||
const finalCollisionState = { ...separatedState, bosses: damageResult.bosses }
|
||||
const finalParty = separatePartyFromBosses(
|
||||
separatePartyFromParty(
|
||||
separatePartyFromBosses(regeneratedParty, finalCollisionState),
|
||||
finalCollisionState,
|
||||
),
|
||||
finalCollisionState,
|
||||
)
|
||||
const nextEvents = assignEventIds(
|
||||
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
|
||||
state.nextEventId,
|
||||
@@ -296,6 +342,7 @@ function createEmptyStatus(): Iwt2StatusState {
|
||||
stunnedSeconds: 0,
|
||||
knockedDownSeconds: 0,
|
||||
invulnerableSeconds: 0,
|
||||
slowedSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,9 +387,10 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
|
||||
indicators.push(...result.indicators)
|
||||
}
|
||||
|
||||
const separatedBosses = separateBosses(bosses, state)
|
||||
return {
|
||||
bosses,
|
||||
boss: getPrimaryBoss(bosses),
|
||||
bosses: separatedBosses,
|
||||
boss: getPrimaryBoss(separatedBosses),
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
@@ -355,22 +403,129 @@ function tickBosses(state: Iwt2ArenaState, dt: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function separateBosses(bosses: Iwt2BossEntityState[], state: Iwt2ArenaState): Iwt2BossEntityState[] {
|
||||
const next = bosses.map((boss) => ({ ...boss, position: { ...boss.position } }))
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
|
||||
const first = next[firstIndex]
|
||||
if (!first || first.health <= 0) continue
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
|
||||
const second = next[secondIndex]
|
||||
if (!second || second.health <= 0) continue
|
||||
|
||||
const minDistance = first.radius + second.radius + 4
|
||||
const distance = distanceVec2(first.position, second.position)
|
||||
if (distance >= minDistance) continue
|
||||
|
||||
const fallback = {
|
||||
x: first.position.x <= second.position.x ? -1 : 1,
|
||||
y: first.position.y <= second.position.y ? -0.4 : 0.4,
|
||||
}
|
||||
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
|
||||
const push = (minDistance - distance) * 0.55
|
||||
const firstPosition = clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds)
|
||||
const secondPosition = clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds)
|
||||
next[firstIndex] = {
|
||||
...first,
|
||||
position: firstPosition,
|
||||
velocity: scaleVec2(subtractVec2(firstPosition, first.position), 30),
|
||||
}
|
||||
next[secondIndex] = {
|
||||
...second,
|
||||
position: secondPosition,
|
||||
velocity: scaleVec2(subtractVec2(secondPosition, second.position), 30),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let position = member.position
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
position = separateCircles(
|
||||
const separated = separateCircles(
|
||||
{ position, radius: member.radius },
|
||||
{ position: boss.position, radius: boss.radius },
|
||||
state.bounds,
|
||||
)
|
||||
position = wallClearance(separated, member, state) <= 2
|
||||
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
|
||||
? separateFromBossTowardCenter(member, boss, state)
|
||||
: separated
|
||||
}
|
||||
return { ...member, position }
|
||||
})
|
||||
}
|
||||
|
||||
function circleOverlapAmount(
|
||||
moving: { position: Iwt2Vec2, radius: number },
|
||||
fixed: { position: Iwt2Vec2, radius: number },
|
||||
): number {
|
||||
return Math.max(0, moving.radius + fixed.radius - distanceVec2(moving.position, fixed.position))
|
||||
}
|
||||
|
||||
function separateFromBossTowardCenter(
|
||||
member: Iwt2PartyEntityState,
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): Iwt2Vec2 {
|
||||
const center = { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5 }
|
||||
const direction = normalizeVec2(subtractVec2(center, boss.position))
|
||||
const minDistance = boss.radius + member.radius + 3
|
||||
return clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(direction, minDistance)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
|
||||
function wallClearance(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
||||
return Math.min(
|
||||
position.x - member.radius,
|
||||
state.bounds.width - member.radius - position.x,
|
||||
position.y - member.radius,
|
||||
state.bounds.height - member.radius - position.y,
|
||||
)
|
||||
}
|
||||
|
||||
function separatePartyFromParty(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
||||
const next = party.map((member) => ({ ...member, position: { ...member.position } }))
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) {
|
||||
const first = next[firstIndex]
|
||||
if (!first || first.health <= 0) continue
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) {
|
||||
const second = next[secondIndex]
|
||||
if (!second || second.health <= 0) continue
|
||||
|
||||
const minDistance = first.radius + second.radius + 2
|
||||
const distance = distanceVec2(first.position, second.position)
|
||||
if (distance >= minDistance) continue
|
||||
|
||||
const fallback = {
|
||||
x: first.position.x <= second.position.x ? -1 : 1,
|
||||
y: first.position.y <= second.position.y ? -0.35 : 0.35,
|
||||
}
|
||||
const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position))
|
||||
const push = (minDistance - distance) * 0.5
|
||||
next[firstIndex] = {
|
||||
...first,
|
||||
position: clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds),
|
||||
}
|
||||
next[secondIndex] = {
|
||||
...second,
|
||||
position: clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function advanceProjectiles(
|
||||
projectiles: Iwt2ProjectileEntityState[],
|
||||
party: Iwt2PartyEntityState[],
|
||||
@@ -461,9 +616,10 @@ function advanceProjectiles(
|
||||
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
|
||||
))
|
||||
if (hitBoss) {
|
||||
const damage = Math.min(projectile.damage, hitBoss.health)
|
||||
const damageResult = applyDamageToBoss(hitBoss, projectile.damage)
|
||||
const damage = damageResult.healthDamage
|
||||
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
|
||||
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||
? damageResult.boss
|
||||
: boss)
|
||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||
events.push({
|
||||
@@ -474,7 +630,7 @@ function advanceProjectiles(
|
||||
targetId: hitBoss.id,
|
||||
value: damage,
|
||||
})
|
||||
if (hitBoss.health - damage <= 0) {
|
||||
if (damageResult.boss.health <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
@@ -673,10 +829,14 @@ function applyPartyAttacks(
|
||||
}
|
||||
}
|
||||
if (member.attackCooldownRemaining > 0) return member
|
||||
const damage = Math.min(metadata.attackDamage, target.health)
|
||||
let damage = Math.min(metadata.attackDamage, target.health)
|
||||
let defeated = target.health - damage <= 0
|
||||
if (target.kind === 'boss') {
|
||||
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
|
||||
damage = damageResult.healthDamage
|
||||
defeated = damageResult.boss.health <= 0
|
||||
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
||||
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||
? damageResult.boss
|
||||
: boss)
|
||||
} else {
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||
@@ -691,7 +851,7 @@ function applyPartyAttacks(
|
||||
targetId: target.id,
|
||||
value: damage,
|
||||
})
|
||||
if (target.health - damage <= 0) {
|
||||
if (defeated) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
@@ -754,6 +914,31 @@ function addDamageDone(
|
||||
: member)
|
||||
}
|
||||
|
||||
function applyDamageToBoss(
|
||||
boss: Iwt2BossEntityState,
|
||||
rawDamage: number,
|
||||
): { boss: Iwt2BossEntityState, healthDamage: number } {
|
||||
if (rawDamage <= 0 || boss.health <= 0) return { boss, healthDamage: 0 }
|
||||
if (boss.armor <= 0) {
|
||||
const healthDamage = Math.min(rawDamage, boss.health)
|
||||
return {
|
||||
boss: { ...boss, health: Math.max(0, boss.health - healthDamage) },
|
||||
healthDamage,
|
||||
}
|
||||
}
|
||||
|
||||
const armorDamage = Math.min(boss.armor, rawDamage * 0.75)
|
||||
const healthDamage = Math.min(boss.health, rawDamage - armorDamage * 0.6)
|
||||
return {
|
||||
boss: {
|
||||
...boss,
|
||||
armor: Math.max(0, boss.armor - armorDamage),
|
||||
health: Math.max(0, boss.health - healthDamage),
|
||||
},
|
||||
healthDamage,
|
||||
}
|
||||
}
|
||||
|
||||
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
|
||||
return events.map((event, index) => ({ ...event, id: firstId + index }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { BARROTH_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addMudPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createConeIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickBarroth(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
let boss = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
boss = {
|
||||
...boss,
|
||||
wallContactSeconds: isBossNearWall(boss, state)
|
||||
? boss.wallContactSeconds + dt
|
||||
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'mudSprayWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyMudSpray(party, boss, state.time + dt)
|
||||
const mudResult = addMudSprayPuddles(hazards, boss, nextHazardId, state.time + dt, state.bounds)
|
||||
party = result.party
|
||||
hazards = mudResult.hazards
|
||||
nextHazardId = mudResult.nextHazardId
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'mudSprayRecover',
|
||||
phaseSecondsRemaining: 0.46,
|
||||
}
|
||||
}
|
||||
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'mudSprayRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= 0.65) {
|
||||
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 1.18 : 1.4)
|
||||
boss = moveBossTowardCenter(boss, state, speed, dt)
|
||||
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'mudSprayWindup',
|
||||
chargeCooldownRemaining: BARROTH_BOSS_METADATA.mudSprayCooldown!,
|
||||
phaseSecondsRemaining: BARROTH_BOSS_METADATA.mudSprayWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withBarrothIndicators({ 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 withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
const speed = BARROTH_BOSS_METADATA.moveSpeed * (boss.armor > 0 ? 0.9 : 1.08)
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, speed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
boss = {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
}
|
||||
return withBarrothIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function moveBossTowardCenter(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
speed: number,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const center = arenaCenter(state)
|
||||
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
|
||||
return {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
|
||||
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const margin = boss.radius + 58
|
||||
return (
|
||||
boss.position.x <= margin
|
||||
|| boss.position.x >= state.bounds.width - margin
|
||||
|| boss.position.y <= margin
|
||||
|| boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
function applyMudSpray(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'cone',
|
||||
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
|
||||
direction: boss.facing,
|
||||
origin: boss.position,
|
||||
range: BARROTH_BOSS_METADATA.mudSprayRange!,
|
||||
}, {
|
||||
damage: BARROTH_BOSS_METADATA.mudSprayDamage!,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: BARROTH_BOSS_METADATA.mudSprayStunSeconds!,
|
||||
time,
|
||||
})
|
||||
return { party: result.party, events: result.events }
|
||||
}
|
||||
|
||||
function addMudSprayPuddles(
|
||||
hazards: Iwt2GroundHazardState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
nextHazardId: number,
|
||||
time: number,
|
||||
bounds: Iwt2ArenaState['bounds'],
|
||||
): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
let nextHazards = hazards
|
||||
let nextId = nextHazardId
|
||||
const offsets = [-0.34, 0, 0.34]
|
||||
for (let index = 0; index < offsets.length; index += 1) {
|
||||
const point = clampVec2ToArena(
|
||||
conePoint(boss.position, boss.facing, BARROTH_BOSS_METADATA.mudSprayRange! * (0.46 + index * 0.17), offsets[index]),
|
||||
BARROTH_BOSS_METADATA.mudPuddleRadius!,
|
||||
bounds,
|
||||
)
|
||||
const puddle = addMudPuddle({
|
||||
duration: BARROTH_BOSS_METADATA.mudPuddleSeconds!,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
position: point,
|
||||
radius: BARROTH_BOSS_METADATA.mudPuddleRadius!,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = puddle.hazards
|
||||
nextId = puddle.nextHazardId
|
||||
}
|
||||
return { hazards: nextHazards, nextHazardId: nextId }
|
||||
}
|
||||
|
||||
function conePoint(origin: Iwt2Vec2, direction: Iwt2Vec2, distance: number, angleOffset: number): Iwt2Vec2 {
|
||||
const base = Math.atan2(direction.y, direction.x) + angleOffset
|
||||
return addVec2(origin, {
|
||||
x: Math.cos(base) * distance,
|
||||
y: Math.sin(base) * distance,
|
||||
})
|
||||
}
|
||||
|
||||
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||
if (livingTank) return livingTank
|
||||
return party.find((member) => member.health > 0)
|
||||
}
|
||||
|
||||
function maybeApplyMelee(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
time: number,
|
||||
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > BARROTH_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: BARROTH_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: BARROTH_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: BARROTH_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withBarrothIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createBarrothIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createBarrothIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
if (boss.attackPhase !== 'mudSprayWindup' && boss.attackPhase !== 'mudSprayRecover') return []
|
||||
return [createConeIndicator({
|
||||
angleRadians: BARROTH_BOSS_METADATA.mudSprayAngleRadians!,
|
||||
color: '#b38a52',
|
||||
direction: boss.facing,
|
||||
id: `${boss.id}:mud-spray`,
|
||||
mechanicId: 'barroth-mud-spray',
|
||||
origin: boss.position,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'mudSprayWindup',
|
||||
boss.attackPhase === 'mudSprayRecover',
|
||||
),
|
||||
range: BARROTH_BOSS_METADATA.mudSprayRange!,
|
||||
sourceId: boss.id,
|
||||
})]
|
||||
}
|
||||
@@ -13,6 +13,18 @@ import type {
|
||||
} from './types'
|
||||
import { tickGreatJaggi } from './greatJaggiAi'
|
||||
import { tickKhezu } from './khezuAi'
|
||||
import { tickBarroth } from './barrothAi'
|
||||
import { tickCinderbackRicochet } from './cinderbackAi'
|
||||
import { tickCrystalBatMatriarch } from './crystalBatMatriarchAi'
|
||||
import { tickEmberMantisDuelist } from './emberMantisAi'
|
||||
import { tickHollowcrownRevenant } from './hollowcrownRevenantAi'
|
||||
import { tickObsidianRamGolem } from './obsidianRamGolemAi'
|
||||
import { tickRathian } from './rathianAi'
|
||||
import { tickRimebastion } from './rimebastionAi'
|
||||
import { tickSandglassScorpion } from './sandglassScorpionAi'
|
||||
import { tickStormcoilWyrm } from './stormcoilWyrmAi'
|
||||
import { tickTobiKadachi } from './tobiKadachiAi'
|
||||
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
|
||||
import { tickYianKutKu } from './yianKutKuAi'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
@@ -48,6 +60,18 @@ export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult
|
||||
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
|
||||
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
|
||||
if (state.boss.bossId === 'khezu') return tickKhezu(state, dt)
|
||||
if (state.boss.bossId === 'rathian') return tickRathian(state, dt)
|
||||
if (state.boss.bossId === 'barroth') return tickBarroth(state, dt)
|
||||
if (state.boss.bossId === 'tobi-kadachi') return tickTobiKadachi(state, dt)
|
||||
if (state.boss.bossId === 'rimebastion') return tickRimebastion(state, dt)
|
||||
if (state.boss.bossId === 'ember-mantis-duelist') return tickEmberMantisDuelist(state, dt)
|
||||
if (state.boss.bossId === 'cinderback-ricochet') return tickCinderbackRicochet(state, dt)
|
||||
if (state.boss.bossId === 'obsidian-ram-golem') return tickObsidianRamGolem(state, dt)
|
||||
if (state.boss.bossId === 'stormcoil-wyrm') return tickStormcoilWyrm(state, dt)
|
||||
if (state.boss.bossId === 'venom-orchid-hydra') return tickVenomOrchidHydra(state, dt)
|
||||
if (state.boss.bossId === 'sandglass-scorpion') return tickSandglassScorpion(state, dt)
|
||||
if (state.boss.bossId === 'crystal-bat-matriarch') return tickCrystalBatMatriarch(state, dt)
|
||||
if (state.boss.bossId === 'hollowcrown-revenant') return tickHollowcrownRevenant(state, dt)
|
||||
return tickBulldrome(state, dt)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
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: 20,
|
||||
armorSlamRadius: 118,
|
||||
armorSlamRecover: 1.05,
|
||||
armorSlamStunSeconds: 0.7,
|
||||
armorSlamWindup: 0.62,
|
||||
centerDisengageSpeed: 152,
|
||||
firePuddleDamage: 5,
|
||||
firePuddleRadius: 34,
|
||||
firePuddleSeconds: 6.4,
|
||||
lavaTrailInterval: 0.26,
|
||||
meleeCooldown: 1.12,
|
||||
meleeDamage: 5,
|
||||
meleeRange: 58,
|
||||
moveSpeed: 104,
|
||||
ricochetCooldown: 7.2,
|
||||
ricochetDamage: 18,
|
||||
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']
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
type EmberMantisAttackPhase =
|
||||
| Iwt2BossEntityState['attackPhase']
|
||||
| 'bladeSidestep'
|
||||
| 'lineSlashWindup'
|
||||
| 'lineSlashRecover'
|
||||
| 'crossSlashWindup'
|
||||
| 'crossSlashRecover'
|
||||
|
||||
const EMBER_MANTIS = {
|
||||
accentColor: '#ffb13b',
|
||||
crossSlashCooldown: 7.5,
|
||||
crossSlashDamage: 20,
|
||||
crossSlashStunSeconds: 0.45,
|
||||
crossSlashWindup: 0.82,
|
||||
duelistRange: 118,
|
||||
lineSlashCooldown: 3.4,
|
||||
lineSlashDamage: 15,
|
||||
lineSlashStunSeconds: 0.25,
|
||||
lineSlashWindup: 0.48,
|
||||
meleeCooldown: 0.8,
|
||||
meleeDamage: 7,
|
||||
meleeRange: 52,
|
||||
moveSpeed: 168,
|
||||
recoverSeconds: 0.38,
|
||||
sidestepInterval: 1.9,
|
||||
sidestepSeconds: 0.32,
|
||||
sidestepSpeed: 330,
|
||||
slashLaneWidth: 34,
|
||||
wallMargin: 86,
|
||||
}
|
||||
|
||||
export function tickEmberMantisDuelist(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
const incomingPhase = state.boss.attackPhase as EmberMantisAttackPhase
|
||||
let boss = {
|
||||
...state.boss,
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
mechanicEnergy: Math.min(EMBER_MANTIS.sidestepInterval, 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 },
|
||||
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
|
||||
? state.boss.wallContactSeconds + dt
|
||||
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
if (boss.health <= 0 || !target) return withEmberMantisIndicators({ boss, party, events })
|
||||
|
||||
const phase = boss.attackPhase as EmberMantisAttackPhase
|
||||
|
||||
if (phase === 'bladeSidestep') {
|
||||
boss = moveBossTowardRelocateTarget(boss, state, target, dt)
|
||||
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 6) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('idle'),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === 'lineSlashWindup') {
|
||||
boss = faceLaneTarget(boss)
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applySlashLanes(
|
||||
party,
|
||||
boss,
|
||||
EMBER_MANTIS.lineSlashDamage,
|
||||
EMBER_MANTIS.lineSlashStunSeconds,
|
||||
state.time + dt,
|
||||
)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('lineSlashRecover'),
|
||||
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds,
|
||||
}
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === 'crossSlashWindup') {
|
||||
boss = faceLaneTarget(boss)
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applySlashLanes(
|
||||
party,
|
||||
boss,
|
||||
EMBER_MANTIS.crossSlashDamage,
|
||||
EMBER_MANTIS.crossSlashStunSeconds,
|
||||
state.time + dt,
|
||||
)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('crossSlashRecover'),
|
||||
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds + 0.12,
|
||||
}
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === 'lineSlashRecover' || phase === 'crossSlashRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('idle'),
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= 0.58) {
|
||||
boss = beginSidestep(boss, state, target, true)
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('crossSlashWindup'),
|
||||
fireballCooldownRemaining: EMBER_MANTIS.crossSlashCooldown,
|
||||
mechanicLanes: createCrossSlashLanes(state, boss, target),
|
||||
phaseSecondsRemaining: EMBER_MANTIS.crossSlashWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('lineSlashWindup'),
|
||||
chargeCooldownRemaining: EMBER_MANTIS.lineSlashCooldown,
|
||||
mechanicLanes: [createLineSlashLane(state, boss, target)],
|
||||
phaseSecondsRemaining: EMBER_MANTIS.lineSlashWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy >= EMBER_MANTIS.sidestepInterval && distanceVec2(boss.position, target.position) <= 220) {
|
||||
boss = beginSidestep(boss, state, target, false)
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withEmberMantisIndicators({ boss, party, events })
|
||||
|
||||
boss = moveDuelist(boss, state, target, dt)
|
||||
return withEmberMantisIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function asBossPhase(phase: EmberMantisAttackPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
|
||||
function beginSidestep(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
forcedFromWall: boolean,
|
||||
): Iwt2BossEntityState {
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase('bladeSidestep'),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: forcedFromWall ? EMBER_MANTIS.sidestepSeconds * 1.6 : EMBER_MANTIS.sidestepSeconds,
|
||||
relocateTarget: chooseSidestepTarget(boss, state, target, forcedFromWall),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardRelocateTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, EMBER_MANTIS.sidestepSpeed * 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 moveDuelist(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const offset = subtractVec2(boss.position, target.position)
|
||||
const distance = distanceVec2(boss.position, target.position)
|
||||
const desiredDistance = distance < EMBER_MANTIS.duelistRange * 0.72
|
||||
? EMBER_MANTIS.duelistRange
|
||||
: EMBER_MANTIS.duelistRange * 0.82
|
||||
const direction = distance < EMBER_MANTIS.duelistRange * 0.72
|
||||
? withFallbackFacing(offset, boss.facing)
|
||||
: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
|
||||
const targetPosition = distance < desiredDistance
|
||||
? addVec2(target.position, scaleVec2(direction, EMBER_MANTIS.duelistRange))
|
||||
: target.position
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, targetPosition, EMBER_MANTIS.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 chooseSidestepTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
forcedFromWall: boolean,
|
||||
): Iwt2Vec2 {
|
||||
if (forcedFromWall) {
|
||||
return clampVec2ToArena(arenaCenter(state), boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
|
||||
}
|
||||
const towardTarget = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
|
||||
const sideSign = boss.chargeCount % 2 === 0 ? 1 : -1
|
||||
const lateral = { x: -towardTarget.y * sideSign, y: towardTarget.x * sideSign }
|
||||
const center = arenaCenter(state)
|
||||
const centerBias = normalizeVec2(subtractVec2(center, boss.position))
|
||||
const sideWeight = forcedFromWall ? 0.35 : 1
|
||||
const centerWeight = forcedFromWall ? 1.15 : 0.35
|
||||
const direction = withFallbackFacing(addVec2(scaleVec2(lateral, sideWeight), scaleVec2(centerBias, centerWeight)), centerBias)
|
||||
const distance = forcedFromWall ? 152 : 118
|
||||
const rawTarget = addVec2(boss.position, scaleVec2(direction, distance))
|
||||
return clampVec2ToArena(rawTarget, boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
|
||||
}
|
||||
|
||||
function createLineSlashLane(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2MechanicLaneState {
|
||||
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
|
||||
return createArenaLane('line-slash', target.position, direction, EMBER_MANTIS.slashLaneWidth, state)
|
||||
}
|
||||
|
||||
function createCrossSlashLanes(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2MechanicLaneState[] {
|
||||
const center = targetClusterCenter(state.party, target.position)
|
||||
const baseAngle = Math.atan2(target.position.y - boss.position.y, target.position.x - boss.position.x)
|
||||
const angles = [baseAngle + Math.PI * 0.25, baseAngle - Math.PI * 0.25]
|
||||
return angles.map((angle, index) => createArenaLane(
|
||||
`cross-slash-${index}`,
|
||||
center,
|
||||
{ x: Math.cos(angle), y: Math.sin(angle) },
|
||||
EMBER_MANTIS.slashLaneWidth,
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
function createArenaLane(
|
||||
id: string,
|
||||
center: Iwt2Vec2,
|
||||
direction: Iwt2Vec2,
|
||||
width: number,
|
||||
state: Iwt2ArenaState,
|
||||
): Iwt2MechanicLaneState {
|
||||
const normalized = withFallbackFacing(direction, { x: 1, y: 0 })
|
||||
const halfLength = Math.hypot(state.bounds.width, state.bounds.height) * 0.62 + 48
|
||||
return {
|
||||
end: clampLanePoint(addVec2(center, scaleVec2(normalized, halfLength)), state),
|
||||
id,
|
||||
start: clampLanePoint(addVec2(center, scaleVec2(normalized, -halfLength)), state),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clampLanePoint(point: Iwt2Vec2, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: clamp(point.x, -24, state.bounds.width + 24),
|
||||
y: clamp(point.y, -24, state.bounds.height + 24),
|
||||
}
|
||||
}
|
||||
|
||||
function targetClusterCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return fallback
|
||||
const total = living.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
|
||||
return scaleVec2(total, 1 / living.length)
|
||||
}
|
||||
|
||||
function applySlashLanes(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
damage: number,
|
||||
stunSeconds: number,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: string[] = []
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'lane',
|
||||
end: lane.end,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}, {
|
||||
damage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
|
||||
const lane = boss.mechanicLanes[0]
|
||||
if (!lane) return boss
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
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) > EMBER_MANTIS.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: EMBER_MANTIS.meleeRange,
|
||||
}, {
|
||||
damage: EMBER_MANTIS.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: EMBER_MANTIS.meleeCooldown },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
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 isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + EMBER_MANTIS.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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function withEmberMantisIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createEmberMantisIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createEmberMantisIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const phase = boss.attackPhase as EmberMantisAttackPhase
|
||||
const isSlashPhase = phase === 'lineSlashWindup'
|
||||
|| phase === 'lineSlashRecover'
|
||||
|| phase === 'crossSlashWindup'
|
||||
|| phase === 'crossSlashRecover'
|
||||
if (!isSlashPhase) return []
|
||||
const isWindup = phase === 'lineSlashWindup' || phase === 'crossSlashWindup'
|
||||
const isRecover = phase === 'lineSlashRecover' || phase === 'crossSlashRecover'
|
||||
return boss.mechanicLanes.map((lane) => createLaneIndicator({
|
||||
color: isRecover ? '#ff6a2a' : EMBER_MANTIS.accentColor,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: phase === 'crossSlashWindup' || phase === 'crossSlashRecover'
|
||||
? 'ember-mantis-cross-slash'
|
||||
: 'ember-mantis-line-slash',
|
||||
phase: indicatorPhaseFromAttack(isWindup, isRecover),
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
+166
-26
@@ -15,6 +15,10 @@ const MAX_FIRE_PUDDLES = 8
|
||||
const FIRE_PUDDLE_TICK_SECONDS = 0.55
|
||||
const FIRE_PUDDLE_FADE_SECONDS = 0.8
|
||||
const FIRE_PUDDLE_MERGE_DISTANCE = 28
|
||||
const MAX_POISON_PUDDLES = 6
|
||||
const MAX_MUD_PUDDLES = 7
|
||||
const MUD_PUDDLE_TICK_SECONDS = 0.22
|
||||
const MUD_SLOW_SECONDS = 0.5
|
||||
|
||||
export function addFirePuddle({
|
||||
damage,
|
||||
@@ -34,9 +38,115 @@ export function addFirePuddle({
|
||||
radius: number
|
||||
sourceId: Iwt2EntityId
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
return addGroundPuddle({
|
||||
damage,
|
||||
duration,
|
||||
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
|
||||
hazardKind: 'firePuddle',
|
||||
hazards,
|
||||
maxActive: MAX_FIRE_PUDDLES,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
})
|
||||
}
|
||||
|
||||
export function addPoisonPuddle({
|
||||
damage,
|
||||
duration,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
}: {
|
||||
damage: number
|
||||
duration: number
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
sourceId: Iwt2EntityId
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
return addGroundPuddle({
|
||||
damage,
|
||||
duration,
|
||||
fadeSeconds: 1,
|
||||
hazardKind: 'poisonPuddle',
|
||||
hazards,
|
||||
maxActive: MAX_POISON_PUDDLES,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
})
|
||||
}
|
||||
|
||||
export function addMudPuddle({
|
||||
duration,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
}: {
|
||||
duration: number
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
sourceId: Iwt2EntityId
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
return addGroundPuddle({
|
||||
damage: 0,
|
||||
duration,
|
||||
fadeSeconds: 0.9,
|
||||
hazardKind: 'mudPuddle',
|
||||
hazards,
|
||||
maxActive: MAX_MUD_PUDDLES,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
})
|
||||
}
|
||||
|
||||
function addGroundPuddle({
|
||||
damage,
|
||||
duration,
|
||||
fadeSeconds,
|
||||
hazardKind,
|
||||
hazards,
|
||||
maxActive,
|
||||
nextHazardId,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
time,
|
||||
}: {
|
||||
damage: number
|
||||
duration: number
|
||||
fadeSeconds: number
|
||||
hazardKind: Iwt2GroundHazardState['hazardKind']
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
maxActive: number
|
||||
nextHazardId: number
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
sourceId: Iwt2EntityId
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
const overlappingIndex = hazards.findIndex((hazard) => (
|
||||
hazard.hazardKind === 'firePuddle'
|
||||
hazard.hazardKind === hazardKind
|
||||
&& hazard.remainingSeconds > hazard.fadeSeconds
|
||||
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
|
||||
))
|
||||
@@ -59,19 +169,19 @@ export function addFirePuddle({
|
||||
}
|
||||
|
||||
const nextHazard: Iwt2GroundHazardState = {
|
||||
id: `fire-puddle-${nextHazardId}`,
|
||||
id: `${hazardKind}-${nextHazardId}`,
|
||||
kind: 'groundHazard',
|
||||
hazardKind: 'firePuddle',
|
||||
hazardKind,
|
||||
sourceId,
|
||||
position: { ...position },
|
||||
radius,
|
||||
damage,
|
||||
remainingSeconds: duration,
|
||||
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
|
||||
fadeSeconds,
|
||||
nextDamageAt: time + 0.12,
|
||||
}
|
||||
return {
|
||||
hazards: capFirePuddles([...hazards, nextHazard]),
|
||||
hazards: capPuddles([...hazards, nextHazard], hazardKind, maxActive),
|
||||
nextHazardId: nextHazardId + 1,
|
||||
}
|
||||
}
|
||||
@@ -101,22 +211,27 @@ export function tickGroundHazards({
|
||||
|
||||
let nextDamageAt = hazard.nextDamageAt
|
||||
if (time >= hazard.nextDamageAt) {
|
||||
const result = applyPartyDamageInShape(
|
||||
nextParty,
|
||||
{
|
||||
kind: 'circle',
|
||||
position: hazard.position,
|
||||
radius: hazard.radius,
|
||||
},
|
||||
{
|
||||
damage: hazard.damage,
|
||||
sourceId: hazard.sourceId,
|
||||
time,
|
||||
},
|
||||
)
|
||||
nextParty = result.party
|
||||
events.push(...result.events)
|
||||
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
|
||||
if (hazard.hazardKind === 'mudPuddle') {
|
||||
nextParty = applyMudSlow(nextParty, hazard)
|
||||
nextDamageAt = time + MUD_PUDDLE_TICK_SECONDS
|
||||
} else {
|
||||
const result = applyPartyDamageInShape(
|
||||
nextParty,
|
||||
{
|
||||
kind: 'circle',
|
||||
position: hazard.position,
|
||||
radius: hazard.radius,
|
||||
},
|
||||
{
|
||||
damage: hazard.damage,
|
||||
sourceId: hazard.sourceId,
|
||||
time,
|
||||
},
|
||||
)
|
||||
nextParty = result.party
|
||||
events.push(...result.events)
|
||||
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
nextHazards.push({
|
||||
@@ -135,9 +250,9 @@ export function tickGroundHazards({
|
||||
|
||||
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
|
||||
return hazards.map((hazard) => createCircleIndicator({
|
||||
color: '#ff7a2f',
|
||||
color: hazardColor(hazard),
|
||||
id: `${hazard.id}:indicator`,
|
||||
mechanicId: 'fire-puddle',
|
||||
mechanicId: hazard.hazardKind,
|
||||
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
|
||||
position: hazard.position,
|
||||
radius: hazard.radius,
|
||||
@@ -145,16 +260,41 @@ export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2Ar
|
||||
}))
|
||||
}
|
||||
|
||||
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
|
||||
function applyMudSlow(party: Iwt2PartyEntityState[], hazard: Iwt2GroundHazardState): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
const distance = Math.hypot(member.position.x - hazard.position.x, member.position.y - hazard.position.y)
|
||||
if (distance > hazard.radius + member.radius) return member
|
||||
return {
|
||||
...member,
|
||||
status: {
|
||||
...member.status,
|
||||
slowedSeconds: Math.max(member.status.slowedSeconds, MUD_SLOW_SECONDS),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hazardColor(hazard: Iwt2GroundHazardState): string {
|
||||
if (hazard.hazardKind === 'poisonPuddle') return '#7bd84f'
|
||||
if (hazard.hazardKind === 'mudPuddle') return '#9a6a3a'
|
||||
return '#ff7a2f'
|
||||
}
|
||||
|
||||
function capPuddles(
|
||||
hazards: Iwt2GroundHazardState[],
|
||||
hazardKind: Iwt2GroundHazardState['hazardKind'],
|
||||
maxActive: number,
|
||||
): Iwt2GroundHazardState[] {
|
||||
let activePuddles = 0
|
||||
let oldestActivePuddleIndex = -1
|
||||
for (let index = 0; index < hazards.length; index += 1) {
|
||||
const hazard = hazards[index]
|
||||
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
|
||||
if (hazard.hazardKind !== hazardKind || hazard.remainingSeconds <= hazard.fadeSeconds) continue
|
||||
activePuddles += 1
|
||||
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
|
||||
}
|
||||
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
|
||||
if (activePuddles <= maxActive || oldestActivePuddleIndex < 0) return hazards
|
||||
|
||||
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
|
||||
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaIndicatorPhase,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createArenaEvent,
|
||||
createCircleIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const HOLLOWCROWN_PHASE = {
|
||||
antlerChargeRecover: 'hollowcrownAntlerChargeRecover',
|
||||
antlerChargeWindup: 'hollowcrownAntlerChargeWindup',
|
||||
hoofCurseRecover: 'hollowcrownHoofCurseRecover',
|
||||
hoofCurseWindup: 'hollowcrownHoofCurseWindup',
|
||||
trailDashing: 'hollowcrownTrailDashing',
|
||||
trailDashWindup: 'hollowcrownTrailDashWindup',
|
||||
trailFade: 'hollowcrownTrailFade',
|
||||
} as const
|
||||
|
||||
type HollowcrownSpecialPhase = typeof HOLLOWCROWN_PHASE[keyof typeof HOLLOWCROWN_PHASE]
|
||||
type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSpecialPhase
|
||||
|
||||
const HOLLOWCROWN_TUNING = {
|
||||
antlerCooldown: 7.4,
|
||||
antlerDamage: 17,
|
||||
antlerLaneLength: 440,
|
||||
antlerLaneOffset: 44,
|
||||
antlerLineWidth: 30,
|
||||
antlerRecover: 0.52,
|
||||
antlerStunSeconds: 0.48,
|
||||
antlerWindup: 0.82,
|
||||
dashCooldown: 5.2,
|
||||
dashDamage: 13,
|
||||
dashLength: 330,
|
||||
dashRecover: 0.42,
|
||||
dashSpeed: 430,
|
||||
dashStunSeconds: 0.32,
|
||||
dashTrailDamage: 9,
|
||||
dashTrailSeconds: 0.5,
|
||||
dashTrailWidth: 40,
|
||||
hoofCooldown: 6.2,
|
||||
hoofDamage: 12,
|
||||
hoofRadius: 46,
|
||||
hoofRecover: 0.46,
|
||||
hoofStunSeconds: 0.34,
|
||||
hoofWindup: 0.74,
|
||||
meleeCooldown: 1.25,
|
||||
meleeDamage: 5,
|
||||
meleeRange: 58,
|
||||
moveSpeed: 116,
|
||||
relocateSeconds: 1.25,
|
||||
relocateSpeed: 168,
|
||||
wallContactLimit: 0.68,
|
||||
wallMargin: 76,
|
||||
} as const
|
||||
|
||||
const CURSE_COLOR = '#9b74ff'
|
||||
const TRAIL_COLOR = '#7ee7ff'
|
||||
const ANTLER_COLOR = '#e8dcff'
|
||||
|
||||
export function tickHollowcrownRevenant(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
const incomingPhase = state.boss.attackPhase as HollowcrownAttackPhase
|
||||
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 },
|
||||
wallContactSeconds: incomingPhase === 'idle' && isBossNearWall(state.boss, state)
|
||||
? state.boss.wallContactSeconds + dt
|
||||
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withHollowcrownIndicators({ boss, party, events, state })
|
||||
|
||||
const phase = boss.attackPhase as HollowcrownAttackPhase
|
||||
|
||||
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),
|
||||
fireballCooldownRemaining: Math.max(boss.fireballCooldownRemaining, 1.0),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup) {
|
||||
boss = faceTarget(boss, target)
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyHoofCurse(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseRecover),
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofRecover,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.trailDashWindup) {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashing),
|
||||
chargeHitEntityIds: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.trailDashing) {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.chargeEnd, HOLLOWCROWN_TUNING.dashSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
const hitResult = applyTrailDashHits(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: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds + dt : 0,
|
||||
}
|
||||
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
|
||||
const trailResult = applyFadingTrail(party, boss, state.time + dt)
|
||||
party = trailResult.party
|
||||
events.push(...trailResult.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailFade),
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.dashTrailSeconds,
|
||||
velocity: { x: 0, y: 0 },
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.trailFade) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup) {
|
||||
boss = faceLaneTarget(boss)
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyAntlerLines(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeRecover),
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerRecover,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= HOLLOWCROWN_TUNING.wallContactLimit) {
|
||||
boss = beginRelocation(boss, state)
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = beginRangedPattern(boss, state, party, target)
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = beginTrailDash(boss, state, target)
|
||||
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withHollowcrownIndicators({ boss, party, events, state })
|
||||
|
||||
boss = moveBossTowardEngagePoint(boss, state, target, dt)
|
||||
return withHollowcrownIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
function asBossPhase(phase: HollowcrownAttackPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
|
||||
function beginRangedPattern(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2BossEntityState {
|
||||
const useAntlers = boss.chargeCount % 2 === 1
|
||||
if (useAntlers) {
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.antlerChargeWindup),
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
fireballCooldownRemaining: HOLLOWCROWN_TUNING.antlerCooldown,
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: createAntlerLines(state, boss, target),
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.antlerWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.hoofCurseWindup),
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
fireballCooldownRemaining: HOLLOWCROWN_TUNING.hoofCooldown,
|
||||
mechanicCircles: createHoofZones(state, party, boss),
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.hoofWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginTrailDash(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2BossEntityState {
|
||||
const lane = createDashTrailLane(state, boss, target)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(HOLLOWCROWN_PHASE.trailDashWindup),
|
||||
chargeCooldownRemaining: HOLLOWCROWN_TUNING.dashCooldown,
|
||||
chargeEnd: lane.end,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: { ...boss.position },
|
||||
facing: withFallbackFacing(subtractVec2(lane.end, boss.position), boss.facing),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [lane],
|
||||
phaseSecondsRemaining: 0.58,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: HOLLOWCROWN_TUNING.relocateSeconds,
|
||||
relocateTarget: relocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function createHoofZones(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const priority = [
|
||||
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player'),
|
||||
...living.filter((member) => member.aiRole !== 'ranged' && member.aiRole !== 'flanker' && member.aiRole !== 'player'),
|
||||
]
|
||||
return priority.slice(0, 4).map((member, index) => {
|
||||
const drift = normalizeVec2(addVec2(
|
||||
subtractVec2(member.position, boss.position),
|
||||
scaleVec2(subtractVec2(arenaCenter(state), member.position), 0.35),
|
||||
))
|
||||
return {
|
||||
id: `hollowcrown-hoof-zone-${index}`,
|
||||
position: clampVec2ToArena(
|
||||
addVec2(member.position, scaleVec2(drift, 18 + index * 4)),
|
||||
HOLLOWCROWN_TUNING.hoofRadius,
|
||||
state.bounds,
|
||||
),
|
||||
radius: HOLLOWCROWN_TUNING.hoofRadius,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createDashTrailLane(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2MechanicLaneState {
|
||||
const wallBias = isBossNearWall(boss, state)
|
||||
? normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
|
||||
: { x: 0, y: 0 }
|
||||
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
|
||||
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.9)), boss.facing)
|
||||
const rawEnd = addVec2(boss.position, scaleVec2(direction, HOLLOWCROWN_TUNING.dashLength))
|
||||
return {
|
||||
end: clampVec2ToArena(rawEnd, boss.radius + 8, state.bounds),
|
||||
id: 'hollowcrown-dash-trail',
|
||||
start: { ...boss.position },
|
||||
width: HOLLOWCROWN_TUNING.dashTrailWidth,
|
||||
}
|
||||
}
|
||||
|
||||
function createAntlerLines(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2MechanicLaneState[] {
|
||||
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
|
||||
const perpendicular = { x: -direction.y, y: direction.x }
|
||||
return [-1, 0, 1].map((index) => {
|
||||
const offset = scaleVec2(perpendicular, index * HOLLOWCROWN_TUNING.antlerLaneOffset)
|
||||
const start = clampVec2ToArena(addVec2(boss.position, offset), 4, state.bounds)
|
||||
const end = clampVec2ToArena(
|
||||
addVec2(start, scaleVec2(direction, HOLLOWCROWN_TUNING.antlerLaneLength)),
|
||||
4,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
end,
|
||||
id: `hollowcrown-antler-line-${index + 1}`,
|
||||
start,
|
||||
width: index === 0 ? HOLLOWCROWN_TUNING.antlerLineWidth + 8 : HOLLOWCROWN_TUNING.antlerLineWidth,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyHoofCurse(
|
||||
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 result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: HOLLOWCROWN_TUNING.hoofDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: HOLLOWCROWN_TUNING.hoofStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function applyTrailDashHits(
|
||||
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: HOLLOWCROWN_TUNING.dashTrailWidth,
|
||||
}, {
|
||||
damage: HOLLOWCROWN_TUNING.dashDamage,
|
||||
damageEventType: 'bossChargeHit',
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function applyFadingTrail(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const lane = boss.mechanicLanes[0]
|
||||
if (!lane) return { party, events: [] }
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
end: lane.end,
|
||||
kind: 'lane',
|
||||
start: lane.start,
|
||||
width: lane.width * 1.15,
|
||||
}, {
|
||||
damage: HOLLOWCROWN_TUNING.dashTrailDamage,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: HOLLOWCROWN_TUNING.dashStunSeconds * 0.65,
|
||||
time,
|
||||
})
|
||||
return { party: result.party, events: result.events }
|
||||
}
|
||||
|
||||
function applyAntlerLines(
|
||||
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: HOLLOWCROWN_TUNING.antlerDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: HOLLOWCROWN_TUNING.antlerStunSeconds,
|
||||
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) > HOLLOWCROWN_TUNING.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: HOLLOWCROWN_TUNING.meleeRange,
|
||||
}, {
|
||||
damage: HOLLOWCROWN_TUNING.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: HOLLOWCROWN_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardRelocationTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, HOLLOWCROWN_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 moveBossTowardEngagePoint(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const center = arenaCenter(state)
|
||||
const targetNearWall = isPositionNearWall(target.position, target.radius + HOLLOWCROWN_TUNING.wallMargin, state)
|
||||
const desired = targetNearWall
|
||||
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), HOLLOWCROWN_TUNING.meleeRange))
|
||||
: target.position
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, desired, HOLLOWCROWN_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 faceTarget(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2BossEntityState {
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
|
||||
const lane = boss.mechanicLanes[0]
|
||||
if (!lane) return { ...boss, velocity: { x: 0, y: 0 } }
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
const center = arenaCenter(state)
|
||||
const awayFromWall = normalizeVec2(addVec2(
|
||||
subtractVec2(center, boss.position),
|
||||
{
|
||||
x: boss.position.x < center.x ? 1 : -1,
|
||||
y: boss.position.y < center.y ? 1 : -1,
|
||||
},
|
||||
))
|
||||
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 72)), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
|
||||
return isPositionNearWall(boss.position, boss.radius + HOLLOWCROWN_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 withHollowcrownIndicators(result: {
|
||||
boss: Iwt2BossEntityState
|
||||
party: Iwt2PartyEntityState[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
state: Iwt2ArenaState
|
||||
}): Iwt2BossTickResult {
|
||||
return {
|
||||
boss: result.boss,
|
||||
events: result.events,
|
||||
indicators: createHollowcrownIndicators(result.boss),
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function createHollowcrownIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const phase = boss.attackPhase as HollowcrownAttackPhase
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.hoofCurseWindup || phase === HOLLOWCROWN_PHASE.hoofCurseRecover) {
|
||||
const indicatorPhase = indicatorPhaseFromAttack(
|
||||
phase === HOLLOWCROWN_PHASE.hoofCurseWindup,
|
||||
phase === HOLLOWCROWN_PHASE.hoofCurseRecover,
|
||||
)
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: CURSE_COLOR,
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'hollowcrown-cursed-hoof-zone',
|
||||
phase: indicatorPhase,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
phase === HOLLOWCROWN_PHASE.trailDashWindup
|
||||
|| phase === HOLLOWCROWN_PHASE.trailDashing
|
||||
|| phase === HOLLOWCROWN_PHASE.trailFade
|
||||
) {
|
||||
const indicatorPhase: Iwt2ArenaIndicatorPhase = phase === HOLLOWCROWN_PHASE.trailDashWindup
|
||||
? 'windup'
|
||||
: phase === HOLLOWCROWN_PHASE.trailDashing
|
||||
? 'active'
|
||||
: 'recover'
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: TRAIL_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'hollowcrown-fading-dash-trail',
|
||||
phase: indicatorPhase,
|
||||
sourceId: boss.id,
|
||||
start: phase === HOLLOWCROWN_PHASE.trailDashing ? boss.position : lane.start,
|
||||
width: phase === HOLLOWCROWN_PHASE.trailFade ? lane.width * 1.15 : lane.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === HOLLOWCROWN_PHASE.antlerChargeWindup || phase === HOLLOWCROWN_PHASE.antlerChargeRecover) {
|
||||
const indicatorPhase = indicatorPhaseFromAttack(
|
||||
phase === HOLLOWCROWN_PHASE.antlerChargeWindup,
|
||||
phase === HOLLOWCROWN_PHASE.antlerChargeRecover,
|
||||
)
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: ANTLER_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'hollowcrown-ghost-antler-line-charge',
|
||||
phase: indicatorPhase,
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return indicators
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Iwt2ArenaBounds,
|
||||
Iwt2ArenaArcIndicator,
|
||||
Iwt2ArenaCircleIndicator,
|
||||
Iwt2ArenaConeIndicator,
|
||||
Iwt2ArenaDonutIndicator,
|
||||
@@ -51,6 +52,14 @@ export type Iwt2CircleHitShape = {
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2ConeHitShape = {
|
||||
kind: 'cone'
|
||||
origin: Iwt2Vec2
|
||||
direction: Iwt2Vec2
|
||||
range: number
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2DonutHitShape = {
|
||||
kind: 'donut'
|
||||
position: Iwt2Vec2
|
||||
@@ -58,7 +67,21 @@ export type Iwt2DonutHitShape = {
|
||||
outerRadius: number
|
||||
}
|
||||
|
||||
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
|
||||
export type Iwt2ArcHitShape = {
|
||||
kind: 'arc'
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
direction: Iwt2Vec2
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2HitShape =
|
||||
| Iwt2LaneHitShape
|
||||
| Iwt2CircleHitShape
|
||||
| Iwt2ConeHitShape
|
||||
| Iwt2DonutHitShape
|
||||
| Iwt2ArcHitShape
|
||||
|
||||
export function createArenaEvent(
|
||||
id: number,
|
||||
@@ -162,9 +185,26 @@ export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape:
|
||||
shape.width,
|
||||
)
|
||||
}
|
||||
if (shape.kind === 'cone') {
|
||||
const distance = distanceVec2(member.position, shape.origin)
|
||||
if (distance > shape.range + member.radius) return false
|
||||
const direction = normalizeVec2(shape.direction)
|
||||
const offset = normalizeVec2(subtractVec2(member.position, shape.origin))
|
||||
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
|
||||
return Math.acos(dot) <= shape.angleRadians / 2
|
||||
}
|
||||
const distance = distanceVec2(member.position, shape.position)
|
||||
if (shape.kind === 'circle') return distance <= shape.radius + member.radius
|
||||
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
|
||||
if (shape.kind === 'donut') {
|
||||
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
|
||||
}
|
||||
if (distance > shape.outerRadius + member.radius || distance < Math.max(0, shape.innerRadius - member.radius)) {
|
||||
return false
|
||||
}
|
||||
const direction = normalizeVec2(shape.direction)
|
||||
const offset = normalizeVec2(subtractVec2(member.position, shape.position))
|
||||
const dot = Math.max(-1, Math.min(1, direction.x * offset.x + direction.y * offset.y))
|
||||
return Math.acos(dot) <= shape.angleRadians / 2
|
||||
}
|
||||
|
||||
export function createLaneIndicator({
|
||||
@@ -303,6 +343,46 @@ export function createDonutIndicator({
|
||||
}
|
||||
}
|
||||
|
||||
export function createArcIndicator({
|
||||
angleRadians,
|
||||
color,
|
||||
direction,
|
||||
id,
|
||||
innerRadius,
|
||||
mechanicId,
|
||||
outerRadius,
|
||||
phase,
|
||||
position,
|
||||
sourceId,
|
||||
}: {
|
||||
angleRadians: number
|
||||
color: string
|
||||
direction: Iwt2Vec2
|
||||
id: string
|
||||
innerRadius: number
|
||||
mechanicId: string
|
||||
outerRadius: number
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
position: Iwt2Vec2
|
||||
sourceId: Iwt2EntityId
|
||||
}): Iwt2ArenaArcIndicator {
|
||||
return {
|
||||
angleRadians,
|
||||
color,
|
||||
direction: normalizeVec2(direction),
|
||||
fillAlpha: phase === 'active' ? 0.16 : 0.09,
|
||||
id,
|
||||
innerRadius,
|
||||
kind: 'arc',
|
||||
lineAlpha: phase === 'active' ? 0.78 : 0.52,
|
||||
mechanicId,
|
||||
outerRadius,
|
||||
phase,
|
||||
position: { ...position },
|
||||
sourceId,
|
||||
}
|
||||
}
|
||||
|
||||
export function indicatorPhaseFromAttack(
|
||||
windup: boolean,
|
||||
active: boolean,
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addMudPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createArenaEvent,
|
||||
createCircleIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const OBSIDIAN_RAM_PHASE = {
|
||||
armorShatterRecover: 'obsidianRamArmorShatterRecover',
|
||||
armorShatterWindup: 'obsidianRamArmorShatterWindup',
|
||||
fractureQuakeRecover: 'obsidianRamFractureQuakeRecover',
|
||||
fractureQuakeWindup: 'obsidianRamFractureQuakeWindup',
|
||||
plateChargeRecover: 'obsidianRamPlateChargeRecover',
|
||||
plateChargeWindup: 'obsidianRamPlateChargeWindup',
|
||||
plateCharging: 'obsidianRamPlateCharging',
|
||||
} as const
|
||||
|
||||
type ObsidianRamPhase = typeof OBSIDIAN_RAM_PHASE[keyof typeof OBSIDIAN_RAM_PHASE]
|
||||
|
||||
const OBSIDIAN_RAM = {
|
||||
armorCracksBeforeShatter: 3,
|
||||
centerDisengageSpeed: 118,
|
||||
chargeCooldown: 4.4,
|
||||
chargeDamage: 8,
|
||||
chargeLength: 340,
|
||||
chargeRecover: 0.5,
|
||||
chargeSpeed: 390,
|
||||
chargeStunSeconds: 0.55,
|
||||
chargeWidth: 54,
|
||||
chargeWindup: 0.68,
|
||||
fractureLaneCount: 5,
|
||||
fractureLaneDamage: 6,
|
||||
fractureLaneLength: 300,
|
||||
fractureLaneWidth: 30,
|
||||
meleeCooldown: 1.3,
|
||||
meleeDamage: 2,
|
||||
meleeRange: 62,
|
||||
moveSpeed: 82,
|
||||
quakeCooldown: 7.4,
|
||||
quakeRadius: 126,
|
||||
quakeRecover: 0.48,
|
||||
quakeStunSeconds: 0.35,
|
||||
quakeWindup: 0.78,
|
||||
slabDamage: 10,
|
||||
slabDuration: 5.8,
|
||||
slabRadius: 42,
|
||||
slabSlowRadius: 46,
|
||||
shatterRecover: 0.8,
|
||||
shatterWindup: 0.9,
|
||||
wallContactLimit: 0.72,
|
||||
wallMargin: 72,
|
||||
} as const
|
||||
|
||||
export function tickObsidianRamGolem(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
let party = state.party
|
||||
const incomingPhase = obsidianRamPhase(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 },
|
||||
wallContactSeconds: isLockedPhase(incomingPhase)
|
||||
? 0
|
||||
: isBossNearWall(state.boss, state)
|
||||
? state.boss.wallContactSeconds + dt
|
||||
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
const phase = obsidianRamPhase(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, 1.1),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup) {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateCharging),
|
||||
chargeHitEntityIds: [],
|
||||
phaseSecondsRemaining: 1.25,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateCharging) {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.chargeEnd, OBSIDIAN_RAM.chargeSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
const hitResult = applyChargeHits(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),
|
||||
}
|
||||
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2 || boss.phaseSecondsRemaining <= 0) {
|
||||
const crackedArmor = clamp(boss.mechanicEnergy + 1, 0, OBSIDIAN_RAM.armorCracksBeforeShatter)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeRecover),
|
||||
mechanicEnergy: crackedArmor,
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.chargeRecover,
|
||||
velocity: { x: 0, y: 0 },
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter
|
||||
? beginArmorShatter(boss, state, party)
|
||||
: {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyFractureQuake(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeRecover),
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.quakeRecover,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const slabResult = dropObsidianSlabs({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time: state.time + dt,
|
||||
})
|
||||
party = slabResult.party
|
||||
hazards = slabResult.hazards
|
||||
nextHazardId = slabResult.nextHazardId
|
||||
events.push(...slabResult.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterRecover),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.shatterRecover,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= OBSIDIAN_RAM.wallContactLimit) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 1.3,
|
||||
relocateTarget: relocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter) {
|
||||
boss = beginArmorShatter(boss, state, party)
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeWindup),
|
||||
fireballCooldownRemaining: OBSIDIAN_RAM.quakeCooldown,
|
||||
mechanicCircles: [{ id: 'obsidian-ram-quake-core', position: { ...boss.position }, radius: OBSIDIAN_RAM.quakeRadius }],
|
||||
mechanicLanes: createFractureWaveLanes(state, boss, target.position),
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.quakeWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = beginPlateCharge(boss, state, target)
|
||||
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
|
||||
return withObsidianRamIndicators({ 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 withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
boss = moveBossTowardEngagePoint(boss, state, target, dt)
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function beginPlateCharge(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2BossEntityState {
|
||||
const direction = wallSafeChargeDirection(boss, state, target.position)
|
||||
const chargeEnd = clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.chargeLength)),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeWindup),
|
||||
chargeCooldownRemaining: OBSIDIAN_RAM.chargeCooldown,
|
||||
chargeEnd,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: { ...boss.position },
|
||||
facing: direction,
|
||||
mechanicLanes: [{
|
||||
end: chargeEnd,
|
||||
id: 'obsidian-ram-plate-charge',
|
||||
start: { ...boss.position },
|
||||
width: OBSIDIAN_RAM.chargeWidth,
|
||||
}],
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.chargeWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginArmorShatter(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2BossEntityState {
|
||||
const slabCircles = createObsidianSlabCircles(state, boss, party)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterWindup),
|
||||
mechanicCircles: slabCircles,
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.shatterWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function applyChargeHits(
|
||||
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: OBSIDIAN_RAM.chargeWidth,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.chargeDamage,
|
||||
damageEventType: 'bossChargeHit',
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: OBSIDIAN_RAM.chargeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.chargeStunSeconds,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function applyFractureQuake(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = [
|
||||
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, OBSIDIAN_RAM.quakeRadius),
|
||||
]
|
||||
const quakeResult = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: OBSIDIAN_RAM.quakeRadius,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.fractureLaneDamage,
|
||||
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = quakeResult.party
|
||||
events.push(...quakeResult.events)
|
||||
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const laneResult = applyPartyDamageInShape(nextParty, {
|
||||
end: lane.end,
|
||||
kind: 'lane',
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.fractureLaneDamage,
|
||||
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = laneResult.party
|
||||
events.push(...laneResult.events)
|
||||
}
|
||||
|
||||
return { events, party: nextParty }
|
||||
}
|
||||
|
||||
function dropObsidianSlabs({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time,
|
||||
}: {
|
||||
boss: Iwt2BossEntityState
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
state: Iwt2ArenaState
|
||||
time: number
|
||||
}): {
|
||||
events: Iwt2ArenaEvent[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
} {
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextId = nextHazardId
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
|
||||
for (const slab of boss.mechanicCircles) {
|
||||
const hitResult = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: slab.position,
|
||||
radius: slab.radius,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.slabDamage,
|
||||
knockdownSeconds: 0.22,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: 0.22,
|
||||
time,
|
||||
})
|
||||
nextParty = hitResult.party
|
||||
events.push(...hitResult.events)
|
||||
|
||||
const hazardResult = addMudPuddle({
|
||||
duration: OBSIDIAN_RAM.slabDuration,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
position: clampVec2ToArena(slab.position, OBSIDIAN_RAM.slabSlowRadius, state.bounds),
|
||||
radius: OBSIDIAN_RAM.slabSlowRadius,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = hazardResult.hazards
|
||||
nextId = hazardResult.nextHazardId
|
||||
}
|
||||
|
||||
return { events, hazards: nextHazards, nextHazardId: nextId, party: nextParty }
|
||||
}
|
||||
|
||||
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) > OBSIDIAN_RAM.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: OBSIDIAN_RAM.meleeRange,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: OBSIDIAN_RAM.meleeCooldown, velocity: { x: 0, y: 0 } },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function createFractureWaveLanes(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
targetPosition: Iwt2Vec2,
|
||||
): Iwt2MechanicLaneState[] {
|
||||
const baseDirection = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
|
||||
const baseAngle = Math.atan2(baseDirection.y, baseDirection.x)
|
||||
const spread = Math.PI * 0.82
|
||||
const lanes: Iwt2MechanicLaneState[] = []
|
||||
const laneCount: number = OBSIDIAN_RAM.fractureLaneCount
|
||||
for (let index = 0; index < laneCount; index += 1) {
|
||||
const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1)
|
||||
const angle = baseAngle - spread * 0.5 + spread * ratio
|
||||
const direction = { x: Math.cos(angle), y: Math.sin(angle) }
|
||||
lanes.push({
|
||||
end: clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.fractureLaneLength)),
|
||||
OBSIDIAN_RAM.fractureLaneWidth,
|
||||
state.bounds,
|
||||
),
|
||||
id: `obsidian-ram-fracture-${index}`,
|
||||
start: { ...boss.position },
|
||||
width: OBSIDIAN_RAM.fractureLaneWidth,
|
||||
})
|
||||
}
|
||||
return lanes
|
||||
}
|
||||
|
||||
function createObsidianSlabCircles(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const targetPositions = living
|
||||
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
|
||||
.slice(0, 3)
|
||||
.map((member) => member.position)
|
||||
const fallbackAngles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
|
||||
while (targetPositions.length < 3) {
|
||||
const angle = fallbackAngles[targetPositions.length]
|
||||
targetPositions.push(addVec2(boss.position, {
|
||||
x: Math.cos(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
|
||||
y: Math.sin(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
|
||||
}))
|
||||
}
|
||||
return targetPositions.map((position, index) => ({
|
||||
id: `obsidian-ram-slab-${index}`,
|
||||
position: clampVec2ToArena(position, OBSIDIAN_RAM.slabRadius, state.bounds),
|
||||
radius: OBSIDIAN_RAM.slabRadius,
|
||||
}))
|
||||
}
|
||||
|
||||
function moveBossTowardEngagePoint(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const wallBias = isBossNearWall(boss, state) ? normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) : { x: 0, y: 0 }
|
||||
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
|
||||
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.8)), targetBias)
|
||||
const desiredPosition = addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.moveSpeed * dt))
|
||||
const nextPosition = clampVec2ToArena(desiredPosition, 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(
|
||||
moveToward(boss.position, boss.relocateTarget, OBSIDIAN_RAM.centerDisengageSpeed * 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 wallSafeChargeDirection(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
targetPosition: Iwt2Vec2,
|
||||
): Iwt2Vec2 {
|
||||
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
|
||||
if (!isBossNearWall(boss, state)) return withFallbackFacing(targetBias, boss.facing)
|
||||
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
|
||||
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.35), scaleVec2(targetBias, 0.55)), centerBias)
|
||||
}
|
||||
|
||||
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
const center = arenaCenter(state)
|
||||
const inward = normalizeVec2(subtractVec2(center, boss.position))
|
||||
const offset = addVec2(scaleVec2(inward, 138), {
|
||||
x: boss.position.y < center.y ? 42 : -42,
|
||||
y: boss.position.x < center.x ? -34 : 34,
|
||||
})
|
||||
return clampVec2ToArena(addVec2(boss.position, offset), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + OBSIDIAN_RAM.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 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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function withObsidianRamIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createObsidianRamIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createObsidianRamIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const phase = obsidianRamPhase(boss)
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup || phase === OBSIDIAN_RAM_PHASE.plateCharging) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: phase === OBSIDIAN_RAM_PHASE.plateCharging ? '#ff7a2f' : '#f0b35a',
|
||||
end: boss.chargeEnd,
|
||||
id: `${boss.id}:obsidian-ram-plate-charge`,
|
||||
mechanicId: 'obsidian-ram-plate-charge',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.plateChargeWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.plateCharging,
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: boss.chargeStart,
|
||||
width: OBSIDIAN_RAM.chargeWidth,
|
||||
}))
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup || phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
|
||||
const indicatorPhase = indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover,
|
||||
)
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#ff9f1c',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'obsidian-ram-quake-core',
|
||||
phase: indicatorPhase,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#ffbf69',
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'obsidian-ram-fracture-wave',
|
||||
phase: indicatorPhase,
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup || phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#b8b6c7',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'obsidian-ram-broken-slab',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.armorShatterWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.armorShatterRecover,
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
|
||||
function isLockedPhase(phase: string): boolean {
|
||||
return phase === OBSIDIAN_RAM_PHASE.plateChargeWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.plateCharging
|
||||
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover
|
||||
|| phase === OBSIDIAN_RAM_PHASE.armorShatterWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.armorShatterRecover
|
||||
}
|
||||
|
||||
function obsidianRamPhase(boss: Iwt2BossEntityState): string {
|
||||
return boss.attackPhase as string
|
||||
}
|
||||
|
||||
function asBossPhase(phase: ObsidianRamPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as unknown as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
+209
-12
@@ -1,6 +1,7 @@
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
||||
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
@@ -20,6 +21,12 @@ const CENTER_LEASH_WALL_MARGIN = 96
|
||||
const CENTER_LEASH_EXTRA_DISTANCE = 36
|
||||
const PARTY_ARRIVAL_RADIUS = 3.5
|
||||
const PARTY_SAFE_DESTINATION_PADDING = 34
|
||||
const PARTY_STEER_ANGLES = [0, 0.42, -0.42, 0.82, -0.82, 1.22, -1.22, 1.7, -1.7, 2.25, -2.25, Math.PI]
|
||||
const HAZARD_ROUTE_BUFFER = 54
|
||||
const INDICATOR_ROUTE_PENALTY = 24
|
||||
const BOSS_ROUTE_BUFFER = 22
|
||||
const PARTY_ROUTE_BUFFER = 8
|
||||
const DANGER_CENTER_WEIGHT = 0.32
|
||||
|
||||
export function tickPartyMember(
|
||||
member: Iwt2PartyEntityState,
|
||||
@@ -32,6 +39,7 @@ export function tickPartyMember(
|
||||
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
|
||||
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
|
||||
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
|
||||
slowedSeconds: Math.max(0, member.status.slowedSeconds - dt),
|
||||
}
|
||||
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
|
||||
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
|
||||
@@ -44,7 +52,7 @@ export function tickPartyMember(
|
||||
|
||||
if (member.aiRole === 'player') {
|
||||
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
|
||||
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
|
||||
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
|
||||
const position = clampVec2ToArena(
|
||||
{
|
||||
x: member.position.x + velocity.x * dt,
|
||||
@@ -88,11 +96,11 @@ export function tickPartyMember(
|
||||
|
||||
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
||||
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
|
||||
const maxDistance = metadata.moveSpeed * dt
|
||||
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
|
||||
const distanceToDesired = distanceVec2(member.position, desired)
|
||||
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
||||
const position = movingToDesired
|
||||
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||
? choosePartyStepPosition(member, state, desired, maxDistance)
|
||||
: member.position
|
||||
const velocity = movingToDesired
|
||||
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
||||
@@ -112,6 +120,112 @@ export function tickPartyMember(
|
||||
}
|
||||
}
|
||||
|
||||
function choosePartyStepPosition(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
desired: Iwt2Vec2,
|
||||
maxDistance: number,
|
||||
): Iwt2Vec2 {
|
||||
if (maxDistance <= 0) return member.position
|
||||
|
||||
const direct = withFallbackFacing(subtractVec2(desired, member.position), member.facing)
|
||||
const currentDanger = routeDangerPenalty(member.position, member, state)
|
||||
let best = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||
let bestScore = scoreRoutePosition(best, member, state, desired, currentDanger)
|
||||
|
||||
for (const angle of PARTY_STEER_ANGLES) {
|
||||
const direction = rotateVec2(direct, angle)
|
||||
const candidate = clampVec2ToArena(addVec2(member.position, scaleVec2(direction, maxDistance)), member.radius, state.bounds)
|
||||
const score = scoreRoutePosition(candidate, member, state, desired, currentDanger)
|
||||
+ (angle === 0 ? 0 : Math.abs(angle) * 1.8)
|
||||
if (score < bestScore) {
|
||||
best = candidate
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function scoreRoutePosition(
|
||||
position: Iwt2Vec2,
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
desired: Iwt2Vec2,
|
||||
currentDanger: number,
|
||||
): number {
|
||||
const progress = distanceVec2(position, desired)
|
||||
const centerPull = currentDanger > 0 ? distanceVec2(position, arenaCenter(state)) * DANGER_CENTER_WEIGHT : 0
|
||||
const progressWeight = currentDanger > 0 ? 0.28 : 1
|
||||
const danger = routeDangerPenalty(position, member, state)
|
||||
const crowd = routeCrowdPenalty(position, member, state)
|
||||
const wall = routeWallPenalty(position, member, state)
|
||||
const stayingInDanger = currentDanger > 0 && distanceVec2(position, member.position) <= 0.75 ? 90 : 0
|
||||
return progress * progressWeight + centerPull + danger * 100 + crowd * 42 + wall + stayingInDanger
|
||||
}
|
||||
|
||||
function routeDangerPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
||||
let penalty = 0
|
||||
for (const hazard of state.hazards) {
|
||||
const distance = distanceVec2(position, hazard.position)
|
||||
const damageRadius = hazard.radius + member.radius
|
||||
const avoidRadius = damageRadius + HAZARD_ROUTE_BUFFER
|
||||
if (distance <= damageRadius) {
|
||||
penalty += 18 + (damageRadius - distance) * 0.7
|
||||
} else if (distance < avoidRadius) {
|
||||
const ratio = (avoidRadius - distance) / HAZARD_ROUTE_BUFFER
|
||||
penalty += ratio * ratio * 8
|
||||
}
|
||||
}
|
||||
|
||||
for (const indicator of state.indicators) {
|
||||
if (indicator.phase === 'recover') continue
|
||||
const shape = indicatorAvoidanceShape(indicator)
|
||||
if (!partyMemberIntersectsShape({ ...member, position }, shape)) continue
|
||||
penalty += indicator.phase === 'active' ? INDICATOR_ROUTE_PENALTY * 1.4 : INDICATOR_ROUTE_PENALTY
|
||||
}
|
||||
|
||||
return penalty
|
||||
}
|
||||
|
||||
function routeCrowdPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
||||
let penalty = 0
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
const distance = distanceVec2(position, boss.position)
|
||||
const avoidRadius = boss.radius + member.radius + BOSS_ROUTE_BUFFER
|
||||
if (distance < avoidRadius) penalty += (avoidRadius - distance) * 1.8
|
||||
}
|
||||
|
||||
for (const other of state.party) {
|
||||
if (other.id === member.id || other.health <= 0) continue
|
||||
const distance = distanceVec2(position, other.position)
|
||||
const avoidRadius = other.radius + member.radius + PARTY_ROUTE_BUFFER
|
||||
if (distance < avoidRadius) penalty += avoidRadius - distance
|
||||
}
|
||||
return penalty
|
||||
}
|
||||
|
||||
function routeWallPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
||||
const clearance = Math.min(
|
||||
position.x - member.radius,
|
||||
state.bounds.width - member.radius - position.x,
|
||||
position.y - member.radius,
|
||||
state.bounds.height - member.radius - position.y,
|
||||
)
|
||||
if (clearance >= PARTY_SAFE_DESTINATION_PADDING) return 0
|
||||
return (PARTY_SAFE_DESTINATION_PADDING - clearance) * (clearance <= 4 ? 12 : 4.8)
|
||||
}
|
||||
|
||||
function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return {
|
||||
x: vector.x * cos - vector.y * sin,
|
||||
y: vector.x * sin + vector.y * cos,
|
||||
}
|
||||
}
|
||||
|
||||
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
|
||||
if (member.health <= 0) return false
|
||||
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
||||
@@ -158,10 +272,10 @@ function getTankCenterLeashPosition(
|
||||
y: boss.position.y < center.y ? 0.35 : -0.35,
|
||||
})
|
||||
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
|
||||
return clampVec2ToArena(
|
||||
return clampPartyDestination(
|
||||
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
member,
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,6 +312,11 @@ function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): bo
|
||||
}
|
||||
|
||||
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
const indicatorEscape = getIndicatorAvoidancePosition(member, state)
|
||||
if (indicatorEscape) {
|
||||
return indicatorEscape
|
||||
}
|
||||
|
||||
const hazardEscape = getHazardAvoidancePosition(member, state)
|
||||
if (hazardEscape) {
|
||||
return hazardEscape
|
||||
@@ -210,7 +329,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
||||
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
||||
if (distance < dangerRadius) {
|
||||
const away = normalizeVec2(subtractVec2(member.position, boss.position))
|
||||
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
|
||||
return clampPartyDestination(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +339,7 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
||||
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
||||
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
|
||||
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
|
||||
return clampVec2ToArena(escape, member.radius, state.bounds)
|
||||
return clampPartyDestination(escape, member, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +347,65 @@ function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
||||
return null
|
||||
}
|
||||
|
||||
function getIndicatorAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
let escape = { x: 0, y: 0 }
|
||||
let pressure = 0
|
||||
|
||||
for (const indicator of state.indicators) {
|
||||
if (indicator.phase === 'recover') continue
|
||||
const shape = indicatorAvoidanceShape(indicator)
|
||||
if (!partyMemberIntersectsShape(member, shape)) continue
|
||||
const origin = indicator.kind === 'lane'
|
||||
? closestPointOnSegment(member.position, indicator.start, indicator.end)
|
||||
: indicator.kind === 'cone'
|
||||
? indicator.origin
|
||||
: indicator.position
|
||||
const away = withFallbackFacing(subtractVec2(member.position, origin), {
|
||||
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
||||
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
||||
})
|
||||
escape = addVec2(escape, scaleVec2(away, indicator.phase === 'active' ? 96 : 72))
|
||||
pressure += indicator.phase === 'active' ? 1.35 : 1
|
||||
}
|
||||
|
||||
if (pressure <= 0) return null
|
||||
return clampPartyDestination(addVec2(member.position, escape), member, state)
|
||||
}
|
||||
|
||||
function indicatorAvoidanceShape(indicator: Iwt2ArenaState['indicators'][number]): Iwt2HitShape {
|
||||
if (indicator.kind === 'lane') {
|
||||
return { kind: 'lane', start: indicator.start, end: indicator.end, width: indicator.width + 22 }
|
||||
}
|
||||
if (indicator.kind === 'circle') {
|
||||
return { kind: 'circle', position: indicator.position, radius: indicator.radius + 18 }
|
||||
}
|
||||
if (indicator.kind === 'donut') {
|
||||
return {
|
||||
kind: 'donut',
|
||||
innerRadius: indicator.innerRadius,
|
||||
outerRadius: indicator.outerRadius + 18,
|
||||
position: indicator.position,
|
||||
}
|
||||
}
|
||||
if (indicator.kind === 'arc') {
|
||||
return {
|
||||
kind: 'arc',
|
||||
angleRadians: indicator.angleRadians,
|
||||
direction: indicator.direction,
|
||||
innerRadius: Math.max(0, indicator.innerRadius - 8),
|
||||
outerRadius: indicator.outerRadius + 24,
|
||||
position: indicator.position,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'cone',
|
||||
angleRadians: indicator.angleRadians,
|
||||
direction: indicator.direction,
|
||||
origin: indicator.origin,
|
||||
range: indicator.range + 24,
|
||||
}
|
||||
}
|
||||
|
||||
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
let escape = { x: 0, y: 0 }
|
||||
let maxNeededDistance = 0
|
||||
@@ -254,10 +432,18 @@ function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2Are
|
||||
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
||||
y: 0,
|
||||
})
|
||||
return clampVec2ToArena(
|
||||
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
const destination = clampPartyDestination(
|
||||
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 96)),
|
||||
member,
|
||||
state,
|
||||
)
|
||||
if (distanceVec2(destination, member.position) > PARTY_ARRIVAL_RADIUS) return destination
|
||||
|
||||
const centerDirection = withFallbackFacing(subtractVec2(arenaCenter(state), member.position), direction)
|
||||
return clampPartyDestination(
|
||||
addVec2(member.position, scaleVec2(centerDirection, maxNeededDistance + 120)),
|
||||
member,
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +481,17 @@ function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
|
||||
}
|
||||
}
|
||||
|
||||
function closestPointOnSegment(position: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): Iwt2Vec2 {
|
||||
const segment = subtractVec2(end, start)
|
||||
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
||||
const t = Math.min(1, Math.max(0, dotVec2(subtractVec2(position, start), segment) / lengthSq))
|
||||
return addVec2(start, scaleVec2(segment, t))
|
||||
}
|
||||
|
||||
function movementSpeedMultiplier(slowedSeconds: number): number {
|
||||
return slowedSeconds > 0 ? 0.58 : 1
|
||||
}
|
||||
|
||||
function getPrimaryBoss(state: Iwt2ArenaState) {
|
||||
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { RATHIAN_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicArcState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addPoisonPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createArcIndicator,
|
||||
createCircleIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickRathian(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
let boss = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
boss = {
|
||||
...boss,
|
||||
wallContactSeconds: isBossNearWall(boss, state)
|
||||
? boss.wallContactSeconds + dt
|
||||
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'tailSweepWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyTailSweep(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'tailSweepRecover',
|
||||
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepRecover!,
|
||||
}
|
||||
}
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'poisonSpitWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const puddle = addPoisonPuddle({
|
||||
damage: RATHIAN_BOSS_METADATA.poisonPuddleDamage!,
|
||||
duration: RATHIAN_BOSS_METADATA.poisonPuddleSeconds!,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
time: state.time + dt,
|
||||
})
|
||||
hazards = puddle.hazards
|
||||
nextHazardId = puddle.nextHazardId
|
||||
}
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'poisonSpitRecover',
|
||||
phaseSecondsRemaining: 0.42,
|
||||
}
|
||||
}
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'tailSweepRecover' || boss.attackPhase === 'poisonSpitRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicArcs: [],
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= 0.65) {
|
||||
boss = moveBossTowardCenter(boss, state, RATHIAN_BOSS_METADATA.moveSpeed * 1.35, dt)
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'tailSweepWindup',
|
||||
chargeCooldownRemaining: RATHIAN_BOSS_METADATA.poisonTailCooldown!,
|
||||
mechanicArcs: [createTailArc(boss, target)],
|
||||
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.tailSweepWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'poisonSpitWindup',
|
||||
fireballCooldownRemaining: RATHIAN_BOSS_METADATA.poisonSpitCooldown!,
|
||||
mechanicCircles: createPoisonTargets(party),
|
||||
phaseSecondsRemaining: RATHIAN_BOSS_METADATA.poisonSpitWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withRathianIndicators({ 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 withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, RATHIAN_BOSS_METADATA.moveSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
boss = {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
}
|
||||
return withRathianIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function moveBossTowardCenter(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
speed: number,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const center = arenaCenter(state)
|
||||
const nextPosition = clampVec2ToArena(moveToward(boss.position, center, speed * dt), boss.radius, state.bounds)
|
||||
return {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
|
||||
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const margin = boss.radius + 58
|
||||
return (
|
||||
boss.position.x <= margin
|
||||
|| boss.position.x >= state.bounds.width - margin
|
||||
|| boss.position.y <= margin
|
||||
|| boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
function createTailArc(boss: Iwt2BossEntityState, target: Iwt2PartyEntityState): Iwt2MechanicArcState {
|
||||
return {
|
||||
id: 'tail-sweep',
|
||||
angleRadians: RATHIAN_BOSS_METADATA.tailSweepAngleRadians!,
|
||||
direction: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
innerRadius: RATHIAN_BOSS_METADATA.tailSweepInnerRadius!,
|
||||
outerRadius: RATHIAN_BOSS_METADATA.tailSweepOuterRadius!,
|
||||
position: { ...boss.position },
|
||||
}
|
||||
}
|
||||
|
||||
function createPoisonTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
|
||||
return [...party]
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((a, b) => b.health / b.maxHealth - a.health / a.maxHealth)
|
||||
.slice(0, 2)
|
||||
.map((member, index) => ({
|
||||
id: `poison-spit-${index}`,
|
||||
position: { ...member.position },
|
||||
radius: RATHIAN_BOSS_METADATA.poisonPuddleRadius!,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyTailSweep(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
for (const arc of boss.mechanicArcs) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'arc',
|
||||
angleRadians: arc.angleRadians,
|
||||
direction: arc.direction,
|
||||
innerRadius: arc.innerRadius,
|
||||
outerRadius: arc.outerRadius,
|
||||
position: arc.position,
|
||||
}, {
|
||||
damage: RATHIAN_BOSS_METADATA.tailSweepDamage!,
|
||||
knockdownSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: RATHIAN_BOSS_METADATA.tailSweepStunSeconds!,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||
if (livingTank) return livingTank
|
||||
return party.find((member) => member.health > 0)
|
||||
}
|
||||
|
||||
function maybeApplyMelee(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
time: number,
|
||||
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > RATHIAN_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: RATHIAN_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: RATHIAN_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: RATHIAN_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withRathianIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createRathianIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createRathianIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (boss.attackPhase === 'tailSweepWindup' || boss.attackPhase === 'tailSweepRecover') {
|
||||
for (const arc of boss.mechanicArcs) {
|
||||
indicators.push(createArcIndicator({
|
||||
angleRadians: arc.angleRadians,
|
||||
color: '#a7e768',
|
||||
direction: arc.direction,
|
||||
id: `${boss.id}:${arc.id}`,
|
||||
innerRadius: arc.innerRadius,
|
||||
mechanicId: 'rathian-tail-sweep',
|
||||
outerRadius: arc.outerRadius,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'tailSweepWindup',
|
||||
boss.attackPhase === 'tailSweepRecover',
|
||||
),
|
||||
position: arc.position,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (boss.attackPhase === 'poisonSpitWindup' || boss.attackPhase === 'poisonSpitRecover') {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#7bd84f',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'rathian-poison-spit',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'poisonSpitWindup',
|
||||
boss.attackPhase === 'poisonSpitRecover',
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaIndicatorPhase,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createConeIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
type RimebastionAttackPhase =
|
||||
| Iwt2BossEntityState['attackPhase']
|
||||
| 'iceWallWindup'
|
||||
| 'shardShatterWindup'
|
||||
| 'shardShatterRecover'
|
||||
|
||||
type RimebastionBossState = Omit<Iwt2BossEntityState, 'attackPhase'> & {
|
||||
attackPhase: RimebastionAttackPhase
|
||||
}
|
||||
|
||||
type ShardCone = {
|
||||
id: string
|
||||
origin: Iwt2Vec2
|
||||
direction: Iwt2Vec2
|
||||
range: number
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
const RIMEBASTION_BALANCE = {
|
||||
iceWallCooldown: 7.2,
|
||||
iceWallDamage: 6,
|
||||
iceWallStunSeconds: 0.25,
|
||||
iceWallWindup: 1,
|
||||
iceCircleCount: 2,
|
||||
iceCircleRadius: 58,
|
||||
iceWallLaneCount: 3,
|
||||
iceWallWidth: 22,
|
||||
meleeCooldown: 1.45,
|
||||
meleeDamage: 4,
|
||||
meleeRange: 66,
|
||||
moveSpeed: 64,
|
||||
relocateCooldownFloor: 1.3,
|
||||
relocateSpeed: 112,
|
||||
relocateSeconds: 1.6,
|
||||
shatterConeAngleRadians: Math.PI * 0.34,
|
||||
shatterConeDamage: 9,
|
||||
shatterConeRange: 158,
|
||||
shatterLaneDamage: 11,
|
||||
shatterLaneWidth: 34,
|
||||
shatterRecover: 0.52,
|
||||
shatterStunSeconds: 0.4,
|
||||
shatterWindup: 0.72,
|
||||
wallDisengageSeconds: 0.7,
|
||||
wallMargin: 68,
|
||||
}
|
||||
|
||||
const WALL_COLOR = '#a7ecff'
|
||||
const SHARD_COLOR = '#e8fbff'
|
||||
|
||||
export function tickRimebastion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
let boss: RimebastionBossState = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
boss = {
|
||||
...boss,
|
||||
wallContactSeconds: isBossNearWall(boss, state)
|
||||
? boss.wallContactSeconds + dt
|
||||
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withRimebastionIndicators({ boss, party, events, state })
|
||||
|
||||
if (boss.attackPhase === '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, RIMEBASTION_BALANCE.relocateCooldownFloor),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'iceWallWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyIceWalls(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'shardShatterWindup',
|
||||
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterWindup,
|
||||
}
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'shardShatterWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyShardShatter(party, boss, state, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'shardShatterRecover',
|
||||
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterRecover,
|
||||
}
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'shardShatterRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= RIMEBASTION_BALANCE.wallDisengageSeconds) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: RIMEBASTION_BALANCE.relocateSeconds,
|
||||
relocateTarget: relocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
const pattern = createIceWallPattern(state, party, boss)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'iceWallWindup',
|
||||
chargeCooldownRemaining: RIMEBASTION_BALANCE.iceWallCooldown,
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
mechanicCircles: pattern.circles,
|
||||
mechanicLanes: pattern.lanes,
|
||||
phaseSecondsRemaining: RIMEBASTION_BALANCE.iceWallWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withRimebastionIndicators({ boss, party, events, state })
|
||||
|
||||
boss = moveBossTowardEngagePoint(boss, state, target, dt)
|
||||
return withRimebastionIndicators({ boss, party, events, state })
|
||||
}
|
||||
|
||||
function createIceWallPattern(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: RimebastionBossState,
|
||||
): { lanes: Iwt2MechanicLaneState[], circles: Iwt2MechanicCircleState[] } {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const center = partyCenter(living, arenaCenter(state))
|
||||
const vertical = boss.chargeCount % 2 === 0
|
||||
const lanes: Iwt2MechanicLaneState[] = []
|
||||
const offsets = [-96, 0, 96]
|
||||
|
||||
for (let index = 0; index < RIMEBASTION_BALANCE.iceWallLaneCount; index += 1) {
|
||||
const offset = offsets[index] ?? 0
|
||||
if (vertical) {
|
||||
const x = clamp(center.x + offset, 82, state.bounds.width - 82)
|
||||
lanes.push({
|
||||
end: { x, y: state.bounds.height - 38 },
|
||||
id: `rime-wall-lane-${index}`,
|
||||
start: { x, y: 38 },
|
||||
width: RIMEBASTION_BALANCE.iceWallWidth,
|
||||
})
|
||||
} else {
|
||||
const y = clamp(center.y + offset * 0.72, 74, state.bounds.height - 74)
|
||||
lanes.push({
|
||||
end: { x: state.bounds.width - 38, y },
|
||||
id: `rime-wall-lane-${index}`,
|
||||
start: { x: 38, y },
|
||||
width: RIMEBASTION_BALANCE.iceWallWidth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const circles = living
|
||||
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
|
||||
.slice(0, RIMEBASTION_BALANCE.iceCircleCount)
|
||||
.map((member, index) => ({
|
||||
id: `rime-wall-circle-${index}`,
|
||||
position: clampVec2ToArena(member.position, RIMEBASTION_BALANCE.iceCircleRadius, state.bounds),
|
||||
radius: RIMEBASTION_BALANCE.iceCircleRadius,
|
||||
}))
|
||||
|
||||
return { circles, lanes }
|
||||
}
|
||||
|
||||
function applyIceWalls(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: RimebastionBossState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: string[] = []
|
||||
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'lane',
|
||||
end: lane.end,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}, {
|
||||
damage: RIMEBASTION_BALANCE.iceWallDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: RIMEBASTION_BALANCE.iceWallDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function applyShardShatter(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: RimebastionBossState,
|
||||
state: Iwt2ArenaState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: string[] = []
|
||||
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'lane',
|
||||
end: lane.end,
|
||||
start: lane.start,
|
||||
width: RIMEBASTION_BALANCE.shatterLaneWidth,
|
||||
}, {
|
||||
damage: RIMEBASTION_BALANCE.shatterLaneDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
for (const cone of createShardCones(boss, state)) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'cone',
|
||||
angleRadians: cone.angleRadians,
|
||||
direction: cone.direction,
|
||||
origin: cone.origin,
|
||||
range: cone.range,
|
||||
}, {
|
||||
damage: RIMEBASTION_BALANCE.shatterConeDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function createShardCones(boss: RimebastionBossState, state: Iwt2ArenaState): ShardCone[] {
|
||||
const cones: ShardCone[] = []
|
||||
const center = arenaCenter(state)
|
||||
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const midpoint = {
|
||||
x: (lane.start.x + lane.end.x) * 0.5,
|
||||
y: (lane.start.y + lane.end.y) * 0.5,
|
||||
}
|
||||
cones.push({
|
||||
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
|
||||
direction: withFallbackFacing(subtractVec2(center, midpoint), boss.facing),
|
||||
id: `${lane.id}-shard-cone`,
|
||||
origin: midpoint,
|
||||
range: RIMEBASTION_BALANCE.shatterConeRange,
|
||||
})
|
||||
}
|
||||
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
cones.push({
|
||||
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
|
||||
direction: withFallbackFacing(subtractVec2(circle.position, boss.position), boss.facing),
|
||||
id: `${circle.id}-shard-cone`,
|
||||
origin: circle.position,
|
||||
range: RIMEBASTION_BALANCE.shatterConeRange * 0.86,
|
||||
})
|
||||
}
|
||||
|
||||
return cones
|
||||
}
|
||||
|
||||
function moveBossTowardRelocationTarget(
|
||||
boss: RimebastionBossState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): RimebastionBossState {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, RIMEBASTION_BALANCE.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 moveBossTowardEngagePoint(
|
||||
boss: RimebastionBossState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): RimebastionBossState {
|
||||
const center = arenaCenter(state)
|
||||
const targetNearWall = isPositionNearWall(target.position, target.radius + RIMEBASTION_BALANCE.wallMargin, state)
|
||||
const desired = targetNearWall
|
||||
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), RIMEBASTION_BALANCE.meleeRange * 0.9))
|
||||
: target.position
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, desired, RIMEBASTION_BALANCE.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 relocationTarget(boss: RimebastionBossState, 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, 58)), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
function maybeApplyMelee(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: RimebastionBossState,
|
||||
target: Iwt2PartyEntityState,
|
||||
time: number,
|
||||
): { boss: RimebastionBossState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > RIMEBASTION_BALANCE.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: RIMEBASTION_BALANCE.meleeRange,
|
||||
}, {
|
||||
damage: RIMEBASTION_BALANCE.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: RIMEBASTION_BALANCE.meleeCooldown },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
|
||||
return isPositionNearWall(boss.position, boss.radius + RIMEBASTION_BALANCE.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 withRimebastionIndicators(result: {
|
||||
boss: RimebastionBossState
|
||||
party: Iwt2PartyEntityState[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
state: Iwt2ArenaState
|
||||
}): Iwt2BossTickResult {
|
||||
const { state, ...rest } = result
|
||||
return {
|
||||
...rest,
|
||||
boss: result.boss as Iwt2BossEntityState,
|
||||
indicators: createRimebastionIndicators(result.boss, state),
|
||||
}
|
||||
}
|
||||
|
||||
function createRimebastionIndicators(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const showWalls = (
|
||||
boss.attackPhase === 'iceWallWindup'
|
||||
|| boss.attackPhase === 'shardShatterWindup'
|
||||
|| boss.attackPhase === 'shardShatterRecover'
|
||||
)
|
||||
if (showWalls) {
|
||||
const wallPhase: Iwt2ArenaIndicatorPhase = boss.attackPhase === 'iceWallWindup'
|
||||
? 'windup'
|
||||
: boss.attackPhase === 'shardShatterWindup'
|
||||
? 'active'
|
||||
: 'recover'
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: WALL_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'rimebastion-ice-wall-lane',
|
||||
phase: wallPhase,
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: WALL_COLOR,
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'rimebastion-ice-wall-circle',
|
||||
phase: wallPhase,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'shardShatterWindup' || boss.attackPhase === 'shardShatterRecover') {
|
||||
const shardPhase = indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'shardShatterWindup',
|
||||
boss.attackPhase === 'shardShatterRecover',
|
||||
)
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: SHARD_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}:shatter`,
|
||||
mechanicId: 'rimebastion-shard-lane',
|
||||
phase: shardPhase,
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: RIMEBASTION_BALANCE.shatterLaneWidth,
|
||||
}))
|
||||
}
|
||||
for (const cone of createShardCones(boss, state)) {
|
||||
indicators.push(createConeIndicator({
|
||||
angleRadians: cone.angleRadians,
|
||||
color: SHARD_COLOR,
|
||||
direction: cone.direction,
|
||||
id: `${boss.id}:${cone.id}`,
|
||||
mechanicId: 'rimebastion-shard-cone',
|
||||
origin: cone.origin,
|
||||
phase: shardPhase,
|
||||
range: cone.range,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return indicators
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2MechanicArcState,
|
||||
Iwt2MechanicLinkState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createArcIndicator,
|
||||
createDonutIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const STORMCOIL_PHASE = {
|
||||
arcRecover: 'stormcoilArcRecover',
|
||||
arcWindup: 'stormcoilArcWindup',
|
||||
chainRecover: 'stormcoilChainRecover',
|
||||
chainWindup: 'stormcoilChainWindup',
|
||||
donutRecover: 'stormcoilDonutRecover',
|
||||
donutWindup: 'stormcoilDonutWindup',
|
||||
} as const
|
||||
|
||||
type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
|
||||
|
||||
const STORMCOIL_TUNING = {
|
||||
arcAngleRadians: Math.PI * 0.34,
|
||||
arcCooldown: 5.6,
|
||||
arcDamage: 6,
|
||||
arcInnerRadius: 78,
|
||||
arcOuterRadius: 196,
|
||||
arcRecover: 0.46,
|
||||
arcStunSeconds: 0.24,
|
||||
arcWindup: 0.74,
|
||||
chainCooldown: 7.4,
|
||||
chainDamage: 5,
|
||||
chainLinkRadius: 150,
|
||||
chainMaxLinks: 7,
|
||||
chainRecover: 0.5,
|
||||
chainStunSeconds: 0.38,
|
||||
chainWidth: 16,
|
||||
chainWindup: 0.68,
|
||||
disengageSpeed: 168,
|
||||
donutCooldown: 6.2,
|
||||
donutDamage: 7,
|
||||
donutInnerRadius: 64,
|
||||
donutOuterRadius: 156,
|
||||
donutRecover: 0.48,
|
||||
donutStunSeconds: 0.44,
|
||||
donutWindup: 0.82,
|
||||
meleeCooldown: 1.12,
|
||||
meleeDamage: 2,
|
||||
meleeRange: 58,
|
||||
moveSpeed: 124,
|
||||
wallContactLimit: 0.72,
|
||||
wallMargin: 68,
|
||||
}
|
||||
|
||||
export function tickStormcoilWyrm(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
const incomingPhase = stormcoilPhase(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),
|
||||
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
boss = {
|
||||
...boss,
|
||||
wallContactSeconds: incomingPhase !== '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 withStormcoilIndicators({ boss, party, events })
|
||||
|
||||
const phase = stormcoilPhase(boss)
|
||||
if (phase === 'relocating') {
|
||||
boss = moveBossToward(boss, boss.relocateTarget, state, STORMCOIL_TUNING.disengageSpeed, dt)
|
||||
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === STORMCOIL_PHASE.donutWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyStormDonut(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.donutRecover),
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.donutRecover,
|
||||
}
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === STORMCOIL_PHASE.arcWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyStormArcs(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.arcRecover),
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.arcRecover,
|
||||
}
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (phase === STORMCOIL_PHASE.chainWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyChainLightning(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.chainRecover),
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.chainRecover,
|
||||
}
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (
|
||||
phase === STORMCOIL_PHASE.donutRecover
|
||||
|| phase === STORMCOIL_PHASE.arcRecover
|
||||
|| phase === STORMCOIL_PHASE.chainRecover
|
||||
) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicArcs: [],
|
||||
mechanicCircles: [],
|
||||
mechanicLinks: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= STORMCOIL_TUNING.wallContactLimit) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicArcs: [],
|
||||
mechanicCircles: [],
|
||||
mechanicLinks: [],
|
||||
phaseSecondsRemaining: 1.25,
|
||||
relocateTarget: wallDisengageTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
const links = createChainLinks(boss, party)
|
||||
if (links.length > 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.chainWindup),
|
||||
fireballCooldownRemaining: STORMCOIL_TUNING.chainCooldown,
|
||||
mechanicLinks: links,
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.chainWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
boss = { ...boss, fireballCooldownRemaining: 1.0 }
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.arcWindup),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
mechanicArcs: createStormArcs(boss, target.position),
|
||||
mechanicEnergy: STORMCOIL_TUNING.arcCooldown,
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.arcWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(STORMCOIL_PHASE.donutWindup),
|
||||
chargeCooldownRemaining: STORMCOIL_TUNING.donutCooldown,
|
||||
mechanicCircles: [{
|
||||
id: 'stormcoil-donut-center',
|
||||
position: { ...boss.position },
|
||||
radius: STORMCOIL_TUNING.donutOuterRadius,
|
||||
}],
|
||||
phaseSecondsRemaining: STORMCOIL_TUNING.donutWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withStormcoilIndicators({ boss, party, events })
|
||||
|
||||
const desired = isBossNearWall(boss, state)
|
||||
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(arenaCenter(state), boss.position)), 84))
|
||||
: target.position
|
||||
boss = moveBossToward(boss, desired, state, STORMCOIL_TUNING.moveSpeed, dt)
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
}
|
||||
return withStormcoilIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function applyStormDonut(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
|
||||
kind: 'donut',
|
||||
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
|
||||
position: boss.position,
|
||||
}, {
|
||||
damage: STORMCOIL_TUNING.donutDamage,
|
||||
knockdownSeconds: STORMCOIL_TUNING.donutStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: STORMCOIL_TUNING.donutStunSeconds,
|
||||
time,
|
||||
})
|
||||
return { party: result.party, events: result.events }
|
||||
}
|
||||
|
||||
function applyStormArcs(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: Iwt2EntityId[] = []
|
||||
for (const arc of boss.mechanicArcs) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
angleRadians: arc.angleRadians,
|
||||
direction: arc.direction,
|
||||
innerRadius: arc.innerRadius,
|
||||
kind: 'arc',
|
||||
outerRadius: arc.outerRadius,
|
||||
position: arc.position,
|
||||
}, {
|
||||
damage: STORMCOIL_TUNING.arcDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: STORMCOIL_TUNING.arcStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function applyChainLightning(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: Iwt2EntityId[] = []
|
||||
for (const link of boss.mechanicLinks) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
end: link.end,
|
||||
kind: 'lane',
|
||||
start: link.start,
|
||||
width: link.width,
|
||||
}, {
|
||||
damage: STORMCOIL_TUNING.chainDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: STORMCOIL_TUNING.chainStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function createStormArcs(boss: Iwt2BossEntityState, targetPosition: Iwt2Vec2): Iwt2MechanicArcState[] {
|
||||
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
|
||||
const directions = [
|
||||
rotateVec2(facing, -0.66),
|
||||
facing,
|
||||
rotateVec2(facing, 0.66),
|
||||
]
|
||||
return directions.map((direction, index) => ({
|
||||
angleRadians: STORMCOIL_TUNING.arcAngleRadians,
|
||||
direction,
|
||||
id: `stormcoil-arc-${index}`,
|
||||
innerRadius: STORMCOIL_TUNING.arcInnerRadius,
|
||||
outerRadius: STORMCOIL_TUNING.arcOuterRadius,
|
||||
position: { ...boss.position },
|
||||
}))
|
||||
}
|
||||
|
||||
function createChainLinks(
|
||||
boss: Iwt2BossEntityState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2MechanicLinkState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const links: Iwt2MechanicLinkState[] = []
|
||||
const nearest = living
|
||||
.map((member) => ({ member, distance: distanceVec2(member.position, boss.position) }))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.member
|
||||
if (nearest) {
|
||||
links.push({
|
||||
end: { ...nearest.position },
|
||||
id: `stormcoil-chain-${boss.id}-${nearest.id}`,
|
||||
start: { ...boss.position },
|
||||
width: STORMCOIL_TUNING.chainWidth,
|
||||
})
|
||||
}
|
||||
|
||||
for (let a = 0; a < living.length; a += 1) {
|
||||
for (let b = a + 1; b < living.length; b += 1) {
|
||||
if (distanceVec2(living[a].position, living[b].position) > STORMCOIL_TUNING.chainLinkRadius) continue
|
||||
links.push({
|
||||
end: { ...living[b].position },
|
||||
id: `stormcoil-chain-${living[a].id}-${living[b].id}`,
|
||||
start: { ...living[a].position },
|
||||
width: STORMCOIL_TUNING.chainWidth,
|
||||
})
|
||||
if (links.length >= STORMCOIL_TUNING.chainMaxLinks) return links
|
||||
}
|
||||
}
|
||||
return links.length > 1 ? links : []
|
||||
}
|
||||
|
||||
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) > STORMCOIL_TUNING.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: STORMCOIL_TUNING.meleeRange,
|
||||
}, {
|
||||
damage: STORMCOIL_TUNING.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: STORMCOIL_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossToward(
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2Vec2,
|
||||
state: Iwt2ArenaState,
|
||||
speed: number,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const offset = subtractVec2(target, boss.position)
|
||||
const distance = Math.hypot(offset.x, offset.y)
|
||||
const nextPosition = clampVec2ToArena(
|
||||
distance <= speed * dt || distance <= 0.0001
|
||||
? target
|
||||
: addVec2(boss.position, scaleVec2(offset, (speed * dt) / distance)),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target, nextPosition), boss.facing),
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
}
|
||||
}
|
||||
|
||||
function wallDisengageTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
const center = arenaCenter(state)
|
||||
const toCenter = normalizeVec2(subtractVec2(center, boss.position))
|
||||
return clampVec2ToArena(addVec2(boss.position, scaleVec2(toCenter, 180)), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + STORMCOIL_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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
|
||||
const cos = Math.cos(radians)
|
||||
const sin = Math.sin(radians)
|
||||
return normalizeVec2({
|
||||
x: vector.x * cos - vector.y * sin,
|
||||
y: vector.x * sin + vector.y * cos,
|
||||
})
|
||||
}
|
||||
|
||||
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 withStormcoilIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createStormcoilIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createStormcoilIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const phase = stormcoilPhase(boss)
|
||||
if (phase === STORMCOIL_PHASE.donutWindup || phase === STORMCOIL_PHASE.donutRecover) {
|
||||
indicators.push(createDonutIndicator({
|
||||
color: '#8ff2ff',
|
||||
id: `${boss.id}:stormcoil-donut`,
|
||||
innerRadius: STORMCOIL_TUNING.donutInnerRadius,
|
||||
mechanicId: 'stormcoil-charged-ring',
|
||||
outerRadius: STORMCOIL_TUNING.donutOuterRadius,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === STORMCOIL_PHASE.donutWindup,
|
||||
phase === STORMCOIL_PHASE.donutRecover,
|
||||
),
|
||||
position: boss.position,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
if (phase === STORMCOIL_PHASE.arcWindup || phase === STORMCOIL_PHASE.arcRecover) {
|
||||
for (const arc of boss.mechanicArcs) {
|
||||
indicators.push(createArcIndicator({
|
||||
angleRadians: arc.angleRadians,
|
||||
color: phase === STORMCOIL_PHASE.arcRecover ? '#d8fbff' : '#60cfff',
|
||||
direction: arc.direction,
|
||||
id: `${boss.id}:${arc.id}`,
|
||||
innerRadius: arc.innerRadius,
|
||||
mechanicId: 'stormcoil-arc-trail',
|
||||
outerRadius: arc.outerRadius,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === STORMCOIL_PHASE.arcWindup,
|
||||
phase === STORMCOIL_PHASE.arcRecover,
|
||||
),
|
||||
position: arc.position,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (phase === STORMCOIL_PHASE.chainWindup || phase === STORMCOIL_PHASE.chainRecover) {
|
||||
for (const link of boss.mechanicLinks) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#b8f7ff',
|
||||
end: link.end,
|
||||
id: `${boss.id}:${link.id}`,
|
||||
mechanicId: 'stormcoil-chain-lightning',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === STORMCOIL_PHASE.chainWindup,
|
||||
phase === STORMCOIL_PHASE.chainRecover,
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: link.start,
|
||||
width: link.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
|
||||
function stormcoilPhase(boss: Iwt2BossEntityState): string {
|
||||
return boss.attackPhase as string
|
||||
}
|
||||
|
||||
function asBossPhase(phase: StormcoilPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as unknown as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2MechanicLinkState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createLaneIndicator,
|
||||
createLinearMovementPlan,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickTobiKadachi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
let boss = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
mechanicEnergy: Math.min(
|
||||
state.boss.mechanicEnergyMax,
|
||||
state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt,
|
||||
),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events })
|
||||
|
||||
if (boss.attackPhase === 'staticPounceWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' }
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'staticPouncing') {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
const hitResult = applyPounceHits(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),
|
||||
}
|
||||
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'staticPounceRecover',
|
||||
phaseSecondsRemaining: 0.42,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'chainShockWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyChainShock(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'chainShockRecover',
|
||||
phaseSecondsRemaining: 0.42,
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLinks: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) {
|
||||
const pounce = createLinearMovementPlan({
|
||||
bounds: state.bounds,
|
||||
from: boss.position,
|
||||
length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!,
|
||||
radius: boss.radius,
|
||||
target: target.position,
|
||||
})
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'staticPounceWindup',
|
||||
chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown,
|
||||
chargeEnd: pounce.end,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: { ...boss.position },
|
||||
facing: pounce.direction,
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
const links = createChainLinks(party)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'chainShockWindup',
|
||||
fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!,
|
||||
mechanicLinks: links,
|
||||
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withTobiIndicators({ boss, party, events })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, TOBI_KADACHI_BOSS_METADATA.moveSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
boss = {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function applyPounceHits(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
start: Iwt2BossEntityState['position'],
|
||||
end: Iwt2BossEntityState['position'],
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'lane',
|
||||
start,
|
||||
end,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!,
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const links: Iwt2MechanicLinkState[] = []
|
||||
for (let a = 0; a < living.length; a += 1) {
|
||||
for (let b = a + 1; b < living.length; b += 1) {
|
||||
if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue
|
||||
links.push({
|
||||
id: `chain-${living[a].id}-${living[b].id}`,
|
||||
start: { ...living[a].position },
|
||||
end: { ...living[b].position },
|
||||
width: 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
return links.slice(0, 6)
|
||||
}
|
||||
|
||||
function applyChainShock(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: Iwt2EntityId[] = []
|
||||
for (const link of boss.mechanicLinks) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'lane',
|
||||
start: link.start,
|
||||
end: link.end,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||
if (livingTank) return livingTank
|
||||
return party.find((member) => member.health > 0)
|
||||
}
|
||||
|
||||
function maybeApplyMelee(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
time: number,
|
||||
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: TOBI_KADACHI_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: {
|
||||
...boss,
|
||||
mechanicEnergy: Math.min(
|
||||
boss.mechanicEnergyMax,
|
||||
boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!,
|
||||
),
|
||||
meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown,
|
||||
},
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withTobiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createTobiIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (
|
||||
boss.attackPhase === 'staticPounceWindup'
|
||||
|| boss.attackPhase === 'staticPouncing'
|
||||
|| boss.attackPhase === 'staticPounceRecover'
|
||||
) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff',
|
||||
end: boss.chargeEnd,
|
||||
id: `${boss.id}:static-pounce`,
|
||||
mechanicId: 'tobi-static-pounce',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'staticPounceWindup',
|
||||
boss.attackPhase === 'staticPouncing',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: boss.chargeStart,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
|
||||
}))
|
||||
}
|
||||
if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') {
|
||||
for (const link of boss.mechanicLinks) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#a8f3ff',
|
||||
end: link.end,
|
||||
id: `${boss.id}:${link.id}`,
|
||||
mechanicId: 'tobi-chain-shock',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'chainShockWindup',
|
||||
boss.attackPhase === 'chainShockRecover',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: link.start,
|
||||
width: link.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export type Iwt2StatusState = {
|
||||
stunnedSeconds: number
|
||||
knockedDownSeconds: number
|
||||
invulnerableSeconds: number
|
||||
slowedSeconds: number
|
||||
}
|
||||
|
||||
export type Iwt2HotEffectState = {
|
||||
@@ -100,6 +101,69 @@ export type Iwt2BossAttackPhase =
|
||||
| 'thunderRingRecover'
|
||||
| 'lightningStrikeWindup'
|
||||
| 'lightningStrikeRecover'
|
||||
| 'tailSweepWindup'
|
||||
| 'tailSweepRecover'
|
||||
| 'poisonSpitWindup'
|
||||
| 'poisonSpitRecover'
|
||||
| 'mudSprayWindup'
|
||||
| 'mudSprayRecover'
|
||||
| 'staticPounceWindup'
|
||||
| 'staticPouncing'
|
||||
| 'staticPounceRecover'
|
||||
| 'chainShockWindup'
|
||||
| 'chainShockRecover'
|
||||
| 'iceWallWindup'
|
||||
| 'shardShatterWindup'
|
||||
| 'shardShatterRecover'
|
||||
| 'bladeSidestep'
|
||||
| 'lineSlashWindup'
|
||||
| 'lineSlashRecover'
|
||||
| 'crossSlashWindup'
|
||||
| 'crossSlashRecover'
|
||||
| 'cinderbackRicochetWindup'
|
||||
| 'cinderbackRicocheting'
|
||||
| 'cinderbackArmorSlamWindup'
|
||||
| 'cinderbackArmorSlamRecover'
|
||||
| 'obsidianRamPlateChargeWindup'
|
||||
| 'obsidianRamPlateCharging'
|
||||
| 'obsidianRamPlateChargeRecover'
|
||||
| 'obsidianRamFractureQuakeWindup'
|
||||
| 'obsidianRamFractureQuakeRecover'
|
||||
| 'obsidianRamArmorShatterWindup'
|
||||
| 'obsidianRamArmorShatterRecover'
|
||||
| 'stormcoilDonutWindup'
|
||||
| 'stormcoilDonutRecover'
|
||||
| 'stormcoilArcWindup'
|
||||
| 'stormcoilArcRecover'
|
||||
| 'stormcoilChainWindup'
|
||||
| 'stormcoilChainRecover'
|
||||
| 'crystalBatSonicWindup'
|
||||
| 'crystalBatSonicRecover'
|
||||
| 'crystalBatShardWindup'
|
||||
| 'crystalBatShardRecover'
|
||||
| 'crystalBatSwoopWindup'
|
||||
| 'crystalBatSwooping'
|
||||
| 'crystalBatSwoopRecover'
|
||||
| 'venomOrchidHydraConeWindup'
|
||||
| 'venomOrchidHydraConeRecover'
|
||||
| 'venomOrchidHydraRootSnareWindup'
|
||||
| 'venomOrchidHydraRootSnareRecover'
|
||||
| 'venomOrchidHydraBloomPodWindup'
|
||||
| 'venomOrchidHydraBloomPodRecover'
|
||||
| 'sandglassBurrowWindup'
|
||||
| 'sandglassBurrowing'
|
||||
| 'sandglassEruptionWindup'
|
||||
| 'sandglassEruptionRecover'
|
||||
| 'sandglassHourglassSafe'
|
||||
| 'sandglassHourglassHazard'
|
||||
| 'sandglassHourglassRecover'
|
||||
| 'hollowcrownHoofCurseWindup'
|
||||
| 'hollowcrownHoofCurseRecover'
|
||||
| 'hollowcrownTrailDashWindup'
|
||||
| 'hollowcrownTrailDashing'
|
||||
| 'hollowcrownTrailFade'
|
||||
| 'hollowcrownAntlerChargeWindup'
|
||||
| 'hollowcrownAntlerChargeRecover'
|
||||
|
||||
export type Iwt2HostileAddAttackPhase =
|
||||
| 'idle'
|
||||
@@ -117,6 +181,10 @@ export type Iwt2BossEntityState = {
|
||||
radius: number
|
||||
health: number
|
||||
maxHealth: number
|
||||
armor: number
|
||||
maxArmor: number
|
||||
mechanicEnergy: number
|
||||
mechanicEnergyMax: number
|
||||
meleeCooldownRemaining: number
|
||||
chargeCooldownRemaining: number
|
||||
chargeCount: number
|
||||
@@ -133,6 +201,8 @@ export type Iwt2BossEntityState = {
|
||||
birdWaveThresholdsTriggered: number[]
|
||||
mechanicLanes: Iwt2MechanicLaneState[]
|
||||
mechanicCircles: Iwt2MechanicCircleState[]
|
||||
mechanicArcs: Iwt2MechanicArcState[]
|
||||
mechanicLinks: Iwt2MechanicLinkState[]
|
||||
}
|
||||
|
||||
export type Iwt2MechanicLaneState = {
|
||||
@@ -148,6 +218,22 @@ export type Iwt2MechanicCircleState = {
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2MechanicArcState = {
|
||||
id: string
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
direction: Iwt2Vec2
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2MechanicLinkState = {
|
||||
id: string
|
||||
start: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
width: number
|
||||
}
|
||||
|
||||
export type Iwt2HostileAddState = {
|
||||
id: string
|
||||
kind: 'hostileAdd'
|
||||
@@ -172,7 +258,7 @@ export type Iwt2HostileAddState = {
|
||||
export type Iwt2GroundHazardState = {
|
||||
id: string
|
||||
kind: 'groundHazard'
|
||||
hazardKind: 'firePuddle'
|
||||
hazardKind: 'firePuddle' | 'poisonPuddle' | 'mudPuddle'
|
||||
sourceId: Iwt2EntityId
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
@@ -241,11 +327,21 @@ export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
|
||||
outerRadius: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaArcIndicator = Iwt2ArenaIndicatorBase & {
|
||||
kind: 'arc'
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
direction: Iwt2Vec2
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaIndicator =
|
||||
| Iwt2ArenaLaneIndicator
|
||||
| Iwt2ArenaCircleIndicator
|
||||
| Iwt2ArenaConeIndicator
|
||||
| Iwt2ArenaDonutIndicator
|
||||
| Iwt2ArenaArcIndicator
|
||||
|
||||
export type Iwt2ArenaState = {
|
||||
schemaVersion: 1
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addPoisonPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createConeIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const VENOM_ORCHID_HYDRA_PHASE = {
|
||||
bloomPodRecover: 'venomOrchidHydraBloomPodRecover',
|
||||
bloomPodWindup: 'venomOrchidHydraBloomPodWindup',
|
||||
coneRecover: 'venomOrchidHydraConeRecover',
|
||||
coneWindup: 'venomOrchidHydraConeWindup',
|
||||
rootSnareRecover: 'venomOrchidHydraRootSnareRecover',
|
||||
rootSnareWindup: 'venomOrchidHydraRootSnareWindup',
|
||||
} as const
|
||||
|
||||
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
|
||||
|
||||
const VENOM_ORCHID_HYDRA = {
|
||||
bloomBurstDamage: 7,
|
||||
bloomCooldown: 8.6,
|
||||
bloomPodCount: 3,
|
||||
bloomPodDamage: 3,
|
||||
bloomPodRadius: 30,
|
||||
bloomPodSeconds: 1.4,
|
||||
bloomRecover: 0.5,
|
||||
bloomWindup: 0.9,
|
||||
coneAngleRadians: Math.PI * 0.34,
|
||||
coneCooldown: 5.7,
|
||||
coneDamage: 10,
|
||||
coneRange: 178,
|
||||
coneRecover: 0.62,
|
||||
coneStaggerWindup: 0.48,
|
||||
coneStunSeconds: 0.18,
|
||||
headOffset: 22,
|
||||
meleeCooldown: 1.35,
|
||||
meleeDamage: 4,
|
||||
meleeRange: 58,
|
||||
moveSpeed: 58,
|
||||
relocateSpeed: 114,
|
||||
relocateSeconds: 1.45,
|
||||
rootLaneDamage: 8,
|
||||
rootLaneLengthInset: 36,
|
||||
rootLaneWidth: 28,
|
||||
rootRecover: 0.58,
|
||||
rootSnareSeconds: 0.85,
|
||||
rootWindup: 0.82,
|
||||
wallDisengageSeconds: 0.72,
|
||||
wallMargin: 70,
|
||||
}
|
||||
|
||||
const VENOM_COLOR = '#b7ff4f'
|
||||
const ROOT_COLOR = '#d68b49'
|
||||
const BLOOM_COLOR = '#ff70d7'
|
||||
|
||||
type PoisonConePlan = {
|
||||
direction: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
origin: Iwt2Vec2
|
||||
}
|
||||
|
||||
export function tickVenomOrchidHydra(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
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 phase = venomOrchidHydraPhase(boss)
|
||||
boss = {
|
||||
...boss,
|
||||
wallContactSeconds: phase === 'relocating'
|
||||
? 0
|
||||
: isBossNearWall(boss, state)
|
||||
? boss.wallContactSeconds + dt
|
||||
: Math.max(0, boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
|
||||
const target = getBossTarget(party)
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === 'relocating') {
|
||||
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
|
||||
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyPoisonCone(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
const nextHeadIndex = Math.floor(boss.mechanicEnergy) + 1
|
||||
if (nextHeadIndex < 3) {
|
||||
const cone = createPoisonConePlan(boss, party, state, nextHeadIndex)
|
||||
boss = {
|
||||
...boss,
|
||||
chargeEnd: cone.end,
|
||||
chargeHitEntityIds: result.hitEntityIds,
|
||||
chargeStart: cone.origin,
|
||||
facing: cone.direction,
|
||||
mechanicEnergy: nextHeadIndex,
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
|
||||
}
|
||||
} else {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneRecover),
|
||||
chargeHitEntityIds: [],
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneRecover,
|
||||
}
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyRootSnareLanes(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover),
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootRecover,
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const burst = applyBloomBursts(party, boss, state.time + dt)
|
||||
const pods = addBloomPoisonPods({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
time: state.time + dt,
|
||||
})
|
||||
party = burst.party
|
||||
hazards = pods.hazards
|
||||
nextHazardId = pods.nextHazardId
|
||||
events.push(...burst.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover),
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomRecover,
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= VENOM_ORCHID_HYDRA.wallDisengageSeconds) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.relocateSeconds,
|
||||
relocateTarget: relocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = beginBloomPods(boss, state, party)
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = boss.chargeCount % 2 === 0
|
||||
? beginStaggeredPoisonCones(boss, state, party)
|
||||
: beginRootSnareLanes(boss, state, party)
|
||||
return withVenomOrchidHydraIndicators({ 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 withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
boss = moveHydraTowardEngagePoint(boss, state, target, dt)
|
||||
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function beginStaggeredPoisonCones(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2BossEntityState {
|
||||
const cone = createPoisonConePlan(boss, party, state, 0)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneWindup),
|
||||
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
chargeEnd: cone.end,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: cone.origin,
|
||||
facing: cone.direction,
|
||||
mechanicCircles: [],
|
||||
mechanicEnergy: 0,
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginRootSnareLanes(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2BossEntityState {
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup),
|
||||
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
|
||||
chargeCount: boss.chargeCount + 1,
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: createRootSnarePattern(state, party),
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginBloomPods(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2BossEntityState {
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup),
|
||||
fireballCooldownRemaining: VENOM_ORCHID_HYDRA.bloomCooldown,
|
||||
mechanicCircles: createBloomPodPattern(state, party, boss),
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function createPoisonConePlan(
|
||||
boss: Iwt2BossEntityState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
state: Iwt2ArenaState,
|
||||
headIndex: number,
|
||||
): PoisonConePlan {
|
||||
const living = livingParty(party)
|
||||
const fallbackTarget = getBossTarget(party)?.position ?? arenaCenter(state)
|
||||
const target = living[(headIndex + boss.chargeCount) % Math.max(1, living.length)]?.position ?? fallbackTarget
|
||||
const headOffset = (headIndex - 1) * VENOM_ORCHID_HYDRA.headOffset
|
||||
const baseDirection = withFallbackFacing(subtractVec2(target, boss.position), boss.facing)
|
||||
const direction = rotateVec2(baseDirection, (headIndex - 1) * 0.28)
|
||||
const perpendicular = { x: -baseDirection.y, y: baseDirection.x }
|
||||
const origin = clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(perpendicular, headOffset)),
|
||||
8,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
direction,
|
||||
end: addVec2(origin, scaleVec2(direction, VENOM_ORCHID_HYDRA.coneRange)),
|
||||
origin,
|
||||
}
|
||||
}
|
||||
|
||||
function createRootSnarePattern(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2MechanicLaneState[] {
|
||||
const living = livingParty(party)
|
||||
const center = partyCenter(living, arenaCenter(state))
|
||||
const selected = [
|
||||
getPriorityMember(living, 'player')?.position ?? center,
|
||||
getPriorityMember(living, 'ranged')?.position ?? center,
|
||||
getPriorityMember(living, 'flanker')?.position ?? center,
|
||||
]
|
||||
const directions = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
normalizeVec2({ x: state.bounds.width >= state.bounds.height ? 1 : 0.8, y: 0.72 }),
|
||||
]
|
||||
|
||||
return selected.map((position, index) => {
|
||||
const lane = createArenaLaneThroughPoint(position, directions[index], state)
|
||||
return {
|
||||
...lane,
|
||||
id: `venom-orchid-root-snare-${index}`,
|
||||
width: VENOM_ORCHID_HYDRA.rootLaneWidth,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createBloomPodPattern(
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const living = livingParty(party)
|
||||
const center = partyCenter(living, arenaCenter(state))
|
||||
const chosen = [
|
||||
getPriorityMember(living, 'ranged')?.position,
|
||||
getPriorityMember(living, 'player')?.position,
|
||||
getPriorityMember(living, 'flanker')?.position,
|
||||
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), 54)),
|
||||
].filter((position): position is Iwt2Vec2 => Boolean(position))
|
||||
|
||||
return chosen.slice(0, VENOM_ORCHID_HYDRA.bloomPodCount).map((position, index) => ({
|
||||
id: `venom-orchid-bloom-pod-${index}`,
|
||||
position: clampVec2ToArena(position, VENOM_ORCHID_HYDRA.bloomPodRadius, state.bounds),
|
||||
radius: VENOM_ORCHID_HYDRA.bloomPodRadius,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyPoisonCone(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
|
||||
direction: boss.facing,
|
||||
kind: 'cone',
|
||||
origin: boss.chargeStart,
|
||||
range: VENOM_ORCHID_HYDRA.coneRange,
|
||||
}, {
|
||||
damage: VENOM_ORCHID_HYDRA.coneDamage,
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: VENOM_ORCHID_HYDRA.coneStunSeconds,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function applyRootSnareLanes(
|
||||
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: VENOM_ORCHID_HYDRA.rootLaneDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: VENOM_ORCHID_HYDRA.rootSnareSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function applyBloomBursts(
|
||||
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 result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: VENOM_ORCHID_HYDRA.bloomBurstDamage,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: 0,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function addBloomPoisonPods({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
time,
|
||||
}: {
|
||||
boss: Iwt2BossEntityState
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
time: number
|
||||
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
|
||||
let nextHazards = hazards
|
||||
let nextId = nextHazardId
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const result = addPoisonPuddle({
|
||||
damage: VENOM_ORCHID_HYDRA.bloomPodDamage,
|
||||
duration: VENOM_ORCHID_HYDRA.bloomPodSeconds,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = result.hazards
|
||||
nextId = result.nextHazardId
|
||||
}
|
||||
return { hazards: nextHazards, nextHazardId: nextId }
|
||||
}
|
||||
|
||||
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) > VENOM_ORCHID_HYDRA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: VENOM_ORCHID_HYDRA.meleeRange,
|
||||
}, {
|
||||
damage: VENOM_ORCHID_HYDRA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: VENOM_ORCHID_HYDRA.meleeCooldown, velocity: { x: 0, y: 0 } },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardRelocationTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, VENOM_ORCHID_HYDRA.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 moveHydraTowardEngagePoint(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const center = arenaCenter(state)
|
||||
const targetNearWall = isPositionNearWall(target.position, target.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
|
||||
const desired = targetNearWall
|
||||
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), 106))
|
||||
: target.position
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, desired, VENOM_ORCHID_HYDRA.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 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, 66)), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
function createArenaLaneThroughPoint(
|
||||
point: Iwt2Vec2,
|
||||
direction: Iwt2Vec2,
|
||||
state: Iwt2ArenaState,
|
||||
): { start: Iwt2Vec2, end: Iwt2Vec2 } {
|
||||
const dir = withFallbackFacing(direction, { x: 1, y: 0 })
|
||||
const inset = VENOM_ORCHID_HYDRA.rootLaneLengthInset
|
||||
const minX = inset
|
||||
const maxX = state.bounds.width - inset
|
||||
const minY = inset
|
||||
const maxY = state.bounds.height - inset
|
||||
const candidates: number[] = []
|
||||
if (Math.abs(dir.x) > 0.0001) {
|
||||
candidates.push((minX - point.x) / dir.x, (maxX - point.x) / dir.x)
|
||||
}
|
||||
if (Math.abs(dir.y) > 0.0001) {
|
||||
candidates.push((minY - point.y) / dir.y, (maxY - point.y) / dir.y)
|
||||
}
|
||||
const valid = candidates.filter((t) => {
|
||||
const position = addVec2(point, scaleVec2(dir, t))
|
||||
return position.x >= minX - 0.1 && position.x <= maxX + 0.1 && position.y >= minY - 0.1 && position.y <= maxY + 0.1
|
||||
})
|
||||
const startT = Math.min(...valid)
|
||||
const endT = Math.max(...valid)
|
||||
return {
|
||||
end: addVec2(point, scaleVec2(dir, endT)),
|
||||
start: addVec2(point, scaleVec2(dir, startT)),
|
||||
}
|
||||
}
|
||||
|
||||
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
|
||||
const sin = Math.sin(radians)
|
||||
const cos = Math.cos(radians)
|
||||
return normalizeVec2({
|
||||
x: vector.x * cos - vector.y * sin,
|
||||
y: vector.x * sin + vector.y * cos,
|
||||
})
|
||||
}
|
||||
|
||||
function livingParty(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState[] {
|
||||
return party.filter((member) => member.health > 0)
|
||||
}
|
||||
|
||||
function getPriorityMember(
|
||||
party: Iwt2PartyEntityState[],
|
||||
role: Iwt2PartyEntityState['aiRole'],
|
||||
): Iwt2PartyEntityState | undefined {
|
||||
return party.find((member) => member.aiRole === role)
|
||||
}
|
||||
|
||||
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 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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
|
||||
return isPositionNearWall(boss.position, boss.radius + VENOM_ORCHID_HYDRA.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 withVenomOrchidHydraIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createVenomOrchidHydraIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createVenomOrchidHydraIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const phase = venomOrchidHydraPhase(boss)
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup || phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
|
||||
indicators.push(createConeIndicator({
|
||||
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
|
||||
color: VENOM_COLOR,
|
||||
direction: boss.facing,
|
||||
id: `${boss.id}:venom-orchid-hydra-cone-${Math.floor(boss.mechanicEnergy)}`,
|
||||
mechanicId: 'venom-orchid-hydra-poison-cone',
|
||||
origin: boss.chargeStart,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup,
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover,
|
||||
),
|
||||
range: VENOM_ORCHID_HYDRA.coneRange,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup || phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: ROOT_COLOR,
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'venom-orchid-hydra-root-snare',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup,
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover,
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup || phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: BLOOM_COLOR,
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'venom-orchid-hydra-bloom-pod',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup,
|
||||
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover,
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return indicators
|
||||
}
|
||||
|
||||
function venomOrchidHydraPhase(boss: Iwt2BossEntityState): string {
|
||||
return boss.attackPhase as string
|
||||
}
|
||||
|
||||
function asBossPhase(phase: VenomOrchidHydraPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as unknown as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
Reference in New Issue
Block a user