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