forked from phenom/healer-man
525 lines
27 KiB
JavaScript
525 lines
27 KiB
JavaScript
import { arenaAiProfile, arenaDampening, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
|
|
import { initializeArenaCharacter, useArenaCharacterAbility, tickArenaCharacter, arenaCharacterSnapshot, damageArenaCharacter, commandArenaCharacter, bindArenaCharacter } from "./generated/arena-combat-runtime.mjs";
|
|
import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs";
|
|
|
|
export const ARENA_TICK_RATE = 20;
|
|
export const ARENA_SNAPSHOT_RATE = 10;
|
|
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
|
|
|
|
function distance2d(left, right) {
|
|
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
|
|
}
|
|
|
|
function segmentIntersectsBox(from, to, object) {
|
|
if (object.shape !== "box" && object.shape !== "cylinder") return false;
|
|
const radiusX = object.size[0] / 2;
|
|
const radiusZ = object.size[2] / 2;
|
|
const dx = to[0] - from[0];
|
|
const dz = to[2] - from[2];
|
|
const lengthSquared = dx * dx + dz * dz;
|
|
const amount = lengthSquared > 0
|
|
? Math.max(0, Math.min(1, ((object.position[0] - from[0]) * dx + (object.position[2] - from[2]) * dz) / lengthSquared))
|
|
: 0;
|
|
const nearestX = from[0] + dx * amount;
|
|
const nearestZ = from[2] + dz * amount;
|
|
return Math.abs(nearestX - object.position[0]) <= radiusX
|
|
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
|
|
}
|
|
|
|
function newActor(participant, spawn, teamIndex, preparationGate) {
|
|
const facing = Math.atan2(preparationGate[0] - spawn[0], preparationGate[2] - spawn[2]);
|
|
// Keep the first player on the validated anchor. Role-based sideways offsets
|
|
// put healers inside the starting-room wall in Nagrand.
|
|
const offset = teamIndex * 2;
|
|
return {
|
|
id: participant.actorId,
|
|
accountId: participant.accountId ?? null,
|
|
characterId: participant.characterId ?? null,
|
|
characterKit: participant.characterKit ?? null,
|
|
name: participant.name,
|
|
team: participant.team,
|
|
role: participant.role,
|
|
isBot: Boolean(participant.isBot),
|
|
disconnected: Boolean(participant.disconnected),
|
|
alive: true,
|
|
position: [spawn[0] + Math.sin(facing) * offset, spawn[1], spawn[2] + Math.cos(facing) * offset],
|
|
facing,
|
|
movement: [0, 0],
|
|
health: 1000,
|
|
maxHealth: 1000,
|
|
resource: participant.characterKit?.resource?.initial ?? 100,
|
|
maxResource: participant.characterKit?.resource?.maximum ?? 100,
|
|
gcdUntil: 0,
|
|
lastAbilityId: undefined,
|
|
abilityRevision: 0,
|
|
auras: [],
|
|
periodic: new Map(),
|
|
targetActorId: null,
|
|
cooldowns: new Map(),
|
|
effects: new Map(),
|
|
dr: new Map(),
|
|
casting: null,
|
|
lockoutUntil: 0,
|
|
lastSequence: 0,
|
|
nextBotDecisionAt: 0,
|
|
rating: Number(participant.rating ?? 0),
|
|
};
|
|
}
|
|
|
|
export function arenaDynamicObjectState(stage, activeElapsedMs) {
|
|
return stage.dynamicObjects.map((definition) => {
|
|
if (definition.kind === "starting-elevators") {
|
|
const delay = definition.initialDelayMs ?? 0;
|
|
const travelMs = definition.activeMs || 1500;
|
|
if (activeElapsedMs < delay) return { id: definition.id, state: "raised", progress: 0, collisionEnabled: true };
|
|
if (activeElapsedMs < delay + travelMs) {
|
|
return { id: definition.id, state: "moving", progress: (activeElapsedMs - delay) / travelMs, collisionEnabled: true };
|
|
}
|
|
return { id: definition.id, state: "open", progress: 1, collisionEnabled: false };
|
|
}
|
|
if (definition.kind === "waterfall") {
|
|
const start = definition.runtimeStartMs ?? definition.initialDelayRangeMs?.[0] ?? 30000;
|
|
const cycle = definition.activeMs + definition.cycleMs;
|
|
const time = activeElapsedMs - start;
|
|
if (time < -definition.warningMs) return { id: definition.id, state: "open", progress: 0, collisionEnabled: false };
|
|
if (time < 0) return { id: definition.id, state: "warning", progress: (definition.warningMs + time) / definition.warningMs, collisionEnabled: false };
|
|
const within = time % Math.max(1, cycle);
|
|
if (within >= definition.activeMs) {
|
|
const remaining = cycle - within;
|
|
const warning = remaining <= definition.warningMs;
|
|
return { id: definition.id, state: warning ? "warning" : "open", progress: warning ? 1 - remaining / definition.warningMs : 0, collisionEnabled: false };
|
|
}
|
|
return { id: definition.id, state: "active", progress: within / definition.activeMs, collisionEnabled: true };
|
|
}
|
|
if (definition.kind === "pipe-knockback") {
|
|
const warningStart = Math.max(0, (definition.initialDelayMs ?? 5000) - definition.warningMs);
|
|
if (activeElapsedMs < warningStart) return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
|
|
if (activeElapsedMs < (definition.initialDelayMs ?? 5000)) return { id: definition.id, state: "warning", progress: 1, collisionEnabled: true };
|
|
if (activeElapsedMs < (definition.initialDelayMs ?? 5000) + definition.activeMs) return { id: definition.id, state: "active", progress: 1, collisionEnabled: false };
|
|
return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
|
|
}
|
|
const delay = definition.initialDelayMs ?? 0;
|
|
const time = activeElapsedMs - delay;
|
|
const raised = time >= 0 && (definition.cycleMs <= 0 || time % definition.cycleMs < definition.activeMs);
|
|
return {
|
|
id: definition.id,
|
|
state: definition.kind === "fire-cycle" ? (raised ? "active" : "closed") : (raised ? "raised" : "lowered"),
|
|
progress: raised ? 1 : 0,
|
|
collisionEnabled: definition.kind === "moving-pillars" ? raised : false,
|
|
};
|
|
});
|
|
}
|
|
|
|
export class ArenaSimulation {
|
|
constructor(options) {
|
|
this.id = options.id;
|
|
this.stage = {
|
|
...options.stage,
|
|
dynamicObjects: options.stage.dynamicObjects.map((object) => ({ ...object })),
|
|
lineOfSight: options.stage.lineOfSight.map((object) => ({ ...object })),
|
|
};
|
|
this.bracket = options.bracket;
|
|
this.mode = options.mode;
|
|
this.ruleset = options.ruleset;
|
|
this.random = options.random ?? Math.random;
|
|
this.onSnapshot = options.onSnapshot ?? (() => {});
|
|
this.onResult = options.onResult ?? (() => {});
|
|
this.phase = "preparation";
|
|
this.tickNumber = 0;
|
|
this.startedAt = Number(options.startedAt ?? Date.now());
|
|
this.phaseEndsAt = this.startedAt + 10_000;
|
|
this.activeStartedAt = null;
|
|
this.finished = false;
|
|
this.interval = null;
|
|
this.actors = new Map();
|
|
this.lastMechanicPulse = new Map();
|
|
const teamCounts = [0, 0];
|
|
for (const participant of options.participants) {
|
|
const team = participant.team;
|
|
const teamIndex = teamCounts[team]++;
|
|
const actor = newActor(participant, this.stage.teamSpawns[team], teamIndex, this.stage.preparationGates[team]);
|
|
if (teamIndex) actor.position[1] = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2) ?? actor.position[1];
|
|
this.actors.set(actor.id, actor);
|
|
initializeArenaCharacter(actor, this.startedAt);
|
|
}
|
|
for (const object of this.stage.dynamicObjects) {
|
|
if (object.initialDelayRangeMs) {
|
|
const [min, max] = object.initialDelayRangeMs;
|
|
object.runtimeStartMs = min + this.random() * Math.max(0, max - min);
|
|
}
|
|
}
|
|
}
|
|
|
|
start() {
|
|
if (!this.interval && !this.finished) this.interval = setInterval(() => this.step(Date.now()), ARENA_TICK_MS);
|
|
return this;
|
|
}
|
|
|
|
resetPreparationClock(startedAt = Date.now()) {
|
|
if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false;
|
|
this.startedAt = Number(startedAt);
|
|
this.phaseEndsAt = this.startedAt + 10_000;
|
|
return true;
|
|
}
|
|
|
|
stop() {
|
|
if (this.interval) clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
|
|
actor(actorId) {
|
|
return this.actors.get(actorId) ?? null;
|
|
}
|
|
|
|
hasLineOfSight(left, right, dynamicStates = null) {
|
|
if (!arenaGeometryLineClear(this.stage.id, [left.position[0], left.position[1] + 1.2, left.position[2]], [right.position[0], right.position[1] + 1.2, right.position[2]])) return false;
|
|
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
|
|
return !this.stage.lineOfSight.some((object) => {
|
|
// Static walls come from the real mesh. Old proxy boxes created invisible
|
|
// obstacles and blocked routes that are open in the detailed arena.
|
|
if (!object.dynamicObjectId || !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
|
|
return segmentIntersectsBox(left.position, right.position, object);
|
|
});
|
|
}
|
|
|
|
targetActor(actorId) {
|
|
const actor = this.actor(actorId);
|
|
if (actor) return actor;
|
|
for (const owner of this.actors.values()) {
|
|
const summon = owner.combat.summons.find((candidate) => candidate.id === actorId);
|
|
if (summon) return { ...owner, ...summon, team: owner.team, alive: summon.health > 0, facing: owner.facing };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
projectGroundPoint(origin, requested, range) {
|
|
if (Math.hypot(requested[0] - origin[0], requested[2] - origin[2]) > range) return null;
|
|
if ([0, 2].some((axis) => requested[axis] < this.stage.bounds.min[axis] || requested[axis] > this.stage.bounds.max[axis])) return null;
|
|
const floor = arenaFloorHeight(this.stage.id, requested[0], requested[2], requested[1], 2);
|
|
if (floor === null && arenaFloorHeight(this.stage.id, origin[0], origin[2], origin[1], 2) !== null) return null;
|
|
const point = [requested[0], floor ?? requested[1], requested[2]];
|
|
return this.hasLineOfSight({ position: origin }, { position: point }) ? point : null;
|
|
}
|
|
|
|
resolvePosition(actor, requested, teleport = false) {
|
|
const x = Math.max(this.stage.bounds.min[0] + .35, Math.min(this.stage.bounds.max[0] - .35, requested[0]));
|
|
const z = Math.max(this.stage.bounds.min[2] + .35, Math.min(this.stage.bounds.max[2] - .35, requested[2]));
|
|
const floor = arenaFloorHeight(this.stage.id, x, z, actor.position[1], teleport ? 3 : 1);
|
|
const oldFloor = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2);
|
|
if (floor === null && oldFloor !== null) return [...actor.position];
|
|
const ground = floor ?? actor.groundY ?? actor.position[1];
|
|
const target = [x, Math.max(ground, requested[1]), z];
|
|
for (const height of [.55, 1.4]) {
|
|
const from = [actor.position[0], actor.position[1] + height, actor.position[2]];
|
|
const to = [target[0], target[1] + height, target[2]];
|
|
if (!arenaGeometryLineClear(this.stage.id, from, to)) return [...actor.position];
|
|
}
|
|
const dynamic = new Map(this.dynamicObjects().map((entry) => [entry.id, entry]));
|
|
for (const object of this.stage.lineOfSight) {
|
|
if (!object.dynamicObjectId || !dynamic.get(object.dynamicObjectId)?.collisionEnabled) continue;
|
|
if (target[1] > object.position[1] + object.size[1] / 2 || target[1] + 1.8 < object.position[1] - object.size[1] / 2) continue;
|
|
if (Math.abs(x - object.position[0]) < object.size[0] / 2 + .35 && Math.abs(z - object.position[2]) < object.size[2] / 2 + .35) return [...actor.position];
|
|
}
|
|
actor.groundY = ground;
|
|
return target;
|
|
}
|
|
|
|
receiveIntent(actorId, intent) {
|
|
const actor = this.actor(actorId);
|
|
if (!actor || actor.isBot || actor.disconnected || !actor.alive || !["active", "preparation"].includes(this.phase)) return { accepted: false, reason: "actor-unavailable" };
|
|
const sequence = Math.trunc(Number(intent?.sequence));
|
|
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
|
|
actor.lastSequence = sequence;
|
|
if (intent.type === "bindings" && intent.actionBindings && typeof intent.actionBindings === "object") {
|
|
bindArenaCharacter(actor, intent.actionBindings);
|
|
return { accepted: true };
|
|
}
|
|
if (intent.type === "movement") {
|
|
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
|
|
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
|
|
const length = Math.hypot(x, z);
|
|
actor.movement = length > 1 ? [x / length, z / length] : [x, z];
|
|
return { accepted: true };
|
|
}
|
|
if (intent.type === "facing") {
|
|
actor.facing = Number.isFinite(Number(intent.facing)) ? Number(intent.facing) : actor.facing;
|
|
return { accepted: true };
|
|
}
|
|
if (intent.type === "target") {
|
|
actor.targetActorId = this.targetActor(intent.targetActorId) ? intent.targetActorId : null;
|
|
return { accepted: true };
|
|
}
|
|
if (intent.type === "ability") {
|
|
const ability = actor.characterKit.abilities[String(intent.abilityId ?? "")];
|
|
if (this.phase !== "active" && (ability?.target === "hostile" || ability?.effects.some((effect) => ["damage", "dot", "status", "execute", "finisher-damage"].includes(effect.kind)))) return { accepted: false, reason: "preparation" };
|
|
const ground = Array.isArray(intent.groundPosition) && intent.groundPosition.length === 3 && intent.groundPosition.every(Number.isFinite) ? intent.groundPosition : undefined;
|
|
return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId, ground);
|
|
}
|
|
if (intent.type === "command") {
|
|
commandArenaCharacter(this, actor, intent.command, this.startedAt + this.tickNumber * ARENA_TICK_MS);
|
|
return { accepted: true };
|
|
}
|
|
if (intent.type === "jump") {
|
|
if (!actor.jumpVelocity && actor.combat.controlledUntil <= this.startedAt + this.tickNumber * ARENA_TICK_MS) actor.jumpVelocity = 7;
|
|
return { accepted: true };
|
|
}
|
|
return { accepted: false, reason: "unknown-intent" };
|
|
}
|
|
|
|
useAbility(actor, abilityId, targetActorId = null, groundPosition) {
|
|
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
|
if (actor.characterKit) return useArenaCharacterAbility(this, actor, abilityId, targetActorId, now, groundPosition);
|
|
return { accepted: false, reason: "actor-unavailable" };
|
|
}
|
|
|
|
damage(target, amount, school = "physical") {
|
|
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
|
damageArenaCharacter(this, target, amount, school, now);
|
|
}
|
|
|
|
dynamicObjects() {
|
|
const elapsed = this.activeStartedAt == null ? -1 : this.tickNumber * ARENA_TICK_MS - (this.activeStartedAt - this.startedAt);
|
|
return arenaDynamicObjectState(this.stage, elapsed);
|
|
}
|
|
|
|
step() {
|
|
if (this.finished) return;
|
|
this.tickNumber += 1;
|
|
const simulationNow = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
|
if (this.phase === "preparation" && simulationNow >= this.phaseEndsAt) {
|
|
this.phase = "active";
|
|
this.activeStartedAt = this.phaseEndsAt;
|
|
this.phaseEndsAt = null;
|
|
}
|
|
if (this.phase === "active") {
|
|
this.updateActors(simulationNow);
|
|
this.applyMechanics(simulationNow);
|
|
this.checkResult();
|
|
} else if (this.phase === "preparation") {
|
|
for (const actor of this.actors.values()) tickArenaCharacter(this, actor, simulationNow, ARENA_TICK_MS / 1000);
|
|
}
|
|
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
|
|
}
|
|
|
|
updateActors(now) {
|
|
const deltaSeconds = ARENA_TICK_MS / 1000;
|
|
for (const actor of this.actors.values()) {
|
|
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
|
|
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
|
|
tickArenaCharacter(this, actor, now, deltaSeconds);
|
|
if (!actor.alive) continue;
|
|
if (actor.isBot && actor.navigationPath?.length && !actor.casting) this.followBotPath(actor);
|
|
if (actor.combat.controlledUntil <= now) this.moveActor(actor, deltaSeconds);
|
|
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
|
|
}
|
|
}
|
|
|
|
moveActor(actor, deltaSeconds) {
|
|
const statuses = actor.statuses ?? [];
|
|
const speed = statuses.some((status) => status.kind === "root") ? 0 : 7 * (1 - Math.max(0, ...statuses.filter((status) => status.kind === "slow").map((status) => status.magnitude ?? 0)));
|
|
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
|
|
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
|
|
const previous = actor.position;
|
|
actor.position = this.resolvePosition(actor, [nextX, previous[1], nextZ]);
|
|
if (actor.position[0] === previous[0] && actor.position[2] === previous[2]) {
|
|
actor.position = this.resolvePosition(actor, [nextX, previous[1], previous[2]]);
|
|
actor.position = this.resolvePosition(actor, [actor.position[0], previous[1], nextZ]);
|
|
}
|
|
if (Math.hypot(...actor.movement) > 0.01) actor.facing = Math.atan2(actor.movement[0], actor.movement[1]);
|
|
actor.groundY ??= actor.position[1];
|
|
if (actor.jumpVelocity || actor.position[1] > actor.groundY + .01) {
|
|
actor.jumpVelocity ??= 0;
|
|
actor.position[1] += actor.jumpVelocity * deltaSeconds;
|
|
actor.jumpVelocity -= 20 * deltaSeconds;
|
|
if (actor.position[1] <= actor.groundY) { actor.position[1] = actor.groundY; actor.jumpVelocity = 0; }
|
|
}
|
|
}
|
|
|
|
botDecision(actor, now) {
|
|
const rating = actor.rating ?? 0;
|
|
const profile = arenaAiProfile(rating);
|
|
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
|
|
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
|
|
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive);
|
|
const target = enemies.sort((left, right) => left.health - right.health)[0];
|
|
if (!target) {
|
|
actor.movement = [0, 0];
|
|
return;
|
|
}
|
|
actor.targetActorId = target.id;
|
|
commandArenaCharacter(this, actor, "attack", now);
|
|
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
|
if (actor.characterKit) {
|
|
if (actor.casting) { actor.movement = [0, 0]; return; }
|
|
const abilities = Object.values(actor.characterKit.abilities).filter((ability) => now >= (actor.cooldowns.get(ability.id) ?? 0));
|
|
const has = (ability, kind) => ability.effects.some((effect) => effect.kind === kind);
|
|
let candidates = [];
|
|
let castTarget = target;
|
|
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * .86) {
|
|
candidates = abilities.filter((ability) => (ability.target === "friendly" || ability.target === "any") && (has(ability, "heal") || has(ability, "hot")));
|
|
castTarget = lowAlly;
|
|
} else if (actor.health < actor.maxHealth * .4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
|
|
candidates = abilities.filter((ability) => ability.target === "self" && (has(ability, "heal") || has(ability, "shield") || has(ability, "apply-aura")));
|
|
castTarget = actor;
|
|
} else {
|
|
if (target.casting && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) candidates.push(...abilities.filter((ability) => has(ability, "interrupt")));
|
|
candidates.push(...abilities.filter((ability) => ability.target === "hostile" && (has(ability, "damage") || has(ability, "finisher-damage") || has(ability, "dot"))));
|
|
}
|
|
const distance = distance2d(actor, castTarget);
|
|
const range = Math.max(4, ...candidates.map((ability) => ability.range.max));
|
|
const dx = castTarget.position[0] - actor.position[0], dz = castTarget.position[2] - actor.position[2];
|
|
actor.facing = Math.atan2(dx, dz);
|
|
const needsMovement = distance > Math.min(range - .5, 24) || !this.hasLineOfSight(actor, castTarget);
|
|
if (needsMovement) {
|
|
if (!actor.navigationPath?.length || now >= (actor.nextPathAt ?? 0)) {
|
|
actor.navigationPath = arenaNavigationPath(this.stage.id, actor.position, castTarget.position);
|
|
actor.nextPathAt = now + 2500;
|
|
}
|
|
actor.movement = [dx / Math.max(.001, distance), dz / Math.max(.001, distance)];
|
|
this.followBotPath(actor);
|
|
} else { actor.navigationPath = []; actor.movement = [0, 0]; }
|
|
actor.targetActorId = castTarget.id;
|
|
for (const ability of candidates) {
|
|
if (distance <= ability.range.max || ability.target === "self") {
|
|
if (this.useAbility(actor, ability.id, castTarget.id, ability.targeting?.shape === "ground" ? castTarget.position : undefined).accepted) {
|
|
if (actor.casting) actor.movement = [0, 0];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
canWalkLine(actor, to) {
|
|
const probe = { ...actor, position: [...actor.position] };
|
|
const distance = distance2d(actor, { position: to });
|
|
const steps = Math.max(1, Math.ceil(distance / .5));
|
|
for (let step = 1; step <= steps; step++) {
|
|
const requested = [actor.position[0] + (to[0] - actor.position[0]) * step / steps, probe.position[1], actor.position[2] + (to[2] - actor.position[2]) * step / steps];
|
|
const resolved = this.resolvePosition(probe, requested);
|
|
if (Math.hypot(resolved[0] - requested[0], resolved[2] - requested[2]) > .01) return false;
|
|
// Keep the navmesh portals on ramps and drops. A straight shortcut can
|
|
// cross an upper floor, fall, and leave the bot below its next waypoint.
|
|
if (Math.abs((probe.groundY ?? resolved[1]) - actor.position[1]) > .2) return false;
|
|
probe.position = [resolved[0], probe.groundY ?? resolved[1], resolved[2]];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
followBotPath(actor) {
|
|
const path = actor.navigationPath;
|
|
if (!path?.length) return;
|
|
while (path.length > 1 && distance2d(actor, { position: path[0] }) < .7) path.shift();
|
|
// Smooth portals only across a collision-checked, continuously walkable line.
|
|
if ((actor.nextPathSmoothTick ?? 0) <= this.tickNumber) {
|
|
for (let index = Math.min(path.length - 1, 8); index > 0; index--) {
|
|
if (this.canWalkLine(actor, path[index])) { path.splice(0, index); break; }
|
|
}
|
|
actor.nextPathSmoothTick = this.tickNumber + 10;
|
|
}
|
|
const dx = path[0][0] - actor.position[0], dz = path[0][2] - actor.position[2], distance = Math.hypot(dx, dz);
|
|
actor.movement = distance < .2 ? [0, 0] : [dx / distance, dz / distance];
|
|
}
|
|
|
|
applyMechanics(now) {
|
|
const activeElapsed = now - this.activeStartedAt;
|
|
const states = new Map(this.dynamicObjects().map((state) => [state.id, state]));
|
|
if (this.stage.id === "dalaran-sewers") {
|
|
for (const id of ["pipes", "waterfall"]) {
|
|
if (states.get(id)?.state !== "active") continue;
|
|
const pulseMs = id === "waterfall" ? 1500 : 750;
|
|
const previous = this.lastMechanicPulse.get(id) ?? -Infinity;
|
|
if (activeElapsed - previous < pulseMs) continue;
|
|
this.lastMechanicPulse.set(id, activeElapsed);
|
|
for (const actor of this.actors.values()) {
|
|
if (!actor.alive || Math.abs(actor.position[0]) > 6 || Math.abs(actor.position[2]) > 14) continue;
|
|
const direction = actor.position[2] >= 0 ? 1 : -1;
|
|
actor.position[2] += direction * (id === "waterfall" ? 4 : 2);
|
|
}
|
|
}
|
|
}
|
|
if (this.stage.id === "ring-of-valor" && states.get("fire")?.state === "active") {
|
|
const previous = this.lastMechanicPulse.get("fire") ?? -Infinity;
|
|
if (activeElapsed - previous >= 1000) {
|
|
this.lastMechanicPulse.set("fire", activeElapsed);
|
|
for (const actor of this.actors.values()) {
|
|
if (actor.alive && Math.abs(actor.position[2]) > 25) this.damage(actor, 35);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
checkResult() {
|
|
const alive = [0, 1].map((team) => [...this.actors.values()].some((actor) => actor.team === team && actor.alive));
|
|
if (alive[0] && alive[1]) return;
|
|
const winnerTeam = alive[0] === alive[1] ? null : alive[0] ? 0 : 1;
|
|
this.finish(winnerTeam, winnerTeam == null ? "simultaneous-elimination" : "elimination");
|
|
}
|
|
|
|
finish(winnerTeam, reason) {
|
|
if (this.finished) return;
|
|
this.finished = true;
|
|
this.phase = reason === "void" ? "void" : "finished";
|
|
this.stop();
|
|
this.onSnapshot(this.snapshot());
|
|
this.onResult({ matchId: this.id, winnerTeam, draw: winnerTeam == null, reason });
|
|
}
|
|
|
|
disconnect(actorId) {
|
|
const actor = this.actor(actorId);
|
|
if (!actor) return false;
|
|
actor.disconnected = true;
|
|
actor.isBot = true;
|
|
actor.nextBotDecisionAt = 0;
|
|
return true;
|
|
}
|
|
|
|
snapshot() {
|
|
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
|
|
const elapsed = this.activeStartedAt == null ? 0 : Math.max(0, now - this.activeStartedAt);
|
|
return {
|
|
matchId: this.id,
|
|
tick: this.tickNumber,
|
|
serverTime: now,
|
|
phase: this.phase,
|
|
phaseEndsAt: this.phaseEndsAt,
|
|
stageId: this.stage.id,
|
|
bracket: this.bracket,
|
|
mode: this.mode,
|
|
ruleset: this.ruleset,
|
|
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
|
|
actors: [...this.actors.values()].map((actor) => ({
|
|
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
|
|
characterId: actor.characterId,
|
|
avatar: actor.avatar, visualEquipment: actor.visualEquipment,
|
|
...(actor.characterKit ? {
|
|
classId: actor.characterKit.classId, level: actor.characterKit.level,
|
|
actionBindings: actor.characterKit.actionBindings, abilityIds: Object.keys(actor.characterKit.abilities),
|
|
resourceType: actor.characterKit.resource.type,
|
|
gcdRemainingMs: Math.max(0, actor.gcdUntil - now),
|
|
lastAbilityId: actor.lastAbilityId, abilityRevision: actor.abilityRevision,
|
|
combat: arenaCharacterSnapshot(actor, now),
|
|
} : {}),
|
|
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
|
|
position: [...actor.position], facing: actor.facing,
|
|
health: actor.health, maxHealth: actor.maxHealth,
|
|
resource: Math.round(actor.resource), maxResource: actor.maxResource,
|
|
targetActorId: actor.targetActorId,
|
|
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
|
|
effects: [
|
|
...actor.auras.map((aura) => ({ id: aura.definition.name, icon: aura.definition.icon, disposition: aura.definition.disposition, remainingMs: aura.expiresAt == null ? 0 : Math.max(0, aura.expiresAt - now), stacks: aura.stacks })),
|
|
...(actor.statuses ?? []).map((status) => ({ id: status.kind, disposition: "debuff", remainingMs: Math.max(0, status.endsAt - now) })),
|
|
...[...actor.periodic.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.nextAt - now) + (effect.ticks - 1) * effect.intervalMs })),
|
|
...(this.combatEffects ?? []).filter((effect) => effect.targetId === actor.id).map((effect) => ({ id: effect.sourceAbilityId, disposition: effect.kind === "dot" ? "debuff" : "buff", remainingMs: Math.max(0, effect.nextTickAt - now) + (effect.remainingTicks - 1) * effect.intervalMs })),
|
|
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
|
|
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
|
|
],
|
|
})),
|
|
dynamicObjects: this.dynamicObjects(),
|
|
combatPresentation: this.combatPresentation ?? [],
|
|
combatEvents: this.combatEvents ?? [],
|
|
};
|
|
}
|
|
}
|