sendou.ink/app/features/comp-analyzer/core/weapon-range.ts
2026-01-20 22:30:14 +02:00

256 lines
6.1 KiB
TypeScript

import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
interface TrajectoryParams {
spawnSpeed: number;
goStraightStateEndMaxSpeed: number;
goStraightToBrakeStateFrame: number;
freeGravity: number;
freeAirResist: number;
brakeAirResist: number;
brakeGravity: number;
brakeToFreeFrame: number;
burstFrame?: number;
bounceAfterMaxSpeed?: number;
}
export interface TrajectoryPoint {
z: number;
y: number;
}
const DEFAULT_BRAKE_AIR_RESIST = 0.36;
const DEFAULT_BRAKE_GRAVITY = 0.07;
const DEFAULT_BRAKE_TO_FREE_FRAME = 4;
const DEFAULT_FREE_GRAVITY = 0.016;
const DEFAULT_FREE_AIR_RESIST = 0;
function getWeaponCategoryName(weaponId: MainWeaponId): string | undefined {
for (const category of weaponCategories) {
if (category.weaponIds.some((id) => id === weaponId)) {
return category.name;
}
}
return undefined;
}
const PLAYER_HEIGHT = 1.0;
function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
for (let i = 1; i < trajectory.length; i++) {
const point = trajectory[i];
const prevPoint = trajectory[i - 1];
if (point.y < 0 && prevPoint.y >= 0) {
const t = prevPoint.y / (prevPoint.y - point.y);
return prevPoint.z + t * (point.z - prevPoint.z);
}
}
const lastPoint = trajectory[trajectory.length - 1];
return lastPoint?.z ?? 0;
}
function calculateBouncingRange(trajectory: TrajectoryPoint[]): number {
const lastPoint = trajectory[trajectory.length - 1];
return lastPoint?.z ?? 0;
}
function simulateTrajectoryPoints(params: TrajectoryParams): TrajectoryPoint[] {
const {
spawnSpeed,
goStraightStateEndMaxSpeed,
goStraightToBrakeStateFrame,
freeGravity,
freeAirResist,
brakeAirResist,
brakeGravity,
brakeToFreeFrame,
burstFrame,
bounceAfterMaxSpeed,
} = params;
const isBouncing = bounceAfterMaxSpeed !== undefined;
const maxBounces =
isBouncing && burstFrame !== undefined ? burstFrame : undefined;
const maxFrames = isBouncing ? 300 : (burstFrame ?? 300);
const points: TrajectoryPoint[] = [];
let z = 0;
let y = PLAYER_HEIGHT;
let vz = spawnSpeed;
let vy = 0;
let frame = 0;
let bounceCount = 0;
points.push({ z, y });
while (frame < goStraightToBrakeStateFrame && frame < maxFrames) {
z += vz;
points.push({ z, y });
frame++;
}
vz = Math.min(vz, goStraightStateEndMaxSpeed);
for (let i = 0; i < brakeToFreeFrame && frame < maxFrames; i++) {
vz *= 1 - brakeAirResist;
vy -= brakeGravity;
z += vz;
y += vy;
points.push({ z, y });
frame++;
if (y < 0) {
if (isBouncing) {
bounceCount++;
if (maxBounces !== undefined && bounceCount >= maxBounces) {
return points;
}
y = Math.abs(y);
vy = Math.abs(vy);
vz *= bounceAfterMaxSpeed;
} else {
return points;
}
}
}
while (frame < maxFrames) {
vz *= 1 - freeAirResist;
vy -= freeGravity;
z += vz;
y += vy;
if (y < 0) {
if (isBouncing) {
bounceCount++;
if (maxBounces !== undefined && bounceCount >= maxBounces) {
points.push({ z, y: 0 });
return points;
}
y = Math.abs(y);
vy = Math.abs(vy);
vz *= bounceAfterMaxSpeed;
} else {
points.push({ z, y });
return points;
}
}
points.push({ z, y });
frame++;
if (!isBouncing && y < 0) {
return points;
}
if (vz < 0.01) {
return points;
}
}
return points;
}
export interface WeaponRangeResult {
range: number;
blastRadius?: number;
rangeType: "calculated" | "direct" | "unsupported";
trajectory?: TrajectoryPoint[];
}
function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
const category = getWeaponCategoryName(weaponId);
if (!category) {
return { range: 0, rangeType: "unsupported" };
}
const params = mainWeaponParams(weaponId);
if (category === "CHARGERS" && params.DistanceFullCharge !== undefined) {
const range = params.DistanceFullCharge;
return {
range,
rangeType: "direct",
trajectory: [
{ z: 0, y: PLAYER_HEIGHT },
{ z: range, y: PLAYER_HEIGHT },
],
};
}
if (params.Range_SpawnSpeed === undefined) {
return { range: 0, rangeType: "unsupported" };
}
const trajectoryParams: TrajectoryParams = {
spawnSpeed: params.Range_SpawnSpeed,
goStraightStateEndMaxSpeed:
params.Range_GoStraightStateEndMaxSpeed ?? params.Range_SpawnSpeed,
goStraightToBrakeStateFrame: params.Range_GoStraightToBrakeStateFrame ?? 4,
freeGravity: params.Range_FreeGravity ?? DEFAULT_FREE_GRAVITY,
freeAirResist: params.Range_FreeAirResist ?? DEFAULT_FREE_AIR_RESIST,
brakeAirResist: params.Range_BrakeAirResist ?? DEFAULT_BRAKE_AIR_RESIST,
brakeGravity: params.Range_BrakeGravity ?? DEFAULT_BRAKE_GRAVITY,
brakeToFreeFrame:
params.Range_BrakeToFreeStateFrame ?? DEFAULT_BRAKE_TO_FREE_FRAME,
burstFrame: params.Range_BurstFrame,
bounceAfterMaxSpeed: params.Range_BounceAfterMaxSpeed,
};
const trajectory = simulateTrajectoryPoints(trajectoryParams);
const range =
params.Range_BounceAfterMaxSpeed !== undefined
? calculateBouncingRange(trajectory)
: calculateGroundRange(trajectory);
return {
range,
blastRadius: params.BlastRadius,
rangeType: "calculated",
trajectory,
};
}
export interface WeaponWithRange {
weaponId: MainWeaponId;
range: number;
blastRadius?: number;
rangeType: "calculated" | "direct" | "unsupported";
trajectory?: TrajectoryPoint[];
}
export function getWeaponsWithRange(
weaponIds: MainWeaponId[],
): WeaponWithRange[] {
return weaponIds
.map((weaponId) => {
const result = getWeaponRange(weaponId);
return {
weaponId,
...result,
};
})
.filter((w) => w.rangeType !== "unsupported");
}
export const BENCHMARK_WEAPON_IDS = [40, 2070] satisfies MainWeaponId[];
export interface BenchmarkTrajectory {
id: MainWeaponId;
range: number;
trajectory?: TrajectoryPoint[];
}
export function getBenchmarkTrajectories(): BenchmarkTrajectory[] {
return BENCHMARK_WEAPON_IDS.map((weaponId) => {
const result = getWeaponRange(weaponId);
return {
id: weaponId,
range: result.range,
trajectory: result.trajectory,
};
}).filter((b) => b.trajectory !== undefined);
}