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 { 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, })) }