Android build v1.1.15

This commit is contained in:
Warren H
2026-07-04 12:43:59 -04:00
parent a849cd2c69
commit 99e97e5208
47 changed files with 8470 additions and 1610 deletions
+230
View File
@@ -0,0 +1,230 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickPartyMember(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
if (member.health <= 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
y: member.position.y + velocity.y * dt,
},
member.radius,
state.bounds,
)
return {
...member,
position,
velocity,
facing: withFallbackFacing(velocity, member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
}
}
const dangerDestination = getDangerAvoidancePosition(member, state)
const attackTarget = getPriorityAttackTarget(member, state)
const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0
&& !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
return {
...member,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
status,
}
}
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
return {
...member,
position,
velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
decisionSecondsRemaining: decisionSecondsRemaining <= 0
? nextDecisionInterval(member)
: decisionSecondsRemaining,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
}
function getPartyDesiredPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position
return {
x: anchor.x + member.preferredOffset.x + drift.x,
y: anchor.y + member.preferredOffset.y + drift.y,
}
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
}
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
}
}
return null
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
for (const hazard of state.hazards) {
const offset = subtractVec2(member.position, hazard.position)
const distance = distanceVec2(member.position, hazard.position)
const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
const urgency = dangerRadius - distance
escape = addVec2(escape, scaleVec2(away, urgency))
maxNeededDistance = Math.max(maxNeededDistance, urgency)
}
if (maxNeededDistance <= 0) return null
const direction = withFallbackFacing(escape, {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
)
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = state.hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return state.boss.health > 0 ? state.boss : undefined
}
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
const boss = state.boss
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment)
const offset = subtractVec2(position, boss.chargeStart)
const along = dotVec2(offset, segment) / lengthSq
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
const distance = distanceVec2(position, closest)
const width = boss.radius + 22
return {
direction,
inside: along >= -0.08 && along <= 1.08 && distance <= width,
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
}
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17
return {
x: Math.sin(time * 0.7 + seed) * 16,
y: Math.cos(time * 0.9 + seed) * 14,
}
}
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
if (member.aiRole === 'tank') return 0.34
if (member.aiRole === 'ranged') return 0.48
if (member.aiRole === 'flanker') return 0.28
return 0.38
}