Files
i-want-to-heal/src/modes/iwt2/sim/yianKutKuAi.ts
T
2026-07-04 12:43:59 -04:00

708 lines
20 KiB
TypeScript

import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
partyMemberIntersectsShape,
} from './mechanics'
import { addFirePuddle } from './hazards'
import {
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2YianTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId: number
nextHazardId: number
nextProjectileId: number
}
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
const YIAN_CENTER_CHARGE_SPEED = 430
export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let party = state.party
let hazards = state.hazards
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
let nextProjectileId = state.nextProjectileId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const birdResult = tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = birdResult.party
hazards = birdResult.hazards
nextHazardId = birdResult.nextHazardId
events.push(...birdResult.events)
let hostileAdds = birdResult.hostileAdds
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_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),
}
if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing)
projectiles.push({
id: `boss-projectile-${nextProjectileId}`,
sourceId: boss.id,
owner: 'boss',
projectileKind: 'fireball',
color: '#ff8b2b',
position: {
x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
},
velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!),
radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!,
damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!,
remainingSeconds: 7,
bouncesRemaining: YIAN_FIREBALL_BOUNCES,
})
nextProjectileId += 1
boss = {
...boss,
attackPhase: 'fireballRecover',
phaseSecondsRemaining: 0.35,
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (shouldYianRelocate(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextThreshold = nextBirdWaveThreshold(boss)
if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) {
const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold)
hostileAdds = [...hostileAdds, ...spawn.hostileAdds]
nextAddId = spawn.nextAddId
boss = {
...boss,
attackPhase: 'birdSummonRecover',
birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold],
phaseSecondsRemaining: 0.55,
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.fireballCooldownRemaining <= 0) {
if (!isYianAtCenter(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
boss = {
...boss,
attackPhase: 'fireballWindup',
fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!,
fireballTarget: { ...target.position },
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
boss = meleeResult.boss
events.push(...meleeResult.events)
if (meleeResult.events.length > 0) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextPosition = clampVec2ToArena(
moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_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 withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
function shouldYianRelocate(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): boolean {
return (
boss.position.x < YIAN_SAFE_WALL_MARGIN
|| boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN
|| boss.position.y < YIAN_SAFE_WALL_MARGIN
|| boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN
)
}
function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE
}
function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
return clampVec2ToArena({
x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190),
y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96),
}, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds)
}
function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
hostileAdds: Iwt2HostileAddState[]
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const hostileAdds: Iwt2HostileAddState[] = []
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
for (const add of state.hostileAdds) {
if (add.health <= 0) continue
let nextAdd = {
...add,
attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt),
flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt),
}
if (nextAdd.attackPhase === 'flightWindup') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = {
...nextAdd,
attackPhase: 'flying',
position: { ...nextAdd.flightStart },
velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!),
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flying') {
const previousPosition = nextAdd.position
const nextPosition = moveToward(
nextAdd.position,
nextAdd.flightEnd,
YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt,
)
const shape = {
kind: 'lane' as const,
start: previousPosition,
end: nextPosition,
width: nextAdd.radius,
}
const hitMembers = nextParty.filter((member) => (
!nextAdd.flightHitEntityIds.includes(member.id)
&& partyMemberIntersectsShape(member, shape)
))
const hitResult = applyPartyDamageInShape(nextParty, shape, {
damage: BIRD_FLIGHT_DAMAGE,
excludedEntityIds: nextAdd.flightHitEntityIds,
knockdownSeconds: 0,
sourceId: nextAdd.id,
stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
for (const member of hitMembers) {
const puddle = addFirePuddle({
damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!,
duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: member.position,
radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!,
sourceId: nextAdd.id,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
nextAdd = {
...nextAdd,
flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds],
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0),
facing: { x: 1, y: 0 },
}
if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) {
nextAdd = {
...nextAdd,
attackPhase: 'flightRecover',
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
phaseSecondsRemaining: 0.4,
velocity: { x: 0, y: 0 },
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flightRecover') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.flightCooldownRemaining <= 0) {
hostileAdds.push(startBirdFlight(nextAdd, state, time))
continue
}
const target = getNearestPartyMember(nextParty, nextAdd.position)
if (!target) {
hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } })
continue
}
const nextPosition = clampVec2ToArena(
moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt),
nextAdd.radius,
state.bounds,
)
nextAdd = {
...nextAdd,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing),
}
if (
nextAdd.attackCooldownRemaining <= 0
&& distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE
) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: nextAdd.position,
radius: nextAdd.radius + BIRD_MELEE_RANGE,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!,
sourceId: nextAdd.id,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN }
}
hostileAdds.push(nextAdd)
}
return {
events,
hazards: nextHazards,
hostileAdds,
nextHazardId: nextHazardIdValue,
party: nextParty,
}
}
function spawnBirdWave(
state: Iwt2ArenaState,
nextAddId: number,
sourceBossId: Iwt2BossId,
threshold: number,
): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } {
const hostileAdds: Iwt2HostileAddState[] = []
let nextId = nextAddId
for (let index = 0; index < BIRD_COUNT; index += 1) {
const y = birdLaneY(state, index, threshold * 100)
const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius!
const flightStart = { x: -radius - 8, y }
const flightEnd = { x: state.bounds.width + radius + 8, y }
hostileAdds.push({
id: `yian-bird-${nextId}`,
kind: 'hostileAdd',
addKind: 'yian-bird',
sourceBossId,
position: { ...flightStart },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius,
health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
damageDone: 0,
attackCooldownRemaining: 0,
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
})
nextId += 1
}
return { hostileAdds, nextAddId: nextId }
}
function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState {
const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time)
const flightStart = { x: -add.radius - 8, y }
const flightEnd = { x: state.bounds.width + add.radius + 8, y }
return {
...add,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
velocity: { x: 0, y: 0 },
}
}
function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number {
const laneHeight = state.bounds.height / (BIRD_COUNT + 1)
const base = laneHeight * (index + 1)
const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34
return clamp(base + wobble, 58, state.bounds.height - 58)
}
function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined {
return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds
?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold))
}
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 getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined {
let nearest: Iwt2PartyEntityState | undefined
let nearestDistance = Number.POSITIVE_INFINITY
for (const member of party) {
if (member.health <= 0) continue
const distance = distanceVec2(member.position, position)
if (distance < nearestDistance) {
nearest = member
nearestDistance = distance
}
}
return nearest
}
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) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withYianIndicators(result: Omit<Iwt2YianTickResult, 'indicators'>): Iwt2YianTickResult {
return {
...result,
indicators: createYianIndicators(result.boss, result.hostileAdds),
}
}
function createYianIndicators(
boss: Iwt2BossEntityState,
hostileAdds: Iwt2HostileAddState[],
): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'relocating') {
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: boss.chargeEnd,
id: `${boss.id}:center-charge`,
mechanicId: 'yian-center-charge',
phase: 'active',
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'fireballWindup') {
indicators.push(createCircleIndicator({
color: '#ff8b2b',
id: `${boss.id}:fireball-cast`,
mechanicId: 'yian-fireball-cast',
phase: 'windup',
position: boss.position,
radius: boss.radius + 18,
sourceId: boss.id,
}))
indicators.push(createLaneIndicator({
color: '#ffb347',
end: boss.fireballTarget,
id: `${boss.id}:fireball-line`,
mechanicId: 'yian-fireball-line',
phase: 'windup',
sourceId: boss.id,
start: boss.position,
width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4,
}))
}
for (const add of hostileAdds) {
if (
add.attackPhase !== 'flightWindup'
&& add.attackPhase !== 'flying'
&& add.attackPhase !== 'flightRecover'
) {
continue
}
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: add.flightEnd,
id: `${add.id}:flight-lane`,
mechanicId: 'yian-bird-flight',
phase: indicatorPhaseFromAttack(
add.attackPhase === 'flightWindup',
add.attackPhase === 'flying',
),
sourceId: add.id,
start: add.flightStart,
width: add.radius,
}))
}
return indicators
}