Android build v1.1.15

This commit is contained in:
Warren H
2026-07-04 12:43:59 -04:00
parent a849cd2c69
commit 99e97e5208
47 changed files with 8470 additions and 1610 deletions
+646
View File
@@ -0,0 +1,646 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
} 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 {
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
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'): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
boss: {
id: bossId,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
},
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
nextHazardId: 1,
events: [],
}
}
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: separatePartyFromBoss(baseState.party, baseState),
}
const bossResult = tickBoss(separatedState, step)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.boss,
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 damageResult = applyPartyAttacks(
hazardResult.party,
projectileResult.boss,
projectileResult.hostileAdds,
separatedState.time,
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
...separatedState,
boss: damageResult.boss,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
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
health = Math.min(member.maxHealth, health + effect.power)
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): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
return {
id: initial.id,
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
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: { ...initial.preferredOffset },
decisionSecondsRemaining: initial.decisionOffset,
}
}
function createEmptyStatus(): Iwt2StatusState {
return {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
}
}
function clampInputAxis(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(-1, value))
}
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const position = separateCircles(
{ position: member.position, radius: member.radius },
{ position: state.boss.position, radius: state.boss.radius },
state.bounds,
)
return { ...member, position }
})
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
nextProjectileId: number,
bounds: Iwt2ArenaState['bounds'],
time: number,
dt: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextProjectileId: number
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBoss = boss
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 (nextBoss.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
}
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
const damage = Math.min(projectile.damage, nextBoss.health)
nextBoss = {
...nextBoss,
health: Math.max(0, nextBoss.health - damage),
}
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
value: damage,
})
if (nextBoss.health <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
})
}
continue
}
nextProjectiles.push({
...projectile,
position: nextPosition,
remainingSeconds,
})
}
return {
boss: nextBoss,
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,
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 (bounced) {
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 ?? 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[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
nextProjectileId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.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' ? 'magic' : '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, metadata.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
remainingSeconds: 1.2,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
} 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,
})
if (target.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: member.id,
targetId: target.id,
})
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
})
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
nextProjectileId: projectileId,
events,
}
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | 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])
}
return boss.health > 0 ? boss : undefined
}
function addDamageDone(
party: Iwt2PartyEntityState[],
sourceId: Iwt2EntityId,
damage: number,
): Iwt2PartyEntityState[] {
return party.map((member) => member.id === sourceId
? { ...member, damageDone: member.damageDone + damage }
: member)
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+148
View File
@@ -0,0 +1,148 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import {
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
tickIwt2Arena as tickCoreIwt2Arena,
} from './arena'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
} from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
export type Iwt2ArenaEntity = {
id: string
kind: Iwt2ArenaEntityKind
icon: string
color: string
x: number
y: number
radius: number
health: number
maxHealth: number
stunnedFor: number
}
export type Iwt2ArenaTelegraph =
| {
kind: 'charge'
x: number
y: number
width: number
height: number
active: boolean
}
| {
kind: 'slam'
x: number
y: number
radius: number
active: boolean
}
export type Iwt2ArenaState = Iwt2CoreArenaState & {
arena: {
width: number
height: number
}
entities: Iwt2ArenaEntity[]
telegraphs: Iwt2ArenaTelegraph[]
}
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
}
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
return {
...state,
arena: { ...state.bounds },
entities: [
...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss),
],
telegraphs: createTelegraphs(state.indicators),
}
}
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
return {
id: add.id,
kind: 'hostileAdd',
icon: 'v',
color: '#f0b84f',
x: add.position.x,
y: add.position.y,
radius: add.radius,
health: add.health,
maxHealth: add.maxHealth,
stunnedFor: 0,
}
}
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
kind: member.aiRole === 'player' ? 'player' : 'party',
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
}
}
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
kind: 'boss',
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
stunnedFor: 0,
}
}
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
if (indicator.kind === 'lane') {
return [{
active: indicator.phase === 'active',
height: indicator.width * 2,
kind: 'charge' as const,
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
x: Math.min(indicator.start.x, indicator.end.x),
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
}]
}
if (indicator.kind === 'circle') {
return [{
active: indicator.phase === 'active',
kind: 'slam' as const,
radius: indicator.radius,
x: indicator.position.x,
y: indicator.position.y,
}]
}
return []
})
}
+378
View File
@@ -0,0 +1,378 @@
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2BossTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds?: Iwt2HostileAddState[]
hazards?: Iwt2GroundHazardState[]
projectiles?: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId?: number
nextHazardId?: number
nextProjectileId?: number
}
export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
return tickBulldrome(state, dt)
}
export function tickBulldrome(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
const target = getBossTarget(state.party)
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
}
const wallContactSeconds = isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = { ...boss, wallContactSeconds }
if (boss.health <= 0 || !target) {
return withBulldromeIndicators({ boss: { ...boss, velocity: { x: 0, y: 0 } }, party, events })
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, BULLDROME_BOSS_METADATA.moveSpeed * 1.45 * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(nextPosition, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.2),
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeWindup') {
boss = {
...boss,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'charging' }
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'charging') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, BULLDROME_BOSS_METADATA.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(velocity, boss.facing),
chargeHitEntityIds: hitResult.hitEntityIds,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
const needsSlam = boss.chargeCount % 3 === 0
boss = {
...boss,
attackPhase: needsSlam ? 'slamWindup' : 'chargeRecover',
phaseSecondsRemaining: needsSlam ? BULLDROME_BOSS_METADATA.slamWindup : 0.45,
slamApplied: false,
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'slamWindup') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applySlam(party, boss, state.time + dt)
party = slamResult.party
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: 'slamRecover',
phaseSecondsRemaining: 0.5,
slamApplied: true,
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeRecover' || boss.attackPhase === 'slamRecover') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= 1.1) {
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.8,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
const charge = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: BULLDROME_BOSS_METADATA.chargeLength,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'chargeWindup',
phaseSecondsRemaining: BULLDROME_BOSS_METADATA.chargeWindup,
chargeCooldownRemaining: BULLDROME_BOSS_METADATA.chargeCooldown,
chargeCount: boss.chargeCount + 1,
chargeStart: { ...boss.position },
chargeEnd: charge.end,
chargeHitEntityIds: [],
slamApplied: false,
facing: charge.direction,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(state.nextEventId + events.length, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withBulldromeIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt, state.nextEventId + events.length)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (meleeResult.events.length > 0) return withBulldromeIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, BULLDROME_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withBulldromeIndicators({ boss, party, events })
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState) {
const margin = boss.radius + 10
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.58, y: state.bounds.height * 0.5 }
const awayFromWall = {
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
}
return {
x: Math.min(state.bounds.width - boss.radius, Math.max(boss.radius, center.x + awayFromWall.x * 70)),
y: Math.min(state.bounds.height - boss.radius, Math.max(boss.radius, center.y + awayFromWall.y * 54)),
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
firstEventId: number,
): Omit<Iwt2BossTickResult, 'indicators'> {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const nextParty = party.map((member) => {
if (member.id !== target.id) return member
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
})
const events = [
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
]
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
}
return {
boss: { ...boss, meleeCooldownRemaining: BULLDROME_BOSS_METADATA.meleeCooldown, velocity: { x: 0, y: 0 } },
party: nextParty,
events,
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'lane',
start,
end,
width: boss.radius,
},
{
damage: BULLDROME_BOSS_METADATA.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
time,
},
)
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applySlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'circle',
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
},
{
damage: BULLDROME_BOSS_METADATA.slamDamage,
knockdownSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
time,
},
)
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, BULLDROME_BOSS_METADATA.slamRadius),
...result.events,
],
}
}
function withBulldromeIndicators(
result: Omit<Iwt2BossTickResult, 'indicators'>,
): Iwt2BossTickResult {
return {
...result,
indicators: createBulldromeIndicators(result.boss),
}
}
function createBulldromeIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'chargeWindup'
|| boss.attackPhase === 'charging'
|| boss.attackPhase === 'chargeRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'charging' ? '#f05b4f' : '#f1c663',
end: boss.chargeEnd,
id: `${boss.id}:charge-lane`,
mechanicId: 'bulldrome-charge',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chargeWindup',
boss.attackPhase === 'charging',
),
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover') {
indicators.push(createCircleIndicator({
color: '#ff7f50',
id: `${boss.id}:slam-circle`,
mechanicId: 'bulldrome-slam',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'slamWindup',
boss.attackPhase === 'slamRecover',
),
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
sourceId: boss.id,
}))
}
return indicators
}
+46
View File
@@ -0,0 +1,46 @@
import type { Iwt2ArenaBounds, Iwt2Circle, Iwt2Vec2 } from './types'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export function circlesOverlap(a: Iwt2Circle, b: Iwt2Circle): boolean {
const radiusSum = a.radius + b.radius
return distanceVec2(a.position, b.position) <= radiusSum
}
export function distanceToSegment(point: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): number {
const segment = subtractVec2(end, start)
const segmentLengthSq = lengthSqVec2(segment)
if (segmentLengthSq <= 0.0001) return distanceVec2(point, start)
const pointOffset = subtractVec2(point, start)
const t = clamp(dotVec2(pointOffset, segment) / segmentLengthSq, 0, 1)
const projection = addVec2(start, scaleVec2(segment, t))
return distanceVec2(point, projection)
}
export function circleIntersectsSegment(
circle: Iwt2Circle,
start: Iwt2Vec2,
end: Iwt2Vec2,
width: number,
): boolean {
return distanceToSegment(circle.position, start, end) <= circle.radius + width
}
export function separateCircles(moving: Iwt2Circle, fixed: Iwt2Circle, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
const offset = subtractVec2(moving.position, fixed.position)
const minDistance = moving.radius + fixed.radius
const distanceSq = lengthSqVec2(offset)
if (distanceSq >= minDistance * minDistance) return moving.position
const direction = distanceSq <= 0.0001 ? { x: 1, y: 0 } : normalizeVec2(offset)
const resolved = addVec2(fixed.position, scaleVec2(direction, minDistance))
return clampVec2ToArena(resolved, moving.radius, bounds)
}
+162
View File
@@ -0,0 +1,162 @@
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
} from './mechanics'
const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
export function addFirePuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
if (overlappingIndex >= 0) {
return {
hazards: hazards.map((hazard, index) => index === overlappingIndex
? {
...hazard,
damage: Math.max(hazard.damage, damage),
position: {
x: (hazard.position.x + position.x) / 2,
y: (hazard.position.y + position.y) / 2,
},
radius: Math.max(hazard.radius, radius),
remainingSeconds: Math.max(hazard.remainingSeconds, duration),
}
: hazard),
nextHazardId,
}
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
nextHazardId: nextHazardId + 1,
}
}
export function tickGroundHazards({
dt,
hazards,
party,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
time: number
}): {
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextParty = party
const nextHazards: Iwt2GroundHazardState[] = []
for (const hazard of hazards) {
const remainingSeconds = hazard.remainingSeconds - dt
if (remainingSeconds <= 0) continue
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
nextHazards.push({
...hazard,
nextDamageAt,
remainingSeconds,
})
}
return {
events,
hazards: nextHazards,
party: nextParty,
}
}
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
sourceId: hazard.sourceId,
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
: hazard)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import type {
Iwt2ArenaEvent,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2PartyEntityState,
} from './types'
import { createArenaEvent } from './mechanics'
const HEALER_ID: Iwt2EntityId = 'player-healer'
const MAX_EVENTS = 80
const DEFAULT_HOT_SECONDS = 8
const DEFAULT_HOT_TICK_SECONDS = 1
export type Iwt2HealerCastResult<TState extends Iwt2ArenaState = Iwt2ArenaState> = {
cast: boolean
state: TState
}
export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
state: TState,
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2HealerCastResult<TState> {
if (state.boss.health <= 0) return { cast: false, state }
const healer = state.party.find((member) => member.id === HEALER_ID)
if (!healer || healer.health <= 0 || healer.mana < ability.manaCost) return { cast: false, state }
const targetIds = targetIdsForAbility(state.party, ability, selectedTargetId)
if (targetIds.length === 0) return { cast: false, state }
const events: Iwt2ArenaEvent[] = []
let eventId = state.nextEventId
const targetIdSet = new Set(targetIds)
const nextParty = state.party.map((member) => {
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
if (!targetIdSet.has(member.id)) return { ...member, mana }
const nextMember = applyAbilityToMember(member, ability)
const healed = Math.max(0, nextMember.health - member.health)
if (healed > 0) {
events.push(createArenaEvent(eventId, state.time, 'partyHealed', HEALER_ID, member.id, healed))
eventId += 1
}
return { ...nextMember, mana }
})
return {
cast: true,
state: {
...state,
party: nextParty,
nextEventId: eventId,
events: [...state.events, ...events].slice(-MAX_EVENTS),
} as TState,
}
}
function targetIdsForAbility(
party: Iwt2PartyEntityState[],
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2EntityId[] {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return []
if (ability.kind === 'group') {
return [...living]
.sort((a, b) => healthRatio(a) - healthRatio(b))
.slice(0, 4)
.map((member) => member.id)
}
const selected = living.find((member) => member.id === selectedTargetId)
return [selected?.id ?? living[0].id]
}
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
if (ability.kind === 'hot') {
return {
...healMember(member, ability.power),
hotEffects: [
...member.hotEffects.filter((effect) => effect.id !== ability.id),
{
id: ability.id,
label: ability.name,
power: Math.max(1, Math.round(ability.power * 0.5)),
remainingSeconds: DEFAULT_HOT_SECONDS,
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
},
],
}
}
if (ability.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, ability.power),
}
}
if (ability.kind === 'cleanse') {
return {
...healMember(member, ability.power),
status: {
...member.status,
knockedDownSeconds: 0,
stunnedSeconds: 0,
},
}
}
return healMember(member, ability.power)
}
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
return {
...member,
health: Math.min(member.maxHealth, member.health + amount),
}
}
function healthRatio(member: Iwt2PartyEntityState) {
return member.health / member.maxHealth
}
+67
View File
@@ -0,0 +1,67 @@
export {
createInitialIwt2ArenaState,
tickIwt2Arena,
} from './arena'
export {
circlesOverlap,
circleIntersectsSegment,
distanceToSegment,
separateCircles,
} from './collision'
export {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createConeIndicator,
createDonutIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
isIndicatorActive,
partyMemberIntersectsShape,
} from './mechanics'
export {
castIwt2HealerAbility,
} from './healing'
export {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
lengthVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type {
Iwt2ArenaBounds,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2BossAttackPhase,
Iwt2BossEntityState,
Iwt2Circle,
Iwt2EntityId,
Iwt2EntityKind,
Iwt2GroundHazardState,
Iwt2HotEffectState,
Iwt2HostileAddAttackPhase,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
+308
View File
@@ -0,0 +1,308 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaLaneIndicator,
Iwt2EntityId,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { circleIntersectsSegment } from './collision'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type Iwt2LinearMovementPlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
}
export type Iwt2PartyDamageEffect = {
sourceId: Iwt2EntityId
time: number
damage: number
damageEventType?: Iwt2ArenaEventType
stunSeconds?: number
knockdownSeconds?: number
statusEventType?: Iwt2ArenaEventType
excludedEntityIds?: Iwt2EntityId[]
}
export type Iwt2LaneHitShape = {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2CircleHitShape = {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape
export function createArenaEvent(
id: number,
time: number,
type: Iwt2ArenaEventType,
sourceId: Iwt2EntityId,
targetId?: Iwt2EntityId,
value?: number,
): Iwt2ArenaEvent {
return { id, time, type, sourceId, targetId, value }
}
export function createLinearMovementPlan({
bounds,
from,
length,
radius,
target,
}: {
bounds: Iwt2ArenaBounds
from: Iwt2Vec2
length: number
radius: number
target: Iwt2Vec2
}): Iwt2LinearMovementPlan {
const direction = normalizeVec2(subtractVec2(target, from))
const rawEnd = addVec2(from, scaleVec2(direction, length))
return {
direction,
end: clampVec2ToArena(rawEnd, radius, bounds),
}
}
export function applyPartyDamageInShape(
party: Iwt2PartyEntityState[],
shape: Iwt2HitShape,
effect: Iwt2PartyDamageEffect,
): {
party: Iwt2PartyEntityState[]
hitEntityIds: Iwt2EntityId[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const excluded = new Set(effect.excludedEntityIds ?? [])
const hitEntityIds: Iwt2EntityId[] = []
const stunSeconds = effect.stunSeconds ?? 0
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
const nextParty = party.map((member) => {
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
return member
}
hitEntityIds.push(member.id)
const shieldAbsorbed = Math.min(member.shield, effect.damage)
const healthDamage = effect.damage - shieldAbsorbed
const nextShield = member.shield - shieldAbsorbed
const nextHealth = Math.max(0, member.health - healthDamage)
events.push(createArenaEvent(
0,
effect.time,
effect.damageEventType ?? 'partyDamaged',
effect.sourceId,
member.id,
effect.damage,
))
if (stunSeconds > 0 || knockdownSeconds > 0) {
events.push(createArenaEvent(
0,
effect.time,
effect.statusEventType ?? 'partyStunned',
effect.sourceId,
member.id,
Math.max(stunSeconds, knockdownSeconds),
))
}
if (member.health > 0 && nextHealth <= 0) {
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
}
return {
...member,
health: nextHealth,
shield: nextShield,
status: {
...member.status,
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
knockedDownSeconds: Math.max(member.status.knockedDownSeconds, knockdownSeconds),
},
}
})
return { party: nextParty, hitEntityIds, events }
}
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
if (shape.kind === 'lane') {
return circleIntersectsSegment(
{ position: member.position, radius: member.radius },
shape.start,
shape.end,
shape.width,
)
}
return distanceVec2(member.position, shape.position) <= shape.radius + member.radius
}
export function createLaneIndicator({
color,
end,
id,
mechanicId,
phase,
sourceId,
start,
width,
}: {
color: string
end: Iwt2Vec2
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
sourceId: Iwt2EntityId
start: Iwt2Vec2
width: number
}): Iwt2ArenaLaneIndicator {
return {
color,
end: { ...end },
fillAlpha: phase === 'active' ? 0.18 : 0.11,
id,
kind: 'lane',
lineAlpha: phase === 'active' ? 0.8 : 0.45,
mechanicId,
phase,
sourceId,
start: { ...start },
width,
}
}
export function createCircleIndicator({
color,
id,
mechanicId,
phase,
position,
radius,
sourceId,
}: {
color: string
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
}): Iwt2ArenaCircleIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.18 : 0.12,
id,
kind: 'circle',
lineAlpha: phase === 'active' ? 0.75 : 0.55,
mechanicId,
phase,
position: { ...position },
radius,
sourceId,
}
}
export function createConeIndicator({
angleRadians,
color,
direction,
id,
mechanicId,
origin,
phase,
range,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
mechanicId: string
origin: Iwt2Vec2
phase: Iwt2ArenaIndicatorPhase
range: number
sourceId: Iwt2EntityId
}): Iwt2ArenaConeIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.18 : 0.1,
id,
kind: 'cone',
lineAlpha: phase === 'active' ? 0.72 : 0.48,
mechanicId,
origin: { ...origin },
phase,
range,
sourceId,
}
}
export function createDonutIndicator({
color,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
color: string
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaDonutIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.14 : 0.08,
id,
innerRadius,
kind: 'donut',
lineAlpha: phase === 'active' ? 0.76 : 0.5,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
): Iwt2ArenaIndicatorPhase {
if (active) return 'active'
if (windup) return 'windup'
return 'recover'
}
export function isIndicatorActive(indicator: Iwt2ArenaIndicator): boolean {
return indicator.phase === 'active'
}
+230
View File
@@ -0,0 +1,230 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickPartyMember(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
if (member.health <= 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
y: member.position.y + velocity.y * dt,
},
member.radius,
state.bounds,
)
return {
...member,
position,
velocity,
facing: withFallbackFacing(velocity, member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
}
}
const dangerDestination = getDangerAvoidancePosition(member, state)
const attackTarget = getPriorityAttackTarget(member, state)
const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0
&& !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
return {
...member,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
status,
}
}
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
return {
...member,
position,
velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
decisionSecondsRemaining: decisionSecondsRemaining <= 0
? nextDecisionInterval(member)
: decisionSecondsRemaining,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
}
function getPartyDesiredPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position
return {
x: anchor.x + member.preferredOffset.x + drift.x,
y: anchor.y + member.preferredOffset.y + drift.y,
}
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
}
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
}
}
return null
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
for (const hazard of state.hazards) {
const offset = subtractVec2(member.position, hazard.position)
const distance = distanceVec2(member.position, hazard.position)
const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
const urgency = dangerRadius - distance
escape = addVec2(escape, scaleVec2(away, urgency))
maxNeededDistance = Math.max(maxNeededDistance, urgency)
}
if (maxNeededDistance <= 0) return null
const direction = withFallbackFacing(escape, {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
)
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = state.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])
}
return state.boss.health > 0 ? state.boss : undefined
}
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
const boss = state.boss
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment)
const offset = subtractVec2(position, boss.chargeStart)
const along = dotVec2(offset, segment) / lengthSq
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
const distance = distanceVec2(position, closest)
const width = boss.radius + 22
return {
direction,
inside: along >= -0.08 && along <= 1.08 && distance <= width,
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
}
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17
return {
x: Math.sin(time * 0.7 + seed) * 16,
y: Math.cos(time * 0.9 + seed) * 14,
}
}
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
if (member.aiRole === 'tank') return 0.34
if (member.aiRole === 'ranged') return 0.48
if (member.aiRole === 'flanker') return 0.28
return 0.38
}
+254
View File
@@ -0,0 +1,254 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
export type Iwt2Vec2 = {
x: number
y: number
}
export type Iwt2ArenaBounds = {
width: number
height: number
}
export type Iwt2PartyEntityId =
| 'player-healer'
| 'paladin-tank'
| 'ranger'
| 'mage'
| 'rogue'
| 'warrior'
export type Iwt2BossEntityId = Iwt2BossId
export type Iwt2EntityId = Iwt2PartyEntityId | Iwt2BossEntityId | string
export type Iwt2EntityKind = 'party' | 'boss' | 'hostileAdd'
export type Iwt2PartyAiRole = 'player' | 'tank' | 'ranged' | 'flanker' | 'melee'
export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
}
export type Iwt2HotEffectState = {
id: string
label: string
power: number
remainingSeconds: number
tickIntervalSeconds: number
nextTickInSeconds: number
}
export type Iwt2PartyEntityState = {
id: Iwt2PartyEntityId
kind: 'party'
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
shield: number
mana: number
maxMana: number
damageDone: number
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
status: Iwt2StatusState
hotEffects: Iwt2HotEffectState[]
preferredOffset: Iwt2Vec2
decisionSecondsRemaining: number
}
export type Iwt2ProjectileEntityState = {
id: string
sourceId: Iwt2EntityId
owner: 'party' | 'boss'
classId?: Iwt2PlayerClassId
projectileKind: 'arrow' | 'fireball' | 'magic'
color: string
position: Iwt2Vec2
velocity: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
bouncesRemaining?: number
bounceCooldownSeconds?: number
}
export type Iwt2BossAttackPhase =
| 'idle'
| 'chargeWindup'
| 'charging'
| 'chargeRecover'
| 'slamWindup'
| 'slamRecover'
| 'relocating'
| 'fireballWindup'
| 'fireballRecover'
| 'birdSummonWindup'
| 'birdSummonRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
| 'flightWindup'
| 'flying'
| 'flightRecover'
export type Iwt2BossEntityState = {
id: Iwt2BossEntityId
kind: 'boss'
bossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
attackPhase: Iwt2BossAttackPhase
phaseSecondsRemaining: number
chargeStart: Iwt2Vec2
chargeEnd: Iwt2Vec2
chargeHitEntityIds: Iwt2EntityId[]
slamApplied: boolean
wallContactSeconds: number
relocateTarget: Iwt2Vec2
fireballCooldownRemaining: number
fireballTarget: Iwt2Vec2
birdWaveThresholdsTriggered: number[]
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
addKind: 'yian-bird'
sourceBossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
damageDone: number
attackCooldownRemaining: number
flightCooldownRemaining: number
attackPhase: Iwt2HostileAddAttackPhase
phaseSecondsRemaining: number
flightStart: Iwt2Vec2
flightEnd: Iwt2Vec2
flightHitEntityIds: Iwt2EntityId[]
}
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
fadeSeconds: number
nextDamageAt: number
}
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
| 'bossChargeStart'
| 'bossChargeHit'
| 'bossSlam'
| 'entityDefeated'
export type Iwt2ArenaEvent = {
id: number
time: number
type: Iwt2ArenaEventType
sourceId: Iwt2EntityId
targetId?: Iwt2EntityId
value?: number
}
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
export type Iwt2ArenaIndicatorBase = {
id: string
mechanicId: string
sourceId: Iwt2EntityId
phase: Iwt2ArenaIndicatorPhase
color: string
fillAlpha?: number
lineAlpha?: number
}
export type Iwt2ArenaLaneIndicator = Iwt2ArenaIndicatorBase & {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2ArenaCircleIndicator = Iwt2ArenaIndicatorBase & {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2ArenaConeIndicator = Iwt2ArenaIndicatorBase & {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
kind: 'donut'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
time: number
bounds: Iwt2ArenaBounds
party: Iwt2PartyEntityState[]
projectiles: Iwt2ProjectileEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState
indicators: Iwt2ArenaIndicator[]
nextEventId: number
nextProjectileId: number
nextAddId: number
nextHazardId: number
events: Iwt2ArenaEvent[]
}
export type Iwt2ArenaInput = {
moveX: number
moveY: number
}
export type Iwt2Circle = {
position: Iwt2Vec2
radius: number
}
+60
View File
@@ -0,0 +1,60 @@
import type { Iwt2ArenaBounds, Iwt2Vec2 } from './types'
export const IWT2_ZERO_VECTOR: Iwt2Vec2 = { x: 0, y: 0 }
export function addVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x + b.x, y: a.y + b.y }
}
export function subtractVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x - b.x, y: a.y - b.y }
}
export function scaleVec2(vector: Iwt2Vec2, scale: number): Iwt2Vec2 {
return { x: vector.x * scale, y: vector.y * scale }
}
export function dotVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return a.x * b.x + a.y * b.y
}
export function lengthSqVec2(vector: Iwt2Vec2): number {
return dotVec2(vector, vector)
}
export function lengthVec2(vector: Iwt2Vec2): number {
return Math.sqrt(lengthSqVec2(vector))
}
export function distanceVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return lengthVec2(subtractVec2(a, b))
}
export function normalizeVec2(vector: Iwt2Vec2): Iwt2Vec2 {
const length = lengthVec2(vector)
if (length <= 0.0001) return { ...IWT2_ZERO_VECTOR }
return { x: vector.x / length, y: vector.y / length }
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function clampVec2ToArena(position: Iwt2Vec2, radius: number, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: clamp(position.x, radius, bounds.width - radius),
y: clamp(position.y, radius, bounds.height - radius),
}
}
export function moveToward(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = lengthVec2(offset)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
export function withFallbackFacing(nextFacing: Iwt2Vec2, fallback: Iwt2Vec2): Iwt2Vec2 {
if (lengthSqVec2(nextFacing) <= 0.0001) return { ...fallback }
return normalizeVec2(nextFacing)
}
+707
View File
@@ -0,0 +1,707 @@
import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
partyMemberIntersectsShape,
} from './mechanics'
import { addFirePuddle } from './hazards'
import {
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2YianTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId: number
nextHazardId: number
nextProjectileId: number
}
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
const YIAN_CENTER_CHARGE_SPEED = 430
export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let party = state.party
let hazards = state.hazards
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
let nextProjectileId = state.nextProjectileId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const birdResult = tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = birdResult.party
hazards = birdResult.hazards
nextHazardId = birdResult.nextHazardId
events.push(...birdResult.events)
let hostileAdds = birdResult.hostileAdds
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_SPEED * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing)
projectiles.push({
id: `boss-projectile-${nextProjectileId}`,
sourceId: boss.id,
owner: 'boss',
projectileKind: 'fireball',
color: '#ff8b2b',
position: {
x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
},
velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!),
radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!,
damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!,
remainingSeconds: 7,
bouncesRemaining: YIAN_FIREBALL_BOUNCES,
})
nextProjectileId += 1
boss = {
...boss,
attackPhase: 'fireballRecover',
phaseSecondsRemaining: 0.35,
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (shouldYianRelocate(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextThreshold = nextBirdWaveThreshold(boss)
if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) {
const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold)
hostileAdds = [...hostileAdds, ...spawn.hostileAdds]
nextAddId = spawn.nextAddId
boss = {
...boss,
attackPhase: 'birdSummonRecover',
birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold],
phaseSecondsRemaining: 0.55,
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.fireballCooldownRemaining <= 0) {
if (!isYianAtCenter(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
boss = {
...boss,
attackPhase: 'fireballWindup',
fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!,
fireballTarget: { ...target.position },
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
boss = meleeResult.boss
events.push(...meleeResult.events)
if (meleeResult.events.length > 0) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextPosition = clampVec2ToArena(
moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
function shouldYianRelocate(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): boolean {
return (
boss.position.x < YIAN_SAFE_WALL_MARGIN
|| boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN
|| boss.position.y < YIAN_SAFE_WALL_MARGIN
|| boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN
)
}
function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE
}
function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
return clampVec2ToArena({
x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190),
y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96),
}, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds)
}
function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
hostileAdds: Iwt2HostileAddState[]
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const hostileAdds: Iwt2HostileAddState[] = []
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
for (const add of state.hostileAdds) {
if (add.health <= 0) continue
let nextAdd = {
...add,
attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt),
flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt),
}
if (nextAdd.attackPhase === 'flightWindup') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = {
...nextAdd,
attackPhase: 'flying',
position: { ...nextAdd.flightStart },
velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!),
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flying') {
const previousPosition = nextAdd.position
const nextPosition = moveToward(
nextAdd.position,
nextAdd.flightEnd,
YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt,
)
const shape = {
kind: 'lane' as const,
start: previousPosition,
end: nextPosition,
width: nextAdd.radius,
}
const hitMembers = nextParty.filter((member) => (
!nextAdd.flightHitEntityIds.includes(member.id)
&& partyMemberIntersectsShape(member, shape)
))
const hitResult = applyPartyDamageInShape(nextParty, shape, {
damage: BIRD_FLIGHT_DAMAGE,
excludedEntityIds: nextAdd.flightHitEntityIds,
knockdownSeconds: 0,
sourceId: nextAdd.id,
stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
for (const member of hitMembers) {
const puddle = addFirePuddle({
damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!,
duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: member.position,
radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!,
sourceId: nextAdd.id,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
nextAdd = {
...nextAdd,
flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds],
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0),
facing: { x: 1, y: 0 },
}
if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) {
nextAdd = {
...nextAdd,
attackPhase: 'flightRecover',
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
phaseSecondsRemaining: 0.4,
velocity: { x: 0, y: 0 },
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flightRecover') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.flightCooldownRemaining <= 0) {
hostileAdds.push(startBirdFlight(nextAdd, state, time))
continue
}
const target = getNearestPartyMember(nextParty, nextAdd.position)
if (!target) {
hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } })
continue
}
const nextPosition = clampVec2ToArena(
moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt),
nextAdd.radius,
state.bounds,
)
nextAdd = {
...nextAdd,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing),
}
if (
nextAdd.attackCooldownRemaining <= 0
&& distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE
) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: nextAdd.position,
radius: nextAdd.radius + BIRD_MELEE_RANGE,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!,
sourceId: nextAdd.id,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN }
}
hostileAdds.push(nextAdd)
}
return {
events,
hazards: nextHazards,
hostileAdds,
nextHazardId: nextHazardIdValue,
party: nextParty,
}
}
function spawnBirdWave(
state: Iwt2ArenaState,
nextAddId: number,
sourceBossId: Iwt2BossId,
threshold: number,
): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } {
const hostileAdds: Iwt2HostileAddState[] = []
let nextId = nextAddId
for (let index = 0; index < BIRD_COUNT; index += 1) {
const y = birdLaneY(state, index, threshold * 100)
const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius!
const flightStart = { x: -radius - 8, y }
const flightEnd = { x: state.bounds.width + radius + 8, y }
hostileAdds.push({
id: `yian-bird-${nextId}`,
kind: 'hostileAdd',
addKind: 'yian-bird',
sourceBossId,
position: { ...flightStart },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius,
health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
damageDone: 0,
attackCooldownRemaining: 0,
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
})
nextId += 1
}
return { hostileAdds, nextAddId: nextId }
}
function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState {
const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time)
const flightStart = { x: -add.radius - 8, y }
const flightEnd = { x: state.bounds.width + add.radius + 8, y }
return {
...add,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
velocity: { x: 0, y: 0 },
}
}
function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number {
const laneHeight = state.bounds.height / (BIRD_COUNT + 1)
const base = laneHeight * (index + 1)
const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34
return clamp(base + wobble, 58, state.bounds.height - 58)
}
function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined {
return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds
?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold))
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined {
let nearest: Iwt2PartyEntityState | undefined
let nearestDistance = Number.POSITIVE_INFINITY
for (const member of party) {
if (member.health <= 0) continue
const distance = distanceVec2(member.position, position)
if (distance < nearestDistance) {
nearest = member
nearestDistance = distance
}
}
return nearest
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withYianIndicators(result: Omit<Iwt2YianTickResult, 'indicators'>): Iwt2YianTickResult {
return {
...result,
indicators: createYianIndicators(result.boss, result.hostileAdds),
}
}
function createYianIndicators(
boss: Iwt2BossEntityState,
hostileAdds: Iwt2HostileAddState[],
): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'relocating') {
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: boss.chargeEnd,
id: `${boss.id}:center-charge`,
mechanicId: 'yian-center-charge',
phase: 'active',
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'fireballWindup') {
indicators.push(createCircleIndicator({
color: '#ff8b2b',
id: `${boss.id}:fireball-cast`,
mechanicId: 'yian-fireball-cast',
phase: 'windup',
position: boss.position,
radius: boss.radius + 18,
sourceId: boss.id,
}))
indicators.push(createLaneIndicator({
color: '#ffb347',
end: boss.fireballTarget,
id: `${boss.id}:fireball-line`,
mechanicId: 'yian-fireball-line',
phase: 'windup',
sourceId: boss.id,
start: boss.position,
width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4,
}))
}
for (const add of hostileAdds) {
if (
add.attackPhase !== 'flightWindup'
&& add.attackPhase !== 'flying'
&& add.attackPhase !== 'flightRecover'
) {
continue
}
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: add.flightEnd,
id: `${add.id}:flight-lane`,
mechanicId: 'yian-bird-flight',
phase: indicatorPhaseFromAttack(
add.attackPhase === 'flightWindup',
add.attackPhase === 'flying',
),
sourceId: add.id,
start: add.flightStart,
width: add.radius,
}))
}
return indicators
}