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