544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
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: 22,
|
|
arcInnerRadius: 78,
|
|
arcOuterRadius: 196,
|
|
arcRecover: 0.46,
|
|
arcStunSeconds: 0.24,
|
|
arcWindup: 0.74,
|
|
chainCooldown: 7.4,
|
|
chainDamage: 18,
|
|
chainLinkRadius: 150,
|
|
chainMaxLinks: 7,
|
|
chainRecover: 0.5,
|
|
chainStunSeconds: 0.38,
|
|
chainWidth: 16,
|
|
chainWindup: 0.68,
|
|
disengageSpeed: 168,
|
|
donutCooldown: 6.2,
|
|
donutDamage: 24,
|
|
donutInnerRadius: 64,
|
|
donutOuterRadius: 156,
|
|
donutRecover: 0.48,
|
|
donutStunSeconds: 0.44,
|
|
donutWindup: 0.82,
|
|
meleeCooldown: 1.12,
|
|
meleeDamage: 8,
|
|
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']
|
|
}
|