recover a bunch of stats from legacy, fix enhanced armor in sandbox, add game's module layout from the upgrade screen, a bunch of toys from legacy

This commit is contained in:
tresabhi committed 2026-11-11 22:43:47 -06:00
1 parent cc0284e2ec
commit 6bef735aea
36 files changed
+2416 -582

No files matched your search

-8
View File
@@ -1,8 +0,0 @@
---
name: proto-workflow
description: Regenerate this repository's TypeScript protobuf outputs after editing .proto schemas.
---
# Proto Workflow
After editing any `.proto` schema in this repository, run `bun run build-protos` from the repository root. Review the generated changes and address any schema or generator errors before finishing the task.
-13
View File
@@ -1,13 +0,0 @@
---
name: repo-boundaries
description: Apply the user's persistent repository boundaries whenever working in their projects.
---
# Repo Boundaries
Apply these boundaries to all work in the user's repositories:
- Read-only Git commands are allowed when useful, including status, diff, log, and show. Do not run Git commands that modify repository state.
- Never edit `bun.lock`, including regenerating or formatting it. When dependency manifests change, leave the lockfile alone; the user will update it by running `bun install`.
These are firm constraints, not preferences to revisit for a task. Continue useful work within them and explain any limitation if a requested result would require crossing either boundary.
@@ -1,3 +0,0 @@
interface:
display_name: "Repo Boundaries"
short_description: "Respect repository editing boundaries"
+44
View File
@@ -0,0 +1,44 @@
---
name: vfs
description: Read files from the World of Tanks Blitz game client (including DVPL-compressed ones) via scripts/vfs.sh. Use when you need to list, read, or parse game data like Data/*.yaml, version.txt, or any asset in the client install, or when you need to know where the client install and wotblitz.exe are.
---
# Game client VFS
`scripts/vfs.sh` reads files out of the installed game client and transparently handles DVPL decompression. Use it instead of reading the Steam directory directly, because many client files are DVPL-compressed and raw reads give you garbage.
Run it from the repo root (the wrapper `cd`s into `packages/scripts` itself):
```bash
scripts/vfs.sh ls <dir> # list a directory, one entry per line
scripts/vfs.sh cat <file> # raw file contents to stdout (decompressed)
scripts/vfs.sh yaml <file> # parse a YAML file, print as JSON
```
The path argument is required. Paths are relative to the client root, e.g. `Data/version.txt`. Without a path it exits 1.
Source of truth for commands: `packages/scripts/src/vfs/index.ts`. Check it if a command here seems missing.
## Examples (verified)
```bash
scripts/vfs.sh cat Data/version.txt # 11.21.0.1235_7376187 release/11.21.0 WOTB_Win7 (no trailing newline)
scripts/vfs.sh ls Data # *.yaml configs, 3d, Configs, Fonts, ...
scripts/vfs.sh yaml Data/countries.yaml # JSON: { "ru": ["AM", "AZ", ...], ... }
```
## Where the client is
- The install dir comes from `CLIENT_DIR` in `packages/website/.env` (read via `packages/scripts/src/vfs/vfs.ts`). The location is machine-specific, so read that `.env` to find it rather than assuming a path. The `.env` may also contain commented-out alternative client locations.
- Executable: `wotblitz.exe` in that directory (~74 MB). `wotblitz-browser.exe` is the small embedded-browser helper, not the game.
- The `.env` holds an application ID and paths; don't paste its secrets elsewhere.
## Reverse engineering the executable
For binary analysis of `wotblitz.exe`, use the REA MCP tools (`mcp__rea__*`, load via ToolSearch). Nothing is open by default: call `open_binary` with the exe path first, and `close_binary` when done. The `rea` CLI is not on PATH, so `rea doctor` is unavailable; the MCP server is the interface.
## Notes
- Read-only: the VFS never modifies the client.
- Prefer `yaml` over `cat` for `.yaml` files when you want to query structure.
- Large outputs: pipe through `head` or `jq`.
@@ -9,6 +9,7 @@ import {
ShellDefinitionsList, ShellDefinitionsList,
ShellKind, ShellKind,
sluggify, sluggify,
TechTreeLayoutYaml,
toUniqueId, toUniqueId,
TurretDefinitionsList, TurretDefinitionsList,
UnlocksListing, UnlocksListing,
@@ -20,6 +21,7 @@ import {
Crew, Crew,
Equalizer, Equalizer,
GunDefinition, GunDefinition,
ModulePosition,
ModuleType, ModuleType,
ResearchCost, ResearchCost,
TankDefinitions, TankDefinitions,
@@ -362,11 +364,77 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
const name_full = const name_full =
name === canonicalFullName ? undefined : canonicalFullName; name === canonicalFullName ? undefined : canonicalFullName;
// The client lays modules out automatically unless the tank has its own
// file, in which case every cell is placed by hand
const module_positions: ModulePosition[] = [];
const layoutPath = `Data/Configs/TechTree/${nation}/${tankKey}.yaml`;
const resolvedLayoutPath = await this.vfs.resolve(layoutPath);
if (resolvedLayoutPath !== null) {
const layout = await this.vfs.yaml<TechTreeLayoutYaml>(layoutPath);
if (layout.modules) {
for (const [moduleKey, { position }] of Object.entries(
layout.modules,
)) {
// keys look like `#vehicleGun:_85mm_S-53`
const [kind, name] = moduleKey.replace(/^#/, "").split(":");
let rawId: number | undefined;
let type: ModuleType;
switch (kind) {
case "vehicle":
rawId = tankList.root[name]?.id;
type = ModuleType.MODULE_TYPE_VEHICLE;
break;
case "vehicleChassis":
rawId = chassisList.root.ids[name];
type = ModuleType.MODULE_TYPE_TRACKS;
break;
case "vehicleEngine":
rawId = enginesList.root.ids[name];
type = ModuleType.MODULE_TYPE_ENGINE;
break;
case "vehicleGun":
rawId = gunList.root.ids[name];
type = ModuleType.MODULE_TYPE_GUN;
break;
case "vehicleTurret":
rawId = turretList.root.ids[name];
type = ModuleType.MODULE_TYPE_TURRET;
break;
default:
throw new Error(
`Unknown module kind in ${layoutPath}: ${kind}`,
);
}
if (rawId === undefined) {
throw new Error(
`Unknown module in ${layoutPath}: ${moduleKey}`,
);
}
module_positions.push({
id: toUniqueId(nation, rawId),
type,
position: { x: position[0], y: position[1] },
});
}
}
}
tankDefinitions.tanks[tankId] = { tankDefinitions.tanks[tankId] = {
ancestors: [], ancestors: [],
successors: [], successors: [],
id: tankId, id: tankId,
dev_name: tankKey, dev_name: tankKey,
module_positions,
roles: {}, roles: {},
camouflages: camouflages, camouflages: camouflages,
fixed_camouflage: fixedCamouflage, fixed_camouflage: fixedCamouflage,
@@ -386,8 +454,8 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
type: tankTags.includes("collectible") type: tankTags.includes("collectible")
? TankType.TANK_TYPE_COLLECTOR ? TankType.TANK_TYPE_COLLECTOR
: (typeof tank.price === "number" ? false : "gold" in tank.price) : (typeof tank.price === "number" ? false : "gold" in tank.price)
? TankType.TANK_TYPE_PREMIUM ? TankType.TANK_TYPE_PREMIUM
: TankType.TANK_TYPE_RESEARCHABLE, : TankType.TANK_TYPE_RESEARCHABLE,
tier: tank.level, tier: tank.level,
class: tankClass, class: tankClass,
testing: tankTags.includes("testTank") || tank.level === 11, testing: tankTags.includes("testTank") || tank.level === 11,
@@ -575,24 +643,24 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
}, },
} }
: gunType === "autoReloader" : gunType === "autoReloader"
? { ? {
$case: "auto_reloader", $case: "auto_reloader",
value: { value: {
intra_clip: 60 / gun.clip!.rate, intra_clip: 60 / gun.clip!.rate,
shell_count: gunClipCount, shell_count: gunClipCount,
shell_reloads: gun shell_reloads: gun
.pumpGunReloadTimes!.split(" ") .pumpGunReloadTimes!.split(" ")
.map(Number), .map(Number),
},
}
: {
$case: "auto_loader",
value: {
intra_clip: 60 / gun.clip!.rate,
clip_reload: gun.reloadTime,
shell_count: gunClipCount,
},
}, },
}
: {
$case: "auto_loader",
value: {
intra_clip: 60 / gun.clip!.rate,
clip_reload: gun.reloadTime,
shell_count: gunClipCount,
},
},
} satisfies GunDefinition); } satisfies GunDefinition);
for (const shellKey in gunListEntry.shots) { for (const shellKey in gunListEntry.shots) {
@@ -635,7 +703,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
type: blitzShellKindToBlitzkit[shell.kind], type: blitzShellKindToBlitzkit[shell.kind],
explosion_radius: explosion_radius:
shell.kind === "HIGH_EXPLOSIVE" shell.kind === "HIGH_EXPLOSIVE"
? (shell.explosionRadius ?? 0) ? shell.explosionRadius ?? 0
: undefined, : undefined,
icon: shell.icon, icon: shell.icon,
penetration: { penetration: {
@@ -16,14 +16,14 @@ import {
VehicleDefinitions, VehicleDefinitions,
VertexAttribute, VertexAttribute,
} from "@blitzkit/core"; } from "@blitzkit/core";
import { Armor, Crew, ModelDefinitions, Vector3 } from "@blitzkit/protos"; import { Armor, Crew, ModelDefinitions, ProtoVector3 } from "@blitzkit/protos";
import { MathUtils, Vector3 as THREEVector3, Vector3Tuple } from "three"; import { MathUtils, Vector3 as THREEVector3, Vector3Tuple } from "three";
import { Cache } from "./0_base"; import { Cache } from "./0_base";
import { ServerBlitzKitAPI2 } from "./2_camouflages"; import { ServerBlitzKitAPI2 } from "./2_camouflages";
export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
private vector3TupleToBlitzkit(tuple: Vector3Tuple) { private vector3TupleToBlitzkit(tuple: Vector3Tuple) {
return { x: tuple[0], y: tuple[1], z: -tuple[2] } satisfies Vector3; return { x: tuple[0], y: tuple[1], z: -tuple[2] } satisfies ProtoVector3;
} }
private async collisionBoundingBoxes( private async collisionBoundingBoxes(
@@ -1,9 +1,9 @@
import { BoundingBox, Vector3 } from "../../../protos/src/blitzkit"; import { BoundingBox, ProtoVector3 } from "../../../protos/src/blitzkit";
export function normalizeBoundingBox(boundingBox: BoundingBox) { export function normalizeBoundingBox(boundingBox: BoundingBox) {
return { return {
x: boundingBox.max!.x - boundingBox.min!.x, x: boundingBox.max!.x - boundingBox.min!.x,
y: boundingBox.max!.y - boundingBox.min!.y, y: boundingBox.max!.y - boundingBox.min!.y,
z: boundingBox.max!.z - boundingBox.min!.z, z: boundingBox.max!.z - boundingBox.min!.z,
} satisfies Vector3; } satisfies ProtoVector3;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export abstract class AbstractVFS {
const path = await import("node:path/posix"); const path = await import("node:path/posix");
this.normalizePath = path.normalize; this.normalizePath = path.normalize;
await this._init(); return await this._init();
} }
protected abstract _init(): Promise<typeof this>; protected abstract _init(): Promise<typeof this>;
+1
View File
@@ -21,6 +21,7 @@ export * from "./squadBattleTypeStylesYaml";
export * from "./tankmenAvatar"; export * from "./tankmenAvatar";
export * from "./tankParameters"; export * from "./tankParameters";
export * from "./tanksStats"; export * from "./tanksStats";
export * from "./techTreeLayoutYaml";
export * from "./turretDefinitionsList"; export * from "./turretDefinitionsList";
export * from "./vehicleDefinitionArmor"; export * from "./vehicleDefinitionArmor";
export * from "./vehicleDefinitionList"; export * from "./vehicleDefinitionList";
@@ -11,7 +11,7 @@ export type ShellDefinitionsList = Record<
ricochetAngle?: number; ricochetAngle?: number;
explosionRadius?: number; explosionRadius?: number;
speed: number; speed: number;
maxDistance?: number; maxDistance: number;
piercingPower: string; piercingPower: string;
} }
> & { > & {
@@ -0,0 +1,7 @@
export interface TechTreeLayoutYamlModule {
position: [number, number];
}
export interface TechTreeLayoutYaml {
modules?: Record<string, TechTreeLayoutYamlModule>;
}
+38 -1
View File
@@ -35,6 +35,7 @@
"bhp_t": "{value}bhp/t", "bhp_t": "{value}bhp/t",
"kN": "{value}kN", "kN": "{value}kN",
"hp_s": "{value}hp/s", "hp_s": "{value}hp/s",
"m3": "{value}m³",
"plus": "+{value}", "plus": "+{value}",
"fraction": "{numerator} / {denominator}" "fraction": "{numerator} / {denominator}"
}, },
@@ -112,6 +113,19 @@
"reload": { "reload": {
"skip": "Skip" "skip": "Skip"
},
"flexibility": {
"pitch": "Pitch",
"yaw": "Yaw",
"max": "Max",
"min": "Min"
},
"view_range": {
"camo": "Camo",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
} }
}, },
@@ -137,6 +151,8 @@
"items": { "items": {
"gun_type": "Gun type", "gun_type": "Gun type",
"dpm": "Damage per minute", "dpm": "Damage per minute",
"dpm_effective": "Effective by 60s",
"dpm_peekaboo": "Peek-a-boo",
"damage": "Damage", "damage": "Damage",
"module_damage": "Module damage", "module_damage": "Module damage",
"reload": "Reload", "reload": "Reload",
@@ -156,9 +172,16 @@
"caliber": "Caliber", "caliber": "Caliber",
"normalization": "Normalization", "normalization": "Normalization",
"ricochet": "Ricochet", "ricochet": "Ricochet",
"explosion_radius": "Explosion radius",
"aim_time": "Aim time", "aim_time": "Aim time",
"dispersion": "Dispersion", "dispersion": "Dispersion",
"dispersion_angle": "Dispersion angle", "dispersion_angle": "Dispersion angle",
"dispersion_penalties": "Dispersion penalties",
"dispersion_moving": "Moving",
"dispersion_hull_traversing": "Hull traversing",
"dispersion_turret_traversing": "Turret traversing",
"dispersion_shooting": "After shooting",
"dispersion_gun_damaged": "Gun damaged",
"gun_depression": "Gun depression", "gun_depression": "Gun depression",
"gun_elevation": "Gun elevation", "gun_elevation": "Gun elevation",
"gun_frontal_depression": "Frontal depression", "gun_frontal_depression": "Frontal depression",
@@ -174,6 +197,10 @@
"brake_force": "Brake force", "brake_force": "Brake force",
"weight": "Weight", "weight": "Weight",
"power_to_weight": "Power to weight", "power_to_weight": "Power to weight",
"power_to_weight_on_terrain": "Power to weight on terrain",
"power_to_weight_hard": "Hard",
"power_to_weight_medium": "Medium",
"power_to_weight_soft": "Soft",
"terrain_coefficient": "Terrain coefficients", "terrain_coefficient": "Terrain coefficients",
"raw_terrain_coefficient": "Raw terrain coefficients", "raw_terrain_coefficient": "Raw terrain coefficients",
"hard_terrain_coefficient": "Hard", "hard_terrain_coefficient": "Hard",
@@ -193,8 +220,14 @@
"fire_chance": "Fire chance", "fire_chance": "Fire chance",
"fire_rate": "Fire rate", "fire_rate": "Fire rate",
"ramming_resistance": "Ramming resistance", "ramming_resistance": "Ramming resistance",
"crew_count": "Crew count",
"view_range": "View range", "view_range": "View range",
"camouflage": "Camouflage", "camouflages": "Camouflage",
"camouflage": "Still",
"camouflage_moving": "Moving",
"camouflage_shooting_still": "Shooting still",
"camouflage_shooting_moving": "Shooting moving",
"camouflage_on_fire": "On fire",
"width": "Width", "width": "Width",
"height": "Height", "height": "Height",
"length": "Length" "length": "Length"
@@ -205,6 +238,10 @@
"title": "Game mode abilities" "title": "Game mode abilities"
}, },
"videos": {
"title": "Videos"
},
"navigation": { "navigation": {
"tank": "Tank", "tank": "Tank",
"characteristics": "Characteristics" "characteristics": "Characteristics"
+14
View File
@@ -0,0 +1,14 @@
syntax = "proto3";
package blitzkit;
message ProtoVector2 {
float x = 1;
float y = 2;
}
message ProtoVector3 {
float x = 1;
float y = 2;
float z = 3;
}
@@ -2,13 +2,15 @@ syntax = "proto3";
package blitzkit; package blitzkit;
import "math.proto";
message ModelDefinitions { message ModelDefinitions {
map<uint32, ModelDefinition> models = 1; map<uint32, ModelDefinition> models = 1;
} }
message ModelDefinition { message ModelDefinition {
Armor armor = 1; Armor armor = 1;
Vector3 turret_origin = 2; ProtoVector3 turret_origin = 2;
optional InitialTurretRotation initial_turret_rotation = 3; optional InitialTurretRotation initial_turret_rotation = 3;
map<uint32, TurretModelDefinition> turrets = 4; map<uint32, TurretModelDefinition> turrets = 4;
map<uint32, TrackModelDefinition> tracks = 5; map<uint32, TrackModelDefinition> tracks = 5;
@@ -20,12 +22,6 @@ message Armor {
repeated uint32 spaced = 2; repeated uint32 spaced = 2;
} }
message Vector3 {
float x = 1;
float y = 2;
float z = 3;
}
message InitialTurretRotation { message InitialTurretRotation {
float yaw = 1; float yaw = 1;
float pitch = 2; float pitch = 2;
@@ -36,7 +32,7 @@ message TurretModelDefinition {
BoundingBox bounding_box = 1; BoundingBox bounding_box = 1;
Armor armor = 2; Armor armor = 2;
uint32 model_id = 3; uint32 model_id = 3;
Vector3 gun_origin = 4; ProtoVector3 gun_origin = 4;
map<uint32, GunModelDefinition> guns = 5; map<uint32, GunModelDefinition> guns = 5;
optional YawLimits yaw = 6; optional YawLimits yaw = 6;
} }
@@ -47,13 +43,13 @@ message YawLimits {
} }
message BoundingBox { message BoundingBox {
Vector3 min = 1; ProtoVector3 min = 1;
Vector3 max = 2; ProtoVector3 max = 2;
} }
message TrackModelDefinition { message TrackModelDefinition {
float thickness = 1; float thickness = 1;
Vector3 origin = 2; ProtoVector3 origin = 2;
} }
message GunModelDefinition { message GunModelDefinition {
@@ -3,6 +3,7 @@ syntax = "proto3";
package blitzkit; package blitzkit;
import "i18n.proto"; import "i18n.proto";
import "math.proto";
message TankDefinitions { message TankDefinitions {
map<uint32, TankDefinition> tanks = 1; map<uint32, TankDefinition> tanks = 1;
@@ -42,6 +43,15 @@ message TankDefinition {
string dev_name = 32; string dev_name = 32;
optional Equalizer equalizer = 33; optional Equalizer equalizer = 33;
optional I18nString name_full = 34; optional I18nString name_full = 34;
repeated ModulePosition module_positions = 35;
}
// Hand-authored cell in the modules screen grid. Tanks without any use the
// automatic layout.
message ModulePosition {
int32 id = 1;
ModuleType type = 2;
ProtoVector2 position = 3;
} }
message Equalizer { message Equalizer {
@@ -87,6 +97,7 @@ message TrackDefinition {
float resistance_medium = 10; float resistance_medium = 10;
float resistance_soft = 11; float resistance_soft = 11;
repeated Unlock unlocks = 12; repeated Unlock unlocks = 12;
float repair_time = 13;
} }
message EngineDefinition { message EngineDefinition {
+3 -1
View File
@@ -1,4 +1,6 @@
import { assertSecret } from "@blitzkit/core"; import { assertSecret } from "@blitzkit/core";
import { LocalVFS } from "@blitzkit/core/src/blitzkit/vfs/local"; import { LocalVFS } from "@blitzkit/core/src/blitzkit/vfs/local";
export const vfs = new LocalVFS(assertSecret(import.meta.env.CLIENT_DIR)); export const vfs = await new LocalVFS(
assertSecret(import.meta.env.CLIENT_DIR),
).init();
@@ -1,11 +1,30 @@
.discriminator { .discriminator {
position: absolute; position: absolute;
right: var(--space-3);
font-size: var(--font-size-minor); font-size: var(--font-size-minor);
text-align: right; text-align: right;
pointer-events: none; pointer-events: none;
top: 50%; white-space: nowrap;
&[data-corner="top-right"],
&[data-corner="bottom-right"] {
right: var(--space-3);
}
&[data-corner="top-left"],
&[data-corner="bottom-left"] {
right: var(--space-3);
}
&[data-corner="top-left"],
&[data-corner="top-right"] {
top: var(--space-3);
}
&[data-corner="bottom-left"],
&[data-corner="bottom-right"] {
bottom: var(--space-3);
}
} }
@@ -2,12 +2,21 @@ import type { ComponentProps } from "react";
import { classNames } from "../../ui/classNames"; import { classNames } from "../../ui/classNames";
import styles from "./index.module.css"; import styles from "./index.module.css";
interface DiscriminatorProps extends ComponentProps<"span"> {
corner?: `${"bottom" | "top"}-${"left" | "right"}`;
}
/** Small label overlaid on the right edge of a `position: relative` parent */ /** Small label overlaid on the right edge of a `position: relative` parent */
export function Discriminator({ export function Discriminator({
className, className,
corner = "bottom-right",
...props ...props
}: ComponentProps<"span">) { }: DiscriminatorProps) {
return ( return (
<span className={classNames(styles.discriminator, className)} {...props} /> <span
data-corner={corner}
className={classNames(styles.discriminator, className)}
{...props}
/>
); );
} }
@@ -38,7 +38,7 @@ export function TankopediaAntagonistControl({
const equalize = Tankopedia.use((state) => state.environment.equalize); const equalize = Tankopedia.use((state) => state.environment.equalize);
const hasCalibratedShells = useEquipment("antagonist", 103); const hasCalibratedShells = useEquipment("antagonist", 103);
const hasEnhancedArmor = useEquipment("antagonist", 110); const hasEnhancedArmor = useEquipment("protagonist", 110);
const tank = useDuel("antagonist", DuelModule.Tank); const tank = useDuel("antagonist", DuelModule.Tank);
const gun = useDuel("antagonist", DuelModule.Gun); const gun = useDuel("antagonist", DuelModule.Gun);
@@ -177,9 +177,9 @@ export function TankopediaAntagonistControl({
draft.shot = undefined; draft.shot = undefined;
if (hasEnhancedArmor) { if (hasEnhancedArmor) {
delete draft.antagonist.equipment[4]; delete draft.protagonist.equipment[4];
} else { } else {
draft.antagonist.equipment[4] = 0; draft.protagonist.equipment[4] = 0;
} }
}); });
}} }}
@@ -125,6 +125,9 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
overflow: hidden;
background-color: var(--gray-2); background-color: var(--gray-2);
outline: var(--gray-5) solid var(--space-1); outline: var(--gray-5) solid var(--space-1);
border-radius: var(--space-3); border-radius: var(--space-3);
@@ -0,0 +1,175 @@
import { mauveDark } from "@radix-ui/colors";
import { useFrame } from "@react-three/fiber";
import type { QuicklimeEvent } from "quicklime";
import { Suspense, useEffect, useRef } from "react";
import { LineBasicMaterial, MeshBasicMaterial, type Group } from "three";
import { degToRad } from "three/src/math/MathUtils.js";
import { api } from "../api/dynamic";
import { jsxTree } from "../core/blitzkit/jsxTree";
import {
modelTransformEvent,
type ModelTransformEventData,
} from "../core/blitzkit/modelTransform";
import { DuelModule, useDuel } from "../hooks/useDuel";
import { useModel } from "../hooks/useModel";
import { SmartCanvas } from "./SmartCanvas";
const modelDefinitions = await api.models();
const ZOOM = 60;
type Chunk = "gun" | "turret" | "body";
const surfaceMaterial: Record<Chunk, MeshBasicMaterial> = {
gun: new MeshBasicMaterial({ color: mauveDark.mauve8, toneMapped: false }),
turret: new MeshBasicMaterial({ color: mauveDark.mauve6, toneMapped: false }),
body: new MeshBasicMaterial({
color: mauveDark.mauve6,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
const outlineMaterial: Record<Chunk, LineBasicMaterial> = {
gun: new LineBasicMaterial({ color: mauveDark.mauve11, toneMapped: false }),
turret: new LineBasicMaterial({ color: mauveDark.mauve8, toneMapped: false }),
body: new LineBasicMaterial({
color: mauveDark.mauve8,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
export function TankopediaFlexibilityCanvas() {
return (
<SmartCanvas
orthographic
camera={{ zoom: ZOOM, rotation: [0, Math.PI / 2, 0] }}
>
<Suspense fallback={null}>
<Content />
</Suspense>
<CameraPositioner />
</SmartCanvas>
);
}
function CameraPositioner() {
useFrame(({ camera, gl: { domElement } }) => {
const { width } = domElement.getBoundingClientRect();
camera.position.set(10, 0, -width / ZOOM / 6);
});
return null;
}
function Content() {
const hullWrapper = useRef<Group>(null);
const gunWrapper = useRef<Group>(null);
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
// baked-in turret mount tilt, applied as a static base rotation so the live
// gun pitch composes on top of it
const initialTurretPitch = -degToRad(
tankModel.initial_turret_rotation?.pitch ?? 0,
);
useEffect(() => {
function handle(event: QuicklimeEvent<ModelTransformEventData>) {
if (!gunWrapper.current || !hullWrapper.current) return;
gunWrapper.current.rotation.x = event.data.pitch;
if (event.data.yaw) hullWrapper.current.rotation.y = event.data.yaw;
}
modelTransformEvent.on(handle);
return () => {
modelTransformEvent.off(handle);
};
}, []);
const origin = turretModel.gun_origin!;
return (
<>
<group ref={hullWrapper} position={[0, 0, -origin.z]}>
<group position={[0, 0, origin.z]}>
<ModelChunk only="body" />
</group>
</group>
<group
position={[0, -origin.y, -origin.z]}
rotation={[initialTurretPitch, 0, 0]}
>
<group position={[0, origin.y, origin.z]}>
<ModelChunk only="turret" />
<group ref={gunWrapper}>
<ModelChunk only="gun" />
</group>
</group>
</group>
</>
);
}
function ModelChunk({ only }: { only: Chunk }) {
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const gun = useDuel("protagonist", DuelModule.Gun);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
const originZ = tankModel.turret_origin!.z + turretModel.gun_origin!.z;
const originY = tankModel.turret_origin!.y + turretModel.gun_origin!.y;
const { gltf } = useModel(tank.id);
const nodes = Object.values(gltf.nodes);
const gunId = gunModel.model_id.toString().padStart(2, "0");
const turretId = turretModel.model_id.toString().padStart(2, "0");
return (
<group position={[0, -originY, -originZ]}>
{nodes.map((node) => {
const isGun =
node.name === `gun_${gunId}` || node.name === `gun_${gunId}_mask`;
const isTurret = node.name === `turret_${turretId}`;
const isBody =
node.name === "hull" ||
node.name.startsWith("chassis_wheel_") ||
node.name.startsWith("chassis_track_");
if (
(only === "gun" && !isGun) ||
(only === "turret" && !isTurret) ||
(only === "body" && !isBody)
) {
return null;
}
return jsxTree(node, {
mesh(mesh, props, key) {
return (
<>
<mesh key={key} {...props} material={surfaceMaterial[only]} />
<lineSegments material={outlineMaterial[only]}>
<edgesGeometry args={[mesh.geometry, 29]} />
</lineSegments>
</>
);
},
});
})}
</group>
);
}
@@ -39,12 +39,6 @@
} }
} }
.state {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.shells { .shells {
display: flex; display: flex;
} }
@@ -144,6 +138,86 @@
} }
} }
.tree {
--column-gap: var(--space-5);
--row-gap: var(--space-4);
--line-thickness: var(--space-1);
--line-border: var(--space-1) solid var(--gray-6);
display: grid;
gap: var(--row-gap) var(--column-gap);
.cell {
position: relative;
.trunk-up,
.trunk-down {
position: absolute;
left: calc(var(--column-gap) / -2);
height: 100%;
}
.trunk-up,
.trunk-down,
.snake-up,
.snake-down {
border-left: var(--line-border);
border-right: var(--line-border);
}
.trunk-up {
bottom: 50%;
}
.trunk-down {
top: 50%;
}
.left,
.right {
position: absolute;
top: 50%;
width: calc(var(--column-gap) / 2);
border-top: var(--line-border);
border-bottom: var(--line-border);
}
.left {
right: 100%;
}
.right {
left: 100%;
}
.snake-up,
.snake-down {
position: absolute;
left: 50%;
transform: translateX(-50%);
height: var(--row-gap);
}
.snake-up {
bottom: 100%;
}
.snake-down {
top: 100%;
}
.module {
position: relative;
.icon {
width: 2em;
height: 2em;
}
}
}
}
.lines { .lines {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3,36 +3,56 @@ import {
MAX_SKILL_LEVEL, MAX_SKILL_LEVEL,
permanentSkills, permanentSkills,
romanize, romanize,
tankIcon,
} from "@blitzkit/core"; } from "@blitzkit/core";
import type { Consumable, EquipmentSlot, Provision } from "@blitzkit/protos"; import {
ModuleType,
type Consumable,
type EngineDefinition,
type EquipmentSlot,
type GunDefinition,
type Provision,
type TankDefinition,
type TrackDefinition,
type TurretDefinition,
type Unlock,
} from "@blitzkit/protos";
import { import {
ClockIcon, ClockIcon,
DoubleArrowDownIcon,
DoubleArrowUpIcon, DoubleArrowUpIcon,
ResetIcon, ResetIcon,
TrashIcon, TrashIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { chunk } from "lodash-es"; import { chunk } from "lodash-es";
import { useCallback, useMemo } from "react";
import { api } from "../../api/dynamic"; import { api } from "../../api/dynamic";
import { equipmentColumns } from "../../config/equipment"; import { equipmentColumns } from "../../config/equipment";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset"; import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
import { useLocale } from "../../hooks/useLocale";
import { useProvision } from "../../hooks/useProvision"; import { useProvision } from "../../hooks/useProvision";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import { useTankCompatibility } from "../../hooks/useTankCompatibility"; import { useTankCompatibility } from "../../hooks/useTankCompatibility";
import { useUnwrapper } from "../../hooks/useUnwrapper"; import { useUnwrapper } from "../../hooks/useUnwrapper";
import { Tankopedia } from "../../stores/tankopedia"; import { Tankopedia } from "../../stores/tankopedia";
import { layoutModuleTree } from "../../tankopedia/moduleTreeLayout";
import { vehicleStatusKeys } from "../../tankopedia/tankState"; import { vehicleStatusKeys } from "../../tankopedia/tankState";
import type { ComputedCharacteristics } from "../../types/characteristics"; import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button"; import { Button } from "../Button";
import { Discriminator } from "../Discriminator";
import { Heading } from "../Heading"; import { Heading } from "../Heading";
import { IconButton } from "../IconButton"; import { IconButton } from "../IconButton";
import { LinkI18n } from "../LinkI18n";
import { Text } from "../Text"; import { Text } from "../Text";
import { Tooltip } from "../Tooltip"; import { Tooltip } from "../Tooltip";
import styles from "./index.module.css"; import styles from "./index.module.css";
// TODO: across the entire repo, wrap these in Promise.all's
const equipment = await api.equipment(); const equipment = await api.equipment();
const provisions = await api.provisions(); const provisions = await api.provisions();
const consumables = await api.consumables(); const consumables = await api.consumables();
const tanks = await api.tanks();
const skills = await api.skills(); const skills = await api.skills();
interface TankopediaLoadoutProps { interface TankopediaLoadoutProps {
@@ -40,23 +60,338 @@ interface TankopediaLoadoutProps {
} }
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) { export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
// const tank = useDuel("protagonist", DuelModule.Tank);
// const showModules = hasUpgrades(tank);
return ( return (
<> <>
{/* {showModules && <Modules />} */} <Modules />
<Shells /> <Shells />
<Equipment /> <Equipment />
<Provisions /> <Provisions />
<Consumables characteristics={characteristics} /> <Consumables characteristics={characteristics} />
<CrewSkills /> <CrewSkills />
<State />
<Status /> <Status />
</> </>
); );
} }
const moduleIconNames: Record<ModuleType, string> = {
[ModuleType.MODULE_TYPE_VEHICLE]: "",
[ModuleType.MODULE_TYPE_ENGINE]: "engine",
[ModuleType.MODULE_TYPE_TRACKS]: "chassis",
[ModuleType.MODULE_TYPE_TURRET]: "turret",
[ModuleType.MODULE_TYPE_GUN]: "gun",
};
type AnyModule =
| TurretDefinition
| GunDefinition
| TrackDefinition
| EngineDefinition;
function Modules() {
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
const hasUpgrades =
tank.turrets.length > 1 ||
tank.turrets[0].guns.length > 1 ||
tank.engines.length > 1 ||
tank.tracks.length > 1;
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.modules}</Heading>
{hasUpgrades && (
<div className={styles["upgrade-buttons"]}>
<IconButton
color="gray"
variant="soft"
onClick={() => {
Tankopedia.mutate((draft) => {
const turret = tank.turrets[0];
const gun = turret.guns[0];
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
draft.protagonist.engine = tank.engines[0].id;
draft.protagonist.track = tank.tracks[0].id;
});
}}
>
<DoubleArrowDownIcon />
</IconButton>
<IconButton
color="gray"
variant="soft"
aria-label={`${strings.tanks.loadout.maximize} ${strings.tanks.loadout.modules}`}
onClick={() => {
Tankopedia.mutate((draft) => {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
draft.protagonist.engine = tank.engines.at(-1)!.id;
draft.protagonist.track = tank.tracks.at(-1)!.id;
});
}}
>
<DoubleArrowUpIcon />
</IconButton>
</div>
)}
</div>
<ModuleTree />
</div>
);
}
function stock(type: ModuleType, id: number) {
return { id, type, cost: { type: "xp", value: 0 } } satisfies Unlock;
}
function ModuleTree() {
const tank = useDuel("protagonist", DuelModule.Tank);
const unwrap = useUnwrapper();
// The same gun can sit on several turrets; reversing keeps the first one,
// which is what a linear find would return
const modules = useMemo(
() =>
new Map<ModuleType, Map<number, AnyModule>>([
[
ModuleType.MODULE_TYPE_TRACKS,
new Map(tank.tracks.map((track) => [track.id, track])),
],
[
ModuleType.MODULE_TYPE_ENGINE,
new Map(tank.engines.map((engine) => [engine.id, engine])),
],
[
ModuleType.MODULE_TYPE_TURRET,
new Map(tank.turrets.map((turret) => [turret.id, turret])),
],
[
ModuleType.MODULE_TYPE_GUN,
new Map(
tank.turrets
.flatMap((turret) => turret.guns)
.toReversed()
.map((gun) => [gun.id, gun]),
),
],
]),
[tank],
);
const resolve = useCallback(
(unlock: Unlock) =>
unlock.type === ModuleType.MODULE_TYPE_VEHICLE
? (tanks.tanks[unlock.id] as TankDefinition | undefined)
: modules.get(unlock.type)?.get(unlock.id),
[modules],
);
const { cells, columns, rows } = useMemo(
() =>
layoutModuleTree(
[
stock(ModuleType.MODULE_TYPE_GUN, tank.turrets[0].guns[0].id),
stock(ModuleType.MODULE_TYPE_TURRET, tank.turrets[0].id),
stock(ModuleType.MODULE_TYPE_ENGINE, tank.engines[0].id),
stock(ModuleType.MODULE_TYPE_TRACKS, tank.tracks[0].id),
].filter((unlock) => resolve(unlock) !== undefined),
(unlock) => {
const module = resolve(unlock);
const children =
module !== undefined && "unlocks" in module ? module.unlocks : [];
return children.filter((child) => resolve(child) !== undefined);
},
tank.module_positions,
),
[tank, resolve],
);
return (
<div
className={styles.tree}
style={{
gridTemplateColumns: `repeat(${columns}, var(--module-size))`,
gridTemplateRows: `repeat(${rows}, var(--module-size))`,
}}
>
{cells.map((cell) => {
const module = cell.unlock && resolve(cell.unlock);
return (
<div
key={cell.position.join(",")}
className={styles.cell}
style={{ gridColumn: cell.position[0], gridRow: cell.position[1] }}
>
{cell.trunkUp && <span className={styles["trunk-up"]} />}
{cell.trunkDown && <span className={styles["trunk-down"]} />}
{cell.snakeUp && <span className={styles["snake-up"]}></span>}
{cell.snakeDown && <span className={styles["snake-down"]}></span>}
{cell.right && <span className={styles.right} />}
{cell.left && <span className={styles.left}></span>}
{cell.unlock && module && (
<ModuleNode
unlock={cell.unlock}
module={module}
tooltip={unwrap(module.name!)}
/>
)}
</div>
);
})}
</div>
);
}
interface ModuleNodeProps {
unlock: Unlock;
module: AnyModule | TankDefinition;
tooltip: string;
}
function ModuleNode({ unlock, module, tooltip }: ModuleNodeProps) {
const locale = useLocale();
const tank = useDuel("protagonist", DuelModule.Tank);
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
const topTurret = tank.turrets.at(-1)!;
const isSelected = Tankopedia.use((state) => {
switch (unlock.type) {
case ModuleType.MODULE_TYPE_TURRET:
return state.protagonist.turret === unlock.id;
case ModuleType.MODULE_TYPE_GUN:
return state.protagonist.gun === unlock.id;
case ModuleType.MODULE_TYPE_ENGINE:
return state.protagonist.engine === unlock.id;
case ModuleType.MODULE_TYPE_TRACKS:
return state.protagonist.track === unlock.id;
default:
return false;
}
});
const isTop =
(unlock.type === ModuleType.MODULE_TYPE_TURRET &&
unlock.id === topTurret.id) ||
(unlock.type === ModuleType.MODULE_TYPE_GUN &&
unlock.id === topTurret.guns.at(-1)!.id) ||
(unlock.type === ModuleType.MODULE_TYPE_ENGINE &&
unlock.id === tank.engines.at(-1)!.id) ||
(unlock.type === ModuleType.MODULE_TYPE_TRACKS &&
unlock.id === tank.tracks.at(-1)!.id);
const button = (
<IconButton
aria-label={tooltip}
size="major"
radius="1"
variant={isSelected ? "surface" : "soft"}
color={isTop ? "amber" : isSelected ? undefined : "gray"}
className={styles.module}
onClick={() => {
if (isTank) return;
Tankopedia.mutate((draft) => {
switch (unlock.type) {
case ModuleType.MODULE_TYPE_TURRET: {
const turret = tank.turrets.find((t) => t.id === unlock.id)!;
const gun =
turret.guns.find((g) => g.id === draft.protagonist.gun) ??
turret.guns.at(-1)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
break;
}
case ModuleType.MODULE_TYPE_GUN: {
const turret = tank.turrets
.find((t) => t.id === draft.protagonist.turret)!
.guns.some((g) => g.id === unlock.id)
? tank.turrets.find((t) => t.id === draft.protagonist.turret)!
: tank.turrets.find((t) =>
t.guns.some((g) => g.id === unlock.id),
)!;
const gun = turret.guns.find((g) => g.id === unlock.id)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
break;
}
case ModuleType.MODULE_TYPE_ENGINE:
draft.protagonist.engine = unlock.id;
break;
case ModuleType.MODULE_TYPE_TRACKS:
draft.protagonist.track = unlock.id;
break;
}
});
}}
>
<Discriminator corner="top-right">{romanize(module.tier)}</Discriminator>
{/* {unlock.cost!.value > 0 && (
<Discriminator>
<Text size="minor">{formatCompact(locale, unlock.cost!.value)}</Text>
<img
alt={unlock.cost!.type}
src={alias("api", `/icons/currencies/${unlock.cost!.type}.webp`)}
/>
</Discriminator>
)} */}
<img
alt={tooltip}
src={
isTank
? tankIcon(unlock.id)
: alias(
"api",
`/icons/modules/${moduleIconNames[unlock.type]}.webp`,
)
}
className={styles.icon}
/>
</IconButton>
);
return (
<Tooltip tooltip={tooltip}>
{isTank ? (
<LinkI18n
locale={locale}
href={`/tanks/${tanks.tanks[unlock.id].slug}`}
>
{button}
</LinkI18n>
) : (
button
)}
</Tooltip>
);
}
function CrewSkills() { function CrewSkills() {
const strings = useStrings(); const strings = useStrings();
const selectedSkills = Tankopedia.use((state) => state.environment.skills); const selectedSkills = Tankopedia.use((state) => state.environment.skills);
@@ -147,38 +482,6 @@ function CrewSkillEntry({ id, level, permanent }: CrewSkillEntryProps) {
); );
} }
function State() {
const strings = useStrings();
const shell = Tankopedia.use((state) => state.protagonist.shell);
const speed = Tankopedia.use((state) => state.protagonist.speed);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.dynamics}</Heading>
<IconButton
color="red"
aria-label={`${strings.tanks.loadout.clear} ${strings.tanks.loadout.consumables}`}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.state}>
<span>shell: {shell}</span>
<span>speed: {speed}</span>
</div>
</div>
);
}
function Status() { function Status() {
const strings = useStrings(); const strings = useStrings();
const status = Tankopedia.use((state) => state.protagonist.status); const status = Tankopedia.use((state) => state.protagonist.status);
@@ -623,293 +926,3 @@ function EquipmentOption({
</Tooltip> </Tooltip>
); );
} }
// function Modules() {
// const strings = useStrings();
// const tank = useDuel("protagonist", DuelModule.Tank);
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const isStock = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return upgrades[line.name] === 0;
// });
// }, [upgrades]);
// const isUpgraded = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return (
// upgrades[line.name] === line.stages.length - 1 ||
// (line.name in alternates && alternates[line.name])
// );
// });
// }, [upgrades]);
// return (
// <div className={styles.section}>
// <div className={styles.header}>
// <Heading size="4">{strings.tanks.loadout.modules}</Heading>
// <div className={styles["upgrade-buttons"]}>
// <IconButton
// variant={isStock ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// if (isAlternativeLine(line.name)) continue;
// draft.protagonist.upgrades[line.name] = 0;
// draft.protagonist.alternates[line.name] = false;
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowLeftIcon />
// </IconButton>
// <IconButton
// variant={isUpgraded ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// draft.protagonist.upgrades[line.name] =
// line.stages.length - 1;
// if (isAlternativeLine(line.name)) {
// const original = originalLineName(line.name)!;
// draft.protagonist.alternates[original] = true;
// }
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowRightIcon />
// </IconButton>
// </div>
// </div>
// <div className={styles.lines}>
// {/* TODO: hide lines with 1 module except for alts */}
// {tank.tank!.upgrade_lines.map((line) => (
// <Line
// key={line.name}
// name={line.name}
// lines={tank.tank!.upgrade_lines}
// />
// ))}
// </div>
// </div>
// );
// }
// interface LineProps {
// name: string;
// lines: UpgradeLine[];
// }
// function Line({ name, lines }: LineProps) {
// const combinedLines = useMemo(() => {
// const names: string[] = [name];
// for (const originalLine in alternativeLines) {
// const alternativeLine = alternativeLines[originalLine];
// if (
// originalLine === name &&
// lines.some((line) => line.name === alternativeLine)
// ) {
// names.push(alternativeLine);
// }
// }
// return names;
// }, [name]);
// return (
// <div className={styles.line}>
// {!isAlternativeLine(name) &&
// combinedLines.map((name) => (
// <div className={styles["chunks-wrapper"]}>
// <LineInner key={name} name={name} lines={lines} />
// </div>
// ))}
// </div>
// );
// }
// function LineInner({ name, lines }: LineProps) {
// const line = lines.find((line) => line.name === name)!;
// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
// <div className={styles["line-wrap"]}>
// {wrapIndex > 0 && (
// <Text size="minor" className={styles["wrap-marker"]} lowContrast>
// <CornerBottomLeftIcon />
// </Text>
// )}
// {chunk.map((stage, elementIndex) => (
// <LineElement
// key={elementIndex}
// index={wrapIndex * maxModulesPerRow + elementIndex}
// lineName={name}
// stage={stage}
// />
// ))}
// </div>
// ));
// }
// interface LineElementProps {
// index: number;
// lineName: string;
// stage: StageParameters;
// }
// function LineElement({ index, lineName, stage }: LineElementProps) {
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const tank = useDuel("protagonist", DuelModule.Tank);
// const strings = useStrings();
// const gameStrings = useGameStrings("TankEntity");
// const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
// let price: StandardPrice | undefined;
// switch (stage.grade) {
// case Grade.GRADE_COMMON:
// price = upgradePreset.common_grade_price;
// break;
// case Grade.GRADE_RARE:
// price = upgradePreset.rare_grade_price;
// break;
// case Grade.GRADE_EPIC:
// price = upgradePreset.unique_grade_price;
// break;
// case Grade.GRADE_LEGENDARY:
// price = upgradePreset.legendary_grade_price;
// break;
// }
// for (const override of upgradePreset.prices_overrides) {
// if (override.stage_tech_name !== stage.tech_name) continue;
// price = override.price;
// }
// price ??= StandardPrice.create();
// let isSelected: boolean;
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName);
// isSelected = alternates[originalLine!];
// } else {
// if (alternates[lineName]) {
// isSelected = false;
// } else {
// isSelected = upgrades[lineName] === index;
// }
// }
// return (
// <div className={styles.wrapper}>
// <Tooltip tooltip={gameStrings[stage.display_name]}>
// <Button
// className={styles.stage}
// color={isSelected ? undefined : "gray"}
// variant={isSelected ? "surface" : "soft"}
// radius="1"
// onClick={() => {
// // TODO: make minimum selectable upgrade the last free module for tanks like the destiny
// Tankopedia.mutate((draft) => {
// const tankData = tank.tank!;
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName)!;
// draft.protagonist.alternates[originalLine] = true;
// draft.protagonist.upgrades[lineName] = index;
// } else {
// draft.protagonist.alternates[lineName] = false;
// draft.protagonist.upgrades[lineName] = index;
// }
// for (const required of stage.required_upgrades) {
// for (const line of tankData.upgrade_lines) {
// let i = 0;
// for (const candidateStage of line.stages) {
// if (candidateStage.tech_name === required) {
// draft.protagonist.upgrades[line.name] = Math.max(
// draft.protagonist.upgrades[line.name],
// i,
// );
// }
// i++;
// }
// }
// }
// for (const line of tankData.upgrade_lines) {
// if (draft.protagonist.alternates[line.name]) {
// continue;
// }
// let i = draft.protagonist.upgrades[line.name];
// while (i > 0) {
// const currentStage = line.stages[i];
// const valid = currentStage.required_upgrades.every(
// (required) => {
// for (const otherLine of tankData.upgrade_lines) {
// const idx = draft.protagonist.upgrades[otherLine.name];
// if (otherLine.stages[idx]?.tech_name === required) {
// return true;
// }
// }
// return false;
// },
// );
// if (valid) break;
// i--;
// }
// draft.protagonist.upgrades[line.name] = i;
// }
// });
// }}
// >
// <img
// className={styles.icon}
// src={`/media/modules/${stage.stage_type}.webp`}
// />
// <Text weight="light" lowContrast size="minor" className={styles.tier}>
// {isAlternativeLine(lineName)
// ? strings.tanks.loadout.alternative
// : romanize(stage.number)}
// </Text>
// </Button>
// </Tooltip>
// {<Price className={styles.price} price={price} />}
// </div>
// );
// }
@@ -1,3 +1,36 @@
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
.value {
color: var(--gray-12);
font-size: inherit;
}
&[data-position="top-left"],
&[data-position="top-right"] {
top: var(--space-3);
}
&[data-position="bottom-left"],
&[data-position="bottom-right"] {
bottom: var(--space-3);
}
&[data-position="top-left"],
&[data-position="bottom-left"] {
left: var(--space-3);
}
&[data-position="top-right"],
&[data-position="bottom-right"] {
right: var(--space-3);
}
}
.visualizer { .visualizer {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -104,39 +137,6 @@
} }
} }
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
.value {
color: var(--gray-12);
font-size: inherit;
}
&[data-position="top-left"] {
top: var(--space-3);
left: var(--space-3);
}
&[data-position="top-right"] {
top: var(--space-3);
right: var(--space-3);
}
&[data-position="bottom-left"] {
bottom: var(--space-3);
left: var(--space-3);
}
&[data-position="bottom-right"] {
right: var(--space-3);
bottom: var(--space-3);
}
}
.explosive-overlay { .explosive-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -386,3 +386,158 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
} }
.view-range {
width: 100%;
height: 100%;
touch-action: none;
border-radius: inherit;
user-select: none;
.map {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: grayscale(0.5) brightness(0.8) contrast(1.2);
}
.range {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
aspect-ratio: 1 / 1;
border-radius: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(var(--black-a6), var(--gray-a12));
pointer-events: none;
}
.camouflage {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
gap: var(--space-3);
box-sizing: border-box;
width: 100%;
padding: var(--space-2) var(--space-3);
background-color: var(--black-a7);
backdrop-filter: blur(4rem);
-webkit-backdrop-filter: blur(4rem);
font-size: var(--font-size-minor);
.slider {
flex: 1;
}
}
.maps {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
background: linear-gradient(
90deg,
transparent,
var(--black-a7),
transparent
);
padding-top: var(--space-2);
}
}
.flexibility {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
border-radius: inherit;
user-select: none;
.side {
position: relative;
flex: 0 0 35%;
background: linear-gradient(var(--gray-1), var(--gray-3));
.cone {
position: absolute;
top: 0;
left: 33%;
width: 67%;
height: 100%;
pointer-events: none;
}
}
.radial {
position: relative;
flex: 1;
min-height: 0;
container-type: size;
/* the interactive disc is always the largest centered square */
.square {
position: absolute;
top: 50%;
left: 50%;
width: min(100cqw, 100cqh);
height: min(100cqw, 100cqh);
transform: translate(-50%, -50%);
pointer-events: none;
}
.texture {
position: absolute;
inset: 0;
opacity: 0.125;
background-image: url("/media/toys/armor-hash.png");
box-shadow: inset 0 0 4rem 1rem black;
}
.limits {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.pointer {
position: absolute;
top: 50%;
left: 50%;
width: 1pt;
height: 50%;
transform-origin: top center;
border-radius: var(--space-1);
background: linear-gradient(var(--gray-a11), transparent);
pointer-events: none;
}
.dot {
position: absolute;
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background-color: var(--gray-12);
transform: translate(-50%, 50%);
}
}
}
@@ -1,6 +1,6 @@
import { alias, isExplosive } from "@blitzkit/core"; import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n"; import { literals } from "@blitzkit/i18n";
import { DoubleArrowRightIcon } from "@radix-ui/react-icons"; import { DoubleArrowRightIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { times } from "lodash-es"; import { times } from "lodash-es";
import { import {
useCallback, useCallback,
@@ -12,15 +12,25 @@ import {
type PointerEvent, type PointerEvent,
} from "react"; } from "react";
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js"; import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
import { api } from "../../api/dynamic";
import type { ToyProps } from "../../config/toys"; import type { ToyProps } from "../../config/toys";
import {
DEFAULT_PITCH_TRANSITION,
applyPitchYawLimits,
} from "../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../../core/blitzkit/modelTransform";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipment } from "../../hooks/useEquipment";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import { Tankopedia } from "../../stores/tankopedia";
import type { ComputedCharacteristics } from "../../types/characteristics"; import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button"; import { Button } from "../Button";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { Code } from "../Code"; import { Code } from "../Code";
import { SegmentedControl } from "../SegmentedControl"; import { SegmentedControl } from "../SegmentedControl";
import { Slider } from "../Slider";
import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder"; import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder";
import { TankopediaFlexibilityCanvas } from "../TankopediaFlexibilityCanvas";
import styles from "./index.module.css"; import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) { export function RicochetToy({ characteristics }: ToyProps) {
@@ -528,7 +538,9 @@ export function ReloadToy({ characteristics }: ToyProps) {
className={styles["shell-track"]} className={styles["shell-track"]}
key={index} key={index}
style={{ style={{
transform: `rotate(${135 - 7 * (index - (reloadData.shells - 1) / 2)}deg)`, transform: `rotate(${
135 - 7 * (index - (reloadData.shells - 1) / 2)
}deg)`,
}} }}
> >
<div className={styles.shell}> <div className={styles.shell}>
@@ -566,3 +578,333 @@ export function ReloadToy({ characteristics }: ToyProps) {
</div> </div>
); );
} }
const viewRangeMaps = [
{ id: "ghost-factory", string: "ghost_factory", size: 512 },
{ id: "dead-rail", string: "dead_rail", size: 512 },
] as const;
export function ViewRangeToy({ characteristics }: ToyProps) {
const strings = useStrings();
const [mapId, setMapId] = useState<string>(viewRangeMaps[0].id);
const [camouflage, setCamouflage] = useState(0);
const container = useRef<HTMLDivElement>(null);
const range = useRef<HTMLDivElement>(null);
const map = viewRangeMaps.find(({ id }) => id === mapId) ?? viewRangeMaps[0];
const viewRange = characteristics.get("view_range") as number;
const resolvedViewRange = viewRange * (1 - camouflage);
function follow(event: PointerEvent<HTMLDivElement>) {
if (!container.current || !range.current) return;
const rect = container.current.getBoundingClientRect();
range.current.style.left = `${event.clientX - rect.left}px`;
range.current.style.top = `${event.clientY - rect.top - 16}px`;
}
return (
<div
ref={container}
className={styles["view-range"]}
onPointerMove={follow}
>
<div
className={styles.map}
style={{
backgroundImage: `url(/assets/images/tankopedia/visualizers/range/${map.id}.png)`,
}}
/>
<div
ref={range}
className={styles.range}
style={{ width: `${(resolvedViewRange / map.size) * 100}%` }}
>
<EyeOpenIcon width="1.25rem" height="1.25rem" />
{literals(strings.common.units.m, {
value: resolvedViewRange.toFixed(0),
})}
</div>
<div className={styles.camouflage}>
<span>{strings.tanks.toys.view_range.camo}</span>
<Slider.Root
className={styles.slider}
value={[camouflage]}
min={0}
max={0.5}
step={0.01}
onValueChange={([value]) => setCamouflage(value)}
>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider.Root>
<Code size="minor">
{literals(strings.common.units.percentage, {
value: Math.round(camouflage * 100),
})}
</Code>
</div>
<div className={styles.maps}>
<SegmentedControl
options={viewRangeMaps.map(({ id, string }) => ({
value: id,
label: strings.tanks.toys.view_range[string],
}))}
value={mapId}
onValueChange={setMapId}
variant="underline"
/>
</div>
</div>
);
}
const modelDefinitions = await api.models();
const ANGLE_COEFFICIENT = 1 / 10;
function mag(x: number) {
return (1 / Math.PI) * Math.atan(ANGLE_COEFFICIENT * x) + 1 / 2;
}
function magInverse(y: number) {
return (1 / ANGLE_COEFFICIENT) * Math.tan(Math.PI * y - Math.PI / 2);
}
function polar(thetaDeg: number, m = 1) {
const theta = degToRad(thetaDeg);
return `${m * Math.cos(theta)} ${-m * Math.sin(theta)}`;
}
export function FlexibilityToy() {
const strings = useStrings();
const degrees = (value: number) =>
literals(strings.common.units.deg, { value: value.toFixed(0) });
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const gun = useDuel("protagonist", DuelModule.Gun);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
const p = gunModel.pitch!;
const y = turretModel.yaw;
const f = p.front;
const b = p.back;
const t = p.transition ?? DEFAULT_PITCH_TRANSITION;
// the turret's baked-in mount tilt is folded into displayed pitch values
const initialTurretPitch = degToRad(
tankModel.initial_turret_rotation?.pitch ?? 0,
);
const tiltDeg = radToDeg(initialTurretPitch);
const magTilted = (deg: number) => mag(deg + tiltDeg);
const defaultMin = -degToRad(f?.max ?? p.max) - initialTurretPitch;
const defaultMax = -degToRad(f?.min ?? p.min) - initialTurretPitch;
const [yaw, setYaw] = useState(0);
const [pitch, setPitch] = useState(0);
const [minPitch, setMinPitch] = useState(defaultMin);
const [maxPitch, setMaxPitch] = useState(defaultMax);
const container = useRef<HTMLDivElement>(null);
// reset when the gun or turret changes
useEffect(() => {
setYaw(0);
setPitch(0);
setMinPitch(defaultMin);
setMaxPitch(defaultMax);
modelTransformEvent.dispatch({ pitch: 0, yaw: 0 });
}, [tank.id, turret.id, gun.id]);
function outline(limit: "max" | "min") {
const m = magTilted(p[limit]);
let d = "";
// right
if (y) {
d += `M 0 0 L ${polar(90 - y.max, m)}`;
} else if (b) {
d += `M ${polar(-90 + b.range / 2 + t, m)}`;
} else {
d += `M ${polar(-90, m)}`;
}
d += f
? `A ${m} ${m} 0 0 0 ${polar(90 - f.range / 2 - t, m)}`
: `A ${m} ${m} 0 0 0 ${polar(90, m)}`;
// front
if (f) {
const mf = magTilted(f[limit]);
d += `L ${polar(90 - f.range / 2, mf)}`;
d += `A ${mf} ${mf} 0 0 0 ${polar(90 + f.range / 2, mf)}`;
}
// left
d += f ? `L ${polar(90 + f.range / 2 + t, m)}` : `L ${polar(90, m)}`;
if (y) {
d += `A ${m} ${m} 0 0 0 ${polar(90 - y.min, m)}`;
} else if (b) {
d += `A ${m} ${m} 0 0 0 ${polar(-90 - b.range / 2 - t, m)}`;
} else {
d += `A ${m} ${m} 0 0 0 ${polar(-90, m)}`;
}
// back
if (!y && b) {
const mb = magTilted(b[limit]);
d += `L ${polar(-90 - b.range / 2, mb)}`;
d += `A ${mb} ${mb} 0 0 0 ${polar(-90 + b.range / 2, mb)}`;
}
return `${d}Z`;
}
const path = outline("max") + outline("min");
const r = mag(radToDeg(-(pitch - initialTurretPitch)));
const dotX = r * Math.sin(yaw);
const dotY = r * Math.cos(yaw);
function update(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
if (!container.current) return;
const rect = container.current.getBoundingClientRect();
const size = Math.min(rect.width, rect.height);
const u = event.clientX - rect.left - rect.width / 2;
const v = event.clientY - rect.top - rect.height / 2;
let nextYaw = Math.atan2(u, -v);
const min = applyPitchYawLimits(
-Math.PI,
nextYaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
const max = applyPitchYawLimits(
Math.PI,
nextYaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
nextYaw = min[1];
const distance = clamp(
2 * Math.sqrt((u / size) ** 2 + (v / size) ** 2),
0,
1,
);
const nextPitch = clamp(
degToRad(magInverse(-distance)) + initialTurretPitch,
min[0],
max[0],
);
setPitch(nextPitch);
setYaw(nextYaw);
setMinPitch(min[0] - initialTurretPitch);
setMaxPitch(max[0] - initialTurretPitch);
modelTransformEvent.dispatch({ pitch: nextPitch, yaw: nextYaw });
}
return (
<div className={styles.flexibility}>
<div className={styles.side}>
{modelRequested && <TankopediaFlexibilityCanvas />}
<div
className={styles.cone}
style={{
background: `conic-gradient(
at 0 50%,
transparent,
transparent ${Math.PI / 2 - maxPitch}rad,
var(--cyan-a4) ${Math.PI / 2 - maxPitch}rad,
var(--jade-a4) ${Math.PI / 2 - minPitch}rad,
transparent ${Math.PI / 2 - minPitch}rad,
transparent 180deg
)`,
}}
/>
</div>
<div ref={container} className={styles.radial} onPointerMove={update}>
<div className={styles.texture} />
<div className={styles.square}>
<svg className={styles.limits} viewBox="-1 -1 2 2">
<path
fillRule="evenodd"
fill="var(--gray-5)"
stroke="var(--gray-8)"
strokeWidth="1px"
vectorEffect="non-scaling-stroke"
d={path}
/>
<path
stroke="var(--gray-10)"
fill="transparent"
strokeWidth="1px"
strokeDasharray="0.5rem 0.5rem"
vectorEffect="non-scaling-stroke"
d="M 0.5 0 A 0.5 0.5 0 1 1 -0.5 0 A 0.5 0.5 0 1 1 0.5 0 Z"
/>
</svg>
<div
className={styles.dot}
style={{
left: `${50 * (dotX + 1)}%`,
bottom: `${50 * (dotY + 1)}%`,
}}
/>
</div>
<div
className={styles.pointer}
style={{ transform: `translateX(-50%) rotate(${yaw + Math.PI}rad)` }}
/>
<CornerStat
label={strings.tanks.toys.flexibility.pitch}
value={degrees(radToDeg(pitch - initialTurretPitch))}
position={CornerStatPosition.TopLeft}
/>
<CornerStat
label={strings.tanks.toys.flexibility.yaw}
value={degrees(radToDeg(yaw))}
position={CornerStatPosition.TopRight}
/>
<CornerStat
label={strings.tanks.toys.flexibility.max}
value={degrees(radToDeg(maxPitch))}
position={CornerStatPosition.BottomLeft}
/>
<CornerStat
label={strings.tanks.toys.flexibility.min}
value={degrees(radToDeg(minPitch))}
position={CornerStatPosition.BottomRight}
/>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
.section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
padding: var(--space-4);
.cards {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4);
width: 100%;
.card {
display: flex;
flex: 0 0 auto;
flex-direction: column;
overflow: hidden;
border-radius: var(--space-3);
background-size: cover;
background-position: 0 18px;
color: inherit;
text-decoration: none;
.thumbnail {
width: 16rem;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
}
.info {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-3);
backdrop-filter: blur(4rem);
-webkit-backdrop-filter: blur(4rem);
}
&:hover .info {
text-decoration: underline;
}
}
}
}
@@ -0,0 +1,56 @@
import { youtubers } from "@blitzkit/core";
import { awaitableReviews } from "../../core/awaitables/reviews";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings";
import { Heading } from "../Heading";
import { Text } from "../Text";
import { VerifiedIcon } from "../VerifiedIcon";
import styles from "./index.module.css";
const reviews = await awaitableReviews;
export function TankopediaVideos() {
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
const videos = reviews.reviews[tank.id]?.videos;
if (videos === undefined || videos.length === 0) return null;
return (
<section className={styles.section}>
<Heading size="3">{strings.tanks.videos.title}</Heading>
<div className={styles.cards}>
{videos.map((video) => {
const image = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
const youtuber = youtubers.find(
(youtuber) => youtuber.id === video.author,
);
if (!youtuber) return null;
return (
<a
key={video.id}
className={styles.card}
style={{ backgroundImage: `url(${image})` }}
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noreferrer"
>
<div
className={styles.thumbnail}
style={{ backgroundImage: `url(${image})` }}
/>
<div className={styles.info}>
<Text>{youtuber.name}</Text>
<VerifiedIcon width="1em" height="1em" />
</div>
</a>
);
})}
</div>
</section>
);
}
+474 -97
View File
@@ -7,11 +7,18 @@ import {
import { ShellType, TankClass } from "@blitzkit/protos"; import { ShellType, TankClass } from "@blitzkit/protos";
import { hullTraverseSpeed } from "../tankopedia/hullTraverseSpeed"; import { hullTraverseSpeed } from "../tankopedia/hullTraverseSpeed";
import { rawTerrainCoefficient } from "../tankopedia/rawTerrainCoefficient"; import { rawTerrainCoefficient } from "../tankopedia/rawTerrainCoefficient";
import { tankClassToBlitzTankClass } from "../tankopedia/tankClass";
import { import {
CharacteristicType, CharacteristicType,
type Characteristic, type Characteristic,
} from "../types/characteristics"; } from "../types/characteristics";
/**
* peek-a-boo assumes at least this many seconds between shots to account for
* peeking out, aiming, and retreating
*/
const PEEKABOO_MIN_CYCLE = 10;
export const characteristics = { export const characteristics = {
gun_type: { gun_type: {
type: CharacteristicType.Enum, type: CharacteristicType.Enum,
@@ -59,10 +66,11 @@ export const characteristics = {
}, },
}, },
// characteristics prefixed with an underscore are compute time only; they are never rendered
_reload_coefficient: { _reload_coefficient: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ script, degressive }) { compute({ script, degressive, gun }) {
let c0 = 1; let c0 = 1;
script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => { script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => {
@@ -73,9 +81,12 @@ export const characteristics = {
let c2 = 1; let c2 = 1;
script("protagonist", "Berserk", (effect) => { // adrenaline only affects regular guns
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100; if (gun.gun_type!.$case === "regular") {
}); script("protagonist", "Berserk", (effect) => {
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
});
}
return (c0 * c1) / c2; return (c0 * c1) / c2;
}, },
@@ -88,7 +99,7 @@ export const characteristics = {
return gun.gun_type!.$case !== "auto_reloader"; return gun.gun_type!.$case !== "auto_reloader";
}, },
compute({ gun, characteristic }) { compute({ gun, characteristic }): number {
if (gun.gun_type!.$case === "auto_reloader") return -Infinity; if (gun.gun_type!.$case === "auto_reloader") return -Infinity;
const c = characteristic("_reload_coefficient") as number; const c = characteristic("_reload_coefficient") as number;
@@ -139,10 +150,26 @@ export const characteristics = {
}, },
}, },
dpm: { // time per shot when continuously firing the fastest shell of an auto reloader
_optimal_shell_cycle: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ gun, characteristic }) { compute({ gun, characteristic }) {
if (gun.gun_type!.$case !== "auto_reloader") return 0;
const intraClip = characteristic("intra_clip") as number;
const reloads = characteristic("reloads") as number[];
return Math.min(
...reloads.map((reload, index) => reload + (index > 0 ? intraClip : 0)),
);
},
},
dpm: {
type: CharacteristicType.Number,
compute({ gun, characteristic }): number {
const alpha = characteristic("damage") as number; const alpha = characteristic("damage") as number;
let dps: number; let dps: number;
@@ -170,32 +197,49 @@ export const characteristics = {
dps = damage / time; dps = damage / time;
} else { } else {
const intraClip = characteristic("intra_clip") as number; dps = alpha / (characteristic("_optimal_shell_cycle") as number);
const reloadCoefficient = characteristic(
"_reload_coefficient",
) as number;
const mostOptimalShell =
gun.gun_type!.value.shell_reloads.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient + (index > 0 ? intraClip : 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
dps = alpha / mostOptimalShell?.reload;
} }
return dps * 60; return dps * 60;
}, },
}, },
dpm_peekaboo: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case === "regular";
},
compute({ characteristic }): number {
const damage = characteristic("damage") as number;
const reload = characteristic("reload") as number;
return damage * (60 / Math.max(reload, PEEKABOO_MIN_CYCLE));
},
},
dpm_effective: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case === "auto_reloader";
},
compute({ characteristic }): number {
const damage = characteristic("damage") as number;
const reloads = characteristic("reloads") as number[];
const intraClip = characteristic("intra_clip") as number;
// dump every other shell at the intra clip rate, spend the rest of the
// minute cycling the optimal shell; ordering doesn't change the total
const rest = reloads.length - 1;
const cycle = characteristic("_optimal_shell_cycle") as number;
return damage * rest + (damage * (60 - rest * intraClip)) / cycle;
},
},
// damage_range: unavailable, // damage_range: unavailable,
module_damage: { module_damage: {
@@ -233,7 +277,7 @@ export const characteristics = {
return gun.gun_type!.$case !== "regular"; return gun.gun_type!.$case !== "regular";
}, },
compute({ characteristic }) { compute({ characteristic }): number {
const clipSize = characteristic("clip_size") as number; const clipSize = characteristic("clip_size") as number;
const damage = characteristic("damage") as number; const damage = characteristic("damage") as number;
@@ -241,36 +285,35 @@ export const characteristics = {
}, },
}, },
// TODO: custom type of shell index /**
// optimal_shell: { * 1-indexed so that it can be rendered as-is
// type: CharacteristicType.Number, */
optimal_shell: {
type: CharacteristicType.Number,
// should_render({ gun }) { should_render({ gun }) {
// return gun.gun_type!.$case === "auto_reloader"; return gun.gun_type!.$case === "auto_reloader";
// }, },
// compute({ gun, script, degressive }) { compute({ characteristic }): number {
// if (gun.gun_type!.$case !== "auto_reloader") return -1; const reloads = characteristic("reloads") as number[];
const intraClip = characteristic("intra_clip") as number;
// const autoReloader = gun.gun_type!.value; let best = 0;
// const coefficient = reloadCoefficient({ script, degressive, gun }); let bestTime = Infinity;
// const intraClip = intraClipCoefficient({ script });
// return autoReloader.shell_reloads.reduce( reloads.forEach((reload, index) => {
// (bestIndex, reload, index, reloads) => { const time = reload + (index > 0 ? intraClip : 0);
// const effectiveReload =
// reload * coefficient +
// (index > 0 ? autoReloader.intra_clip * intraClip : 0);
// const bestReload =
// reloads[bestIndex] * coefficient +
// (bestIndex > 0 ? autoReloader.intra_clip * intraClip : 0);
// return effectiveReload < bestReload ? index : bestIndex; if (time < bestTime) {
// }, best = index;
// 0, bestTime = time;
// ); }
// }, });
// },
return best + 1;
},
},
burst_size: { burst_size: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
@@ -410,41 +453,87 @@ export const characteristics = {
}, },
}, },
explosion_radius: {
type: CharacteristicType.Number,
should_render({ shell }) {
return shell.explosion_radius !== undefined;
},
compute({ shell }) {
return shell.explosion_radius!;
},
},
speed_forward: { speed_forward: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ tank }) { compute({ tank, script }) {
return tank.speed_forwards; let bias = 0;
let factor = 1;
script("protagonist", "GearOil", (effect) => {
bias += effect.bonusValues?.fwdSpeedLimitBias ?? 0;
});
script("protagonist", "ImprovedAfterburning", (effect) => {
bias += effect.fwdSpeedLimitBias ?? 0;
});
script("protagonist", "AttributesModifier", (effect) => {
factor *= effect.attributes?.speedLimitsFactor ?? 1;
});
return (tank.speed_forwards + bias) * factor;
}, },
}, },
speed_backward: { speed_backward: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ tank }) { compute({ tank, script }) {
return tank.speed_backwards; let bias = 0;
let factor = 1;
script("protagonist", "GearOil", (effect) => {
bias += effect.bonusValues?.bkwdSpeedLimitBias ?? 0;
});
script("protagonist", "ImprovedAfterburning", (effect) => {
bias += effect.bkwdSpeedLimitBias ?? 0;
});
script("protagonist", "AttributesModifier", (effect) => {
factor *= effect.attributes?.speedLimitsFactor ?? 1;
});
return (tank.speed_backwards + bias) * factor;
}, },
}, },
aim_time: { aim_time: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ gun, turret, track, degressive, script, characteristic }) { compute({ tank, gun, turret, track, degressive, script }): number {
/* /*
T = (A / C) × ln(√(1 + k² × S)) / (1 − p/100) Mirrors the game's hangar params builder (wotblitz.exe 11.21, aim time
at 0x19eb300). Consumables such as FireControlSystem are battle-time
only and are not part of it.
T = (A × C) × ln(√(1 + k² × S)) / (1 − p/100)
S = (turret rotation speed × turret dispersion factor)² S = (turret rotation speed × turret dispersion factor)²
+ (hull rotation speed × hull dispersion factor)² + (hull rotation speed × hull dispersion factor)²
+ (min(top speed, 30) × movement dispersion factor)² + (min(raw top speed, 30) × movement dispersion factor)²
raw top speed is the vehicle's unmodified forward speed limit; equipment
and engine modifiers do not affect it here.
A: the gun’s raw aimingTime—the time for the reticle to shrink by a factor of e ≈ 2.718. A: the gun’s raw aimingTime—the time for the reticle to shrink by a factor of e ≈ 2.718.
C: gunner crew factor, accounting for training, provisions, and shared crew roles. C: gunner degressive crew factor (the reciprocal of the game's gunner value, which divides the time).
k: stabilization multiplier; normally 1, reduced by the stabilizer. k: stabilization multiplier; normally 1, reduced by the stabilizer.
p: signed aiming-drive modifier. For −12%, the displayed time is divided by 1.12, reducing it by approximately 10.7%. p: signed aiming-drive modifier. For −12%, the displayed time is divided by 1.12, reducing it by approximately 10.7%.
*/ */
const topSpeed = characteristic("speed_forward") as number;
const S = const S =
(turret.traverse_speed * gun.dispersion_traverse) ** 2 + (turret.traverse_speed * gun.dispersion_traverse) ** 2 +
(track.traverse_speed * track.dispersion_traverse) ** 2 + (track.traverse_speed * track.dispersion_traverse) ** 2 +
(Math.min(topSpeed, 30) * track.dispersion_move) ** 2; (Math.min(tank.speed_forwards, 30) * track.dispersion_move) ** 2;
const A = gun.aim_time; const A = gun.aim_time;
const C = degressive("gunner"); const C = degressive("gunner");
@@ -452,7 +541,7 @@ export const characteristics = {
let p = 0; let p = 0;
script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => { script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => {
p = effect.bonusValues!.percent / 100; p += effect.bonusValues!.percent / 100;
}); });
let k = 1; let k = 1;
@@ -465,7 +554,7 @@ export const characteristics = {
}, },
); );
const T = ((A / C) * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p / 100); const T = (A * C * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p);
return T; return T;
}, },
@@ -488,28 +577,171 @@ export const characteristics = {
}, },
}, },
_stabilizer_coefficient: {
type: CharacteristicType.Number,
compute({ script }) {
let coefficient = 0;
script(
"protagonist",
"miscAttrs/additiveShotDispersionFactor",
(effect) => {
coefficient += effect.bonusValues!.percent / 100;
},
);
return coefficient;
},
},
_camouflage_bonus: {
type: CharacteristicType.Number,
compute({ tank, state }) {
if (!state.camouflage) return 0;
switch (tank.class) {
case TankClass.TANK_CLASS_TANK_DESTROYER:
return 0.04;
case TankClass.TANK_CLASS_HEAVY:
return 0.03;
default:
return 0.02;
}
},
},
_camouflage_net_bonus_still: {
type: CharacteristicType.Number,
compute({ tank, script }) {
let bonus = 0;
script("protagonist", "CamouflageNet", (effect) => {
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
bonus += base * (effect.activateBonusFactor ?? 1);
});
return bonus;
},
},
_camouflage_net_bonus_moving: {
type: CharacteristicType.Number,
compute({ tank, script }) {
let bonus = 0;
script("protagonist", "CamouflageNet", (effect) => {
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
bonus += base * 1;
});
return bonus;
},
},
dispersion_moving: {
type: CharacteristicType.Number,
compute(context): number {
const skill = (context.environment.skills.smooth_driving ?? 0) / 100;
return (
context.track.dispersion_move *
(1 +
(context.characteristic("_stabilizer_coefficient") as number) -
skill)
);
},
},
dispersion_hull_traversing: {
type: CharacteristicType.Number,
compute(context): number {
const skill = (context.environment.skills.smooth_turn ?? 0) / 100;
return (
context.track.dispersion_traverse *
(1 +
(context.characteristic("_stabilizer_coefficient") as number) -
skill)
);
},
},
dispersion_turret_traversing: {
type: CharacteristicType.Number,
compute(context): number {
const skill = (2 * (context.environment.skills.smooth_turret ?? 0)) / 100;
return (
context.gun.dispersion_traverse *
(1 +
(context.characteristic("_stabilizer_coefficient") as number) -
skill)
);
},
},
dispersion_shooting: {
type: CharacteristicType.Number,
compute(context): number {
const skill = (context.environment.skills.soft_recoil ?? 0) / 100;
return (
context.gun.dispersion_shot *
(1 +
(context.characteristic("_stabilizer_coefficient") as number) -
skill)
);
},
},
dispersion_gun_damaged: {
type: CharacteristicType.Number,
compute(context): number {
return (
context.gun.dispersion_damaged *
(1 + (context.characteristic("_stabilizer_coefficient") as number))
);
},
},
// dispersion_angle: unavailable, // dispersion_angle: unavailable,
gun_depression: { gun_depression: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ state, turret, gun }) { compute({ state, turret, gun, script }) {
const turretModel = state.model.turrets[turret.id]; const turretModel = state.model.turrets[turret.id];
const gunModel = turretModel.guns[gun.id]; const gunModel = turretModel.guns[gun.id];
let bonus = 0;
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
bonus += effect.bonusValues?.lowerPitchLimitIncrease ?? 0;
});
return ( return (
gunModel.pitch!.max + (state.model.initial_turret_rotation?.pitch ?? 0) gunModel.pitch!.max +
(state.model.initial_turret_rotation?.pitch ?? 0) +
bonus
); );
}, },
}, },
gun_elevation: { gun_elevation: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ state, turret, gun }) { compute({ state, turret, gun, script }) {
const turretModel = state.model.turrets[turret.id]; const turretModel = state.model.turrets[turret.id];
const gunModel = turretModel.guns[gun.id]; const gunModel = turretModel.guns[gun.id];
let bonus = 0;
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
bonus += effect.bonusValues?.upperPitchLimitIncrease ?? 0;
});
return ( return (
-gunModel.pitch!.min - (state.model.initial_turret_rotation?.pitch ?? 0) -gunModel.pitch!.min -
(state.model.initial_turret_rotation?.pitch ?? 0) +
bonus
); );
}, },
}, },
@@ -596,22 +828,59 @@ export const characteristics = {
}, },
}, },
_engine_power_coefficient: {
type: CharacteristicType.Number,
compute({ tank, script }) {
let coefficient = 1;
const tankClass = tankClassToBlitzTankClass[tank.class];
script("protagonist", "RecycledEngine", (effect) => {
coefficient +=
(effect.enginePowerPercentageBonus?.[tankClass] ?? 0) / 100;
});
script("protagonist", "Afterburning", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "ImprovedAfterburning", (effect) => {
coefficient += (effect.enginePowerIncrease ?? 1) - 1;
});
script("protagonist", "Fuel", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "GearOil", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "AttributesModifier", (effect) => {
coefficient *= effect.attributes?.enginePowerFactor ?? 1;
});
return coefficient;
},
},
engine_power: { engine_power: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ engine }) { // explicit return type breaks the circular inference through characteristic()
return engine.power; compute({ engine, characteristic }): number {
return (
engine.power * (characteristic("_engine_power_coefficient") as number)
);
}, },
}, },
power_to_weight: { power_to_weight: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ engine, characteristic }) { compute({ characteristic }): number {
const weight = characteristic("weight"); const enginePower = characteristic("engine_power") as number;
if (typeof weight !== "number") { const weight = characteristic("weight") as number;
throw new Error("Weight characteristic must be numeric");
}
return engine.power / weight; return enginePower / weight;
}, },
}, },
@@ -631,7 +900,7 @@ export const characteristics = {
hard_terrain_coefficient: { hard_terrain_coefficient: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ characteristic }) { compute({ characteristic }): number {
const raw = characteristic("hard_terrain_coefficient_raw") as number; const raw = characteristic("hard_terrain_coefficient_raw") as number;
return 100 / raw; return 100 / raw;
}, },
@@ -651,7 +920,7 @@ export const characteristics = {
medium_terrain_coefficient: { medium_terrain_coefficient: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ characteristic }) { compute({ characteristic }): number {
const raw = characteristic("medium_terrain_coefficient_raw") as number; const raw = characteristic("medium_terrain_coefficient_raw") as number;
return 100 / raw; return 100 / raw;
}, },
@@ -671,12 +940,42 @@ export const characteristics = {
soft_terrain_coefficient: { soft_terrain_coefficient: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ characteristic }) { compute({ characteristic }): number {
const raw = characteristic("soft_terrain_coefficient_raw") as number; const raw = characteristic("soft_terrain_coefficient_raw") as number;
return 100 / raw; return 100 / raw;
}, },
}, },
power_to_weight_hard: {
type: CharacteristicType.Number,
compute({ characteristic }): number {
const powerToWeight = characteristic("power_to_weight") as number;
const raw = characteristic("hard_terrain_coefficient_raw") as number;
return powerToWeight / raw;
},
},
power_to_weight_medium: {
type: CharacteristicType.Number,
compute({ characteristic }): number {
const powerToWeight = characteristic("power_to_weight") as number;
const raw = characteristic("medium_terrain_coefficient_raw") as number;
return powerToWeight / raw;
},
},
power_to_weight_soft: {
type: CharacteristicType.Number,
compute({ characteristic }): number {
const powerToWeight = characteristic("power_to_weight") as number;
const raw = characteristic("soft_terrain_coefficient_raw") as number;
return powerToWeight / raw;
},
},
hull_traverse_speed_hard: { hull_traverse_speed_hard: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute(context): number { compute(context): number {
@@ -707,17 +1006,32 @@ export const characteristics = {
}, },
}, },
_turret_traverse_coefficient: {
type: CharacteristicType.Number,
compute({ progressive, script }) {
let c = 1;
script("protagonist", "Fuel", (effect) => {
c += (effect.bonusValues?.turretRotationSpeedIncrease ?? 0) / 100;
});
return c * progressive("gunner");
},
},
turret_traverse_speed: { turret_traverse_speed: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ turret, progressive }) { compute({ turret, characteristic }): number {
return turret.traverse_speed * progressive("gunner"); const c = characteristic("_turret_traverse_coefficient") as number;
return turret.traverse_speed * c;
}, },
}, },
gun_traverse_speed: { gun_traverse_speed: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ gun, progressive }) { compute({ gun, characteristic }): number {
return gun.rotation_speed * progressive("gunner"); const c = characteristic("_turret_traverse_coefficient") as number;
return gun.rotation_speed * c;
}, },
}, },
@@ -754,37 +1068,100 @@ export const characteristics = {
fire_chance: { fire_chance: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ engine }) { compute({ engine, script }) {
return engine.fire_chance; let c = 1;
script("protagonist", "SafetySet", (effect) => {
c -= (effect.bonusValues?.fireProtectionIncrease ?? 0) / 100;
});
return 100 * c * engine.fire_chance;
}, },
}, },
// fire_rate: unavailable, // fire_rate: unavailable,
// ramming_resistance: unavailable, // ramming_resistance: unavailable,
view_range: { crew_count: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ turret, progressive }) { compute({ tank }) {
return turret.view_range * progressive("commander"); return tank.crew.length;
}, },
}, },
view_range: {
type: CharacteristicType.Number,
compute({ tank, turret, progressive, script }) {
let c = 1;
script("protagonist", "CoatedOptics", (effect) => {
c +=
(effect.visionRadiusPercentageBonus?.[
tankClassToBlitzTankClass[tank.class]
] ?? 0) / 100;
});
return turret.view_range * c * progressive("commander");
},
},
/**
* camouflage characteristics are stored as percentages
*/
camouflage: { camouflage: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ tank, state }) { compute(context): number {
const camouflageBonus = const skill = (3 * (context.environment.skills.camouflage ?? 0)) / 100;
tank.class === TankClass.TANK_CLASS_TANK_DESTROYER const c =
? 0.04 1 +
: tank.class === TankClass.TANK_CLASS_HEAVY (context.characteristic("_camouflage_bonus") as number) +
? 0.03 skill +
: 0.02; (context.characteristic("_camouflage_net_bonus_still") as number);
return 100 * context.tank.camouflage_still * c;
},
},
camouflage_moving: {
type: CharacteristicType.Number,
compute(context): number {
// light tanks retain their full camouflage while moving
if (context.tank.class === TankClass.TANK_CLASS_LIGHT) {
return context.characteristic("camouflage") as number;
}
const c =
1 +
(context.characteristic("_camouflage_bonus") as number) +
(context.characteristic("_camouflage_net_bonus_moving") as number);
return 100 * context.tank.camouflage_moving * c;
},
},
camouflage_shooting_still: {
type: CharacteristicType.Number,
compute({ gun, characteristic }): number {
return gun.camouflage_loss * (characteristic("camouflage") as number);
},
},
camouflage_shooting_moving: {
type: CharacteristicType.Number,
compute({ gun, characteristic }): number {
return ( return (
tank.camouflage_still * (1 + (state.camouflage ? camouflageBonus : 0)) gun.camouflage_loss * (characteristic("camouflage_moving") as number)
); );
}, },
}, },
camouflage_on_fire: {
type: CharacteristicType.Number,
compute({ tank, characteristic }): number {
return tank.camouflage_on_fire * (characteristic("camouflage") as number);
},
},
width: { width: {
type: CharacteristicType.Number, type: CharacteristicType.Number,
compute({ state, turret }) { compute({ state, turret }) {
@@ -81,6 +81,20 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
}, },
}, },
{ name: "dpm", decimals: 0, units: "hp", localize: true }, { name: "dpm", decimals: 0, units: "hp", localize: true },
{
name: "dpm_effective",
decimals: 0,
units: "hp",
localize: true,
indent: true,
},
{
name: "dpm_peekaboo",
decimals: 0,
units: "hp",
localize: true,
indent: true,
},
{ name: "damage", decimals: 0, units: "hp", localize: true }, { name: "damage", decimals: 0, units: "hp", localize: true },
{ name: "damage_range", decimals: 0, units: "hp", localize: true }, { name: "damage_range", decimals: 0, units: "hp", localize: true },
{ name: "module_damage", decimals: 0, units: "hp", localize: true }, { name: "module_damage", decimals: 0, units: "hp", localize: true },
@@ -108,7 +122,7 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
order: [ order: [
{ name: "clipping_potential", localize: true, units: "hp" }, { name: "clipping_potential", localize: true, units: "hp" },
{ name: "clip_size" }, { name: "clip_size" },
{ name: "optimal_shell" }, { name: "optimal_shell", ranking: CharacteristicRanking.None },
{ {
name: "intra_clip", name: "intra_clip",
decimals: 2, decimals: 2,
@@ -156,6 +170,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
units: "deg", units: "deg",
ranking: CharacteristicRanking.None, ranking: CharacteristicRanking.None,
}, },
{
name: "explosion_radius",
decimals: 2,
units: "m",
ranking: CharacteristicRanking.None,
},
{ toy: "ricochet" }, { toy: "ricochet" },
], ],
@@ -178,6 +198,44 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
ranking: CharacteristicRanking.LowerIsBetter, ranking: CharacteristicRanking.LowerIsBetter,
}, },
{ name: "dispersion_angle", decimals: 3, units: "deg" }, { name: "dispersion_angle", decimals: 3, units: "deg" },
{ label: "dispersion_penalties" },
{
name: "dispersion_moving",
decimals: 3,
units: "plus",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "dispersion_hull_traversing",
decimals: 3,
units: "plus",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "dispersion_turret_traversing",
decimals: 3,
units: "plus",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "dispersion_shooting",
decimals: 3,
units: "plus",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{
name: "dispersion_gun_damaged",
decimals: 1,
units: "x",
indent: true,
ranking: CharacteristicRanking.LowerIsBetter,
},
{ toy: "aim_time" }, { toy: "aim_time" },
], ],
}, },
@@ -215,6 +273,26 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
}, },
{ name: "engine_power", units: "bhp" }, { name: "engine_power", units: "bhp" },
{ name: "power_to_weight", decimals: 1, units: "bhp_t" }, { name: "power_to_weight", decimals: 1, units: "bhp_t" },
{ label: "power_to_weight_on_terrain" },
{
name: "power_to_weight_hard",
decimals: 1,
units: "bhp_t",
indent: true,
},
{
name: "power_to_weight_medium",
decimals: 1,
units: "bhp_t",
indent: true,
},
{
name: "power_to_weight_soft",
decimals: 1,
units: "bhp_t",
indent: true,
},
// { name: "brake_force", decimals: 1, units: "kN" }, // { name: "brake_force", decimals: 1, units: "kN" },
{ label: "hull_traverse_speeds" }, { label: "hull_traverse_speeds" },
@@ -264,9 +342,11 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "health", units: "hp" }, { name: "health", units: "hp" },
{ {
name: "fire_chance", name: "fire_chance",
decimals: 0,
units: "percentage", units: "percentage",
ranking: CharacteristicRanking.LowerIsBetter, ranking: CharacteristicRanking.LowerIsBetter,
}, },
{ name: "crew_count" },
// { name: "fire_rate", units: "hp_s" }, // { name: "fire_rate", units: "hp_s" },
// { name: "ramming_resistance", units: "percentage" }, // { name: "ramming_resistance", units: "percentage" },
@@ -281,7 +361,38 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
// render: CharacteristicsGroupRender.Statistical, // render: CharacteristicsGroupRender.Statistical,
order: [ order: [
{ name: "view_range", units: "m" }, { name: "view_range", units: "m" },
{ name: "camouflage", decimals: 0, units: "percentage" },
{ label: "camouflages" },
{
name: "camouflage",
decimals: 2,
units: "percentage",
indent: true,
},
{
name: "camouflage_moving",
decimals: 2,
units: "percentage",
indent: true,
},
{
name: "camouflage_shooting_still",
decimals: 2,
units: "percentage",
indent: true,
},
{
name: "camouflage_shooting_moving",
decimals: 2,
units: "percentage",
indent: true,
},
{
name: "camouflage_on_fire",
decimals: 2,
units: "percentage",
indent: true,
},
{ toy: "view_range" }, { toy: "view_range" },
+4
View File
@@ -1,8 +1,10 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { import {
FlexibilityToy,
ReloadToy, ReloadToy,
RicochetToy, RicochetToy,
TraverseToy, TraverseToy,
ViewRangeToy,
} from "../components/TankopediaToys"; } from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics"; import type { ComputedCharacteristics } from "../types/characteristics";
@@ -14,4 +16,6 @@ export const toys: Record<string, (props: ToyProps) => ReactNode> = {
ricochet: RicochetToy, ricochet: RicochetToy,
traverse: TraverseToy, traverse: TraverseToy,
reload: ReloadToy, reload: ReloadToy,
view_range: ViewRangeToy,
flexibility: FlexibilityToy,
}; };
@@ -4,6 +4,7 @@ import { Scroller } from "../../../../../components/Scroller";
import type { ThicknessRange } from "../../../../../components/TankStaticArmor"; import type { ThicknessRange } from "../../../../../components/TankStaticArmor";
import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics"; import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics";
import { TankopediaGameModeAbilities } from "../../../../../components/TankopediaGameModeAbilities"; import { TankopediaGameModeAbilities } from "../../../../../components/TankopediaGameModeAbilities";
import { TankopediaVideos } from "../../../../../components/TankopediaVideos";
import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout"; import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout";
import { TankopediaSandbox } from "../../../../../components/TankopediaSandbox"; import { TankopediaSandbox } from "../../../../../components/TankopediaSandbox";
import { defaultEqualizer } from "../../../../../config/equalizer"; import { defaultEqualizer } from "../../../../../config/equalizer";
@@ -69,6 +70,7 @@ export const Page = withErrorWrapper(
/> />
<TankopediaCharacteristics characteristics={characteristics} /> <TankopediaCharacteristics characteristics={characteristics} />
<TankopediaGameModeAbilities /> <TankopediaGameModeAbilities />
<TankopediaVideos />
</div> </div>
</div> </div>
); );
@@ -67,9 +67,15 @@ export function calculateRankings(
if (currentValue === undefined || name[0] === "_") continue; if (currentValue === undefined || name[0] === "_") continue;
const config = characteristicConfigs.get(name)!; const config = characteristicConfigs.get(name);
if (config.ranking === CharacteristicRankingDirection.None) continue; // characteristics without a render config are never displayed
if (
config === undefined ||
config.ranking === CharacteristicRankingDirection.None
) {
continue;
}
const lowerIsBetter = const lowerIsBetter =
config.ranking === CharacteristicRankingDirection.LowerIsBetter; config.ranking === CharacteristicRankingDirection.LowerIsBetter;
@@ -2,43 +2,6 @@ import type {
CharacteristicContext, CharacteristicContext,
CharacteristicScript, CharacteristicScript,
} from "../types/characteristics"; } from "../types/characteristics";
import { tankClassToBlitzTankClass } from "./tankClass";
function hullTraverseEnginePowerCoefficient(
tank: TankDefinition,
script: CharacteristicScript,
) {
let coefficient = 1;
const enginePowerPercentage = tankClassToBlitzTankClass[tank.class];
script("protagonist", "RecycledEngine", (effect) => {
coefficient +=
(effect.enginePowerPercentageBonus?.[enginePowerPercentage] ?? 0) / 100;
});
script("protagonist", "Afterburning", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "ImprovedAfterburning", (effect) => {
coefficient += (effect.enginePowerIncrease ?? 1) - 1;
});
script("protagonist", "Fuel", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "GearOil", (effect) => {
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
});
script("protagonist", "AttributesModifier", (effect) => {
coefficient *= effect.attributes?.enginePowerFactor ?? 1;
});
return coefficient;
}
function hullTraverseRotationCoefficient( function hullTraverseRotationCoefficient(
driverCoefficient: number, driverCoefficient: number,
@@ -76,6 +39,7 @@ export function hullTraverseSpeed(
progressive, progressive,
script, script,
environment, environment,
characteristic,
} = context; } = context;
const terrainResistance = track.resistance_hard; const terrainResistance = track.resistance_hard;
const stockEngine = tank.engines[0]; const stockEngine = tank.engines[0];
@@ -91,7 +55,7 @@ export function hullTraverseSpeed(
const weight = const weight =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight; tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const enginePower = const enginePower =
(engine.power * hullTraverseEnginePowerCoefficient(tank, script)) / (engine.power * (characteristic("_engine_power_coefficient") as number)) /
stockEngine.power; stockEngine.power;
const rotationSpeed = hullTraverseRotationCoefficient( const rotationSpeed = hullTraverseRotationCoefficient(
progressive("driver"), progressive("driver"),
@@ -0,0 +1,320 @@
import { ModuleType, type ModulePosition, type Unlock } from "@blitzkit/protos";
import type { Vector2Tuple } from "three";
/**
* Port of the game client's modules screen layout (wotblitz.exe 11.21,
* `ModulesTreeInfoCache` auto-layout + `TankModulesTreeController` arrows).
*
* Every node lives on one shared grid: depth is the column, and leaves take
* consecutive rows with each parent centered over its children. Connectors are
* per-cell flags rather than a nested tree.
*/
// The client sorts siblings by this order: guns, turrets, engines, tracks, tanks
const moduleTypeOrder = [
ModuleType.MODULE_TYPE_GUN,
ModuleType.MODULE_TYPE_TURRET,
ModuleType.MODULE_TYPE_ENGINE,
ModuleType.MODULE_TYPE_TRACKS,
ModuleType.MODULE_TYPE_VEHICLE,
];
const moduleTypeRank = new Map(
moduleTypeOrder.map((type, index) => [type, index])
);
export interface ModuleTreeCell {
// 1-based grid column (also the depth in the unlock graph) and row
position: Vector2Tuple;
// Missing for empty cells that only carry a connector line
unlock?: Unlock;
// Arrow entering from the left
left: boolean;
// Stub leaving to the right
right: boolean;
// Vertical line from the cell center up to the previous row
trunkUp: boolean;
// Vertical line from the cell center down to the next row
trunkDown: boolean;
// Snake mode: line leaving the top edge with an arrow pointing up
snakeUp: boolean;
// Snake mode: line leaving the bottom edge with an arrow pointing down
snakeDown: boolean;
}
interface Branch {
unlock: Unlock;
key: string;
children: Branch[];
// Rows this branch spans
leaves: number;
}
interface Placement {
unlock: Unlock;
position: Vector2Tuple;
}
function key(unlock: Unlock) {
return `${unlock.type}:${unlock.id}`;
}
// Cells are indexed column first, then row
type CellGrid = Map<number, Map<number, ModuleTreeCell>>;
function flattenGrid(grid: CellGrid) {
return [...grid.values()].flatMap((column) => [...column.values()]);
}
function emptyCell(x: number, y: number) {
return {
position: [x, y],
left: false,
right: false,
trunkUp: false,
trunkDown: false,
snakeUp: false,
snakeDown: false,
} satisfies ModuleTreeCell;
}
/**
* The client switches to this when any module is bought with something other
* than xp (season tanks). It ignores the unlock graph past the stock modules
* and folds the sequence into columns the height of the stock column,
* alternating direction, with arrows decided purely by position.
*/
function layoutSnake(placements: Placement[]) {
const ordered = [...placements].sort(
(a, b) => a.position[0] - b.position[0] || a.position[1] - b.position[1]
);
const height = ordered.filter(({ position: [x] }) => x === 1).length;
const deep = ordered.filter(({ position: [x] }) => x >= 2);
// The client takes this from whichever deep module sorts last
const lastOnFirstRow = deep.length > 0 && deep.at(-1)!.position[1] === 1;
const grid: CellGrid = new Map();
for (const {
unlock,
position: [x, y],
} of ordered) {
let column = x;
let row = y;
if (x >= 2) {
const index = x - 2;
const offset = index % height;
column = Math.floor(index / height) + 2;
row =
((lastOnFirstRow ? ~column : column) & 1) === 0
? height - offset
: offset + 1;
}
let columnCells = grid.get(column);
if (columnCells === undefined) {
columnCells = new Map();
grid.set(column, columnCells);
}
columnCells.set(row, { ...emptyCell(column, row), unlock });
}
const cells = flattenGrid(grid);
const width = Math.ceil(ordered.length / height);
for (const cell of cells) {
const [column, row] = cell.position;
const i = column - 1;
const j = row - 1;
const down = lastOnFirstRow ? i % 2 === 1 : i % 2 === 0;
const hasNext = i < width - 1;
// The first column connects to the tank panel on the page this was
// modeled on, which doesn't exist here, so nothing enters it from the left
if (j === 0) {
cell.left = i > 0 && down;
cell.right = !down && hasNext;
cell.snakeDown = down && i > 0;
} else if (j === height - 1) {
cell.left = i > 0 && !down;
cell.right = down && hasNext;
cell.snakeUp = !down && i > 0;
} else {
cell.snakeUp = !down && i > 0;
cell.snakeDown = down && i > 0;
}
}
let columns = width;
let rows = height;
for (const cell of cells) {
columns = Math.max(columns, cell.position[0]);
rows = Math.max(rows, cell.position[1]);
}
return { cells, columns, rows };
}
function sortSiblings(unlocks: Unlock[]) {
return [...unlocks].sort(
(a, b) =>
(moduleTypeRank.get(a.type) ?? -1) - (moduleTypeRank.get(b.type) ?? -1)
);
}
/**
* @param positions hand-authored cells for this tank. When given, the client
* places exactly those modules and hides the rest instead of running the
* automatic layout.
*/
export function layoutModuleTree(
roots: Unlock[],
getChildren: (unlock: Unlock) => Unlock[],
positions: ModulePosition[] = []
) {
const authored =
positions.length === 0
? undefined
: new Map<string, Vector2Tuple>(
positions.map(({ type, id, position }) => [
`${type}:${id}`,
[position!.x, position!.y],
])
);
// A module's subtree doesn't depend on which parent reached it, so each one
// is grown once instead of once per path through the unlock graph
const branches = new Map<string, Branch>();
// Recursive, so the return type can't be inferred
const grow = (unlock: Unlock): Branch => {
const unlockKey = key(unlock);
const cached = branches.get(unlockKey);
if (cached !== undefined) return cached;
const children = sortSiblings(getChildren(unlock)).map(grow);
const branch = {
unlock,
key: unlockKey,
children,
leaves:
children.length === 0
? 1
: children.reduce((total, child) => total + child.leaves, 0),
} satisfies Branch;
branches.set(unlockKey, branch);
return branch;
};
// A module unlocked from several parents is laid out once per parent and the
// last placement wins, same as the client
const placements = new Map<string, Placement>();
// Keyed by both ends so shared subtrees don't repeat their connectors
const edges = new Map<string, [parent: string, child: string]>();
let isSnake = false;
const place = (branch: Branch, depth: number, startRow: number) => {
const position =
authored === undefined
? ([
depth,
Math.floor((branch.leaves - 1) / 2) + startRow,
] as Vector2Tuple)
: authored.get(branch.key);
if (position !== undefined) {
const { unlock } = branch;
placements.set(branch.key, { unlock, position });
if (unlock.cost !== undefined && unlock.cost.type !== "xp") {
isSnake = true;
}
}
let row = startRow;
for (const child of branch.children) {
place(child, depth + 1, row);
edges.set(`${branch.key}>${child.key}`, [branch.key, child.key]);
row += child.leaves;
}
};
// The tank itself is the invisible root at depth 0
let row = 1;
for (const branch of sortSiblings(roots).map(grow)) {
place(branch, 1, row);
row += branch.leaves;
}
if (isSnake) return layoutSnake([...placements.values()]);
const grid: CellGrid = new Map();
let columns = 0;
let rows = 0;
const cellAt = (x: number, y: number) => {
let columnCells = grid.get(x);
if (columnCells === undefined) {
columnCells = new Map();
grid.set(x, columnCells);
}
let cell = columnCells.get(y);
if (cell === undefined) {
cell = emptyCell(x, y);
columnCells.set(y, cell);
columns = Math.max(columns, x);
rows = Math.max(rows, y);
}
return cell;
};
for (const {
unlock,
position: [x, y],
} of placements.values()) {
cellAt(x, y).unlock = unlock;
}
for (const [parentKey, childKey] of edges.values()) {
const parent = placements.get(parentKey);
const child = placements.get(childKey);
// Unplaced modules are hidden, and so are the connectors touching them
if (parent === undefined || child === undefined) continue;
const [, parentRow] = parent.position;
const [childColumn, childRow] = child.position;
cellAt(...parent.position).right = true;
cellAt(...child.position).left = true;
// The vertical bus runs in the gap left of the child's column, through
// empty cells where it has to
const top = Math.min(parentRow, childRow);
const bottom = Math.max(parentRow, childRow);
for (let y = top; y <= bottom; y++) {
const cell = cellAt(childColumn, y);
if (y > top) cell.trunkUp = true;
if (y < bottom) cell.trunkDown = true;
}
}
return { cells: flattenGrid(grid), columns, rows };
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Vector3 as BlitzKitVector } from "@blitzkit/protos"; import type { ProtoVector3 } from "@blitzkit/protos";
import { Vector3 } from "three"; import { Vector3 } from "three";
export function toThreeVector(vector?: BlitzKitVector) { export function toThreeVector(vector?: ProtoVector3) {
return new Vector3(vector!.x, vector!.y, vector!.z); return new Vector3(vector!.x, vector!.y, vector!.z);
} }
+1 -1
View File
@@ -1,2 +1,2 @@
cd packages/scripts cd packages/scripts
bun src/vfs/index.ts bun src/vfs/index.ts "$@"