Android build v1.1.22

This commit is contained in:
Warren H
2026-07-05 12:08:11 -04:00
parent 547044892d
commit 956c9e32f9
69 changed files with 9259 additions and 196 deletions
+459
View File
@@ -0,0 +1,459 @@
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type EmberMantisAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'bladeSidestep'
| 'lineSlashWindup'
| 'lineSlashRecover'
| 'crossSlashWindup'
| 'crossSlashRecover'
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
sidestepInterval: 1.9,
sidestepSeconds: 0.32,
sidestepSpeed: 330,
slashLaneWidth: 34,
wallMargin: 86,
}
export function tickEmberMantisDuelist(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
const incomingPhase = state.boss.attackPhase as EmberMantisAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.min(EMBER_MANTIS.sidestepInterval, state.boss.mechanicEnergy + dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
wallContactSeconds: incomingPhase === 'idle' && 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 withEmberMantisIndicators({ boss, party, events })
const phase = boss.attackPhase as EmberMantisAttackPhase
if (phase === 'bladeSidestep') {
boss = moveBossTowardRelocateTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 6) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicEnergy: 0,
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.lineSlashDamage,
EMBER_MANTIS.lineSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('lineSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'crossSlashWindup') {
boss = faceLaneTarget(boss)
if (boss.phaseSecondsRemaining <= 0) {
const result = applySlashLanes(
party,
boss,
EMBER_MANTIS.crossSlashDamage,
EMBER_MANTIS.crossSlashStunSeconds,
state.time + dt,
)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('crossSlashRecover'),
phaseSecondsRemaining: EMBER_MANTIS.recoverSeconds + 0.12,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (phase === 'lineSlashRecover' || phase === 'crossSlashRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('idle'),
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.wallContactSeconds >= 0.58) {
boss = beginSidestep(boss, state, target, true)
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('crossSlashWindup'),
fireballCooldownRemaining: EMBER_MANTIS.crossSlashCooldown,
mechanicLanes: createCrossSlashLanes(state, boss, target),
phaseSecondsRemaining: EMBER_MANTIS.crossSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('lineSlashWindup'),
chargeCooldownRemaining: EMBER_MANTIS.lineSlashCooldown,
mechanicLanes: [createLineSlashLane(state, boss, target)],
phaseSecondsRemaining: EMBER_MANTIS.lineSlashWindup,
velocity: { x: 0, y: 0 },
}
return withEmberMantisIndicators({ boss, party, events })
}
if (boss.mechanicEnergy >= EMBER_MANTIS.sidestepInterval && distanceVec2(boss.position, target.position) <= 220) {
boss = beginSidestep(boss, state, target, false)
return withEmberMantisIndicators({ 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 withEmberMantisIndicators({ boss, party, events })
boss = moveDuelist(boss, state, target, dt)
return withEmberMantisIndicators({ boss, party, events })
}
function asBossPhase(phase: EmberMantisAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function beginSidestep(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase('bladeSidestep'),
mechanicEnergy: 0,
phaseSecondsRemaining: forcedFromWall ? EMBER_MANTIS.sidestepSeconds * 1.6 : EMBER_MANTIS.sidestepSeconds,
relocateTarget: chooseSidestepTarget(boss, state, target, forcedFromWall),
velocity: { x: 0, y: 0 },
}
}
function moveBossTowardRelocateTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, EMBER_MANTIS.sidestepSpeed * 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 moveDuelist(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const desiredDistance = distance < EMBER_MANTIS.duelistRange * 0.72
? EMBER_MANTIS.duelistRange
: EMBER_MANTIS.duelistRange * 0.82
const direction = distance < EMBER_MANTIS.duelistRange * 0.72
? withFallbackFacing(offset, boss.facing)
: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const targetPosition = distance < desiredDistance
? addVec2(target.position, scaleVec2(direction, EMBER_MANTIS.duelistRange))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, targetPosition, EMBER_MANTIS.moveSpeed * 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),
}
}
function chooseSidestepTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
forcedFromWall: boolean,
): Iwt2Vec2 {
if (forcedFromWall) {
return clampVec2ToArena(arenaCenter(state), boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
const towardTarget = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const sideSign = boss.chargeCount % 2 === 0 ? 1 : -1
const lateral = { x: -towardTarget.y * sideSign, y: towardTarget.x * sideSign }
const center = arenaCenter(state)
const centerBias = normalizeVec2(subtractVec2(center, boss.position))
const sideWeight = forcedFromWall ? 0.35 : 1
const centerWeight = forcedFromWall ? 1.15 : 0.35
const direction = withFallbackFacing(addVec2(scaleVec2(lateral, sideWeight), scaleVec2(centerBias, centerWeight)), centerBias)
const distance = forcedFromWall ? 152 : 118
const rawTarget = addVec2(boss.position, scaleVec2(direction, distance))
return clampVec2ToArena(rawTarget, boss.radius + EMBER_MANTIS.wallMargin * 0.35, state.bounds)
}
function createLineSlashLane(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState {
const direction = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
return createArenaLane('line-slash', target.position, direction, EMBER_MANTIS.slashLaneWidth, state)
}
function createCrossSlashLanes(
state: Iwt2ArenaState,
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2MechanicLaneState[] {
const center = targetClusterCenter(state.party, target.position)
const baseAngle = Math.atan2(target.position.y - boss.position.y, target.position.x - boss.position.x)
const angles = [baseAngle + Math.PI * 0.25, baseAngle - Math.PI * 0.25]
return angles.map((angle, index) => createArenaLane(
`cross-slash-${index}`,
center,
{ x: Math.cos(angle), y: Math.sin(angle) },
EMBER_MANTIS.slashLaneWidth,
state,
))
}
function createArenaLane(
id: string,
center: Iwt2Vec2,
direction: Iwt2Vec2,
width: number,
state: Iwt2ArenaState,
): Iwt2MechanicLaneState {
const normalized = withFallbackFacing(direction, { x: 1, y: 0 })
const halfLength = Math.hypot(state.bounds.width, state.bounds.height) * 0.62 + 48
return {
end: clampLanePoint(addVec2(center, scaleVec2(normalized, halfLength)), state),
id,
start: clampLanePoint(addVec2(center, scaleVec2(normalized, -halfLength)), state),
width,
}
}
function clampLanePoint(point: Iwt2Vec2, state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: clamp(point.x, -24, state.bounds.width + 24),
y: clamp(point.y, -24, state.bounds.height + 24),
}
}
function targetClusterCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return fallback
const total = living.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / living.length)
}
function applySlashLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
damage: number,
stunSeconds: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function faceLaneTarget(boss: Iwt2BossEntityState): Iwt2BossEntityState {
const lane = boss.mechanicLanes[0]
if (!lane) return boss
return {
...boss,
facing: withFallbackFacing(subtractVec2(lane.end, lane.start), boss.facing),
velocity: { x: 0, y: 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) > EMBER_MANTIS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: EMBER_MANTIS.meleeRange,
}, {
damage: EMBER_MANTIS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: EMBER_MANTIS.meleeCooldown },
events: result.events,
party: result.party,
}
}
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 isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + EMBER_MANTIS.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 arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function withEmberMantisIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createEmberMantisIndicators(result.boss),
}
}
function createEmberMantisIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as EmberMantisAttackPhase
const isSlashPhase = phase === 'lineSlashWindup'
|| phase === 'lineSlashRecover'
|| phase === 'crossSlashWindup'
|| phase === 'crossSlashRecover'
if (!isSlashPhase) return []
const isWindup = phase === 'lineSlashWindup' || phase === 'crossSlashWindup'
const isRecover = phase === 'lineSlashRecover' || phase === 'crossSlashRecover'
return boss.mechanicLanes.map((lane) => createLaneIndicator({
color: isRecover ? '#ff6a2a' : EMBER_MANTIS.accentColor,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: phase === 'crossSlashWindup' || phase === 'crossSlashRecover'
? 'ember-mantis-cross-slash'
: 'ember-mantis-line-slash',
phase: indicatorPhaseFromAttack(isWindup, isRecover),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}