Android build v1.1.22
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2MechanicLaneState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { addMudPuddle } from './hazards'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createArenaEvent,
|
||||
createCircleIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const OBSIDIAN_RAM_PHASE = {
|
||||
armorShatterRecover: 'obsidianRamArmorShatterRecover',
|
||||
armorShatterWindup: 'obsidianRamArmorShatterWindup',
|
||||
fractureQuakeRecover: 'obsidianRamFractureQuakeRecover',
|
||||
fractureQuakeWindup: 'obsidianRamFractureQuakeWindup',
|
||||
plateChargeRecover: 'obsidianRamPlateChargeRecover',
|
||||
plateChargeWindup: 'obsidianRamPlateChargeWindup',
|
||||
plateCharging: 'obsidianRamPlateCharging',
|
||||
} as const
|
||||
|
||||
type ObsidianRamPhase = typeof OBSIDIAN_RAM_PHASE[keyof typeof OBSIDIAN_RAM_PHASE]
|
||||
|
||||
const OBSIDIAN_RAM = {
|
||||
armorCracksBeforeShatter: 3,
|
||||
centerDisengageSpeed: 118,
|
||||
chargeCooldown: 4.4,
|
||||
chargeDamage: 8,
|
||||
chargeLength: 340,
|
||||
chargeRecover: 0.5,
|
||||
chargeSpeed: 390,
|
||||
chargeStunSeconds: 0.55,
|
||||
chargeWidth: 54,
|
||||
chargeWindup: 0.68,
|
||||
fractureLaneCount: 5,
|
||||
fractureLaneDamage: 6,
|
||||
fractureLaneLength: 300,
|
||||
fractureLaneWidth: 30,
|
||||
meleeCooldown: 1.3,
|
||||
meleeDamage: 2,
|
||||
meleeRange: 62,
|
||||
moveSpeed: 82,
|
||||
quakeCooldown: 7.4,
|
||||
quakeRadius: 126,
|
||||
quakeRecover: 0.48,
|
||||
quakeStunSeconds: 0.35,
|
||||
quakeWindup: 0.78,
|
||||
slabDamage: 10,
|
||||
slabDuration: 5.8,
|
||||
slabRadius: 42,
|
||||
slabSlowRadius: 46,
|
||||
shatterRecover: 0.8,
|
||||
shatterWindup: 0.9,
|
||||
wallContactLimit: 0.72,
|
||||
wallMargin: 72,
|
||||
} as const
|
||||
|
||||
export function tickObsidianRamGolem(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let hazards = state.hazards
|
||||
let nextHazardId = state.nextHazardId
|
||||
let party = state.party
|
||||
const incomingPhase = obsidianRamPhase(state.boss)
|
||||
let boss = {
|
||||
...state.boss,
|
||||
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
wallContactSeconds: isLockedPhase(incomingPhase)
|
||||
? 0
|
||||
: isBossNearWall(state.boss, state)
|
||||
? state.boss.wallContactSeconds + dt
|
||||
: Math.max(0, state.boss.wallContactSeconds - dt * 2),
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
const phase = obsidianRamPhase(boss)
|
||||
if (phase === 'relocating') {
|
||||
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
|
||||
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.1),
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup) {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateCharging),
|
||||
chargeHitEntityIds: [],
|
||||
phaseSecondsRemaining: 1.25,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateCharging) {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.chargeEnd, OBSIDIAN_RAM.chargeSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
const hitResult = applyChargeHits(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) <= 2 || boss.phaseSecondsRemaining <= 0) {
|
||||
const crackedArmor = clamp(boss.mechanicEnergy + 1, 0, OBSIDIAN_RAM.armorCracksBeforeShatter)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeRecover),
|
||||
mechanicEnergy: crackedArmor,
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.chargeRecover,
|
||||
velocity: { x: 0, y: 0 },
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter
|
||||
? beginArmorShatter(boss, state, party)
|
||||
: {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyFractureQuake(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeRecover),
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.quakeRecover,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup) {
|
||||
boss = { ...boss, velocity: { x: 0, y: 0 } }
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const slabResult = dropObsidianSlabs({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time: state.time + dt,
|
||||
})
|
||||
party = slabResult.party
|
||||
hazards = slabResult.hazards
|
||||
nextHazardId = slabResult.nextHazardId
|
||||
events.push(...slabResult.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterRecover),
|
||||
mechanicEnergy: 0,
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.shatterRecover,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.wallContactSeconds >= OBSIDIAN_RAM.wallContactLimit) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
mechanicCircles: [],
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: 1.3,
|
||||
relocateTarget: relocationTarget(boss, state),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.mechanicEnergy >= OBSIDIAN_RAM.armorCracksBeforeShatter) {
|
||||
boss = beginArmorShatter(boss, state, party)
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.fractureQuakeWindup),
|
||||
fireballCooldownRemaining: OBSIDIAN_RAM.quakeCooldown,
|
||||
mechanicCircles: [{ id: 'obsidian-ram-quake-core', position: { ...boss.position }, radius: OBSIDIAN_RAM.quakeRadius }],
|
||||
mechanicLanes: createFractureWaveLanes(state, boss, target.position),
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.quakeWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = beginPlateCharge(boss, state, target)
|
||||
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
events.push(...meleeResult.events)
|
||||
boss = meleeResult.boss
|
||||
if (events.length > 0) return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
|
||||
boss = moveBossTowardEngagePoint(boss, state, target, dt)
|
||||
return withObsidianRamIndicators({ boss, party, hazards, events, nextHazardId })
|
||||
}
|
||||
|
||||
function beginPlateCharge(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
): Iwt2BossEntityState {
|
||||
const direction = wallSafeChargeDirection(boss, state, target.position)
|
||||
const chargeEnd = clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.chargeLength)),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.plateChargeWindup),
|
||||
chargeCooldownRemaining: OBSIDIAN_RAM.chargeCooldown,
|
||||
chargeEnd,
|
||||
chargeHitEntityIds: [],
|
||||
chargeStart: { ...boss.position },
|
||||
facing: direction,
|
||||
mechanicLanes: [{
|
||||
end: chargeEnd,
|
||||
id: 'obsidian-ram-plate-charge',
|
||||
start: { ...boss.position },
|
||||
width: OBSIDIAN_RAM.chargeWidth,
|
||||
}],
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.chargeWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function beginArmorShatter(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2BossEntityState {
|
||||
const slabCircles = createObsidianSlabCircles(state, boss, party)
|
||||
return {
|
||||
...boss,
|
||||
attackPhase: asBossPhase(OBSIDIAN_RAM_PHASE.armorShatterWindup),
|
||||
mechanicCircles: slabCircles,
|
||||
mechanicLanes: [],
|
||||
phaseSecondsRemaining: OBSIDIAN_RAM.shatterWindup,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function applyChargeHits(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
start: Iwt2Vec2,
|
||||
end: Iwt2Vec2,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
end,
|
||||
kind: 'lane',
|
||||
start,
|
||||
width: OBSIDIAN_RAM.chargeWidth,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.chargeDamage,
|
||||
damageEventType: 'bossChargeHit',
|
||||
excludedEntityIds: boss.chargeHitEntityIds,
|
||||
knockdownSeconds: OBSIDIAN_RAM.chargeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.chargeStunSeconds,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
|
||||
}
|
||||
}
|
||||
|
||||
function applyFractureQuake(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = [
|
||||
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, OBSIDIAN_RAM.quakeRadius),
|
||||
]
|
||||
const quakeResult = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: OBSIDIAN_RAM.quakeRadius,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.fractureLaneDamage,
|
||||
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = quakeResult.party
|
||||
events.push(...quakeResult.events)
|
||||
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
const laneResult = applyPartyDamageInShape(nextParty, {
|
||||
end: lane.end,
|
||||
kind: 'lane',
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.fractureLaneDamage,
|
||||
knockdownSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: OBSIDIAN_RAM.quakeStunSeconds,
|
||||
time,
|
||||
})
|
||||
nextParty = laneResult.party
|
||||
events.push(...laneResult.events)
|
||||
}
|
||||
|
||||
return { events, party: nextParty }
|
||||
}
|
||||
|
||||
function dropObsidianSlabs({
|
||||
boss,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time,
|
||||
}: {
|
||||
boss: Iwt2BossEntityState
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
state: Iwt2ArenaState
|
||||
time: number
|
||||
}): {
|
||||
events: Iwt2ArenaEvent[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
} {
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextId = nextHazardId
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
|
||||
for (const slab of boss.mechanicCircles) {
|
||||
const hitResult = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: slab.position,
|
||||
radius: slab.radius,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.slabDamage,
|
||||
knockdownSeconds: 0.22,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: 0.22,
|
||||
time,
|
||||
})
|
||||
nextParty = hitResult.party
|
||||
events.push(...hitResult.events)
|
||||
|
||||
const hazardResult = addMudPuddle({
|
||||
duration: OBSIDIAN_RAM.slabDuration,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextId,
|
||||
position: clampVec2ToArena(slab.position, OBSIDIAN_RAM.slabSlowRadius, state.bounds),
|
||||
radius: OBSIDIAN_RAM.slabSlowRadius,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = hazardResult.hazards
|
||||
nextId = hazardResult.nextHazardId
|
||||
}
|
||||
|
||||
return { events, hazards: nextHazards, nextHazardId: nextId, party: nextParty }
|
||||
}
|
||||
|
||||
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) > OBSIDIAN_RAM.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: OBSIDIAN_RAM.meleeRange,
|
||||
}, {
|
||||
damage: OBSIDIAN_RAM.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: OBSIDIAN_RAM.meleeCooldown, velocity: { x: 0, y: 0 } },
|
||||
events: result.events,
|
||||
party: result.party,
|
||||
}
|
||||
}
|
||||
|
||||
function createFractureWaveLanes(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
targetPosition: Iwt2Vec2,
|
||||
): Iwt2MechanicLaneState[] {
|
||||
const baseDirection = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
|
||||
const baseAngle = Math.atan2(baseDirection.y, baseDirection.x)
|
||||
const spread = Math.PI * 0.82
|
||||
const lanes: Iwt2MechanicLaneState[] = []
|
||||
const laneCount: number = OBSIDIAN_RAM.fractureLaneCount
|
||||
for (let index = 0; index < laneCount; index += 1) {
|
||||
const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1)
|
||||
const angle = baseAngle - spread * 0.5 + spread * ratio
|
||||
const direction = { x: Math.cos(angle), y: Math.sin(angle) }
|
||||
lanes.push({
|
||||
end: clampVec2ToArena(
|
||||
addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.fractureLaneLength)),
|
||||
OBSIDIAN_RAM.fractureLaneWidth,
|
||||
state.bounds,
|
||||
),
|
||||
id: `obsidian-ram-fracture-${index}`,
|
||||
start: { ...boss.position },
|
||||
width: OBSIDIAN_RAM.fractureLaneWidth,
|
||||
})
|
||||
}
|
||||
return lanes
|
||||
}
|
||||
|
||||
function createObsidianSlabCircles(
|
||||
state: Iwt2ArenaState,
|
||||
boss: Iwt2BossEntityState,
|
||||
party: Iwt2PartyEntityState[],
|
||||
): Iwt2MechanicCircleState[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const targetPositions = living
|
||||
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
|
||||
.slice(0, 3)
|
||||
.map((member) => member.position)
|
||||
const fallbackAngles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
|
||||
while (targetPositions.length < 3) {
|
||||
const angle = fallbackAngles[targetPositions.length]
|
||||
targetPositions.push(addVec2(boss.position, {
|
||||
x: Math.cos(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
|
||||
y: Math.sin(angle) * OBSIDIAN_RAM.quakeRadius * 1.05,
|
||||
}))
|
||||
}
|
||||
return targetPositions.map((position, index) => ({
|
||||
id: `obsidian-ram-slab-${index}`,
|
||||
position: clampVec2ToArena(position, OBSIDIAN_RAM.slabRadius, state.bounds),
|
||||
radius: OBSIDIAN_RAM.slabRadius,
|
||||
}))
|
||||
}
|
||||
|
||||
function moveBossTowardEngagePoint(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const wallBias = isBossNearWall(boss, state) ? normalizeVec2(subtractVec2(arenaCenter(state), boss.position)) : { x: 0, y: 0 }
|
||||
const targetBias = normalizeVec2(subtractVec2(target.position, boss.position))
|
||||
const direction = withFallbackFacing(addVec2(targetBias, scaleVec2(wallBias, 0.8)), targetBias)
|
||||
const desiredPosition = addVec2(boss.position, scaleVec2(direction, OBSIDIAN_RAM.moveSpeed * dt))
|
||||
const nextPosition = clampVec2ToArena(desiredPosition, boss.radius, state.bounds)
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
}
|
||||
}
|
||||
|
||||
function moveBossTowardRelocationTarget(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
target: Iwt2PartyEntityState,
|
||||
dt: number,
|
||||
): Iwt2BossEntityState {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, OBSIDIAN_RAM.centerDisengageSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
wallContactSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function wallSafeChargeDirection(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
targetPosition: Iwt2Vec2,
|
||||
): Iwt2Vec2 {
|
||||
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
|
||||
if (!isBossNearWall(boss, state)) return withFallbackFacing(targetBias, boss.facing)
|
||||
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
|
||||
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.35), scaleVec2(targetBias, 0.55)), centerBias)
|
||||
}
|
||||
|
||||
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
const center = arenaCenter(state)
|
||||
const inward = normalizeVec2(subtractVec2(center, boss.position))
|
||||
const offset = addVec2(scaleVec2(inward, 138), {
|
||||
x: boss.position.y < center.y ? 42 : -42,
|
||||
y: boss.position.x < center.x ? -34 : 34,
|
||||
})
|
||||
return clampVec2ToArena(addVec2(boss.position, offset), boss.radius, state.bounds)
|
||||
}
|
||||
|
||||
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + OBSIDIAN_RAM.wallMargin
|
||||
return (
|
||||
boss.position.x <= margin
|
||||
|| boss.position.x >= state.bounds.width - margin
|
||||
|| boss.position.y <= margin
|
||||
|| boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function withObsidianRamIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createObsidianRamIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createObsidianRamIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
const phase = obsidianRamPhase(boss)
|
||||
if (phase === OBSIDIAN_RAM_PHASE.plateChargeWindup || phase === OBSIDIAN_RAM_PHASE.plateCharging) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: phase === OBSIDIAN_RAM_PHASE.plateCharging ? '#ff7a2f' : '#f0b35a',
|
||||
end: boss.chargeEnd,
|
||||
id: `${boss.id}:obsidian-ram-plate-charge`,
|
||||
mechanicId: 'obsidian-ram-plate-charge',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.plateChargeWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.plateCharging,
|
||||
),
|
||||
sourceId: boss.id,
|
||||
start: boss.chargeStart,
|
||||
width: OBSIDIAN_RAM.chargeWidth,
|
||||
}))
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup || phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover) {
|
||||
const indicatorPhase = indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover,
|
||||
)
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#ff9f1c',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'obsidian-ram-quake-core',
|
||||
phase: indicatorPhase,
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
for (const lane of boss.mechanicLanes) {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#ffbf69',
|
||||
end: lane.end,
|
||||
id: `${boss.id}:${lane.id}`,
|
||||
mechanicId: 'obsidian-ram-fracture-wave',
|
||||
phase: indicatorPhase,
|
||||
sourceId: boss.id,
|
||||
start: lane.start,
|
||||
width: lane.width,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === OBSIDIAN_RAM_PHASE.armorShatterWindup || phase === OBSIDIAN_RAM_PHASE.armorShatterRecover) {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#b8b6c7',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'obsidian-ram-broken-slab',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
phase === OBSIDIAN_RAM_PHASE.armorShatterWindup,
|
||||
phase === OBSIDIAN_RAM_PHASE.armorShatterRecover,
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
|
||||
function isLockedPhase(phase: string): boolean {
|
||||
return phase === OBSIDIAN_RAM_PHASE.plateChargeWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.plateCharging
|
||||
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.fractureQuakeRecover
|
||||
|| phase === OBSIDIAN_RAM_PHASE.armorShatterWindup
|
||||
|| phase === OBSIDIAN_RAM_PHASE.armorShatterRecover
|
||||
}
|
||||
|
||||
function obsidianRamPhase(boss: Iwt2BossEntityState): string {
|
||||
return boss.attackPhase as string
|
||||
}
|
||||
|
||||
function asBossPhase(phase: ObsidianRamPhase): Iwt2BossEntityState['attackPhase'] {
|
||||
return phase as unknown as Iwt2BossEntityState['attackPhase']
|
||||
}
|
||||
Reference in New Issue
Block a user