Map planner special weapon ranges

This commit is contained in:
Kalle
2026-07-01 19:48:41 +03:00
parent 4c12adafd0
commit 0c5a55fcdf
11 changed files with 480 additions and 8 deletions

View File

@@ -188,6 +188,23 @@ export type SpecialWeaponParams = SpecialWeaponParamsObject[SpecialWeaponId] & {
BumpDamage?: number;
JumpDamage?: number;
TickDamage?: number;
// Map planner range circle params (populated by scripts/create-analyzer-json.ts).
/** Effect radius for area specials (Big Bubbler, Ink Storm, ...) */
Range_Radius?: number;
/** Straight-flight range for projectile specials with a fixed distance (Inkjet) */
Range_Distance?: number;
/** Outer blast radius drawn around a projectile special's impact */
Range_BlastRadius?: number;
/** Projectile trajectory params (Trizooka, Crab Tank); see comp-analyzer weapon-range */
Range_SpawnSpeed?: number;
Range_GoStraightStateEndMaxSpeed?: number;
Range_GoStraightToBrakeStateFrame?: number;
Range_FreeGravity?: number;
Range_FreeAirResist?: number;
Range_BrakeAirResist?: number;
Range_BrakeGravity?: number;
Range_BrakeToFreeStateFrame?: number;
};
export type ParamsJson = {

View File

@@ -51,6 +51,14 @@ export function mainWeaponParams(weaponId: MainWeaponId): MainWeaponParams {
return { ...baseStats, ...kit } as MainWeaponParams;
}
export function specialWeaponParams(
specialWeaponId: SpecialWeaponId,
): SpecialWeaponParams {
const params = rawWeaponParams as unknown as ParamsJson;
return params.specialWeapons[specialWeaponId] as SpecialWeaponParams;
}
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
const result: AbilityPoints = new Map();

View File

@@ -2326,6 +2326,15 @@ export const weaponParams = {
},
],
DirectDamage: 2200,
Range_SpawnSpeed: 1,
Range_GoStraightStateEndMaxSpeed: 1,
Range_GoStraightToBrakeStateFrame: 18,
Range_FreeGravity: 0.0190565,
Range_FreeAirResist: 0.01985,
Range_BrakeAirResist: 0.09,
Range_BrakeGravity: 0.09,
Range_BrakeToFreeStateFrame: 10,
Range_BlastRadius: 4,
},
"2": {
overwrites: {
@@ -2364,6 +2373,7 @@ export const weaponParams = {
Distance: 6,
},
],
Range_Radius: 35,
},
"4": {
overwrites: {
@@ -2412,6 +2422,8 @@ export const weaponParams = {
},
},
TickDamage: 33,
Range_Distance: 27,
Range_BlastRadius: 12.6,
},
"7": {
overwrites: {
@@ -2428,6 +2440,7 @@ export const weaponParams = {
},
DirectDamage: 300,
WaveDamage: 450,
Range_Radius: 20,
},
"8": {
overwrites: {
@@ -2504,6 +2517,8 @@ export const weaponParams = {
},
],
DirectDamage: 1200,
Range_Distance: 30,
Range_BlastRadius: 5,
},
"11": {
overwrites: {
@@ -2534,6 +2549,8 @@ export const weaponParams = {
},
],
ThrowDirectDamage: 2200,
Range_Distance: 24,
Range_BlastRadius: 8,
},
"12": {
ArmorHP: 5000,
@@ -2557,6 +2574,11 @@ export const weaponParams = {
},
],
BumpDamage: 400,
Range_SpawnSpeed: 3.36,
Range_GoStraightStateEndMaxSpeed: 2.232,
Range_GoStraightToBrakeStateFrame: 7,
Range_FreeGravity: 0.016,
Range_BlastRadius: 4.8,
},
"13": {
overwrites: {
@@ -2591,6 +2613,7 @@ export const weaponParams = {
Distance: 14.9,
},
],
Range_Radius: 9,
},
"14": {
overwrites: {
@@ -2601,6 +2624,8 @@ export const weaponParams = {
},
},
TickDamage: 75,
Range_Distance: 28,
Range_BlastRadius: 7.7,
},
"15": {
overwrites: {
@@ -2641,6 +2666,8 @@ export const weaponParams = {
Distance: 6,
},
],
Range_Distance: 30,
Range_BlastRadius: 6,
},
"17": {
overwrites: {
@@ -2676,6 +2703,7 @@ export const weaponParams = {
Distance: 10.5,
},
],
Range_Radius: 7,
},
"19": {
overwrites: {

View File

@@ -0,0 +1,60 @@
.container {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.legend {
display: flex;
gap: var(--s-4);
font-size: var(--font-xs);
color: var(--color-text-high);
}
.legendItem {
display: flex;
align-items: center;
gap: var(--s-1-5);
}
.legendSwatch {
width: 12px;
height: 12px;
border-radius: 2px;
}
.row {
display: grid;
grid-template-columns: 32px 1fr 3rem;
align-items: center;
gap: var(--s-2);
}
.track {
position: relative;
height: 14px;
border-radius: var(--radius-field);
background: var(--color-bg-high);
overflow: hidden;
}
.bar {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
border-radius: var(--radius-field);
}
.blast {
position: absolute;
inset-block: 0;
opacity: 0.4;
}
.range {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
text-align: end;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,85 @@
// note: dev only component, not used in production code
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { specialWeaponImageUrl } from "~/utils/urls";
import {
getSpecialsWithRange,
type SpecialWeaponWithRange,
} from "../core/special-weapon-range";
import styles from "./SpecialRangeVisualization.module.css";
const RANGE_TYPE_COLOR: Record<SpecialWeaponWithRange["rangeType"], string> = {
projectile: "#8cd4f5",
radius: "#f5b8d0",
};
export function SpecialRangeVisualization() {
const { t } = useTranslation(["weapons"]);
const specials = getSpecialsWithRange();
if (specials.length === 0) {
return null;
}
const maxRange = Math.max(
...specials.map((special) => special.range + (special.blastRadius ?? 0)),
);
return (
<div className={styles.container} data-testid="special-range-visualization">
<div className={styles.legend}>
<div className={styles.legendItem}>
<span
className={styles.legendSwatch}
style={{ backgroundColor: RANGE_TYPE_COLOR.projectile }}
/>
projectile
</div>
<div className={styles.legendItem}>
<span
className={styles.legendSwatch}
style={{ backgroundColor: RANGE_TYPE_COLOR.radius }}
/>
radius
</div>
</div>
{specials.map((special) => {
const color = RANGE_TYPE_COLOR[special.rangeType];
const rangeWidth = (special.range / maxRange) * 100;
const blastWidth = special.blastRadius
? (special.blastRadius / maxRange) * 100
: 0;
return (
<div key={special.specialWeaponId} className={styles.row}>
<Image
path={specialWeaponImageUrl(special.specialWeaponId)}
width={28}
height={28}
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
/>
<div className={styles.track}>
{blastWidth > 0 ? (
<span
className={styles.blast}
style={{
insetInlineStart: `${rangeWidth}%`,
width: `${blastWidth}%`,
backgroundColor: color,
}}
/>
) : null}
<span
className={styles.bar}
style={{ width: `${rangeWidth}%`, backgroundColor: color }}
/>
</div>
<span className={styles.range}>{special.range.toFixed(1)}</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import {
BIG_BUBBLER_ID,
BOOYAH_BOMB_ID,
CRAB_TANK_ID,
SPLATTERCOLOR_SCREEN_ID,
specialWeaponIds,
TRIZOOKA_ID,
WAVE_BREAKER_ID,
} from "~/modules/in-game-lists/weapon-ids";
import { getSpecialWeaponRange } from "./special-weapon-range";
describe("special weapon range", () => {
test("computes a projectile range for Trizooka", () => {
const result = getSpecialWeaponRange(TRIZOOKA_ID);
expect(result?.rangeType).toBe("projectile");
expect(result?.range).toBeGreaterThan(0);
});
test("Crab Tank reaches further than Trizooka", () => {
const crab = getSpecialWeaponRange(CRAB_TANK_ID);
const trizooka = getSpecialWeaponRange(TRIZOOKA_ID);
expect(crab!.range).toBeGreaterThan(trizooka!.range);
});
test("throws-and-bursts specials expose a throw range plus explosion blast", () => {
const booyah = getSpecialWeaponRange(BOOYAH_BOMB_ID);
expect(booyah?.rangeType).toBe("projectile");
expect(booyah?.range).toBeGreaterThan(0);
expect(booyah?.blastRadius).toBeGreaterThan(0);
});
test("returns the effect radius for area specials", () => {
const waveBreaker = getSpecialWeaponRange(WAVE_BREAKER_ID);
expect(waveBreaker?.rangeType).toBe("radius");
expect(waveBreaker?.range).toBeGreaterThan(0);
});
test("returns null for global / utility specials without a meaningful circle", () => {
expect(getSpecialWeaponRange(BIG_BUBBLER_ID)).toBeNull();
expect(getSpecialWeaponRange(SPLATTERCOLOR_SCREEN_ID)).toBeNull();
});
test("every special is either unsupported or has a positive finite range", () => {
for (const id of specialWeaponIds) {
const result = getSpecialWeaponRange(id);
if (result === null) continue;
expect(Number.isFinite(result.range)).toBe(true);
expect(result.range).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,88 @@
import { specialWeaponParams } from "~/features/build-analyzer/core/utils";
import type { SpecialWeaponId } from "~/modules/in-game-lists/types";
import { specialWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import {
calculateGroundRange,
simulateTrajectoryPoints,
type TrajectoryParams,
} from "./weapon-range";
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;
export interface SpecialWeaponRangeResult {
/** Radius of the range circle, in game distance units. */
range: number;
/** Extra outer radius covered by the projectile's blast, in game distance units. */
blastRadius?: number;
rangeType: "projectile" | "radius";
}
/**
* Range circle definition for a special weapon, or `null` when the special has no meaningful
* range to draw. The underlying values come from `weapon-params.ts` (populated by
* `scripts/create-analyzer-json.ts`): projectile specials reuse the main weapon trajectory
* model, the rest carry a single effect radius.
*/
export function getSpecialWeaponRange(
specialWeaponId: SpecialWeaponId,
): SpecialWeaponRangeResult | null {
const params = specialWeaponParams(specialWeaponId);
if (params.Range_Radius !== undefined) {
return { range: params.Range_Radius, rangeType: "radius" };
}
if (params.Range_Distance !== undefined) {
return {
range: params.Range_Distance,
blastRadius: params.Range_BlastRadius,
rangeType: "projectile",
};
}
if (params.Range_SpawnSpeed === undefined) return null;
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,
};
const range = calculateGroundRange(
simulateTrajectoryPoints(trajectoryParams),
);
return {
range,
blastRadius: params.Range_BlastRadius,
rangeType: "projectile",
};
}
export interface SpecialWeaponWithRange extends SpecialWeaponRangeResult {
specialWeaponId: SpecialWeaponId;
}
/**
* Every special weapon that has a range to draw, widest first. Specials without a meaningful
* range circle are omitted.
*/
export function getSpecialsWithRange(): SpecialWeaponWithRange[] {
return specialWeaponIds
.flatMap((specialWeaponId): SpecialWeaponWithRange[] => {
const result = getSpecialWeaponRange(specialWeaponId);
return result ? [{ specialWeaponId, ...result }] : [];
})
.sort((a, b) => b.range - a.range);
}

View File

@@ -2,7 +2,7 @@ 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 {
export interface TrajectoryParams {
spawnSpeed: number;
goStraightStateEndMaxSpeed: number;
goStraightToBrakeStateFrame: number;
@@ -37,7 +37,7 @@ function getWeaponCategoryName(weaponId: MainWeaponId): string | undefined {
const PLAYER_HEIGHT = 1.0;
function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
export function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
for (let i = 1; i < trajectory.length; i++) {
const point = trajectory[i];
const prevPoint = trajectory[i - 1];
@@ -55,7 +55,9 @@ function calculateBouncingRange(trajectory: TrajectoryPoint[]): number {
return lastPoint?.z ?? 0;
}
function simulateTrajectoryPoints(params: TrajectoryParams): TrajectoryPoint[] {
export function simulateTrajectoryPoints(
params: TrajectoryParams,
): TrajectoryPoint[] {
const {
spawnSpeed,
goStraightStateEndMaxSpeed,

View File

@@ -5,6 +5,7 @@ import {
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import { RangeVisualization } from "../components/RangeVisualization";
import { SpecialRangeVisualization } from "../components/SpecialRangeVisualization";
export default function AllRangesPage() {
return (
@@ -31,6 +32,10 @@ export default function AllRangesPage() {
</section>
);
})}
<section>
<h2 style={{ textTransform: "capitalize" }}>specials</h2>
<SpecialRangeVisualization />
</section>
</Main>
);
}

View File

@@ -33,6 +33,7 @@ import {
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { getSpecialWeaponRange } from "~/features/comp-analyzer/core/special-weapon-range";
import { getWeaponRange } from "~/features/comp-analyzer/core/weapon-range";
import { useTheme } from "~/features/theme/core/provider";
import type { LanguageCode } from "~/modules/i18n/config";
@@ -41,6 +42,7 @@ import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import {
@@ -71,6 +73,7 @@ const GAME_UNITS_TO_PX: Record<"MINI" | "OVER", number> = {
OVER: 8.4,
};
const MAIN_WEAPON_URL_PATTERN = /main-weapons-outlined\/(\d+)/;
const SPECIAL_WEAPON_URL_PATTERN = /special-weapons\/(\d+)/;
export default function Planner() {
const { t, i18n } = useTranslation(["common"]);
@@ -783,6 +786,36 @@ function extractMainWeaponIdFromSrc(src: string): MainWeaponId | null {
return id as MainWeaponId;
}
function extractSpecialWeaponIdFromSrc(src: string): SpecialWeaponId | null {
const match = src.match(SPECIAL_WEAPON_URL_PATTERN);
if (!match) return null;
const id = Number(match[1]);
if (!specialWeaponIds.includes(id as SpecialWeaponId)) return null;
return id as SpecialWeaponId;
}
function rangeForSrc(
src: string,
): { range: number; blastRadius?: number } | null {
const mainWeaponId = extractMainWeaponIdFromSrc(src);
if (mainWeaponId !== null) {
const result = getWeaponRange(mainWeaponId);
if (result.rangeType === "unsupported" || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
const specialWeaponId = extractSpecialWeaponIdFromSrc(src);
if (specialWeaponId !== null) {
const result = getSpecialWeaponRange(specialWeaponId);
if (!result || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
return null;
}
function createRangeCircleForShape(
editor: Editor,
shape: ReturnType<Editor["getCurrentPageShapes"]>[number],
@@ -796,11 +829,8 @@ function createRangeCircleForShape(
const asset = editor.getAsset(assetId as TLAssetId);
if (asset?.type !== "image" || !asset.props.src) return;
const weaponId = extractMainWeaponIdFromSrc(asset.props.src);
if (!weaponId) return;
const rangeResult = getWeaponRange(weaponId);
if (rangeResult.rangeType === "unsupported" || rangeResult.range <= 0) return;
const rangeResult = rangeForSrc(asset.props.src);
if (!rangeResult) return;
const centerX = shape.x + (shape.props as { w: number }).w / 2;
const centerY = shape.y + (shape.props as { h: number }).h / 2;

View File

@@ -129,6 +129,8 @@ async function main() {
params.DistanceDamage = undefined;
}
Object.assign(params, specialWeaponRangeParams(specialWeapon, rawParams));
if (hasLangDicts) {
translationsToArray({
arr: translations,
@@ -693,6 +695,96 @@ function parametersToSubWeaponResult(
};
}
// Thrown targeting specials (Booyah Bomb, Super Chump, Triple Inkstrike, Ultra Stamp) are
// player-aimed and have no throw-distance param in the game data, so their throw (fly) range is
// approximated with a constant. Kept as named per-special constants so each can be tuned later;
// for now they all reuse Booyah Bomb's value.
const BOOYAH_BOMB_FLY_DISTANCE = 27;
const SUPER_CHUMP_FLY_DISTANCE = 30;
const TRIPLE_INKSTRIKE_FLY_DISTANCE = 28;
const ULTRA_STAMP_FLY_DISTANCE = 24;
// Range circle data for the map planner. Projectile / thrown specials store the distance the shot
// travels plus its blast; a few store a single effect radius. Specials whose reach is global
// (Tenta Missiles, Killer Wail), placed or utility (Big Bubbler, Tacticooler, Ink Storm, Ink Vac),
// or otherwise has no meaningful circle (Kraken Royale, Splattercolor Screen) are left out.
// See app/features/comp-analyzer/core/special-weapon-range.ts.
function specialWeaponRangeParams(
specialWeapon: SpecialWeapon,
rawParams: any,
): Record<string, number | undefined> {
const outerBlastDistance = (blastParam: any): number | undefined =>
blastParam?.DistanceDamage?.at(-1)?.Distance;
const trajectoryFromMoveParam = (moveParam: any) => ({
Range_SpawnSpeed: moveParam?.SpawnSpeed,
Range_GoStraightStateEndMaxSpeed: moveParam?.GoStraightStateEndMaxSpeed,
Range_GoStraightToBrakeStateFrame: moveParam?.GoStraightToBrakeStateFrame,
Range_FreeGravity: moveParam?.FreeGravity,
Range_FreeAirResist: moveParam?.FreeAirResist,
Range_BrakeAirResist: moveParam?.BrakeAirResist,
Range_BrakeGravity: moveParam?.BrakeGravity,
Range_BrakeToFreeStateFrame: moveParam?.BrakeToFreeStateFrame,
});
switch (specialWeapon.__RowId) {
case "SpUltraShot": // Trizooka
return {
...trajectoryFromMoveParam(rawParams.MoveParam),
Range_BlastRadius: outerBlastDistance(rawParams.BlastParam),
};
case "SpJetpack": // Inkjet (shot flies straight for a fixed distance)
return {
Range_Distance: rawParams.MoveParam?.Distance,
Range_BlastRadius: outerBlastDistance(rawParams.BlastParam),
};
case "SpChariot": // Crab Tank (rapid gun reach)
return {
...trajectoryFromMoveParam(rawParams.ShooterMoveParam),
Range_BlastRadius: outerBlastDistance(
rawParams.CannonParam?.BlastParam,
),
};
case "SpNiceBall": // Booyah Bomb (thrown, then bursts on impact)
return {
Range_Distance: BOOYAH_BOMB_FLY_DISTANCE,
Range_BlastRadius: rawParams.BlastParam?.DamageRadiusEnd,
};
case "SpFirework": // Super Chump (thrown, chumps burst on impact)
return {
Range_Distance: SUPER_CHUMP_FLY_DISTANCE,
Range_BlastRadius: outerBlastDistance(rawParams.IceParam?.BlastParam),
};
case "SpTripleTornado": // Triple Inkstrike (thrown beacons, strikes burst)
return {
Range_Distance: TRIPLE_INKSTRIKE_FLY_DISTANCE,
Range_BlastRadius: rawParams.BlastParam?.DamageRadiusEnd,
};
case "SpUltraStamp": // Ultra Stamp (thrown stamp bursts on impact)
return {
Range_Distance: ULTRA_STAMP_FLY_DISTANCE,
Range_BlastRadius: outerBlastDistance(rawParams.ThrowBlastParam),
};
case "SpSuperHook": // Zipcaster (grapple reach)
return { Range_Radius: rawParams.WeaponParam?.MaxLengthHook };
case "SpShockSonar": // Wave Breaker (tracking wave max radius, base Special Power Up)
return {
Range_Radius:
rawParams.spl__BulletSpShockSonarParam?.WaveParam?.MaxRadius?.Low,
};
case "SpSkewer": // Reefslider (lethal blast)
return {
Range_Radius: rawParams.BulletBlastParam?.DistanceDamage?.[0]?.Distance,
};
case "SpPogo": // Triple Splashdown (lethal blast, per splashdown)
return {
Range_Radius: rawParams.BlastParamNormal?.DistanceDamage?.[0]?.Distance,
};
default:
return {};
}
}
// some specials lack damage values in the params
// so they are instead hardcoded here as a workaround
function parametersToSpecialWeaponResult(params: any) {