From 6bef735aea5c243ab09a05aeb02045142cff0196 Mon Sep 17 00:00:00 2001
From: tresabhi <43380238+tresabhi@users.noreply.github.com>
Date: Wed, 11 Nov 2026 22:43:47 -0600
Subject: [PATCH] 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
---
.agents/skills/proto-workflow/SKILL.md | 8 -
.agents/skills/repo-boundaries/SKILL.md | 13 -
.../skills/repo-boundaries/agents/openai.yaml | 3 -
.claude/skills/vfs/SKILL.md | 44 ++
.../core/src/blitzkit/api/server/1_tanks.ts | 108 ++-
.../core/src/blitzkit/api/server/3_models.ts | 4 +-
.../core/src/blitzkit/normalizeBoundingBox.ts | 4 +-
packages/core/src/blitzkit/vfs/abstract.ts | 2 +-
packages/core/src/types/index.ts | 1 +
.../core/src/types/shellDefinitionsList.ts | 2 +-
packages/core/src/types/techTreeLayoutYaml.ts | 7 +
packages/i18n/strings/en.json | 39 +-
packages/protos/src/blitzkit/math.proto | 14 +
.../src/blitzkit/model_definitions.proto | 20 +-
.../src/blitzkit/tank_definitions.proto | 11 +
packages/scripts/src/vfs/vfs.ts | 4 +-
.../components/Discriminator/index.module.css | 23 +-
.../src/components/Discriminator/index.tsx | 13 +-
.../TankopediaAntagonistControl/index.tsx | 6 +-
.../index.module.css | 3 +
.../TankopediaFlexibilityCanvas.tsx | 175 +++++
.../TankopediaLoadout/index.module.css | 86 ++-
.../components/TankopediaLoadout/index.tsx | 669 +++++++++---------
.../TankopediaToys/index.module.css | 221 +++++-
.../src/components/TankopediaToys/index.tsx | 346 ++++++++-
.../TankopediaVideos/index.module.css | 48 ++
.../src/components/TankopediaVideos/index.tsx | 56 ++
.../website/src/config/characteristics.ts | 571 ++++++++++++---
.../src/config/characteristicsGroups.ts | 115 ++-
packages/website/src/config/toys.ts | 4 +
.../[...locale]/tanks/[slug]/_index.tsx | 2 +
.../src/tankopedia/calculateRankings.ts | 10 +-
.../src/tankopedia/hullTraverseSpeed.ts | 40 +-
.../src/tankopedia/moduleTreeLayout.ts | 320 +++++++++
packages/website/src/three/toThreeVector.ts | 4 +-
scripts/vfs.sh | 2 +-
36 files changed, 2416 insertions(+), 582 deletions(-)
delete mode 100644 .agents/skills/proto-workflow/SKILL.md
delete mode 100644 .agents/skills/repo-boundaries/SKILL.md
delete mode 100644 .agents/skills/repo-boundaries/agents/openai.yaml
create mode 100644 .claude/skills/vfs/SKILL.md
create mode 100644 packages/core/src/types/techTreeLayoutYaml.ts
create mode 100644 packages/protos/src/blitzkit/math.proto
create mode 100644 packages/website/src/components/TankopediaFlexibilityCanvas.tsx
create mode 100644 packages/website/src/components/TankopediaVideos/index.module.css
create mode 100644 packages/website/src/components/TankopediaVideos/index.tsx
create mode 100644 packages/website/src/tankopedia/moduleTreeLayout.ts
diff --git a/.agents/skills/proto-workflow/SKILL.md b/.agents/skills/proto-workflow/SKILL.md
deleted file mode 100644
index d996edcfd..000000000
--- a/.agents/skills/proto-workflow/SKILL.md
+++ /dev/null
@@ -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.
diff --git a/.agents/skills/repo-boundaries/SKILL.md b/.agents/skills/repo-boundaries/SKILL.md
deleted file mode 100644
index ac28a38a0..000000000
--- a/.agents/skills/repo-boundaries/SKILL.md
+++ /dev/null
@@ -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.
diff --git a/.agents/skills/repo-boundaries/agents/openai.yaml b/.agents/skills/repo-boundaries/agents/openai.yaml
deleted file mode 100644
index 37b4d88ef..000000000
--- a/.agents/skills/repo-boundaries/agents/openai.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-interface:
- display_name: "Repo Boundaries"
- short_description: "Respect repository editing boundaries"
diff --git a/.claude/skills/vfs/SKILL.md b/.claude/skills/vfs/SKILL.md
new file mode 100644
index 000000000..4bec9dc48
--- /dev/null
+++ b/.claude/skills/vfs/SKILL.md
@@ -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
# list a directory, one entry per line
+scripts/vfs.sh cat # raw file contents to stdout (decompressed)
+scripts/vfs.sh yaml # 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`.
diff --git a/packages/core/src/blitzkit/api/server/1_tanks.ts b/packages/core/src/blitzkit/api/server/1_tanks.ts
index f2c5975c6..0f5cd3de5 100644
--- a/packages/core/src/blitzkit/api/server/1_tanks.ts
+++ b/packages/core/src/blitzkit/api/server/1_tanks.ts
@@ -9,6 +9,7 @@ import {
ShellDefinitionsList,
ShellKind,
sluggify,
+ TechTreeLayoutYaml,
toUniqueId,
TurretDefinitionsList,
UnlocksListing,
@@ -20,6 +21,7 @@ import {
Crew,
Equalizer,
GunDefinition,
+ ModulePosition,
ModuleType,
ResearchCost,
TankDefinitions,
@@ -362,11 +364,77 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
const name_full =
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(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] = {
ancestors: [],
successors: [],
id: tankId,
dev_name: tankKey,
+ module_positions,
roles: {},
camouflages: camouflages,
fixed_camouflage: fixedCamouflage,
@@ -386,8 +454,8 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
type: tankTags.includes("collectible")
? TankType.TANK_TYPE_COLLECTOR
: (typeof tank.price === "number" ? false : "gold" in tank.price)
- ? TankType.TANK_TYPE_PREMIUM
- : TankType.TANK_TYPE_RESEARCHABLE,
+ ? TankType.TANK_TYPE_PREMIUM
+ : TankType.TANK_TYPE_RESEARCHABLE,
tier: tank.level,
class: tankClass,
testing: tankTags.includes("testTank") || tank.level === 11,
@@ -575,24 +643,24 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
},
}
: gunType === "autoReloader"
- ? {
- $case: "auto_reloader",
- value: {
- intra_clip: 60 / gun.clip!.rate,
- shell_count: gunClipCount,
- shell_reloads: gun
- .pumpGunReloadTimes!.split(" ")
- .map(Number),
- },
- }
- : {
- $case: "auto_loader",
- value: {
- intra_clip: 60 / gun.clip!.rate,
- clip_reload: gun.reloadTime,
- shell_count: gunClipCount,
- },
+ ? {
+ $case: "auto_reloader",
+ value: {
+ intra_clip: 60 / gun.clip!.rate,
+ shell_count: gunClipCount,
+ shell_reloads: gun
+ .pumpGunReloadTimes!.split(" ")
+ .map(Number),
},
+ }
+ : {
+ $case: "auto_loader",
+ value: {
+ intra_clip: 60 / gun.clip!.rate,
+ clip_reload: gun.reloadTime,
+ shell_count: gunClipCount,
+ },
+ },
} satisfies GunDefinition);
for (const shellKey in gunListEntry.shots) {
@@ -635,7 +703,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
type: blitzShellKindToBlitzkit[shell.kind],
explosion_radius:
shell.kind === "HIGH_EXPLOSIVE"
- ? (shell.explosionRadius ?? 0)
+ ? shell.explosionRadius ?? 0
: undefined,
icon: shell.icon,
penetration: {
diff --git a/packages/core/src/blitzkit/api/server/3_models.ts b/packages/core/src/blitzkit/api/server/3_models.ts
index 3693770a8..db1ab1a84 100644
--- a/packages/core/src/blitzkit/api/server/3_models.ts
+++ b/packages/core/src/blitzkit/api/server/3_models.ts
@@ -16,14 +16,14 @@ import {
VehicleDefinitions,
VertexAttribute,
} 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 { Cache } from "./0_base";
import { ServerBlitzKitAPI2 } from "./2_camouflages";
export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
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(
diff --git a/packages/core/src/blitzkit/normalizeBoundingBox.ts b/packages/core/src/blitzkit/normalizeBoundingBox.ts
index b1b0f99b9..26ae27ae7 100644
--- a/packages/core/src/blitzkit/normalizeBoundingBox.ts
+++ b/packages/core/src/blitzkit/normalizeBoundingBox.ts
@@ -1,9 +1,9 @@
-import { BoundingBox, Vector3 } from "../../../protos/src/blitzkit";
+import { BoundingBox, ProtoVector3 } from "../../../protos/src/blitzkit";
export function normalizeBoundingBox(boundingBox: BoundingBox) {
return {
x: boundingBox.max!.x - boundingBox.min!.x,
y: boundingBox.max!.y - boundingBox.min!.y,
z: boundingBox.max!.z - boundingBox.min!.z,
- } satisfies Vector3;
+ } satisfies ProtoVector3;
}
diff --git a/packages/core/src/blitzkit/vfs/abstract.ts b/packages/core/src/blitzkit/vfs/abstract.ts
index e342e82a8..d0b06fe5f 100644
--- a/packages/core/src/blitzkit/vfs/abstract.ts
+++ b/packages/core/src/blitzkit/vfs/abstract.ts
@@ -14,7 +14,7 @@ export abstract class AbstractVFS {
const path = await import("node:path/posix");
this.normalizePath = path.normalize;
- await this._init();
+ return await this._init();
}
protected abstract _init(): Promise;
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 2d3726d47..5c2f1845f 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -21,6 +21,7 @@ export * from "./squadBattleTypeStylesYaml";
export * from "./tankmenAvatar";
export * from "./tankParameters";
export * from "./tanksStats";
+export * from "./techTreeLayoutYaml";
export * from "./turretDefinitionsList";
export * from "./vehicleDefinitionArmor";
export * from "./vehicleDefinitionList";
diff --git a/packages/core/src/types/shellDefinitionsList.ts b/packages/core/src/types/shellDefinitionsList.ts
index e8ec812c1..b37302e70 100644
--- a/packages/core/src/types/shellDefinitionsList.ts
+++ b/packages/core/src/types/shellDefinitionsList.ts
@@ -11,7 +11,7 @@ export type ShellDefinitionsList = Record<
ricochetAngle?: number;
explosionRadius?: number;
speed: number;
- maxDistance?: number;
+ maxDistance: number;
piercingPower: string;
}
> & {
diff --git a/packages/core/src/types/techTreeLayoutYaml.ts b/packages/core/src/types/techTreeLayoutYaml.ts
new file mode 100644
index 000000000..8a257f660
--- /dev/null
+++ b/packages/core/src/types/techTreeLayoutYaml.ts
@@ -0,0 +1,7 @@
+export interface TechTreeLayoutYamlModule {
+ position: [number, number];
+}
+
+export interface TechTreeLayoutYaml {
+ modules?: Record;
+}
diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json
index 75b4b8ac1..27f475170 100644
--- a/packages/i18n/strings/en.json
+++ b/packages/i18n/strings/en.json
@@ -35,6 +35,7 @@
"bhp_t": "{value}bhp/t",
"kN": "{value}kN",
"hp_s": "{value}hp/s",
+ "m3": "{value}m³",
"plus": "+{value}",
"fraction": "{numerator} / {denominator}"
},
@@ -112,6 +113,19 @@
"reload": {
"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": {
"gun_type": "Gun type",
"dpm": "Damage per minute",
+ "dpm_effective": "Effective by 60s",
+ "dpm_peekaboo": "Peek-a-boo",
"damage": "Damage",
"module_damage": "Module damage",
"reload": "Reload",
@@ -156,9 +172,16 @@
"caliber": "Caliber",
"normalization": "Normalization",
"ricochet": "Ricochet",
+ "explosion_radius": "Explosion radius",
"aim_time": "Aim time",
"dispersion": "Dispersion",
"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_elevation": "Gun elevation",
"gun_frontal_depression": "Frontal depression",
@@ -174,6 +197,10 @@
"brake_force": "Brake force",
"weight": "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",
"raw_terrain_coefficient": "Raw terrain coefficients",
"hard_terrain_coefficient": "Hard",
@@ -193,8 +220,14 @@
"fire_chance": "Fire chance",
"fire_rate": "Fire rate",
"ramming_resistance": "Ramming resistance",
+ "crew_count": "Crew count",
"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",
"height": "Height",
"length": "Length"
@@ -205,6 +238,10 @@
"title": "Game mode abilities"
},
+ "videos": {
+ "title": "Videos"
+ },
+
"navigation": {
"tank": "Tank",
"characteristics": "Characteristics"
diff --git a/packages/protos/src/blitzkit/math.proto b/packages/protos/src/blitzkit/math.proto
new file mode 100644
index 000000000..b0e1a621f
--- /dev/null
+++ b/packages/protos/src/blitzkit/math.proto
@@ -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;
+}
diff --git a/packages/protos/src/blitzkit/model_definitions.proto b/packages/protos/src/blitzkit/model_definitions.proto
index a36660672..e745e2034 100644
--- a/packages/protos/src/blitzkit/model_definitions.proto
+++ b/packages/protos/src/blitzkit/model_definitions.proto
@@ -2,13 +2,15 @@ syntax = "proto3";
package blitzkit;
+import "math.proto";
+
message ModelDefinitions {
map models = 1;
}
message ModelDefinition {
Armor armor = 1;
- Vector3 turret_origin = 2;
+ ProtoVector3 turret_origin = 2;
optional InitialTurretRotation initial_turret_rotation = 3;
map turrets = 4;
map tracks = 5;
@@ -20,12 +22,6 @@ message Armor {
repeated uint32 spaced = 2;
}
-message Vector3 {
- float x = 1;
- float y = 2;
- float z = 3;
-}
-
message InitialTurretRotation {
float yaw = 1;
float pitch = 2;
@@ -36,7 +32,7 @@ message TurretModelDefinition {
BoundingBox bounding_box = 1;
Armor armor = 2;
uint32 model_id = 3;
- Vector3 gun_origin = 4;
+ ProtoVector3 gun_origin = 4;
map guns = 5;
optional YawLimits yaw = 6;
}
@@ -47,13 +43,13 @@ message YawLimits {
}
message BoundingBox {
- Vector3 min = 1;
- Vector3 max = 2;
+ ProtoVector3 min = 1;
+ ProtoVector3 max = 2;
}
message TrackModelDefinition {
- float thickness = 1;
- Vector3 origin = 2;
+ float thickness = 1;
+ ProtoVector3 origin = 2;
}
message GunModelDefinition {
diff --git a/packages/protos/src/blitzkit/tank_definitions.proto b/packages/protos/src/blitzkit/tank_definitions.proto
index ef563ee4d..aec8124a5 100644
--- a/packages/protos/src/blitzkit/tank_definitions.proto
+++ b/packages/protos/src/blitzkit/tank_definitions.proto
@@ -3,6 +3,7 @@ syntax = "proto3";
package blitzkit;
import "i18n.proto";
+import "math.proto";
message TankDefinitions {
map tanks = 1;
@@ -42,6 +43,15 @@ message TankDefinition {
string dev_name = 32;
optional Equalizer equalizer = 33;
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 {
@@ -87,6 +97,7 @@ message TrackDefinition {
float resistance_medium = 10;
float resistance_soft = 11;
repeated Unlock unlocks = 12;
+ float repair_time = 13;
}
message EngineDefinition {
diff --git a/packages/scripts/src/vfs/vfs.ts b/packages/scripts/src/vfs/vfs.ts
index a94b39bae..2461b5dc5 100644
--- a/packages/scripts/src/vfs/vfs.ts
+++ b/packages/scripts/src/vfs/vfs.ts
@@ -1,4 +1,6 @@
import { assertSecret } from "@blitzkit/core";
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();
diff --git a/packages/website/src/components/Discriminator/index.module.css b/packages/website/src/components/Discriminator/index.module.css
index 7d76b905f..7578ab6ed 100644
--- a/packages/website/src/components/Discriminator/index.module.css
+++ b/packages/website/src/components/Discriminator/index.module.css
@@ -1,11 +1,30 @@
.discriminator {
position: absolute;
- right: var(--space-3);
font-size: var(--font-size-minor);
text-align: right;
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);
+ }
}
diff --git a/packages/website/src/components/Discriminator/index.tsx b/packages/website/src/components/Discriminator/index.tsx
index 53485bd67..8238e8570 100644
--- a/packages/website/src/components/Discriminator/index.tsx
+++ b/packages/website/src/components/Discriminator/index.tsx
@@ -2,12 +2,21 @@ import type { ComponentProps } from "react";
import { classNames } from "../../ui/classNames";
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 */
export function Discriminator({
className,
+ corner = "bottom-right",
...props
-}: ComponentProps<"span">) {
+}: DiscriminatorProps) {
return (
-
+
);
}
diff --git a/packages/website/src/components/TankopediaAntagonistControl/index.tsx b/packages/website/src/components/TankopediaAntagonistControl/index.tsx
index 8323a8273..6e3c3e1cf 100644
--- a/packages/website/src/components/TankopediaAntagonistControl/index.tsx
+++ b/packages/website/src/components/TankopediaAntagonistControl/index.tsx
@@ -38,7 +38,7 @@ export function TankopediaAntagonistControl({
const equalize = Tankopedia.use((state) => state.environment.equalize);
const hasCalibratedShells = useEquipment("antagonist", 103);
- const hasEnhancedArmor = useEquipment("antagonist", 110);
+ const hasEnhancedArmor = useEquipment("protagonist", 110);
const tank = useDuel("antagonist", DuelModule.Tank);
const gun = useDuel("antagonist", DuelModule.Gun);
@@ -177,9 +177,9 @@ export function TankopediaAntagonistControl({
draft.shot = undefined;
if (hasEnhancedArmor) {
- delete draft.antagonist.equipment[4];
+ delete draft.protagonist.equipment[4];
} else {
- draft.antagonist.equipment[4] = 0;
+ draft.protagonist.equipment[4] = 0;
}
});
}}
diff --git a/packages/website/src/components/TankopediaCharacteristics/index.module.css b/packages/website/src/components/TankopediaCharacteristics/index.module.css
index d4156b92c..8640a4c14 100644
--- a/packages/website/src/components/TankopediaCharacteristics/index.module.css
+++ b/packages/website/src/components/TankopediaCharacteristics/index.module.css
@@ -125,6 +125,9 @@
width: 100%;
height: 100%;
+ position: relative;
+ overflow: hidden;
+
background-color: var(--gray-2);
outline: var(--gray-5) solid var(--space-1);
border-radius: var(--space-3);
diff --git a/packages/website/src/components/TankopediaFlexibilityCanvas.tsx b/packages/website/src/components/TankopediaFlexibilityCanvas.tsx
new file mode 100644
index 000000000..717198138
--- /dev/null
+++ b/packages/website/src/components/TankopediaFlexibilityCanvas.tsx
@@ -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 = {
+ 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 = {
+ 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 (
+
+
+
+
+
+
+ );
+}
+
+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(null);
+ const gunWrapper = useRef(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) {
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+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 (
+
+ {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 (
+ <>
+
+
+
+
+ >
+ );
+ },
+ });
+ })}
+
+ );
+}
diff --git a/packages/website/src/components/TankopediaLoadout/index.module.css b/packages/website/src/components/TankopediaLoadout/index.module.css
index a4ff906db..9d23a0ce2 100644
--- a/packages/website/src/components/TankopediaLoadout/index.module.css
+++ b/packages/website/src/components/TankopediaLoadout/index.module.css
@@ -39,12 +39,6 @@
}
}
- .state {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
- }
-
.shells {
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 {
display: flex;
flex-direction: column;
diff --git a/packages/website/src/components/TankopediaLoadout/index.tsx b/packages/website/src/components/TankopediaLoadout/index.tsx
index f62e69f80..eaa1a63f0 100644
--- a/packages/website/src/components/TankopediaLoadout/index.tsx
+++ b/packages/website/src/components/TankopediaLoadout/index.tsx
@@ -3,36 +3,56 @@ import {
MAX_SKILL_LEVEL,
permanentSkills,
romanize,
+ tankIcon,
} 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 {
ClockIcon,
+ DoubleArrowDownIcon,
DoubleArrowUpIcon,
ResetIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import { chunk } from "lodash-es";
+import { useCallback, useMemo } from "react";
import { api } from "../../api/dynamic";
import { equipmentColumns } from "../../config/equipment";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
+import { useLocale } from "../../hooks/useLocale";
import { useProvision } from "../../hooks/useProvision";
import { useStrings } from "../../hooks/useStrings";
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
import { useUnwrapper } from "../../hooks/useUnwrapper";
import { Tankopedia } from "../../stores/tankopedia";
+import { layoutModuleTree } from "../../tankopedia/moduleTreeLayout";
import { vehicleStatusKeys } from "../../tankopedia/tankState";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
+import { Discriminator } from "../Discriminator";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
+import { LinkI18n } from "../LinkI18n";
import { Text } from "../Text";
import { Tooltip } from "../Tooltip";
import styles from "./index.module.css";
+// TODO: across the entire repo, wrap these in Promise.all's
const equipment = await api.equipment();
const provisions = await api.provisions();
const consumables = await api.consumables();
+const tanks = await api.tanks();
const skills = await api.skills();
interface TankopediaLoadoutProps {
@@ -40,23 +60,338 @@ interface TankopediaLoadoutProps {
}
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
- // const tank = useDuel("protagonist", DuelModule.Tank);
- // const showModules = hasUpgrades(tank);
-
return (
<>
- {/* {showModules && } */}
+
-
>
);
}
+const moduleIconNames: Record = {
+ [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 (
+
+
+
{strings.tanks.loadout.modules}
+
+ {hasUpgrades && (
+
+ {
+ 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;
+ });
+ }}
+ >
+
+
+
+ {
+ 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;
+ });
+ }}
+ >
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+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.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 (
+
+ {cells.map((cell) => {
+ const module = cell.unlock && resolve(cell.unlock);
+
+ return (
+
+ {cell.trunkUp && }
+ {cell.trunkDown && }
+ {cell.snakeUp && }
+ {cell.snakeDown && }
+ {cell.right && }
+ {cell.left && }
+
+ {cell.unlock && module && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+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 = (
+ {
+ 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;
+ }
+ });
+ }}
+ >
+ {romanize(module.tier)}
+
+ {/* {unlock.cost!.value > 0 && (
+
+ {formatCompact(locale, unlock.cost!.value)}
+
+
+ )} */}
+
+
+
+ );
+
+ return (
+
+ {isTank ? (
+
+ {button}
+
+ ) : (
+ button
+ )}
+
+ );
+}
+
function CrewSkills() {
const strings = useStrings();
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 (
-
-
- {strings.tanks.loadout.dynamics}
-
- {
- Tankopedia.mutate((draft) => {
- draft.protagonist.consumables = [];
- });
- }}
- >
-
-
-
-
-
- shell: {shell}
- speed: {speed}
-
-
- );
-}
-
function Status() {
const strings = useStrings();
const status = Tankopedia.use((state) => state.protagonist.status);
@@ -623,293 +926,3 @@ function EquipmentOption({
);
}
-
-// 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 (
-//
-//
-//
{strings.tanks.loadout.modules}
-
-//
-// {
-// 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"
-// >
-//
-//
-
-// {
-// 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"
-// >
-//
-//
-//
-//
-
-//
-// {/* TODO: hide lines with 1 module except for alts */}
-// {tank.tank!.upgrade_lines.map((line) => (
-//
-// ))}
-//
-//
-// );
-// }
-
-// 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 (
-//
-// {!isAlternativeLine(name) &&
-// combinedLines.map((name) => (
-//
-//
-//
-// ))}
-//
-// );
-// }
-
-// function LineInner({ name, lines }: LineProps) {
-// const line = lines.find((line) => line.name === name)!;
-
-// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
-//
-// {wrapIndex > 0 && (
-//
-//
-//
-// )}
-
-// {chunk.map((stage, elementIndex) => (
-//
-// ))}
-//
-// ));
-// }
-
-// 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 (
-//
-//
-// {
-// // 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;
-// }
-// });
-// }}
-// >
-//
-
-//
-// {isAlternativeLine(lineName)
-// ? strings.tanks.loadout.alternative
-// : romanize(stage.number)}
-//
-//
-//
-
-// {
}
-//
-// );
-// }
diff --git a/packages/website/src/components/TankopediaToys/index.module.css b/packages/website/src/components/TankopediaToys/index.module.css
index 3dee2edf1..afe5500dd 100644
--- a/packages/website/src/components/TankopediaToys/index.module.css
+++ b/packages/website/src/components/TankopediaToys/index.module.css
@@ -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 {
position: relative;
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 {
position: absolute;
inset: 0;
@@ -386,3 +386,158 @@
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%);
+ }
+ }
+}
diff --git a/packages/website/src/components/TankopediaToys/index.tsx b/packages/website/src/components/TankopediaToys/index.tsx
index efdc22e92..47e24faec 100644
--- a/packages/website/src/components/TankopediaToys/index.tsx
+++ b/packages/website/src/components/TankopediaToys/index.tsx
@@ -1,6 +1,6 @@
import { alias, isExplosive } from "@blitzkit/core";
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 {
useCallback,
@@ -12,15 +12,25 @@ import {
type PointerEvent,
} from "react";
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
+import { api } from "../../api/dynamic";
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 { useEquipment } from "../../hooks/useEquipment";
import { useStrings } from "../../hooks/useStrings";
+import { Tankopedia } from "../../stores/tankopedia";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
import { Checkbox } from "../Checkbox";
import { Code } from "../Code";
import { SegmentedControl } from "../SegmentedControl";
+import { Slider } from "../Slider";
import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder";
+import { TankopediaFlexibilityCanvas } from "../TankopediaFlexibilityCanvas";
import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) {
@@ -528,7 +538,9 @@ export function ReloadToy({ characteristics }: ToyProps) {
className={styles["shell-track"]}
key={index}
style={{
- transform: `rotate(${135 - 7 * (index - (reloadData.shells - 1) / 2)}deg)`,
+ transform: `rotate(${
+ 135 - 7 * (index - (reloadData.shells - 1) / 2)
+ }deg)`,
}}
>
@@ -566,3 +578,333 @@ export function ReloadToy({ characteristics }: ToyProps) {
);
}
+
+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(viewRangeMaps[0].id);
+ const [camouflage, setCamouflage] = useState(0);
+ const container = useRef(null);
+ const range = useRef(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) {
+ 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 (
+
+
+
+
+
+ {literals(strings.common.units.m, {
+ value: resolvedViewRange.toFixed(0),
+ })}
+
+
+
+ {strings.tanks.toys.view_range.camo}
+
+ setCamouflage(value)}
+ >
+
+
+
+
+
+
+
+ {literals(strings.common.units.percentage, {
+ value: Math.round(camouflage * 100),
+ })}
+
+
+
+
+ ({
+ value: id,
+ label: strings.tanks.toys.view_range[string],
+ }))}
+ value={mapId}
+ onValueChange={setMapId}
+ variant="underline"
+ />
+
+
+ );
+}
+
+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(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) {
+ 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 (
+
+
+ {modelRequested &&
}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/website/src/components/TankopediaVideos/index.module.css b/packages/website/src/components/TankopediaVideos/index.module.css
new file mode 100644
index 000000000..9c24e66c1
--- /dev/null
+++ b/packages/website/src/components/TankopediaVideos/index.module.css
@@ -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;
+ }
+ }
+ }
+}
diff --git a/packages/website/src/components/TankopediaVideos/index.tsx b/packages/website/src/components/TankopediaVideos/index.tsx
new file mode 100644
index 000000000..5289bd44d
--- /dev/null
+++ b/packages/website/src/components/TankopediaVideos/index.tsx
@@ -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 (
+
+ {strings.tanks.videos.title}
+
+
+ {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 (
+
+
+
+
+ {youtuber.name}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/website/src/config/characteristics.ts b/packages/website/src/config/characteristics.ts
index 27e08c4db..f7d18ec65 100644
--- a/packages/website/src/config/characteristics.ts
+++ b/packages/website/src/config/characteristics.ts
@@ -7,11 +7,18 @@ import {
import { ShellType, TankClass } from "@blitzkit/protos";
import { hullTraverseSpeed } from "../tankopedia/hullTraverseSpeed";
import { rawTerrainCoefficient } from "../tankopedia/rawTerrainCoefficient";
+import { tankClassToBlitzTankClass } from "../tankopedia/tankClass";
import {
CharacteristicType,
type Characteristic,
} 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 = {
gun_type: {
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: {
type: CharacteristicType.Number,
- compute({ script, degressive }) {
+ compute({ script, degressive, gun }) {
let c0 = 1;
script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => {
@@ -73,9 +81,12 @@ export const characteristics = {
let c2 = 1;
- script("protagonist", "Berserk", (effect) => {
- c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
- });
+ // adrenaline only affects regular guns
+ if (gun.gun_type!.$case === "regular") {
+ script("protagonist", "Berserk", (effect) => {
+ c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
+ });
+ }
return (c0 * c1) / c2;
},
@@ -88,7 +99,7 @@ export const characteristics = {
return gun.gun_type!.$case !== "auto_reloader";
},
- compute({ gun, characteristic }) {
+ compute({ gun, characteristic }): number {
if (gun.gun_type!.$case === "auto_reloader") return -Infinity;
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,
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;
let dps: number;
@@ -170,32 +197,49 @@ export const characteristics = {
dps = damage / time;
} else {
- const intraClip = characteristic("intra_clip") as number;
- const reloadCoefficient = characteristic(
- "_reload_coefficient",
- ) as number;
-
- const mostOptimalShell =
- gun.gun_type!.value.shell_reloads.reduce((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;
+ dps = alpha / (characteristic("_optimal_shell_cycle") as number);
}
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,
module_damage: {
@@ -233,7 +277,7 @@ export const characteristics = {
return gun.gun_type!.$case !== "regular";
},
- compute({ characteristic }) {
+ compute({ characteristic }): number {
const clipSize = characteristic("clip_size") as number;
const damage = characteristic("damage") as number;
@@ -241,36 +285,35 @@ export const characteristics = {
},
},
- // TODO: custom type of shell index
- // optimal_shell: {
- // type: CharacteristicType.Number,
+ /**
+ * 1-indexed so that it can be rendered as-is
+ */
+ optimal_shell: {
+ type: CharacteristicType.Number,
- // should_render({ gun }) {
- // return gun.gun_type!.$case === "auto_reloader";
- // },
+ should_render({ gun }) {
+ return gun.gun_type!.$case === "auto_reloader";
+ },
- // compute({ gun, script, degressive }) {
- // if (gun.gun_type!.$case !== "auto_reloader") return -1;
+ compute({ characteristic }): number {
+ const reloads = characteristic("reloads") as number[];
+ const intraClip = characteristic("intra_clip") as number;
- // const autoReloader = gun.gun_type!.value;
- // const coefficient = reloadCoefficient({ script, degressive, gun });
- // const intraClip = intraClipCoefficient({ script });
+ let best = 0;
+ let bestTime = Infinity;
- // return autoReloader.shell_reloads.reduce(
- // (bestIndex, reload, index, reloads) => {
- // const effectiveReload =
- // reload * coefficient +
- // (index > 0 ? autoReloader.intra_clip * intraClip : 0);
- // const bestReload =
- // reloads[bestIndex] * coefficient +
- // (bestIndex > 0 ? autoReloader.intra_clip * intraClip : 0);
+ reloads.forEach((reload, index) => {
+ const time = reload + (index > 0 ? intraClip : 0);
- // return effectiveReload < bestReload ? index : bestIndex;
- // },
- // 0,
- // );
- // },
- // },
+ if (time < bestTime) {
+ best = index;
+ bestTime = time;
+ }
+ });
+
+ return best + 1;
+ },
+ },
burst_size: {
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: {
type: CharacteristicType.Number,
- compute({ tank }) {
- return tank.speed_forwards;
+ compute({ tank, script }) {
+ 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: {
type: CharacteristicType.Number,
- compute({ tank }) {
- return tank.speed_backwards;
+ compute({ tank, script }) {
+ 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: {
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)²
+ (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.
- 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.
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 =
(turret.traverse_speed * gun.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 C = degressive("gunner");
@@ -452,7 +541,7 @@ export const characteristics = {
let p = 0;
script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => {
- p = effect.bonusValues!.percent / 100;
+ p += effect.bonusValues!.percent / 100;
});
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;
},
@@ -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,
gun_depression: {
type: CharacteristicType.Number,
- compute({ state, turret, gun }) {
+ compute({ state, turret, gun, script }) {
const turretModel = state.model.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
+ let bonus = 0;
+
+ script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
+ bonus += effect.bonusValues?.lowerPitchLimitIncrease ?? 0;
+ });
return (
- gunModel.pitch!.max + (state.model.initial_turret_rotation?.pitch ?? 0)
+ gunModel.pitch!.max +
+ (state.model.initial_turret_rotation?.pitch ?? 0) +
+ bonus
);
},
},
gun_elevation: {
type: CharacteristicType.Number,
- compute({ state, turret, gun }) {
+ compute({ state, turret, gun, script }) {
const turretModel = state.model.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
+ let bonus = 0;
+
+ script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
+ bonus += effect.bonusValues?.upperPitchLimitIncrease ?? 0;
+ });
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: {
type: CharacteristicType.Number,
- compute({ engine }) {
- return engine.power;
+ // explicit return type breaks the circular inference through characteristic()
+ compute({ engine, characteristic }): number {
+ return (
+ engine.power * (characteristic("_engine_power_coefficient") as number)
+ );
},
},
power_to_weight: {
type: CharacteristicType.Number,
- compute({ engine, characteristic }) {
- const weight = characteristic("weight");
- if (typeof weight !== "number") {
- throw new Error("Weight characteristic must be numeric");
- }
+ compute({ characteristic }): number {
+ const enginePower = characteristic("engine_power") as number;
+ const weight = characteristic("weight") as number;
- return engine.power / weight;
+ return enginePower / weight;
},
},
@@ -631,7 +900,7 @@ export const characteristics = {
hard_terrain_coefficient: {
type: CharacteristicType.Number,
- compute({ characteristic }) {
+ compute({ characteristic }): number {
const raw = characteristic("hard_terrain_coefficient_raw") as number;
return 100 / raw;
},
@@ -651,7 +920,7 @@ export const characteristics = {
medium_terrain_coefficient: {
type: CharacteristicType.Number,
- compute({ characteristic }) {
+ compute({ characteristic }): number {
const raw = characteristic("medium_terrain_coefficient_raw") as number;
return 100 / raw;
},
@@ -671,12 +940,42 @@ export const characteristics = {
soft_terrain_coefficient: {
type: CharacteristicType.Number,
- compute({ characteristic }) {
+ compute({ characteristic }): number {
const raw = characteristic("soft_terrain_coefficient_raw") as number;
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: {
type: CharacteristicType.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: {
type: CharacteristicType.Number,
- compute({ turret, progressive }) {
- return turret.traverse_speed * progressive("gunner");
+ compute({ turret, characteristic }): number {
+ const c = characteristic("_turret_traverse_coefficient") as number;
+ return turret.traverse_speed * c;
},
},
gun_traverse_speed: {
type: CharacteristicType.Number,
- compute({ gun, progressive }) {
- return gun.rotation_speed * progressive("gunner");
+ compute({ gun, characteristic }): number {
+ const c = characteristic("_turret_traverse_coefficient") as number;
+ return gun.rotation_speed * c;
},
},
@@ -754,37 +1068,100 @@ export const characteristics = {
fire_chance: {
type: CharacteristicType.Number,
- compute({ engine }) {
- return engine.fire_chance;
+ compute({ engine, script }) {
+ let c = 1;
+
+ script("protagonist", "SafetySet", (effect) => {
+ c -= (effect.bonusValues?.fireProtectionIncrease ?? 0) / 100;
+ });
+
+ return 100 * c * engine.fire_chance;
},
},
// fire_rate: unavailable,
// ramming_resistance: unavailable,
- view_range: {
+ crew_count: {
type: CharacteristicType.Number,
- compute({ turret, progressive }) {
- return turret.view_range * progressive("commander");
+ compute({ tank }) {
+ 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: {
type: CharacteristicType.Number,
- compute({ tank, state }) {
- const camouflageBonus =
- tank.class === TankClass.TANK_CLASS_TANK_DESTROYER
- ? 0.04
- : tank.class === TankClass.TANK_CLASS_HEAVY
- ? 0.03
- : 0.02;
+ compute(context): number {
+ const skill = (3 * (context.environment.skills.camouflage ?? 0)) / 100;
+ const c =
+ 1 +
+ (context.characteristic("_camouflage_bonus") as number) +
+ skill +
+ (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 (
- 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: {
type: CharacteristicType.Number,
compute({ state, turret }) {
diff --git a/packages/website/src/config/characteristicsGroups.ts b/packages/website/src/config/characteristicsGroups.ts
index 1d4602efb..884a0cc70 100644
--- a/packages/website/src/config/characteristicsGroups.ts
+++ b/packages/website/src/config/characteristicsGroups.ts
@@ -81,6 +81,20 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
},
},
{ 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_range", decimals: 0, units: "hp", localize: true },
{ name: "module_damage", decimals: 0, units: "hp", localize: true },
@@ -108,7 +122,7 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
order: [
{ name: "clipping_potential", localize: true, units: "hp" },
{ name: "clip_size" },
- { name: "optimal_shell" },
+ { name: "optimal_shell", ranking: CharacteristicRanking.None },
{
name: "intra_clip",
decimals: 2,
@@ -156,6 +170,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
units: "deg",
ranking: CharacteristicRanking.None,
},
+ {
+ name: "explosion_radius",
+ decimals: 2,
+ units: "m",
+ ranking: CharacteristicRanking.None,
+ },
{ toy: "ricochet" },
],
@@ -178,6 +198,44 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
ranking: CharacteristicRanking.LowerIsBetter,
},
{ 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" },
],
},
@@ -215,6 +273,26 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
},
{ name: "engine_power", units: "bhp" },
{ 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" },
{ label: "hull_traverse_speeds" },
@@ -264,9 +342,11 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
{ name: "health", units: "hp" },
{
name: "fire_chance",
+ decimals: 0,
units: "percentage",
ranking: CharacteristicRanking.LowerIsBetter,
},
+ { name: "crew_count" },
// { name: "fire_rate", units: "hp_s" },
// { name: "ramming_resistance", units: "percentage" },
@@ -281,7 +361,38 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
// render: CharacteristicsGroupRender.Statistical,
order: [
{ 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" },
diff --git a/packages/website/src/config/toys.ts b/packages/website/src/config/toys.ts
index 4eff69120..bf54d4cdf 100644
--- a/packages/website/src/config/toys.ts
+++ b/packages/website/src/config/toys.ts
@@ -1,8 +1,10 @@
import type { ReactNode } from "react";
import {
+ FlexibilityToy,
ReloadToy,
RicochetToy,
TraverseToy,
+ ViewRangeToy,
} from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics";
@@ -14,4 +16,6 @@ export const toys: Record ReactNode> = {
ricochet: RicochetToy,
traverse: TraverseToy,
reload: ReloadToy,
+ view_range: ViewRangeToy,
+ flexibility: FlexibilityToy,
};
diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx
index 2c002add0..a10320ac0 100644
--- a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx
+++ b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx
@@ -4,6 +4,7 @@ import { Scroller } from "../../../../../components/Scroller";
import type { ThicknessRange } from "../../../../../components/TankStaticArmor";
import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics";
import { TankopediaGameModeAbilities } from "../../../../../components/TankopediaGameModeAbilities";
+import { TankopediaVideos } from "../../../../../components/TankopediaVideos";
import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout";
import { TankopediaSandbox } from "../../../../../components/TankopediaSandbox";
import { defaultEqualizer } from "../../../../../config/equalizer";
@@ -69,6 +70,7 @@ export const Page = withErrorWrapper(
/>
+
);
diff --git a/packages/website/src/tankopedia/calculateRankings.ts b/packages/website/src/tankopedia/calculateRankings.ts
index bf26b823b..57587d001 100644
--- a/packages/website/src/tankopedia/calculateRankings.ts
+++ b/packages/website/src/tankopedia/calculateRankings.ts
@@ -67,9 +67,15 @@ export function calculateRankings(
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 =
config.ranking === CharacteristicRankingDirection.LowerIsBetter;
diff --git a/packages/website/src/tankopedia/hullTraverseSpeed.ts b/packages/website/src/tankopedia/hullTraverseSpeed.ts
index c3445fd93..e83e59756 100644
--- a/packages/website/src/tankopedia/hullTraverseSpeed.ts
+++ b/packages/website/src/tankopedia/hullTraverseSpeed.ts
@@ -2,43 +2,6 @@ import type {
CharacteristicContext,
CharacteristicScript,
} 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(
driverCoefficient: number,
@@ -76,6 +39,7 @@ export function hullTraverseSpeed(
progressive,
script,
environment,
+ characteristic,
} = context;
const terrainResistance = track.resistance_hard;
const stockEngine = tank.engines[0];
@@ -91,7 +55,7 @@ export function hullTraverseSpeed(
const weight =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const enginePower =
- (engine.power * hullTraverseEnginePowerCoefficient(tank, script)) /
+ (engine.power * (characteristic("_engine_power_coefficient") as number)) /
stockEngine.power;
const rotationSpeed = hullTraverseRotationCoefficient(
progressive("driver"),
diff --git a/packages/website/src/tankopedia/moduleTreeLayout.ts b/packages/website/src/tankopedia/moduleTreeLayout.ts
new file mode 100644
index 000000000..7ab1ab8c0
--- /dev/null
+++ b/packages/website/src/tankopedia/moduleTreeLayout.ts
@@ -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>;
+
+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(
+ 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();
+
+ // 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();
+ // Keyed by both ends so shared subtrees don't repeat their connectors
+ const edges = new Map();
+ 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 };
+}
diff --git a/packages/website/src/three/toThreeVector.ts b/packages/website/src/three/toThreeVector.ts
index 1fba6df1a..e0fb241af 100644
--- a/packages/website/src/three/toThreeVector.ts
+++ b/packages/website/src/three/toThreeVector.ts
@@ -1,6 +1,6 @@
-import type { Vector3 as BlitzKitVector } from "@blitzkit/protos";
+import type { ProtoVector3 } from "@blitzkit/protos";
import { Vector3 } from "three";
-export function toThreeVector(vector?: BlitzKitVector) {
+export function toThreeVector(vector?: ProtoVector3) {
return new Vector3(vector!.x, vector!.y, vector!.z);
}
diff --git a/scripts/vfs.sh b/scripts/vfs.sh
index 3ac48cf99..e075d2df6 100755
--- a/scripts/vfs.sh
+++ b/scripts/vfs.sh
@@ -1,2 +1,2 @@
cd packages/scripts
-bun src/vfs/index.ts
\ No newline at end of file
+bun src/vfs/index.ts "$@"
\ No newline at end of file