Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
export type AiMemberId = Exclude<MemberId, "aelia">;
|
||||
|
||||
export interface PartyBehaviorContext {
|
||||
memberId: AiMemberId;
|
||||
current: WorldPosition;
|
||||
formationTarget: WorldPosition;
|
||||
bossMotion: BossMotionState;
|
||||
partyPositions: Record<MemberId, WorldPosition>;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface PartyBehaviorDecision {
|
||||
target: WorldPosition;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PartyBehavior {
|
||||
id: string;
|
||||
decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null;
|
||||
}
|
||||
|
||||
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
|
||||
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
|
||||
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
|
||||
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
brann: [-0.55, 0],
|
||||
nia: [0.45, 0.4],
|
||||
orin: [0.45, -0.4],
|
||||
vale: [0, 0.65],
|
||||
};
|
||||
|
||||
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
|
||||
|
||||
export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPosition> {
|
||||
return {
|
||||
brann: [boss[0], boss[1] + 4.25],
|
||||
nia: [boss[0] - 3.3, boss[1] + 7.2],
|
||||
orin: [boss[0] + 3.3, boss[1] + 7.2],
|
||||
vale: [boss[0] + 1.75, boss[1] + 3.4],
|
||||
};
|
||||
}
|
||||
|
||||
function clampToArena(position: WorldPosition): WorldPosition {
|
||||
return [
|
||||
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
|
||||
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
|
||||
];
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
if (bossMotion.mode !== "stacking") return null;
|
||||
const offset = STACK_OFFSETS[memberId];
|
||||
return {
|
||||
target: memberId === bossMotion.pounceTargetId
|
||||
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
|
||||
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
|
||||
speed: 3.4,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const breakTetherBehavior: PartyBehavior = {
|
||||
id: "break-tether",
|
||||
decide: ({ memberId, current, bossMotion, partyPositions }) => {
|
||||
if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null;
|
||||
const otherId = bossMotion.tetherIds.find((id) => id !== memberId);
|
||||
if (!otherId) return null;
|
||||
const other = partyPositions[otherId];
|
||||
const dx = current[0] - other[0];
|
||||
const dz = current[1] - other[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5;
|
||||
const target = length < 0.01
|
||||
? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition
|
||||
: [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition;
|
||||
return { target: clampToArena(target), speed: 3.7 };
|
||||
},
|
||||
};
|
||||
|
||||
export const evadeChargeBehavior: PartyBehavior = {
|
||||
id: "evade-charge",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion }) => {
|
||||
if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null;
|
||||
const { chargeStart, chargeEnd } = bossMotion;
|
||||
const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
|
||||
const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance;
|
||||
if (!currentUnsafe && !formationUnsafe) return null;
|
||||
|
||||
// Derive from the stable formation slot so the target cannot flip sides as the member moves.
|
||||
const evadeTarget = pointOutsideLane(
|
||||
formationTarget,
|
||||
chargeStart,
|
||||
chargeEnd,
|
||||
BULL_CHARGE.aiClearance,
|
||||
EVADE_SIDES[memberId],
|
||||
);
|
||||
return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed };
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidBreathBehavior: PartyBehavior = {
|
||||
id: "avoid-breath",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
|
||||
const side = EVADE_SIDES[memberId];
|
||||
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
|
||||
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
|
||||
return {
|
||||
target: clampToArena([
|
||||
bossMotion.position[0] + Math.sin(safeAngle) * radius,
|
||||
bossMotion.position[1] + Math.cos(safeAngle) * radius,
|
||||
]),
|
||||
speed: 4.1,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
id: "avoid-circle-hazards",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
|
||||
for (let index = 0; index < bossMotion.hazards.length; index += 1) {
|
||||
const hazard = bossMotion.hazards[index];
|
||||
if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue;
|
||||
const clearance = hazard.radius + 0.55;
|
||||
const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance;
|
||||
const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance;
|
||||
if (!currentUnsafe && !formationUnsafe) continue;
|
||||
const source = formationUnsafe ? formationTarget : current;
|
||||
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
|
||||
return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
|
||||
breakTetherBehavior,
|
||||
stackForPounceBehavior,
|
||||
evadeChargeBehavior,
|
||||
avoidBreathBehavior,
|
||||
avoidCircleHazardsBehavior,
|
||||
maintainFormationBehavior,
|
||||
];
|
||||
|
||||
export function updatePartyPositions(
|
||||
current: Record<MemberId, WorldPosition>,
|
||||
bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
|
||||
party: PartyMember[],
|
||||
time: number,
|
||||
delta: number,
|
||||
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
|
||||
) {
|
||||
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
|
||||
const activeMotions = bossMotions;
|
||||
const next: Record<MemberId, WorldPosition> = {
|
||||
aelia: [current.aelia[0], current.aelia[1]],
|
||||
brann: [current.brann[0], current.brann[1]],
|
||||
nia: [current.nia[0], current.nia[1]],
|
||||
orin: [current.orin[0], current.orin[1]],
|
||||
vale: [current.vale[0], current.vale[1]],
|
||||
};
|
||||
if (!activeMotions.length) return next;
|
||||
const formationOrigin: WorldPosition = [
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
];
|
||||
const formation = combatFormation(formationOrigin);
|
||||
// Vale fights from the boss-cluster midpoint so short-range cleaves can
|
||||
// connect with both targets when their hit volumes overlap.
|
||||
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
|
||||
|
||||
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
|
||||
const memberId = AI_MEMBER_IDS[index];
|
||||
const member = party[index + 1];
|
||||
if (!member || member.hp <= 0 || member.knockedUntil > time) continue;
|
||||
for (const behavior of behaviors) {
|
||||
let handled = false;
|
||||
for (const bossMotion of activeMotions) {
|
||||
const decision = behavior.decide({
|
||||
memberId,
|
||||
current: next[memberId],
|
||||
formationTarget: formation[memberId],
|
||||
bossMotion,
|
||||
partyPositions: next,
|
||||
time,
|
||||
});
|
||||
if (!decision) continue;
|
||||
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user