Compare commits

...
9 Commits
Author SHA1 Message Date
tresabhi 1a2d4aaa9c revert colors 2026-10-18 00:32:25 -05:00
tresabhi d7914bb2fb reduce call count 2026-10-18 00:31:53 -05:00
tresabhi 8cb15d488d code split 2026-10-18 00:10:24 -05:00
tresabhi 5c3ab2e10d actual batching 2026-10-18 00:08:43 -05:00
tresabhi 28c30f210e cool 2026-10-18 00:02:48 -05:00
tresabhi 5c92f9f7ee hull 2026-10-17 23:41:23 -05:00
tresabhi 249b185836 proof of concept 2026-10-17 23:21:47 -05:00
tresabhi 7f7d0cc499 remove outdated docs 2026-10-10 16:41:57 -05:00
TrèsAbhi 0c91ee3600 New Crowdin updates (#389)
* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]
2026-10-09 23:33:06 -05:00
19 changed files with 398 additions and 587 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
# Timezones
What is considered a "day's worth of games" is determined by the reset time. The reset time is generally at midnight everyday after which newer games are counted towards the next day. Reset times vary from region to region:
- North America: `UTC-5:00` (Central North American Time midnight)
- Europe: `UTC+1:00` (Central European Time midnight)
- Asia: `UTC+7:00` (Central Asia Standard Time midnight)
**Reset times are different from Blitz Stars** (Blitz Stars resets at 5:00 am locally relative to the user opening their website). Inconsistencies with Blitz Stars only occur with sleep-deprived players and players who play in the wrong region.
-45
View File
@@ -1,45 +0,0 @@
# Blitzkit Rating Leaderboard
A straightforward way of storing the entire World of Tanks Blitz rating leaderboard to disk.
```cpp
primary BlitzkitRatingLeaderboard {
utf8"BKRL" magic;
Header header;
primary Body<header> body;
}
Header {
uint16 version; // always 1
Format format;
uint32 count;
}
Format enum<uint8> {
Base,
Superset1,
}
Body<Header header> match<header.format> {
Base {
primary {
uint32 id;
uint16 score;
}[header.count] entries;
};
Superset1 {
primary {
uint32 id;
uint16 score;
uint32 battles;
uint32 wins;
uint32 survived;
uint32 damage;
uint32 kills;
}[header.count] entries;
};
}
```
-18
View File
@@ -1,18 +0,0 @@
# Discovered Ids
```c
primary DiscoveredIds {
utf8"DIDS" magic;
Header header;
primary Body body;
}
Header {
uint16 version; // always 1
}
Body {
uint32 count;
uint32[count] ids;
}
```
+1 -170
View File
@@ -1,172 +1,3 @@
# KitScore
## Memo
KitScore is a performance metric that makes sense. This metric can be applied to any game, virtual or real. Specific pieces of information relating directly to World of Tanks Blitz will be provided within parentheses and with a "WoTB:" prefix.
## Consumption of Observations
KitScore is driven solely by the behavior of other players and finding meaning of a new observation within a pool of condensed data based on previously observed data. Generally, we will be provided with a number of statistics (the $x_i$ of an observation) per player (WoTB: $x_\text{frags}$, $x_\text{xp}$, $x_\text{spots}$, etc.) including a target statistic (the $x_j$ of an observation) that all other statistics try to maximize (WoTB: "winrate" which we will defined later as $x_j$).
If the statistics are cumulative, they will have to be normalized (WoTB: statistics fetched from Wargaming are indeed cumulative even for a single player; $x_\text{frags}=\frac{\Sigma x_\text{frags}}{\Sigma x_\text{battles}}$, $x_\text{spots}=\frac{\Sigma x_\text{spots}}{\Sigma x_\text{battles}}$, $x_j=\frac{\Sigma x_\text{wins}}{\Sigma x_\text{battles}}$, etc.). Each player will have such average statistics, giving us many points of data to work with. If the statistics are not cumulative, then you will be dealing with multiple observations per player which will only help improve accuracy.
## It's Impossible to "Find What Makes the Metric Tick"
WN8, a widely adopted metric in the World of Tanks and World of Tanks Blitz communities is plagued with problems. One of them being that people often find and exploit "what makes the metric tick." In other words, people easily exploit the rudimentary nature of WN8 to score undeservedly high.
In WN8, the gains of some rewarding categories overshadow the losses of others though that may not necessarily be in the best interest in maximizing winrate. KitScore makes this nearly impossible as it punishes a player for scoring well solely high in a certain category. You will never exploit KitScore. Period.
## Generating Reasonable Values
TODO: there's like no runaway cases
## Symbol Interpretation
| Symbol | Interpretation |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| $A_\text{human}$ | The human readable version of the raw score $A$. |
| $A$ | The raw KitScore. |
| $C$ | An arbitrary coefficient. |
| $I$ | The number of statistics including the target statistic. This means that there are $I-1$ entries for $x_i$ and $1$ entry for $y$ for each observation. |
| $i$ | The statistic index of where there are $I$ entries. |
| $j$ | A discriminator for the target statistic. There may never be more than $1$ entries for $j$. |
| $m$ | The degree of the polynomial regression. |
| $n$ | The number of observations. |
| $a$ | An "atomic" score which is a function of the $i$th statistic. |
| $w$ | The weight of the $i$th atomic statistic. |
## Computing the Score
The final metric $A_\text{human}$ is an arbitrarily scaled value of the raw score $A$ where $C$ is any coefficient of your liking. BlitzKit uses $C=10^3$.
$$
A_\text{human}=CA
$$
The final metric $A$ is the weighted average of individual scores $a$ which are functions of the $i$th statistic excluding the target statistic $j$ itself.
$$
A=\frac{\sum_{i \neq j} w_{i}a_{i}}{\sum_{i \neq j} w_{i}}
$$
The "atomic" score $a$ is the difference between the predicted target statistic $P_{i \leftrightarrow j}(x_i)$ acquired using a polynomial regression of degree $m$ and the actual newly observed $x_j$.
$$
a_i=x_j-P_{i \leftrightarrow j}(x_i)
$$
The polynomial regression of $m$th degree can be written as follows:
$$
P(x)=\beta_0x^0+\beta_1x^1+\beta_2x^2+\dots+\beta_mx^m
$$
Polynomial regression coefficients $\beta$ can be acquired through basic linear algebra where an $m \times m$ of constants is multiplied with $m \times 1$ of unknowns which equals another $m \times 1$ of constants.
```math
\begin{bmatrix}
\sum_{k=1}^n x_i^0 & \sum_{k=1}^n x_i^1 & \sum_{k=1}^n x_i^2 & \dots & \sum_{k=1}^n x_i^m \\
\sum_{k=1}^n x_i^1 & \sum_{k=1}^n x_i^2 & \sum_{k=1}^n x_i^3 & \dots & \sum_{k=1}^n x_i^{m+1} \\
\sum_{k=1}^n x_i^2 & \sum_{k=1}^n x_i^3 & \sum_{k=1}^n x_i^4 & \dots & \sum_{k=1}^n x_i^{m+2} \\
\vdots & \vdots & \vdots & \ddots & \vdots \\
\sum_{k=1}^n x_i^m & \sum_{k=1}^n x_i^{m+1} & \sum_{k=1}^n x_i^{m+1} & \dots & \sum_{k=1}^n x_i^{2m} \\
\end{bmatrix}
\begin{bmatrix}
\beta_0 \\
\beta_1 \\
\beta_2 \\
\vdots \\
\beta_m
\end{bmatrix}
=
\begin{bmatrix}
\sum_{k=1}^n x_jx_i^0 \\
\sum_{k=1}^n x_jx_i^1 \\
\sum_{k=1}^n x_jx_i^2 \\
\vdots \\
\sum_{k=1}^n x_jx_i^m
\end{bmatrix}
```
The weight $w$ is a slice of the Pearson corelation matrix. The numerator is the corelation between the $i$th statistic and the target statistic $j$ while the denominator the sum of the corelations between the $i$th statistic and all statistics but the target statistic $j$.
$$
w_{i}=\frac{r_{i\leftrightarrow j}^2}{\sum_{i_{k}\neq j}r_{i\leftrightarrow i_{k}}^2}
$$
## Understanding the Corelation Matrix
Forming the corelation matrix will create an $I \times I$ matrix where. This matrix is best represented as a table. The example below illustrates a matrix/table for $I=4$ which includes $3$ entries for $i$ statistics and $1$ entry for the $j$ target statistic.
| | $x_1$ | $x_2$ | $x_3$ | $x_j$ |
| ----- | --------------------------- | --------------------------- | --------------------------- | --------------------------- |
| $x_1$ | $1$ | $r_{1 \leftrightarrow 2}^2$ | $r_{1 \leftrightarrow 3}^2$ | $r_{1 \leftrightarrow j}^2$ |
| $x_2$ | $r_{1 \leftrightarrow 2}^2$ | $1$ | $r_{2 \leftrightarrow 3}^2$ | $r_{2 \leftrightarrow j}^2$ |
| $x_3$ | $r_{1 \leftrightarrow 3}^2$ | $r_{2 \leftrightarrow 3}^2$ | $1$ | $r_{3 \leftrightarrow j}^2$ |
| $x_j$ | $r_{1 \leftrightarrow j}^2$ | $r_{2 \leftrightarrow j}^2$ | $r_{3 \leftrightarrow j}^2$ | $1$ |
It should not be a surprise to find $r_{i \leftrightarrow i}^2=1$ which forms a neat diagonal across the table. Note that there are many duplicates within the $I^2$ entries. The "bottom-right triangle" can be eliminated to remove redundancies leaving us with only $\frac{I(I+1)}{2}$ fields occupied.
| | $x_1$ | $x_2$ | $x_3$ | $x_j$ |
| ----- | ----- | --------------------------- | --------------------------- | --------------------------- |
| $x_1$ | $1$ | $r_{1 \leftrightarrow 2}^2$ | $r_{1 \leftrightarrow 3}^2$ | $r_{1 \leftrightarrow j}^2$ |
| $x_2$ | | $1$ | $r_{2 \leftrightarrow 3}^2$ | $r_{2 \leftrightarrow j}^2$ |
| $x_3$ | | | $1$ | $r_{3 \leftrightarrow j}^2$ |
| $x_j$ | | | | $1$ |
When programming this matrix into an array, $r_{i \leftrightarrow i}^2=1$ will be redundant. I recommend condensing the matrix into an array of size $\frac{I(I+1)}{2}+I=\frac{I(I-1)}{2}$ to avoid all redundant entries. For $I=4$, the array will look something like the one illustrated below (here, $j=4$).
$$
[r_{1 \leftrightarrow 2}^2, r_{1 \leftrightarrow 3}^2, r_{1 \leftrightarrow 4}^2, r_{2 \leftrightarrow 3}^2, r_{2 \leftrightarrow 4}^2, r_{3 \leftrightarrow 4}^2]
$$
## Understanding the Weights
Some may find the weight equation arbitrary; it evidently isn't. The goal of $w$ is to eliminate over-rewarding the same behavior represented in multiple statistics. It makes all atomic statistics concurrent by leveraging the Pearson corelation matrix.
A simplified example will clarify the idea. First, we compute a Pearson corelation matrix for $I=3$ statistics. We have $x_1$ and $x_2$, the statistics that are trying to maximize $x_j$. Reminder: a Pearson corelation matrix finds the $r$ value not only between $x_i$ and $x_j$ but also between all combinations of $x_i$ and themselves. Here, $r^2$ is calculated using polynomial regression corelation analysis to the $m$th degree.
| | $x_1$ | $x_2$ | $x_3$ | $x_j$ |
| ----- | ----- | ----------- | ----- | ----- |
| $x_1$ | $1$ | $\boxed{0}$ | $0$ | $1$ |
| $x_2$ | | $1$ | $0$ | $1$ |
| $x_3$ | | | $1$ | $1$ |
| $x_j$ | | | | $1$ |
In the case above, $r^2_{1 \leftrightarrow 2}=0$ meaning that both statistics have absolutely nothing to do with each other. Hence, the player will be awarded for optimizing both individually. And since $r^2_{1 \leftrightarrow j}=r^2_{2 \leftrightarrow j}$, they should both be rewarded equally; in other words, they should have equal weights.
$$
w_{1}=\frac{1}{1 + 0 + 0}=1
$$
$$
w_{2}=\frac{1}{0 + 1 + 0}=1
$$
$$
w_{3}=\frac{1}{0 + 0 + 1}=1
$$
Now consider a case where $x_1$ and $x_2$ are fully correlated. It would be a bad idea to reward them equally; hence, their weights suffer.
| | $x_1$ | $x_2$ | $x_3$ | $x_j$ |
| ----- | ----- | ----------- | ----- | ----- |
| $x_1$ | $1$ | $\boxed{1}$ | $0$ | $1$ |
| $x_2$ | | $1$ | $0$ | $1$ |
| $x_3$ | | | $1$ | $1$ |
| $x_j$ | | | | $1$ |
$$
w_{1}=\frac{1}{1 + 1 + 0}=0.5
$$
$$
w_{2}=\frac{1}{1 + 1 + 0}=0.5
$$
$$
w_{3}=\frac{1}{0 + 0 + 1}=1
$$
Remind me to write out the linearized model!
-71
View File
@@ -1,71 +0,0 @@
# BlitzKit Regular Tank Stats
A straightforward way of storing the entire World of Tanks Blitz tank statistics for players to disk.
## Manifest `manifest.rtsm`
```cpp
primary RegularTankStatisticsManifest {
utf8"RTSM" magic;
Header header;
primary Body body;
}
Header {
uint16 version; // always 1
}
Body {
uint32 time;
uint8 base;
}
```
## Chunk `*.rtsc`
```cpp
primary RegularTankStatisticsChunk {
utf8"RTSC" magic;
Header header;
primary Body body<header.playerCount>;
}
Header {
uint16 version; // always 1
uint32 playerCount;
}
Body<uint32 playerCount> {
Player[playerCount] players;
}
Player {
uint32 id;
uint16 tankCount;
Tank[tankCount] tanks;
}
Tank {
uint32 id;
uint32 battleLifeTime;
uint32 lastBattleTime;
uint8 markOfMastery;
uint32 battles;
uint32 capturePoints;
uint32 damageDealt;
uint32 damageReceived;
uint32 droppedCapturePoints;
uint32 frags;
uint32 frags8p;
uint32 hits;
uint32 losses;
uint8 maxFrags;
uint16 maxXp;
uint32 shots;
uint32 spotted;
uint32 survivedBattles;
uint32 winAndSurvived;
uint32 wins;
uint32 xp;
}
```
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "Suave",
"health": "Salud",
"fireChance": "Probabilidad de incendio",
"trackRepairTime": "Track repair time",
"viewRange": "Rango de visión",
"camouflageStill": "Quieto",
"camouflageMoving": "En movimiento",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "Soft",
"health": "Health",
"fireChance": "Fire chance",
"trackRepairTime": "Track repair time",
"viewRange": "View range",
"camouflageStill": "Still",
"camouflageMoving": "Moving",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "軟弱地",
"health": "耐久値",
"fireChance": "火災発生率",
"trackRepairTime": "Track repair time",
"viewRange": "視認範囲",
"camouflageStill": "静止時",
"camouflageMoving": "移動時",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "Soft",
"health": "Health",
"fireChance": "Fire chance",
"trackRepairTime": "Track repair time",
"viewRange": "View range",
"camouflageStill": "Still",
"camouflageMoving": "Moving",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "Macio",
"health": "Vida",
"fireChance": "Chance de incêndio",
"trackRepairTime": "Track repair time",
"viewRange": "Alcance de visão",
"camouflageStill": "Parado",
"camouflageMoving": "Em movimento",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "На мягком грунте",
"health": "Прочность",
"fireChance": "Вероятность возгорания",
"trackRepairTime": "Track repair time",
"viewRange": "Радиус обзора",
"camouflageStill": "Когда неподвижен",
"camouflageMoving": "В движении",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "Soft",
"health": "Здоров'я",
"fireChance": "Fire chance",
"trackRepairTime": "Track repair time",
"viewRange": "View range",
"camouflageStill": "Нерухомо",
"camouflageMoving": "У русі",
+1
View File
@@ -623,6 +623,7 @@
"hullTraverseSoftTerrain": "水面上车转速率",
"health": "血量",
"fireChance": "发动机受击时着火概率",
"trackRepairTime": "Track repair time",
"viewRange": "视野范围",
"camouflageStill": "静止隐蔽",
"camouflageMoving": "移动时",
@@ -1,9 +1,9 @@
import { useModel } from "../hooks/useModel";
import { Guess, GuessState } from "../stores/guess";
import { SmartCanvas } from "./SmartCanvas";
import { TankModel } from "./TankModel";
import { Controls } from "./Tankopedia/HeroSection/components/TankSandbox/components/Control";
import { Lighting } from "./Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { TankModel } from "./Tankopedia/HeroSection/components/TankSandbox/components/TankModel";
const BRIGHTNESS = 2 ** -5.5;
const CONTRAST = 2 ** 2;
@@ -0,0 +1,297 @@
import {
invalidate,
useThree,
type ObjectMap,
type ThreeEvent,
} from "@react-three/fiber";
import { useRef } from "react";
import type { Texture } from "three";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../core/blitzkit/hasEquipment";
import { modelTransformEvent } from "../core/blitzkit/modelTransform";
import { controlsEnabledEvent } from "../core/controlsEnabled";
import { useBatched } from "../hooks/useBatched";
import { useModel } from "../hooks/useModel";
import { useTankModelDefinition } from "../hooks/useTankModelDefinition";
import { useTankTransform } from "../hooks/useTankTransform";
import { Duel } from "../stores/duel";
import { Tankopedia } from "../stores/tankopedia";
import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "./Armor/components/ModelTankWrapper";
interface SectionProps {
map: ObjectMap;
}
function Tracks({ map }: SectionProps) {
const Batched = useBatched(map, true);
const position = useRef(new Vector2());
const delta = useRef(new Vector2());
const textures = useRef(new Set<Texture | null>());
function handlePointerMove(event: PointerEvent) {
delta.current.set(event.clientX, event.clientY).sub(position.current);
position.current.set(event.clientX, event.clientY);
const deltaX = delta.current.x / window.innerWidth;
const deltaY = delta.current.y / window.innerHeight;
const offset = deltaX + deltaY;
for (const texture of textures.current) {
if (!texture) continue;
texture.offset.y += offset;
}
invalidate();
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function handlePointerDown(event: ThreeEvent<PointerEvent>) {
if (Tankopedia.state.display !== TankopediaDisplay.Model) {
return;
}
const material = (event.object as Mesh).material as MeshStandardMaterial;
textures.current = new Set([
material.map,
material.aoMap,
material.normalMap,
material.roughnessMap,
material.metalnessMap,
]);
position.current.set(event.clientX, event.clientY);
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
return (
<Batched
onPointerDown={handlePointerDown}
slicer={(node) => {
return node.name.startsWith("chassis_track_");
}}
/>
);
}
function Hull({ map }: SectionProps) {
const Batched = useBatched(map, true);
function handlePointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
}
return (
<Batched
onPointerDown={handlePointerDown}
slicer={(node) => {
const isHull = node.name === "hull";
const isWheel = node.name.startsWith("chassis_wheel_");
return isHull || isWheel;
}}
/>
);
}
function Turret({ map }: SectionProps) {
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const Batched = useBatched(map, true);
const position = useRef(new Vector2());
const delta = useRef(new Vector2());
const canvas = useThree((state) => state.gl.domElement);
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
position.current.set(event.clientX, event.clientY);
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.current.set(event.clientX, event.clientY).sub(position.current);
position.current.set(event.clientX, event.clientY);
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw +
delta.current.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return (
<Batched
onPointerDown={onPointerDown}
slicer={(node) => {
const isCurrentTurret =
node.name ===
`turret_${turretModelDefinition.model_id
.toString()
.padStart(2, "0")}`;
return isCurrentTurret;
}}
/>
);
}
function Gun({ map }: SectionProps) {
const Batched = useBatched(map, true);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const position = useRef(new Vector2());
const delta = useRef(new Vector2());
const pitch = useRef(0);
const yaw = useRef(0);
const canvas = useThree((state) => state.gl.domElement);
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.current.set(event.clientX, event.clientY).sub(position.current);
position.current.set(event.clientX, event.clientY);
[pitch.current, yaw.current] = applyPitchYawLimits(
pitch.current - delta.current.y * (Math.PI / boundingRect.height),
yaw.current + delta.current.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch: pitch.current, yaw: yaw.current });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
position.current.set(event.clientX, event.clientY);
pitch.current = modelTransformEvent.last!.pitch;
yaw.current = modelTransformEvent.last!.yaw;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
return (
<Batched
onPointerDown={onPointerDown}
slicer={(node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`;
return isCurrentGun || isCurrentMantlet;
}}
/>
);
}
export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const hullContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null!);
const gunContainer = useRef<Group>(null!);
const { gltf } = useModel(protagonist.tank.id);
useTankTransform(track, turret, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={hullContainer}>
<Tracks map={gltf} />
<Hull map={gltf} />
<group ref={turretContainer}>
<Turret map={gltf} />
<group ref={gunContainer}>
<Gun map={gltf} />
</group>
</group>
</ModelTankWrapper>
);
}
@@ -125,7 +125,7 @@ export function Lighting({ hasPbr }: Props) {
position={position}
intensity={intensity}
penumbra={1}
castShadow={highGraphics}
castShadow={highGraphics && index === 0}
decay={1}
color="#ffffff"
angle={0}
@@ -1,271 +0,0 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { useModel } from "../../../../../../hooks/useModel";
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const canvas = useThree((state) => state.gl.domElement);
const hullContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null!);
const gunContainer = useRef<Group>(null!);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
useTankTransform(track, turret, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={hullContainer}>
{nodes.map((node) => {
const isHull = node.name === "hull";
const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isHull || isWheel || isTrack;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
function translateTexture(offset: number) {
const mesh = node.children[0] as Mesh;
const material = mesh?.material as MeshStandardMaterial;
if (!material) return;
invalidate();
const textures = new Set([
material.map,
material.aoMap,
material.normalMap,
material.roughnessMap,
material.metalnessMap,
]);
for (const texture of textures) {
if (texture) texture.offset.y += offset;
}
}
function onPointerDown(event: ThreeEvent<PointerEvent>) {
if (isTrack && Tankopedia.state.display === TankopediaDisplay.Model) {
position.set(event.clientX, event.clientY);
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
} else {
event.stopPropagation();
}
}
function handlePointerMove(event: PointerEvent) {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
const deltaX = delta.x / window.innerWidth;
const deltaY = delta.y / window.innerHeight;
translateTexture(deltaX + deltaY);
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
<group ref={turretContainer}>
{nodes.map((node) => {
const isTurret = node.name.startsWith("turret_");
const isCurrentTurret =
node.name ===
`turret_${turretModelDefinition.model_id
.toString()
.padStart(2, "0")}`;
const isVisible = isCurrentTurret;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
if (!isTurret) return;
position.set(event.clientX, event.clientY);
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
<group ref={gunContainer}>
{nodes.map((node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModelDefinition.model_id
.toString()
.padStart(2, "0")}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
position.set(event.clientX, event.clientY);
pitch = modelTransformEvent.last!.pitch;
yaw = modelTransformEvent.last!.yaw;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
</group>
</group>
</ModelTankWrapper>
);
}
@@ -23,12 +23,12 @@ import {
type ThicknessRange,
} from "../../../../Armor/components/StaticArmor";
import { SmartCanvas } from "../../../../SmartCanvas";
import { TankModel } from "../../../../TankModel";
import { AutoClear } from "./components/AutoClear";
import { Controls } from "./components/Control";
import { InitialAligner } from "./components/InitialAligner";
import { Lighting } from "./components/Lighting";
import { SceneProps } from "./components/SceneProps";
import { TankModel } from "./components/TankModel";
interface TankSandboxProps {
thicknessRange: ThicknessRange;
+89
View File
@@ -0,0 +1,89 @@
import type { ObjectMap } from "@react-three/fiber";
import type { ComponentProps } from "react";
import {
BatchedMesh,
Group,
Mesh,
MeshStandardMaterial,
type Object3D,
} from "three";
interface BatchedProps extends ComponentProps<"group"> {
slicer: (node: Object3D) => boolean;
mutator?: (mesh: Mesh) => void;
}
export function useBatched(map: ObjectMap, shadows = false) {
return function Batched({ slicer, mutator, ...props }: BatchedProps) {
const groups = new Map<
MeshStandardMaterial,
{ clonedMaterial: MeshStandardMaterial; meshes: Mesh[] }
>();
for (const node of Object.values(map.nodes)) {
if (!slicer(node)) continue;
node.traverse((object) => {
if (!(object instanceof Mesh)) return;
const originalMaterial = object.material;
if (!(originalMaterial instanceof MeshStandardMaterial)) return;
let group = groups.get(originalMaterial);
if (!group) {
group = {
clonedMaterial: originalMaterial.clone(),
meshes: [],
};
groups.set(originalMaterial, group);
}
group.meshes.push(object);
});
}
const wrapper = new Group();
for (const { clonedMaterial: material, meshes } of groups.values()) {
let vertices = 0;
let indices = 0;
for (const mesh of meshes) {
vertices += mesh.geometry.attributes.position.count;
indices += mesh.geometry.index!.count;
}
const batched = new BatchedMesh(
meshes.length,
vertices,
indices,
material,
);
for (const mesh of meshes) {
const geometryId = batched.addGeometry(mesh.geometry);
const instanceId = batched.addInstance(geometryId);
mesh.updateWorldMatrix(true, false);
batched.setMatrixAt(instanceId, mesh.matrixWorld);
if (shadows) {
batched.castShadow = true;
batched.receiveShadow = true;
}
mutator?.(mesh);
}
wrapper.add(batched);
}
return (
<group {...props}>
<primitive object={wrapper} />
</group>
);
};
}