460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
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: 44,
|
|
crossSlashStunSeconds: 0.45,
|
|
crossSlashWindup: 0.82,
|
|
duelistRange: 118,
|
|
lineSlashCooldown: 3.4,
|
|
lineSlashDamage: 36,
|
|
lineSlashStunSeconds: 0.25,
|
|
lineSlashWindup: 0.48,
|
|
meleeCooldown: 0.8,
|
|
meleeDamage: 12,
|
|
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,
|
|
}))
|
|
}
|