import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' import { IWT2_CLASS_METADATA } from '../content/classes' import type { Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaInput, Iwt2ArenaState, Iwt2BossEntityState, Iwt2EntityId, Iwt2GroundHazardState, Iwt2HostileAddState, Iwt2ArenaBounds, Iwt2PartyAiRole, Iwt2PartyEntityId, Iwt2PartyEntityState, Iwt2ProjectileEntityState, Iwt2RoguelikePressureState, Iwt2StatusState, Iwt2Vec2, } from './types' import type { Iwt2PlayerClassId } from '../content/classes' import { tickBoss } from './bossAi' import { separateCircles } from './collision' import { canPartyMemberHitTarget, tickPartyMember } from './partyAi' import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards' import { applyPartyDamageInShape } from './mechanics' import { tickRoguelikePressure } from './roguelikePressure' import { addVec2, clampVec2ToArena, distanceVec2, normalizeVec2, scaleVec2, subtractVec2, } from './vector' const DEFAULT_ARENA_WIDTH = 960 const DEFAULT_ARENA_HEIGHT = 540 const MAX_DT = 1 / 15 const MAX_EVENTS = 80 const HEALER_MANA_REGEN_PER_SECOND = 3 const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22 const IWT2_ARENA_BOSS_IDS = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] type InitialPartyMember = { id: Iwt2PartyEntityId classId: Iwt2PlayerClassId aiRole: Iwt2PartyAiRole x: number y: number preferredOffset: { x: number; y: number } decisionOffset: number } const INITIAL_PARTY: InitialPartyMember[] = [ { id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 }, { id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 }, { id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 }, { id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 }, { id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 }, { id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 }, ] export function createInitialIwt2ArenaState( bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[], bossHealthScale = 1, partyDamageTakenScale = 1, roguelikePressure?: Iwt2RoguelikePressureState, bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT }, ): Iwt2ArenaState { const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId) const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds)) return { schemaVersion: 1, time: 0, bounds, party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)), projectiles: [], hostileAdds: [], hazards: [], indicators: [], roguelikePressure, boss: bosses[0], bosses, nextEventId: 1, nextProjectileId: 1, nextAddId: 1, nextHazardId: 1, events: [], } } function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] { const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId) const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId return [primaryBossId, random] } function createBossEntity( bossId: Iwt2BossId, index: number, bossCount: number, healthScale: number, bounds: Iwt2ArenaBounds, ): Iwt2BossEntityState { const bossMetadata = IWT2_BOSS_METADATA[bossId] const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds) const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale))) return { id: bossId, kind: 'boss', bossId, position, velocity: { x: 0, y: 0 }, facing: { x: -1, y: 0 }, radius: bossMetadata.radius, health: maxHealth, maxHealth, armor: bossMetadata.mudArmor ?? 0, maxArmor: bossMetadata.mudArmor ?? 0, mechanicEnergy: 0, mechanicEnergyMax: bossMetadata.staticEnergyMax ?? 0, meleeCooldownRemaining: 0.6 + index * 0.25, chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7, chargeCount: 0, attackPhase: 'idle', phaseSecondsRemaining: 0, chargeStart: { ...position }, chargeEnd: { ...position }, chargeHitEntityIds: [], slamApplied: false, wallContactSeconds: 0, relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds), fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7, fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds), birdWaveThresholdsTriggered: [], mechanicLanes: [], mechanicCircles: [], mechanicArcs: [], mechanicLinks: [], } } function initialBossPosition(index: number, bossCount = 2) { if (bossCount > 2) { const angle = -Math.PI * 0.5 + (Math.PI * (index + 0.5)) / bossCount return { x: 690 + Math.cos(angle) * 120, y: DEFAULT_ARENA_HEIGHT * 0.5 + Math.sin(angle) * 165, } } return { x: index === 0 ? 660 : 760, y: index === 0 ? 190 : 345, } } function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 { return { x: point.x * bounds.width / DEFAULT_ARENA_WIDTH, y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT, } } function initialBossSpecialCooldown(bossId: Iwt2BossId): number { if (bossId === 'bulldrome') return 2 if (bossId === 'great-jaggi') return 2.4 if (bossId === 'khezu') return 3 if (bossId === 'rathian') return 2.2 if (bossId === 'barroth') return 2.1 if (bossId === 'tobi-kadachi') return 2.6 if (bossId === 'rimebastion') return 2.8 if (bossId === 'ember-mantis-duelist') return 1.4 if (bossId === 'cinderback-ricochet') return 2.5 if (bossId === 'obsidian-ram-golem') return 2.2 if (bossId === 'stormcoil-wyrm') return 2.6 if (bossId === 'venom-orchid-hydra') return 2.4 if (bossId === 'sandglass-scorpion') return 2.1 if (bossId === 'crystal-bat-matriarch') return 2.3 if (bossId === 'hollowcrown-revenant') return 1.8 return 0 } function initialBossSecondaryCooldown(bossId: Iwt2BossId): number { if (bossId === 'yian-kut-ku') return 1.2 if (bossId === 'khezu') return 1.6 if (bossId === 'rathian') return 3 if (bossId === 'tobi-kadachi') return 2.4 if (bossId === 'ember-mantis-duelist') return 3 if (bossId === 'stormcoil-wyrm') return 3.1 if (bossId === 'venom-orchid-hydra') return 2.8 if (bossId === 'sandglass-scorpion') return 3.2 if (bossId === 'crystal-bat-matriarch') return 2.9 return 0 } export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { const step = Math.max(0, Math.min(dt, MAX_DT)) if (step <= 0) return { ...state, events: [...state.events] } const inputMove = { x: clampInputAxis(input.moveX), y: clampInputAxis(input.moveY), } const baseState = { ...state, time: state.time + step, party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)), events: [], } const separatedState = { ...baseState, party: separatePartyFromBosses( separatePartyFromParty(separatePartyFromBosses(baseState.party, baseState), baseState), baseState, ), } const bossResult = tickBosses(separatedState, step) const postBossState = { ...separatedState, bosses: bossResult.bosses } const postBossParty = separatePartyFromBosses( separatePartyFromParty( separatePartyFromBosses(bossResult.party, postBossState), postBossState, ), postBossState, ) const projectileResult = advanceProjectiles( [...separatedState.projectiles, ...(bossResult.projectiles ?? [])], postBossParty, bossResult.bosses, bossResult.hostileAdds ?? separatedState.hostileAdds, bossResult.hazards ?? separatedState.hazards, bossResult.nextHazardId ?? state.nextHazardId, bossResult.nextProjectileId ?? state.nextProjectileId, separatedState.bounds, separatedState.time, step, ) const hazardResult = tickGroundHazards({ dt: step, hazards: projectileResult.hazards, party: projectileResult.party, time: separatedState.time, }) const pressureResult = tickRoguelikePressure({ party: hazardResult.party, pressure: separatedState.roguelikePressure, time: separatedState.time, }) const damageResult = applyPartyAttacks( pressureResult.party, projectileResult.bosses, projectileResult.hostileAdds, separatedState.time, projectileResult.nextProjectileId, ) const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time) const regeneratedParty = regeneratePartyMana(hotResult.party, step) const finalCollisionState = { ...separatedState, bosses: damageResult.bosses } const finalParty = separatePartyFromBosses( separatePartyFromParty( separatePartyFromBosses(regeneratedParty, finalCollisionState), finalCollisionState, ), finalCollisionState, ) const nextEvents = assignEventIds( [...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...pressureResult.events, ...damageResult.events, ...hotResult.events], state.nextEventId, ) return { ...separatedState, boss: getPrimaryBoss(damageResult.bosses), bosses: damageResult.bosses, indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)], roguelikePressure: pressureResult.pressure, party: finalParty, hostileAdds: damageResult.hostileAdds, hazards: hazardResult.hazards, projectiles: [...projectileResult.projectiles, ...damageResult.projectiles], nextProjectileId: damageResult.nextProjectileId, nextAddId: bossResult.nextAddId ?? state.nextAddId, nextHazardId: projectileResult.nextHazardId, nextEventId: state.nextEventId + nextEvents.length, events: [...state.events, ...nextEvents].slice(-MAX_EVENTS), } } function tickPartyHotEffects( party: Iwt2PartyEntityState[], dt: number, time: number, ): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { const events: Iwt2ArenaEvent[] = [] const nextParty = party.map((member) => { if (member.hotEffects.length === 0) return member let health = member.health const hotEffects = member.hotEffects.flatMap((effect) => { const remainingSeconds = effect.remainingSeconds - dt if (remainingSeconds <= 0) return [] let nextTickInSeconds = effect.nextTickInSeconds - dt if (member.health > 0 && nextTickInSeconds <= 0) { const before = health const healingMultiplier = member.shield > 0 ? member.shieldedHotHealingMultiplier ?? 1 : 1 health = Math.min(member.maxHealth, health + effect.power * healingMultiplier) const healed = health - before if (healed > 0) { events.push({ id: 0, time, type: 'partyHealed', sourceId: 'player-healer', targetId: member.id, value: healed, }) } nextTickInSeconds += effect.tickIntervalSeconds } return [{ ...effect, nextTickInSeconds, remainingSeconds, }] }) return { ...member, health, hotEffects, } }) return { party: nextParty, events } } function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] { return party.map((member) => { if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member return { ...member, mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt), } }) } function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState { const metadata = IWT2_CLASS_METADATA[initial.classId] const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1 return { id: initial.id, kind: 'party', classId: initial.classId, aiRole: initial.aiRole, position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds), velocity: { x: 0, y: 0 }, facing: { x: 1, y: 0 }, radius: metadata.radius, moveSpeed: metadata.moveSpeed, attackRange: metadata.attackRange, attackDamage: metadata.attackDamage, attackCooldown: metadata.attackCooldown, castTime: metadata.castTime, projectileSpeed: metadata.projectileSpeed, health: metadata.maxHealth, maxHealth: metadata.maxHealth, damageTakenScale: safeDamageTakenScale, stunTakenScale: 1, shield: 0, mana: initial.classId === 'healer' ? 100 : 0, maxMana: initial.classId === 'healer' ? 100 : 0, damageDone: 0, attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35, castSecondsRemaining: 0, attackReady: false, status: createEmptyStatus(), hotEffects: [], preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds), decisionSecondsRemaining: initial.decisionOffset, } } function createEmptyStatus(): Iwt2StatusState { return { stunnedSeconds: 0, knockedDownSeconds: 0, invulnerableSeconds: 0, slowedSeconds: 0, } } function clampInputAxis(value: number): number { if (!Number.isFinite(value)) return 0 return Math.min(1, Math.max(-1, value)) } function tickBosses(state: Iwt2ArenaState, dt: number) { let nextParty = state.party let nextHostileAdds = state.hostileAdds let nextHazards = state.hazards let nextProjectileId = state.nextProjectileId let nextAddId = state.nextAddId let nextHazardId = state.nextHazardId const bosses: Iwt2BossEntityState[] = [] const projectiles: Iwt2ProjectileEntityState[] = [] const events: Iwt2ArenaEvent[] = [] const indicators: Iwt2ArenaIndicator[] = [] for (const boss of state.bosses) { const bossState = { ...state, boss, party: nextParty, hostileAdds: nextHostileAdds, hazards: nextHazards, nextProjectileId, nextAddId, nextHazardId, } const result = tickBoss(bossState, dt) bosses.push(result.boss) nextParty = result.party nextHostileAdds = result.hostileAdds ?? nextHostileAdds nextHazards = result.hazards ?? nextHazards nextProjectileId = result.nextProjectileId ?? nextProjectileId nextAddId = result.nextAddId ?? nextAddId nextHazardId = result.nextHazardId ?? nextHazardId projectiles.push(...(result.projectiles ?? [])) events.push(...result.events) indicators.push(...result.indicators) } const separatedBosses = separateBosses(bosses, state) return { bosses: separatedBosses, boss: getPrimaryBoss(separatedBosses), party: nextParty, hostileAdds: nextHostileAdds, hazards: nextHazards, projectiles, events, indicators, nextAddId, nextHazardId, nextProjectileId, } } function separateBosses(bosses: Iwt2BossEntityState[], state: Iwt2ArenaState): Iwt2BossEntityState[] { const next = bosses.map((boss) => ({ ...boss, position: { ...boss.position } })) for (let pass = 0; pass < 3; pass += 1) { for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) { const first = next[firstIndex] if (!first || first.health <= 0) continue for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) { const second = next[secondIndex] if (!second || second.health <= 0) continue const minDistance = first.radius + second.radius + 4 const distance = distanceVec2(first.position, second.position) if (distance >= minDistance) continue const fallback = { x: first.position.x <= second.position.x ? -1 : 1, y: first.position.y <= second.position.y ? -0.4 : 0.4, } const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position)) const push = (minDistance - distance) * 0.55 const firstPosition = clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds) const secondPosition = clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds) next[firstIndex] = { ...first, position: firstPosition, velocity: scaleVec2(subtractVec2(firstPosition, first.position), 30), } next[secondIndex] = { ...second, position: secondPosition, velocity: scaleVec2(subtractVec2(secondPosition, second.position), 30), } } } } return next } function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] { return party.map((member) => { if (member.health <= 0) return member let position = member.position for (const boss of state.bosses) { if (boss.health <= 0) continue const overlapBeforeSeparation = circleOverlapAmount({ position, radius: member.radius }, boss) const separated = separateCircles( { position, radius: member.radius }, { position: boss.position, radius: boss.radius }, state.bounds, ) const overlapAfterSeparation = circleOverlapAmount({ position: separated, radius: member.radius }, boss) position = overlapBeforeSeparation > 0.25 && (wallClearance(separated, member, state) <= 2 || overlapAfterSeparation > 0.25) ? separateFromBossTowardCenter(member, boss, state) : separated } return { ...member, position } }) } function circleOverlapAmount( moving: { position: Iwt2Vec2, radius: number }, fixed: { position: Iwt2Vec2, radius: number }, ): number { return Math.max(0, moving.radius + fixed.radius - distanceVec2(moving.position, fixed.position)) } function separateFromBossTowardCenter( member: Iwt2PartyEntityState, boss: Iwt2BossEntityState, state: Iwt2ArenaState, ): Iwt2Vec2 { const center = { x: state.bounds.width * 0.5, y: state.bounds.height * 0.5 } const direction = normalizeVec2(subtractVec2(center, boss.position)) const minDistance = boss.radius + member.radius + 3 return clampVec2ToArena( addVec2(boss.position, scaleVec2(direction, minDistance)), member.radius, state.bounds, ) } function wallClearance(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number { return Math.min( position.x - member.radius, state.bounds.width - member.radius - position.x, position.y - member.radius, state.bounds.height - member.radius - position.y, ) } function separatePartyFromParty(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] { const next = party.map((member) => ({ ...member, position: { ...member.position } })) for (let pass = 0; pass < 2; pass += 1) { for (let firstIndex = 0; firstIndex < next.length; firstIndex += 1) { const first = next[firstIndex] if (!first || first.health <= 0) continue for (let secondIndex = firstIndex + 1; secondIndex < next.length; secondIndex += 1) { const second = next[secondIndex] if (!second || second.health <= 0) continue const minDistance = first.radius + second.radius + 2 const distance = distanceVec2(first.position, second.position) if (distance >= minDistance) continue const fallback = { x: first.position.x <= second.position.x ? -1 : 1, y: first.position.y <= second.position.y ? -0.35 : 0.35, } const direction = distance <= 0.001 ? normalizeVec2(fallback) : normalizeVec2(subtractVec2(first.position, second.position)) const push = (minDistance - distance) * 0.5 next[firstIndex] = { ...first, position: clampVec2ToArena(addVec2(first.position, scaleVec2(direction, push)), first.radius, state.bounds), } next[secondIndex] = { ...second, position: clampVec2ToArena(addVec2(second.position, scaleVec2(direction, -push)), second.radius, state.bounds), } } } } return next } function advanceProjectiles( projectiles: Iwt2ProjectileEntityState[], party: Iwt2PartyEntityState[], bosses: Iwt2BossEntityState[], hostileAdds: Iwt2HostileAddState[], hazards: Iwt2GroundHazardState[], nextHazardId: number, nextProjectileId: number, bounds: Iwt2ArenaState['bounds'], time: number, dt: number, ): { bosses: Iwt2BossEntityState[] party: Iwt2PartyEntityState[] hostileAdds: Iwt2HostileAddState[] hazards: Iwt2GroundHazardState[] nextHazardId: number nextProjectileId: number projectiles: Iwt2ProjectileEntityState[] events: Iwt2ArenaEvent[] } { const events: Iwt2ArenaEvent[] = [] let nextBosses = bosses let nextParty = party let nextHostileAdds = hostileAdds let nextHazards = hazards let nextHazardIdValue = nextHazardId const nextProjectiles: Iwt2ProjectileEntityState[] = [] for (const projectile of projectiles) { if (projectile.owner === 'boss') { const result = advanceBossProjectile({ bounds, dt, hazards: nextHazards, nextHazardId: nextHazardIdValue, party: nextParty, projectile, time, }) nextParty = result.party nextHazards = result.hazards nextHazardIdValue = result.nextHazardId events.push(...result.events) if (result.projectile) nextProjectiles.push(result.projectile) continue } if (nextBosses.every((boss) => boss.health <= 0) && nextHostileAdds.every((add) => add.health <= 0)) continue const nextPosition = { x: projectile.position.x + projectile.velocity.x * dt, y: projectile.position.y + projectile.velocity.y * dt, } const remainingSeconds = projectile.remainingSeconds - dt const hitAdd = nextHostileAdds.find((add) => ( add.health > 0 && distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius )) if (remainingSeconds <= 0) continue if (hitAdd) { const damage = Math.min(projectile.damage, hitAdd.health) nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id ? { ...add, health: Math.max(0, add.health - damage) } : add) nextParty = addDamageDone(nextParty, projectile.sourceId, damage) events.push({ id: 0, time, type: 'bossDamaged', sourceId: projectile.sourceId, targetId: hitAdd.id, value: damage, }) if (hitAdd.health - damage <= 0) { events.push({ id: 0, time, type: 'entityDefeated', sourceId: projectile.sourceId, targetId: hitAdd.id, }) } continue } const hitBoss = nextBosses.find((boss) => ( boss.health > 0 && distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius )) if (hitBoss) { const damageResult = applyDamageToBoss(hitBoss, projectile.damage) const damage = damageResult.healthDamage nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id ? damageResult.boss : boss) nextParty = addDamageDone(nextParty, projectile.sourceId, damage) events.push({ id: 0, time, type: 'bossDamaged', sourceId: projectile.sourceId, targetId: hitBoss.id, value: damage, }) if (damageResult.boss.health <= 0) { events.push({ id: 0, time, type: 'entityDefeated', sourceId: projectile.sourceId, targetId: hitBoss.id, }) } continue } nextProjectiles.push({ ...projectile, position: nextPosition, remainingSeconds, }) } return { bosses: nextBosses, party: nextParty, hostileAdds: nextHostileAdds, hazards: nextHazards, nextHazardId: nextHazardIdValue, nextProjectileId, projectiles: nextProjectiles, events, } } function advanceBossProjectile({ bounds, dt, hazards, nextHazardId, party, projectile, time, }: { bounds: Iwt2ArenaState['bounds'] dt: number hazards: Iwt2GroundHazardState[] nextHazardId: number party: Iwt2PartyEntityState[] projectile: Iwt2ProjectileEntityState time: number }): { party: Iwt2PartyEntityState[] hazards: Iwt2GroundHazardState[] nextHazardId: number projectile?: Iwt2ProjectileEntityState events: Iwt2ArenaEvent[] } { const events: Iwt2ArenaEvent[] = [] const remainingSeconds = projectile.remainingSeconds - dt if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events } let nextParty = party let nextHazards = hazards let nextHazardIdValue = nextHazardId let bounced = false const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt) const canBounce = bounceCooldownSeconds <= 0 let velocity = { ...projectile.velocity } let position = { x: projectile.position.x + projectile.velocity.x * dt, y: projectile.position.y + projectile.velocity.y * dt, } if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) { velocity = { ...velocity, x: -velocity.x } position = clampVec2ToArena(position, projectile.radius, bounds) bounced = true } if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) { velocity = { ...velocity, y: -velocity.y } position = clampVec2ToArena(position, projectile.radius, bounds) bounced = true } const hitMember = canBounce ? nextParty.find((member) => ( member.health > 0 && distanceVec2(position, member.position) <= projectile.radius + member.radius )) : undefined if (hitMember) { const result = applyPartyDamageInShape(nextParty, { kind: 'circle', position: hitMember.position, radius: hitMember.radius + projectile.radius, }, { damage: projectile.damage, damageEventType: 'bossProjectileHit', sourceId: projectile.sourceId, time, }) nextParty = result.party events.push(...result.events) const away = normalizeVec2(subtractVec2(position, hitMember.position)) const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1)) const direction = away.x === 0 && away.y === 0 ? fallback : away const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y)) velocity = scaleVec2(direction, speed) position = clampVec2ToArena({ x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2), y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2), }, projectile.radius, bounds) bounced = true } if (hitMember) { const puddle = addFirePuddle({ damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!, duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!, hazards: nextHazards, nextHazardId: nextHazardIdValue, position: hitMember.position, radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!, sourceId: projectile.sourceId, time, }) nextHazards = puddle.hazards nextHazardIdValue = puddle.nextHazardId } const bouncesRemaining = bounced ? (projectile.bouncesRemaining ?? 0) - 1 : projectile.bouncesRemaining if (bouncesRemaining !== undefined && bouncesRemaining < 0) { return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events } } return { events, hazards: nextHazards, nextHazardId: nextHazardIdValue, party: nextParty, projectile: { ...projectile, bouncesRemaining, bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds, position, remainingSeconds, velocity, }, } } function applyPartyAttacks( party: Iwt2PartyEntityState[], bosses: Iwt2BossEntityState[], hostileAdds: Iwt2HostileAddState[], time: number, nextProjectileId: number, ): { bosses: Iwt2BossEntityState[] party: Iwt2PartyEntityState[] hostileAdds: Iwt2HostileAddState[] projectiles: Iwt2ProjectileEntityState[] nextProjectileId: number events: Iwt2ArenaEvent[] } { const events: Iwt2ArenaEvent[] = [] const projectiles: Iwt2ProjectileEntityState[] = [] let nextBosses = bosses let nextHostileAdds = hostileAdds let projectileId = nextProjectileId const nextParty = party.map((member) => { const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds) if (!target) return member if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member const metadata = IWT2_CLASS_METADATA[member.classId] if (member.projectileSpeed > 0) { if (!member.attackReady) return member const direction = normalizeVec2(subtractVec2(target.position, member.position)) projectiles.push({ id: `projectile-${projectileId}`, sourceId: member.id, owner: 'party', classId: member.classId, projectileKind: member.classId === 'mage' ? 'fireball' : 'arrow', color: metadata.accentColor, position: { x: member.position.x + direction.x * (member.radius + 4), y: member.position.y + direction.y * (member.radius + 4), }, velocity: scaleVec2(direction, member.projectileSpeed), radius: member.classId === 'mage' ? 7 : 4, damage: member.attackDamage, remainingSeconds: 1.2, }) events.push({ id: 0, time, type: 'partyAttack', sourceId: member.id, targetId: target.id, }) projectileId += 1 return { ...member, attackCooldownRemaining: member.attackCooldown, attackReady: false, } } if (member.attackCooldownRemaining > 0) return member let damage = Math.min(member.attackDamage, target.health) let defeated = target.health - damage <= 0 if (target.kind === 'boss') { const damageResult = applyDamageToBoss(target, member.attackDamage) damage = damageResult.healthDamage defeated = damageResult.boss.health <= 0 nextBosses = nextBosses.map((boss) => boss.id === target.id ? damageResult.boss : boss) } else { nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id ? { ...add, health: Math.max(0, add.health - damage) } : add) } events.push({ id: 0, time, type: 'bossDamaged', sourceId: member.id, targetId: target.id, value: damage, }) events.push({ id: 0, time, type: 'partyAttack', sourceId: member.id, targetId: target.id, }) if (defeated) { events.push({ id: 0, time, type: 'entityDefeated', sourceId: member.id, targetId: target.id, }) return { ...member, damageDone: member.damageDone + damage, attackCooldownRemaining: member.attackCooldown, } } return { ...member, damageDone: member.damageDone + damage, attackCooldownRemaining: member.attackCooldown, } }) return { bosses: nextBosses, party: nextParty, hostileAdds: nextHostileAdds.filter((add) => add.health > 0), projectiles, nextProjectileId: projectileId, events, } } function getPriorityAttackTarget( member: Iwt2PartyEntityState, bosses: Iwt2BossEntityState[], hostileAdds: Iwt2HostileAddState[], ): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined { if (member.health <= 0) return undefined const livingAdds = hostileAdds.filter((add) => add.health > 0) if (livingAdds.length > 0) { return livingAdds.reduce((best, add) => ( distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best ), livingAdds[0]) } const livingBosses = bosses.filter((boss) => boss.health > 0) if (livingBosses.length === 0) return undefined return livingBosses.reduce((best, boss) => ( distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best ), livingBosses[0]) } function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState { return bosses.find((boss) => boss.health > 0) ?? bosses[0] } function addDamageDone( party: Iwt2PartyEntityState[], sourceId: Iwt2EntityId, damage: number, ): Iwt2PartyEntityState[] { return party.map((member) => member.id === sourceId ? { ...member, damageDone: member.damageDone + damage } : member) } function applyDamageToBoss( boss: Iwt2BossEntityState, rawDamage: number, ): { boss: Iwt2BossEntityState, healthDamage: number } { if (rawDamage <= 0 || boss.health <= 0) return { boss, healthDamage: 0 } if (boss.armor <= 0) { const healthDamage = Math.min(rawDamage, boss.health) return { boss: { ...boss, health: Math.max(0, boss.health - healthDamage) }, healthDamage, } } const armorDamage = Math.min(boss.armor, rawDamage * 0.75) const healthDamage = Math.min(boss.health, rawDamage - armorDamage * 0.6) return { boss: { ...boss, armor: Math.max(0, boss.armor - armorDamage), health: Math.max(0, boss.health - healthDamage), }, healthDamage, } } function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] { return events.map((event, index) => ({ ...event, id: firstId + index })) }