Android build v1.1.15
This commit is contained in:
@@ -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 }))
|
||||
}
|
||||
@@ -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 []
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user