1007 lines
33 KiB
TypeScript
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 }))
|
|
}
|