Android build v1.1.22
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { TOBI_KADACHI_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2MechanicLinkState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createLaneIndicator,
|
||||
createLinearMovementPlan,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickTobiKadachi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let party = state.party
|
||||
let boss = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
mechanicEnergy: Math.min(
|
||||
state.boss.mechanicEnergyMax,
|
||||
state.boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerSecond! * dt,
|
||||
),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withTobiIndicators({ boss, party, events })
|
||||
|
||||
if (boss.attackPhase === 'staticPounceWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'staticPouncing' }
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'staticPouncing') {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.chargeEnd, TOBI_KADACHI_BOSS_METADATA.staticPounceSpeed! * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
const hitResult = applyPounceHits(party, boss, boss.position, nextPosition, state.time + dt)
|
||||
party = hitResult.party
|
||||
events.push(...hitResult.events)
|
||||
boss = {
|
||||
...boss,
|
||||
chargeHitEntityIds: hitResult.hitEntityIds,
|
||||
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
}
|
||||
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'staticPounceRecover',
|
||||
phaseSecondsRemaining: 0.42,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'chainShockWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyChainShock(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'chainShockRecover',
|
||||
phaseSecondsRemaining: 0.42,
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'staticPounceRecover' || boss.attackPhase === 'chainShockRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicLinks: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy >= boss.mechanicEnergyMax && boss.chargeCooldownRemaining <= 0) {
|
||||
const pounce = createLinearMovementPlan({
|
||||
bounds: state.bounds,
|
||||
from: boss.position,
|
||||
length: TOBI_KADACHI_BOSS_METADATA.staticPounceLength!,
|
||||
radius: boss.radius,
|
||||
target: target.position,
|
||||
})
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'staticPounceWindup',
|
||||
chargeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chargeCooldown,
|
||||
chargeEnd: pounce.end,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: { ...boss.position },
|
||||
facing: pounce.direction,
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.staticPounceWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
const links = createChainLinks(party)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'chainShockWindup',
|
||||
fireballCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockCooldown!,
|
||||
mechanicLinks: links,
|
||||
phaseSecondsRemaining: TOBI_KADACHI_BOSS_METADATA.chainShockWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withTobiIndicators({ boss, party, events })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, TOBI_KADACHI_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 withTobiIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function applyPounceHits(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
start: Iwt2BossEntityState['position'],
|
||||
end: Iwt2BossEntityState['position'],
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'lane',
|
||||
start,
|
||||
end,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.staticPounceDamage!,
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: TOBI_KADACHI_BOSS_METADATA.staticPounceStunSeconds!,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function createChainLinks(party: Iwt2PartyEntityState[]): Iwt2MechanicLinkState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const links: Iwt2MechanicLinkState[] = []
|
||||
for (let a = 0; a < living.length; a += 1) {
|
||||
for (let b = a + 1; b < living.length; b += 1) {
|
||||
if (distanceVec2(living[a].position, living[b].position) > TOBI_KADACHI_BOSS_METADATA.chainShockRadius!) continue
|
||||
links.push({
|
||||
id: `chain-${living[a].id}-${living[b].id}`,
|
||||
start: { ...living[a].position },
|
||||
end: { ...living[b].position },
|
||||
width: 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
return links.slice(0, 6)
|
||||
}
|
||||
|
||||
function applyChainShock(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hitEntityIds: Iwt2EntityId[] = []
|
||||
for (const link of boss.mechanicLinks) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'lane',
|
||||
start: link.start,
|
||||
end: link.end,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.chainShockRadius! * 0.12,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.chainShockDamage!,
|
||||
excludedEntityIds: hitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: TOBI_KADACHI_BOSS_METADATA.chainShockStunSeconds!,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
hitEntityIds.push(...result.hitEntityIds)
|
||||
events.push(...result.events)
|
||||
}
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
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,
|
||||
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > TOBI_KADACHI_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: TOBI_KADACHI_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: TOBI_KADACHI_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: {
|
||||
...boss,
|
||||
mechanicEnergy: Math.min(
|
||||
boss.mechanicEnergyMax,
|
||||
boss.mechanicEnergy + TOBI_KADACHI_BOSS_METADATA.staticEnergyPerMelee!,
|
||||
),
|
||||
meleeCooldownRemaining: TOBI_KADACHI_BOSS_METADATA.meleeCooldown,
|
||||
},
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withTobiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createTobiIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createTobiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (
|
||||
boss.attackPhase === 'staticPounceWindup'
|
||||
|| boss.attackPhase === 'staticPouncing'
|
||||
|| boss.attackPhase === 'staticPounceRecover'
|
||||
) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: boss.attackPhase === 'staticPouncing' ? '#a8f3ff' : '#6bdcff',
|
||||
end: boss.chargeEnd,
|
||||
id: `${boss.id}:static-pounce`,
|
||||
mechanicId: 'tobi-static-pounce',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'staticPounceWindup',
|
||||
boss.attackPhase === 'staticPouncing',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: boss.chargeStart,
|
||||
width: TOBI_KADACHI_BOSS_METADATA.staticPounceWidth!,
|
||||
}))
|
||||
}
|
||||
if (boss.attackPhase === 'chainShockWindup' || boss.attackPhase === 'chainShockRecover') {
|
||||
for (const link of boss.mechanicLinks) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#a8f3ff',
|
||||
end: link.end,
|
||||
id: `${boss.id}:${link.id}`,
|
||||
mechanicId: 'tobi-chain-shock',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'chainShockWindup',
|
||||
boss.attackPhase === 'chainShockRecover',
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: link.start,
|
||||
width: link.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
Reference in New Issue
Block a user