Files
i-want-to-heal/src/modes/iwt2/sim/arena.ts
T
2026-07-07 23:25:40 -04:00

1007 lines
33 KiB
TypeScript

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