Compare commits

...
39 Commits
Author SHA1 Message Date
TrèsAbhi fe4afdcbf0 feat: upgrade CompareEphemeral 2025-09-01 23:05:22 -05:00
TrèsAbhi ef2a47e756 feat: upgrade ComparePersistent 2025-09-01 23:02:20 -05:00
TrèsAbhi c2d625e1ed feat: upgrade EmbedBreakdown 2025-09-01 23:00:44 -05:00
TrèsAbhi d33990bdb4 feat: upgrade Gallery 2025-09-01 22:58:26 -05:00
TrèsAbhi 96482d4268 feat: renaming 2025-09-01 22:56:39 -05:00
TrèsAbhi 8b527f2333 fix: last and next checking 2025-09-01 22:55:31 -05:00
TrèsAbhi f56c31fb48 feat: upgrade Guess 2025-09-01 19:22:38 -05:00
TrèsAbhi 7d164e415a feat: upgrade Mixer 2025-09-01 19:19:44 -05:00
TrèsAbhi b3a9f0889e feat: upgrade Session 2025-09-01 19:16:36 -05:00
TrèsAbhi 6d24ed92d0 feat: upgrade TankFilters 2025-09-01 19:13:08 -05:00
TrèsAbhi d287e66265 feat: upgrade tankopediaEphemeral 2025-09-01 19:01:01 -05:00
TrèsAbhi ac29d1ead0 feat: upgrade tankopediaEphemeral 2025-09-01 18:51:04 -05:00
TrèsAbhi b6c3fdc4e9 feat: upgrade performance 2025-09-01 18:16:41 -05:00
TrèsAbhi 4efdaaf7c9 feat: upgrade TankSort 2025-09-01 18:13:41 -05:00
TrèsAbhi 87b6a36520 feat: use custom state manager 2025-09-01 18:03:07 -05:00
TrèsAbhi cf38cb3a6e feat: optimize legacy redirect pages 2025-09-01 17:27:42 -05:00
TrèsAbhi 9cb5113f6e feat: haha 2025-08-29 23:58:14 -05:00
TrèsAbhi 7dd1c0c32a feat: logs 2025-08-29 23:57:33 -05:00
TrèsAbhi 03233f3fdc fix: align antagonist selector filters correctly 2025-08-29 23:46:57 -05:00
TrèsAbhi 377159c298 fix: consider tank models 2025-08-29 23:41:35 -05:00
TrèsAbhi d8431fb620 fix: gap alignemnt 2025-08-29 23:28:49 -05:00
TrèsAbhi 0546ddefbd fix: better shot card alignment 2025-08-29 23:24:12 -05:00
TrèsAbhi 26f6889012 feat: revert subtler advanced highlighting coloring 2025-08-29 23:10:20 -05:00
TrèsAbhi 3ad6088565 feat: skip button for reload visualizer 2025-08-29 23:08:44 -05:00
TrèsAbhi b6fd568cc6 fix: screenshot props 2025-08-29 23:00:02 -05:00
TrèsAbhi 3861b07aef fix: remove "show grid" 2025-08-29 22:53:04 -05:00
TrèsAbhi 2bf57e2c72 feat: better framing 2025-08-29 22:51:24 -05:00
TrèsAbhi 744c6654cf feat: cooler initial position 2025-08-29 22:39:34 -05:00
TrèsAbhi b82e905757 fix: dead code 2025-08-29 13:42:44 -05:00
TrèsAbhi c42e5b7dc3 feat: logs 2025-08-29 13:32:46 -05:00
TrèsAbhi b716218ecd fix: loading bar 2025-08-29 13:31:48 -05:00
TrèsAbhi 511346e4bb fix: select 2025-08-29 13:26:34 -05:00
TrèsAbhi 5c2e49453e feat: more lighting 2025-08-29 13:21:41 -05:00
TrèsAbhi 5d397de766 feat: more progress 2025-08-28 18:23:02 -05:00
TrèsAbhi aa763f65d1 feat: more progress 2025-08-28 17:59:31 -05:00
TrèsAbhi 94cbd95509 feat: start of new lighting 2025-08-28 16:52:23 -05:00
TrèsAbhi f3762e356f feat: start of mixer 2025-08-28 16:12:40 -05:00
TrèsAbhi 5747d7549f feat: tweak a few things and enable guess game 2025-08-27 17:43:42 -05:00
TrèsAbhi aea4905d20 fix: vars 2025-08-27 17:22:06 -05:00
119 changed files with 2808 additions and 2947 deletions

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }} PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }} PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }}
PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }} PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }}
PUBLIC_PROMOTE_OPENTEST: ${{ secrets.PUBLIC_PROMOTE_OPENTEST }}
ALLOW_ROBOTS: ${{ secrets.ALLOW_ROBOTS }}
WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }}
PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }}
PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ${{ secrets.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }}
WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }}
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs: jobs:
host: host:
-7
View File
@@ -1,7 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 80
}
BIN
View File
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
# BlitzKit 1.27.18
## Features
- Added a mini-game to BlitzKit similar to "Guess Who's That Pokemon" but with tanks: http://blitzkit.vercel.app/guess
- Switched to a new renderer for better lighting for the Tankopedia
## Fixes
- The loading bar for the Tankopedia sandbox now shows up again
- Removed a lot of dead code, improving performance
+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.27.19
Two updates in a day? I just got a little excited by the new Tankopedia lighting.
## Features
- The camera is now lower town in the Tankopedia, giving the tank a more heroic feel
- Tanks are better framed across all devices, minimizing whitespace
- Added a skip button for the reload visualizer to skip to the next event
## Fixes
- Removed the useless "show grid" setting
- Taking screenshots now no longer includes the scene props like the floor
- The subtler color contrast for the advanced highlighting has now been reverted back to the higher contrast
- The shot display card is now properly aligned as a proper table again
- Tank modules are once again considered in the penetration tester
- Tank filters in the antagonist selector in the Tankopedia sandbox are now properly aligned
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"private": true, "private": true,
"version": "1.27.17", "version": "1.27.19",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"type": "module", "type": "module",
"types": "./types.d.ts", "types": "./types.d.ts",
+12 -3
View File
@@ -861,6 +861,13 @@
}, },
"tools": { "tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": { "guess": {
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
@@ -887,6 +894,10 @@
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.", "tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": { "visualizers": {
"reload": {
"skip": "Skip"
},
"assault": { "assault": {
"title": "Assault shells" "title": "Assault shells"
}, },
@@ -935,9 +946,7 @@
"green_penetration": "Green penetration", "green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor", "hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque", "opaque": "Opaque",
"dev_wireframe": "DEV: wireframe", "dev_wireframe": "DEV: wireframe"
"environment": "Environment",
"show_grid": "Show grid"
}, },
"screenshot": { "screenshot": {
+8
View File
@@ -0,0 +1,8 @@
{
"name": "varuna",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"react": "^18.3.1"
}
}
+104
View File
@@ -0,0 +1,104 @@
import { produce } from "immer";
import { merge } from "lodash-es";
import { useEffect, useState } from "react";
export class Varuna<Type, Arguments = void> {
private listeners = new Set<(state: Type) => void>();
private initialized = false;
private _initial?: Type;
private _state?: Type;
constructor(
private creator: Type | ((...args: Arguments[]) => Type),
private persistence?: string
) {
if (typeof creator !== "function") this.initialize(creator);
}
private initialize(initial: Type) {
let data = initial;
if (this.persistence && typeof localStorage !== "undefined") {
const dehydrated = localStorage.getItem(this.persistence);
if (dehydrated) {
const rehydrated = JSON.parse(dehydrated) as unknown;
data = merge(data, rehydrated);
}
}
this.initialized = true;
this._initial = data;
this._state = data;
}
useInitialization(...args: Arguments[]) {
if (this.initialized) return;
if (typeof this.creator !== "function") {
throw new Error("Provider must be used with a function creator");
}
this.initialize((this.creator as (...args: Arguments[]) => Type)(...args));
}
private dispatch() {
const state = this.state;
this.listeners.forEach((callback) => callback(state));
}
on<Slice>(slicer: (state: Type) => Slice, callback: (state: Slice) => void) {
let last = slicer(this.state);
function listener(state: Type) {
const next = slicer(state);
if (next !== last) {
callback(next);
last = next;
}
}
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
mutate(mutator: (state: Type) => void) {
this.set(produce(this.state, mutator));
}
private assertInitialized() {
if (!this.initialized) throw new Error("State used before initialization");
}
get state() {
this.assertInitialized();
return this._state as Type;
}
set(state: Type) {
this.assertInitialized();
this._state = state;
this.dispatch();
}
get initial() {
this.assertInitialized();
return this._initial as Type;
}
use<Slice = Type>(
slicer: (state: Type) => Slice = (state) => state as unknown as Slice
) {
const instance = this;
const [slice, setSlice] = useState(slicer(this.state));
useEffect(() => instance.on(slicer, setSlice), []);
return slice;
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+1
View File
@@ -13,6 +13,7 @@
"@astrolib/seo": "^1.0.0-beta.8", "@astrolib/seo": "^1.0.0-beta.8",
"@blitzkit/core": "workspace:*", "@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*", "@blitzkit/i18n": "workspace:*",
"varuna": "workspace:*",
"@fontsource/inter": "^5.2.5", "@fontsource/inter": "^5.2.5",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 KiB

@@ -1,4 +1,4 @@
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { import {
Card, Card,
Code, Code,
@@ -6,26 +6,22 @@ import {
IconButton, IconButton,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { useRef } from 'react'; import { useRef } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { resolveArmorIndex } from '../../../core/blitzkit/resolveArmorIndex'; import { resolveArmorIndex } from "../../../core/blitzkit/resolveArmorIndex";
import { App } from '../../../stores/app'; import { App } from "../../../stores/app";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { layerTypeNames } from './ShotDisplayCard'; import { layerTypeNames } from "./ShotDisplayCard";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export function ArmorPlateDisplay() { export function ArmorPlateDisplay() {
const highlightArmor = TankopediaEphemeral.use( const highlightArmor = Tankopedia.use((state) => state.highlightArmor);
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.useDeferred((state) => state.developerMode, false); const developerMode = App.useDeferred((state) => state.developerMode, false);
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
if (highlightArmor === undefined) return null; if (highlightArmor === undefined) return null;
@@ -33,20 +29,20 @@ export function ArmorPlateDisplay() {
<group position={highlightArmor.point}> <group position={highlightArmor.point}>
<Html <Html
center center
style={{ pointerEvents: highlightArmor.editingPlate ? 'auto' : 'none' }} style={{ pointerEvents: highlightArmor.editingPlate ? "auto" : "none" }}
> >
<Card <Card
variant="classic" variant="classic"
mb="4" mb="4"
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
color: highlightArmor.color, color: highlightArmor.color,
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex direction="column"> <Flex direction="column">
<Text weight="bold"> <Text weight="bold">
{layerTypeNames[highlightArmor.type]}{' '} {layerTypeNames[highlightArmor.type]}{" "}
{highlightArmor.thickness.toFixed(0)} {highlightArmor.thickness.toFixed(0)}
<Text size="1" weight="regular"> <Text size="1" weight="regular">
mm mm
@@ -92,10 +88,10 @@ export function ArmorPlateDisplay() {
if (isNaN(thickness) || thickness < 0) return; if (isNaN(thickness) || thickness < 0) return;
if (event.key === 'Enter') { if (event.key === "Enter") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const { protagonist } = duelStore.getState(); const { protagonist } = duelStore.getState();
const tank = draft.model; const tank = draft.model;
@@ -103,23 +99,23 @@ export function ArmorPlateDisplay() {
const gun = turret.guns[protagonist.gun.id]; const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id]; const track = tank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = thickness; tank.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = thickness; turret.armor.thickness[index] = thickness;
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = thickness; gun.armor.thickness[index] = thickness;
} else { } else {
gun.thickness = thickness; gun.thickness = thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = thickness; track.thickness = thickness;
} }
}); });
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
input.current?.blur(); input.current?.blur();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
} }
@@ -137,7 +133,7 @@ export function ArmorPlateDisplay() {
mt="2" mt="2"
color="red" color="red"
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
if (!highlightArmor.editingPlate) return; if (!highlightArmor.editingPlate) return;
const index = resolveArmorIndex(highlightArmor.name); const index = resolveArmorIndex(highlightArmor.name);
@@ -145,8 +141,7 @@ export function ArmorPlateDisplay() {
if (index === undefined) return; if (index === undefined) return;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
const initialState = const initialState = Tankopedia.initial;
tankopediaEphemeralStore.getInitialState();
const { protagonist } = duelStore.getState(); const { protagonist } = duelStore.getState();
const tank = draft.model; const tank = draft.model;
const initialTank = initialState.model; const initialTank = initialState.model;
@@ -159,20 +154,20 @@ export function ArmorPlateDisplay() {
const initialTrack = const initialTrack =
initialTank.tracks[protagonist.track.id]; initialTank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) { if (highlightArmor.name.startsWith("hull_")) {
tank.armor.thickness[index] = tank.armor.thickness[index] =
initialTank.armor.thickness[index]; initialTank.armor.thickness[index];
} else if (highlightArmor.name.startsWith('turret_')) { } else if (highlightArmor.name.startsWith("turret_")) {
turret.armor.thickness[index] = turret.armor.thickness[index] =
initialTurret.armor.thickness[index]; initialTurret.armor.thickness[index];
} else if (highlightArmor.name.startsWith('gun_')) { } else if (highlightArmor.name.startsWith("gun_")) {
if (highlightArmor.name.includes('_armor_')) { if (highlightArmor.name.includes("_armor_")) {
gun.armor.thickness[index] = gun.armor.thickness[index] =
initialGun.armor.thickness[index]; initialGun.armor.thickness[index];
} else { } else {
gun.thickness = initialGun.thickness; gun.thickness = initialGun.thickness;
} }
} else if (highlightArmor.name.startsWith('chassis_')) { } else if (highlightArmor.name.startsWith("chassis_")) {
track.thickness = initialTrack.thickness; track.thickness = initialTrack.thickness;
} }
}); });
@@ -14,7 +14,6 @@ export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
// const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -2,9 +2,9 @@ import {
canSplash, canSplash,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
MeshBasicMaterial, MeshBasicMaterial,
NormalBlending, NormalBlending,
@@ -14,16 +14,16 @@ import {
UniformsLib, UniformsLib,
UniformsUtils, UniformsUtils,
Vector2, Vector2,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../stores/tankopediaPersistent";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
import { spacedArmorRenderTarget } from './target'; import { spacedArmorRenderTarget } from "./target";
interface PrimaryArmorSceneComponentProps { interface PrimaryArmorSceneComponentProps {
thickness: number; thickness: number;
@@ -43,7 +43,6 @@ export function PrimaryArmorSceneComponent({
}: PrimaryArmorSceneComponentProps) { }: PrimaryArmorSceneComponentProps) {
const tankopediaPersistentStore = TankopediaPersistent.useStore(); const tankopediaPersistentStore = TankopediaPersistent.useStore();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
vertexShader, vertexShader,
@@ -80,31 +79,25 @@ export function PrimaryArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
async function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
material.uniforms.isExplosive.value = isExplosive(shell.type); material.uniforms.isExplosive.value = isExplosive(shell.type);
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.armor_damage; material.uniforms.damage.value = shell.armor_damage;
material.uniforms.explosionRadius.value = shell.explosion_radius; material.uniforms.explosionRadius.value = shell.explosion_radius;
await handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(duel.protagonist.equipmentMatrix, true);
duel.protagonist.equipmentMatrix, handleAntagonistEquipmentChange(duel.antagonist.equipmentMatrix, true);
true,
);
await handleAntagonistEquipmentChange(
duel.antagonist.equipmentMatrix,
true,
);
invalidate(); invalidate();
} }
@@ -121,15 +114,15 @@ export function PrimaryArmorSceneComponent({
function handleWireframeChange(wireframe: boolean) { function handleWireframeChange(wireframe: boolean) {
material.wireframe = wireframe; material.wireframe = wireframe;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
@@ -137,18 +130,18 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate(); if (!noInvalidate) invalidate();
} }
async function handleAntagonistEquipmentChange( function handleAntagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
noInvalidate = false, noInvalidate = false
) { ) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, duel.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -166,37 +159,34 @@ export function PrimaryArmorSceneComponent({
handleOpaqueChange(initialState.opaque); handleOpaqueChange(initialState.opaque);
handleWireframeChange(initialState.wireframe); handleWireframeChange(initialState.wireframe);
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix, duelStore.getState().protagonist.equipmentMatrix
); );
handleAntagonistEquipmentChange( handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix, duelStore.getState().antagonist.equipmentMatrix
); );
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell,
handleShellChange,
),
tankopediaPersistentStore.subscribe( tankopediaPersistentStore.subscribe(
(state) => state.greenPenetration, (state) => state.greenPenetration,
handleGreenPenetrationChange, handleGreenPenetrationChange
), ),
tankopediaPersistentStore.subscribe( tankopediaPersistentStore.subscribe(
(state) => state.advancedHighlighting, (state) => state.advancedHighlighting,
handleAdvancedHighlightingChange, handleAdvancedHighlightingChange
), ),
tankopediaPersistentStore.subscribe( tankopediaPersistentStore.subscribe(
(state) => state.opaque, (state) => state.opaque,
handleOpaqueChange, handleOpaqueChange
), ),
duelStore.subscribe( duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
(equipment) => handleProtagonistEquipmentChange(equipment), (equipment) => handleProtagonistEquipmentChange(equipment)
), ),
duelStore.subscribe( duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
(equipment) => handleAntagonistEquipmentChange(equipment), (equipment) => handleAntagonistEquipmentChange(equipment)
), ),
]; ];
@@ -207,7 +197,7 @@ export function PrimaryArmorSceneComponent({
useFrame(({ gl, camera }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio()
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
@@ -37,7 +37,7 @@ float getDistance(vec2 coord, float depth) {
vec3 getPenetrationColor(bool isThreeCalibersRule, bool couldHaveRicochet) { vec3 getPenetrationColor(bool isThreeCalibersRule, bool couldHaveRicochet) {
if (advancedHighlighting && couldHaveRicochet) { if (advancedHighlighting && couldHaveRicochet) {
return vec3(0.0, 1.0, isThreeCalibersRule ? 0.5 : 0.0); return vec3(0.0, 1.0, isThreeCalibersRule ? 1.0 : 0.0);
} }
return vec3(0.0, 1.0, 0.0); return vec3(0.0, 1.0, 0.0);
@@ -107,7 +107,7 @@ void main() {
vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0); vec3 baseColor = vec3(1.0, splashChance * GREEN_VALUE, 0.0);
if (advancedHighlighting && didRicochet) { if (advancedHighlighting && didRicochet) {
baseColor = vec3(1.0, baseColor.g, 0.5); baseColor = vec3(1.0, baseColor.g, 1.0);
} }
if (greenPenetration || advancedHighlighting) { if (greenPenetration || advancedHighlighting) {
@@ -1,9 +1,9 @@
import { J_HAT, K_HAT } from '@blitzkit/core'; import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from "@radix-ui/themes";
import { Html } from '@react-three/drei'; import { Html } from "@react-three/drei";
import { invalidate, useFrame } from '@react-three/fiber'; import { invalidate, useFrame } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import { useEffect, useRef, type ComponentProps } from 'react'; import { useEffect, useRef, type ComponentProps } from "react";
import { import {
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
@@ -15,23 +15,24 @@ import {
MeshBasicMaterial, MeshBasicMaterial,
Path, Path,
Quaternion, Quaternion,
} from 'three'; } from "three";
import { LocaleProvider, useLocale } from '../../../hooks/useLocale'; import { isHalloween } from "../../../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import { import {
TankopediaEphemeral, Tankopedia,
type ShotLayerNonExternal, type ShotLayerNonExternal,
type ShotStatus, type ShotStatus,
} from '../../../stores/tankopediaEphemeral'; } from "../../../stores/tankopedia";
import { ShotDisplayCard } from './ShotDisplayCard'; import { ShotDisplayCard } from "./ShotDisplayCard";
export const shotStatusColors: Record< export const shotStatusColors: Record<
ShotStatus, ShotStatus,
ComponentProps<typeof Text>['color'] ComponentProps<typeof Text>["color"]
> = { > = {
blocked: 'red', blocked: "red",
penetration: 'green', penetration: "green",
ricochet: 'yellow', ricochet: "yellow",
splash: 'orange', splash: "orange",
}; };
const SURFACE_POINT_SIZE = 12; const SURFACE_POINT_SIZE = 12;
@@ -40,7 +41,7 @@ const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2; const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() { export function ShotDisplay() {
const shot = TankopediaEphemeral.use((state) => state.shot); const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null); const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null); const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null); const splashRadiusWrapper = useRef<Group>(null);
@@ -51,16 +52,16 @@ export function ShotDisplay() {
useEffect(() => { useEffect(() => {
if (shot?.splashRadius !== undefined) { if (shot?.splashRadius !== undefined) {
const outlineGeometry = new BufferGeometry().setFromPoints( const outlineGeometry = new BufferGeometry().setFromPoints(
new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50), new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50)
); );
const outlineMaterial = new LineDashedMaterial({ const outlineMaterial = new LineDashedMaterial({
color: 'orange', color: "orange",
dashSize: 0.05, dashSize: 0.05,
gapSize: 0.025, gapSize: 0.025,
}); });
const outline = new Line( const outline = new Line(
outlineGeometry, outlineGeometry,
outlineMaterial, outlineMaterial
).computeLineDistances(); ).computeLineDistances();
splashRadiusWrapper.current?.add(outline); splashRadiusWrapper.current?.add(outline);
@@ -72,15 +73,12 @@ export function ShotDisplay() {
}); });
useEffect(() => { useEffect(() => {
const data = new Date();
const isHalloween = data.getMonth() === 9 && data.getDate() === 31;
if ( if (
isHalloween && isHalloween() &&
shot?.splashRadius !== undefined && shot?.splashRadius !== undefined &&
shot.in.status === 'penetration' shot.in.status === "penetration"
) { ) {
const audio = new Audio('/assets/audio/lotta-damage.mp3'); const audio = new Audio("/assets/audio/lotta-damage.mp3");
audio.currentTime = 0.7; audio.currentTime = 0.7;
audio.volume = 0.25; audio.volume = 0.25;
@@ -136,11 +134,11 @@ export function ShotDisplay() {
const outLength = const outLength =
shot.out && shot.out.layers.length > 0 shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo( ? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point, (shot.out.layers.at(-1) as ShotLayerNonExternal).point
) )
: SHOT_DISPLAY_LENGTH_INFINITY; : SHOT_DISPLAY_LENGTH_INFINITY;
const inLast = shot.in.layers.findLast( const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null, (layer) => layer.type !== null
) as ShotLayerNonExternal; ) as ShotLayerNonExternal;
const outTitleColor = shot.out const outTitleColor = shot.out
? shotStatusColors[shot.out.status] ? shotStatusColors[shot.out.status]
@@ -156,7 +154,7 @@ export function ShotDisplay() {
position={inLast.point} position={inLast.point}
ref={splashRadiusWrapper} ref={splashRadiusWrapper}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh> <mesh>
@@ -174,13 +172,13 @@ export function ShotDisplay() {
<Html position={inLast.point} center> <Html position={inLast.point} center>
<Box <Box
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`, transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}} }}
mb={tracerGoingUp ? '0' : '9'} mb={tracerGoingUp ? "0" : "9"}
pb={tracerGoingUp ? '0' : '4'} pb={tracerGoingUp ? "0" : "4"}
mt={tracerGoingUp ? '9' : '0'} mt={tracerGoingUp ? "9" : "0"}
pt={tracerGoingUp ? '4' : '0'} pt={tracerGoingUp ? "4" : "0"}
> >
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} /> <ShotDisplayCard shot={shot} />
@@ -192,7 +190,7 @@ export function ShotDisplay() {
if (layer.type === null) return null; if (layer.type === null) return null;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -201,8 +199,8 @@ export function ShotDisplay() {
position={layer.point} position={layer.point}
key={index} key={index}
style={{ style={{
pointerEvents: 'none', pointerEvents: "none",
transform: 'translateY(-50%)', transform: "translateY(-50%)",
}} }}
> >
<Flex <Flex
@@ -227,7 +225,7 @@ export function ShotDisplay() {
<group <group
position={inLast.point} position={inLast.point}
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal), new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal)
)} )}
> >
<mesh position={[0, inLength / 2, 0]} renderOrder={2}> <mesh position={[0, inLength / 2, 0]} renderOrder={2}>
@@ -255,8 +253,8 @@ export function ShotDisplay() {
rotation={new Euler().setFromQuaternion( rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors( new Quaternion().setFromUnitVectors(
J_HAT, J_HAT,
shot.out.surfaceNormal.clone(), shot.out.surfaceNormal.clone()
), )
)} )}
> >
<mesh position={[0, outLength / 2, 0]} renderOrder={2}> <mesh position={[0, outLength / 2, 0]} renderOrder={2}>
@@ -1,4 +1,4 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { import {
Card, Card,
Flex, Flex,
@@ -6,19 +6,19 @@ import {
Separator, Separator,
Text, Text,
type CardProps, type CardProps,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import type { ComponentProps } from 'react'; import type { ComponentProps } from "react";
import { radToDeg } from 'three/src/math/MathUtils.js'; import { radToDeg } from "three/src/math/MathUtils.js";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import type { Shot, ShotLayer } from '../../../stores/tankopediaEphemeral'; import type { Shot, ShotLayer } from "../../../stores/tankopedia";
import { shotStatusColors } from './ShotDisplay'; import { shotStatusColors } from "./ShotDisplay";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
export const layerTypeNames = { export const layerTypeNames = {
[ArmorType.Primary]: 'primary', [ArmorType.Primary]: "primary",
[ArmorType.Spaced]: 'spaced', [ArmorType.Spaced]: "spaced",
[ArmorType.External]: 'external', [ArmorType.External]: "external",
null: 'gap', null: "gap",
} as const; } as const;
function LayerEntry({ function LayerEntry({
@@ -29,7 +29,7 @@ function LayerEntry({
angle, angle,
}: { }: {
layerIndex?: string; layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color']; shotStatusColor: ComponentProps<typeof Text>["color"];
layerName: string; layerName: string;
layer: ShotLayer; layer: ShotLayer;
angle?: number; angle?: number;
@@ -37,44 +37,38 @@ function LayerEntry({
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
return ( return (
<Flex align="center" gap="2"> <tr>
<Text size="1" color="gray" style={{ width: 12 }}> <td>
{layerIndex} <Text size="1" color="gray">
</Text> {layerIndex}
<Text size="2" color={shotStatusColor} style={{ width: 48 }}> </Text>
{layerName} </td>
</Text>
{layer.type === null && ( <td>
<> <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}> {layerName}
</Text>
</td>
<td>
{layer.type === null && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, [ {literals(strings.common.units.mm, [
Math.round(layer.distance * 1000).toLocaleString(locale), Math.round(layer.distance * 1000).toLocaleString(locale),
])} ])}
</Text> </Text>
)}
<Text size="2" color="gray"> {layer.type === ArmorType.External && (
{literals( <Text size="2" color={shotStatusColor}>
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats {literals(strings.common.units.mm, [
.ricochet_loss, Math.round(layer.thickness).toLocaleString(locale),
[Math.max(-100, -50 * layer.distance).toFixed(0)], ])}
)}
</Text> </Text>
</> )}
)} {(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
{layer.type === ArmorType.External && ( <Text size="2" color={shotStatusColor}>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
{literals( {literals(
angle === undefined angle === undefined
? strings.common.units.mm ? strings.common.units.mm
@@ -83,20 +77,35 @@ function LayerEntry({
[ [
Math.round(layer.thicknessAngled).toLocaleString(locale), Math.round(layer.thicknessAngled).toLocaleString(locale),
radToDeg(angle ?? 0).toFixed(0), radToDeg(angle ?? 0).toFixed(0),
], ]
)} )}
</Text> </Text>
)}
</td>
<td>
{layer.type === null && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)]
)}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color="gray"> <Text size="2" color="gray">
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.nominal, .nominal,
[Math.round(layer.thickness).toLocaleString(locale)], [Math.round(layer.thickness).toLocaleString(locale)]
)} )}
</Text> </Text>
</> )}
)} </td>
</Flex> </tr>
); );
} }
@@ -115,7 +124,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const inTitleColor = shotStatusColors[shot.in.status]; const inTitleColor = shotStatusColors[shot.in.status];
return ( return (
<Card variant="classic" style={{ width: 300 }} {...props}> <Card variant="classic" {...props}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Flex direction="column" gap="1"> <Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold"> <Text color={inTitleColor} weight="bold">
@@ -123,11 +132,16 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[ strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[
shot.in.status shot.in.status
], ],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
<Flex direction="column"> <table
style={{
whiteSpace: "nowrap",
borderSpacing: "var(--space-1) 0",
}}
>
{shot.in.layers.map((layer, index) => { {shot.in.layers.map((layer, index) => {
const layerName = const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
@@ -141,7 +155,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
? undefined ? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`; : `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor = const shotStatusColor =
shot.in.status === 'splash' shot.in.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -161,7 +175,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
/> />
); );
})} })}
</Flex> </table>
</Flex> </Flex>
{shot.out && shot.out.layers.length > 0 && ( {shot.out && shot.out.layers.length > 0 && (
@@ -188,7 +202,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
{literals( {literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status], .status[shot.out.status],
[Math.round(shot.damage).toLocaleString(locale)], [Math.round(shot.damage).toLocaleString(locale)]
)} )}
</Text> </Text>
@@ -210,7 +224,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
1 1
}.`; }.`;
const shotStatusColor = const shotStatusColor =
shot.out!.status === 'splash' shot.out!.status === "splash"
? shotStatusColors.splash ? shotStatusColors.splash
: shotStatusColors[layer.status]; : shotStatusColors[layer.status];
@@ -1,20 +1,20 @@
import { resolvePenetrationCoefficient } from '@blitzkit/core'; import { resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
ShaderMaterial, ShaderMaterial,
} from 'three'; } from "three";
import type { ArmorUserData, ExternalModuleVariant } from '../..'; import type { ArmorUserData, ExternalModuleVariant } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubExternalProps { interface SpacedArmorSubExternalProps {
node: Object3D; node: Object3D;
@@ -29,7 +29,6 @@ export function SpacedArmorSubExternal({
variant, variant,
clip, clip,
}: SpacedArmorSubExternalProps) { }: SpacedArmorSubExternalProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
@@ -50,32 +49,30 @@ export function SpacedArmorSubExternal({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix,
) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, duel.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -85,25 +82,22 @@ export function SpacedArmorSubExternal({
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix, duelStore.getState().protagonist.equipmentMatrix
); );
handleAntagonistEquipmentChange( handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix, duelStore.getState().antagonist.equipmentMatrix
); );
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell,
handleShellChange,
),
duelStore.subscribe( duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -125,6 +119,7 @@ export function SpacedArmorSubExternal({
return ( return (
<mesh <mesh
{...props} {...props}
onClick={() => {}}
key={`${key}-spaced-sub-external-exclude`} key={`${key}-spaced-sub-external-exclude`}
renderOrder={3} renderOrder={3}
material={ material={
@@ -1,20 +1,20 @@
import { isExplosive, resolvePenetrationCoefficient } from '@blitzkit/core'; import { isExplosive, resolvePenetrationCoefficient } from "@blitzkit/core";
import { useEffect } from 'react'; import { useEffect } from "react";
import { import {
AdditiveBlending, AdditiveBlending,
MeshBasicMaterial, MeshBasicMaterial,
ShaderMaterial, ShaderMaterial,
type Object3D, type Object3D,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import type { ArmorUserData } from '../..'; import type { ArmorUserData } from "../..";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from "../../../SpacedArmorScene";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
interface SpacedArmorSubSpacedProps { interface SpacedArmorSubSpacedProps {
node: Object3D; node: Object3D;
@@ -30,7 +30,6 @@ export function SpacedArmorSubSpaced({
node, node,
thickness, thickness,
}: SpacedArmorSubSpacedProps) { }: SpacedArmorSubSpacedProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const material = new ShaderMaterial({ const material = new ShaderMaterial({
fragmentShader, fragmentShader,
@@ -52,40 +51,38 @@ export function SpacedArmorSubSpaced({
useEffect(() => { useEffect(() => {
function handleShellChange() { function handleShellChange() {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; material.uniforms.penetration.value = shell.penetration.near;
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, shell.normalization ?? 0
); );
} }
async function handleProtagonistEquipmentChange( function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
equipment: EquipmentMatrix,
) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 ? thickness * 1.03
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); const tankopediaEphemeral = Tankopedia.state;
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near; const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
duel.antagonist.tank.equipment_preset, duel.antagonist.tank.equipment_preset,
equipment, equipment
); );
material.uniforms.penetration.value = material.uniforms.penetration.value =
@@ -95,25 +92,22 @@ export function SpacedArmorSubSpaced({
handleShellChange(); handleShellChange();
handleProtagonistEquipmentChange( handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix, duelStore.getState().protagonist.equipmentMatrix
); );
handleAntagonistEquipmentChange( handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix, duelStore.getState().antagonist.equipmentMatrix
); );
const unsubscribes = [ const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( Tankopedia.on((state) => state.customShell, handleShellChange),
(state) => state.customShell,
handleShellChange,
),
duelStore.subscribe( duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange
), ),
duelStore.subscribe( duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix, (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange
), ),
]; ];
@@ -2,9 +2,9 @@ import {
ShellType, ShellType,
isExplosive, isExplosive,
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useThree } from '@react-three/fiber'; import { useThree } from "@react-three/fiber";
import { useCallback } from 'react'; import { useCallback } from "react";
import { import {
type Intersection, type Intersection,
MeshBasicMaterial, MeshBasicMaterial,
@@ -14,21 +14,21 @@ import {
Raycaster, Raycaster,
Scene, Scene,
Vector3, Vector3,
} from 'three'; } from "three";
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../../core/three/discardClippingPlane";
import { Duel } from '../../../../stores/duel'; import { Duel } from "../../../../stores/duel";
import { import {
type Shot, type Shot,
type ShotLayerBase, type ShotLayerBase,
type ShotLayerNonExternal, type ShotLayerNonExternal,
TankopediaEphemeral, Tankopedia,
} from '../../../../stores/tankopediaEphemeral'; } from "../../../../stores/tankopedia";
import { ArmorType } from '../SpacedArmorScene'; import { ArmorType } from "../SpacedArmorScene";
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal'; import { SpacedArmorSubExternal } from "./components/SpacedArmorSubExternal";
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced'; import { SpacedArmorSubSpaced } from "./components/SpacedArmorSubSpaced";
type SpacedArmorSceneComponentProps = { type SpacedArmorSceneComponentProps = {
node: Object3D; node: Object3D;
@@ -45,7 +45,7 @@ type SpacedArmorSceneComponentProps = {
} }
); );
export type ExternalModuleVariant = 'gun' | 'track'; export type ExternalModuleVariant = "gun" | "track";
export type ArmorUserData = { export type ArmorUserData = {
thickness: number; thickness: number;
} & ( } & (
@@ -76,19 +76,18 @@ export function SpacedArmorSceneComponent({
clip, clip,
...props ...props
}: SpacedArmorSceneComponentProps) { }: SpacedArmorSceneComponentProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const shoot = useCallback( const shoot = useCallback(
async ( (
point: Vector3, point: Vector3,
intersections: Intersection[], intersections: Intersection[],
allowRicochet: boolean, allowRicochet: boolean,
remainingPenetrationInput?: number, remainingPenetrationInput?: number
) => { ) => {
const { antagonist, protagonist } = duelStore.getState(); const { antagonist, protagonist } = duelStore.getState();
const { customShell } = tankopediaEphemeralStore.getState(); const { customShell } = Tankopedia.state;
const shell = customShell ?? antagonist.shell; const shell = customShell ?? antagonist.shell;
const cameraNormal = camera.position.clone().sub(point).normalize(); const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: Shot = { const shot: Shot = {
@@ -99,19 +98,19 @@ export function SpacedArmorSceneComponent({
in: { in: {
surfaceNormal: cameraNormal, surfaceNormal: cameraNormal,
layers: [], layers: [],
status: 'blocked', status: "blocked",
}, },
}; };
const hasCalibratedShells = await hasEquipment( const hasCalibratedShells = hasEquipment(
103, 103,
antagonist.tank.equipment_preset, antagonist.tank.equipment_preset,
antagonist.equipmentMatrix, antagonist.equipmentMatrix
); );
const hasEnhancedArmor = await hasEquipment( const hasEnhancedArmor = hasEquipment(
110, 110,
protagonist.tank.equipment_preset, protagonist.tank.equipment_preset,
protagonist.equipmentMatrix, protagonist.equipmentMatrix
); );
const penetration = const penetration =
shell.penetration.near * shell.penetration.near *
@@ -119,8 +118,8 @@ export function SpacedArmorSceneComponent({
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter( const filteredIntersections = intersections.filter(
(intersection) => (intersection) =>
'type' in intersection.object.userData && "type" in intersection.object.userData &&
!discardClippingPlane(intersection.object, intersection.point), !discardClippingPlane(intersection.object, intersection.point)
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[]; ) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = []; const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = []; const noDuplicateIntersections: typeof filteredIntersections = [];
@@ -129,12 +128,12 @@ export function SpacedArmorSceneComponent({
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
if ( if (
!encounteredExternalModuleVariants.includes( !encounteredExternalModuleVariants.includes(
intersection.object.userData.variant, intersection.object.userData.variant
) )
) { ) {
noDuplicateIntersections.push(intersection); noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push( encounteredExternalModuleVariants.push(
intersection.object.userData.variant, intersection.object.userData.variant
); );
} }
} else { } else {
@@ -162,7 +161,7 @@ export function SpacedArmorSceneComponent({
const surfaceNormal = intersection const surfaceNormal = intersection
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()), intersection.object.getWorldQuaternion(new Quaternion())
); );
if (shell.type === ShellType.HEAT && index > 0) { if (shell.type === ShellType.HEAT && index > 0) {
@@ -174,7 +173,7 @@ export function SpacedArmorSceneComponent({
shot.in.layers.push({ shot.in.layers.push({
type: null, type: null,
distance, distance,
status: blocked ? 'blocked' : 'penetration', status: blocked ? "blocked" : "penetration",
}); });
if (blocked) break; if (blocked) break;
@@ -194,16 +193,16 @@ export function SpacedArmorSceneComponent({
surfaceNormal, surfaceNormal,
status: status:
shell.type === ShellType.HE shell.type === ShellType.HE
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
variant: layer.variant, variant: layer.variant,
}); });
} else { } else {
const thickness = layer.thickness * thicknessCoefficient; const thickness = layer.thickness * thicknessCoefficient;
const ricochet = degToRad( const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, isExplosive(shell.type) ? 90 : shell.ricochet!
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
@@ -227,7 +226,7 @@ export function SpacedArmorSceneComponent({
thicknessAngled: finalThickness, thicknessAngled: finalThickness,
point: intersection.point, point: intersection.point,
surfaceNormal, surfaceNormal,
status: 'ricochet', status: "ricochet",
angle, angle,
}); });
@@ -245,10 +244,10 @@ export function SpacedArmorSceneComponent({
surfaceNormal: intersection.face!.normal, surfaceNormal: intersection.face!.normal,
status: status:
shell.type === ShellType.HE && layer.type !== ArmorType.Primary shell.type === ShellType.HE && layer.type !== ArmorType.Primary
? 'blocked' ? "blocked"
: blocked : blocked
? 'blocked' ? "blocked"
: 'penetration', : "penetration",
angle, angle,
}); });
} }
@@ -282,10 +281,10 @@ export function SpacedArmorSceneComponent({
return acc + layer.thicknessAngled; return acc + layer.thicknessAngled;
}, },
0, 0
); );
const distanceFromSpacedArmor = firstLayer.point.distanceTo( const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point, lastLayer.point
); );
const finalDamage = Math.max( const finalDamage = Math.max(
0, 0,
@@ -294,7 +293,7 @@ export function SpacedArmorSceneComponent({
(1 - distanceFromSpacedArmor / shell.explosion_radius!) - (1 - distanceFromSpacedArmor / shell.explosion_radius!) -
1.1 * 1.1 *
(lastLayer.thicknessAngled + (lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)), Math.min(penetration, totalSpacedArmorThickness))
); );
/** /**
@@ -308,19 +307,19 @@ export function SpacedArmorSceneComponent({
* for parity's sake, I am capping the value here. * for parity's sake, I am capping the value here.
*/ */
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') { if (shot.in.layers.length > 1 || lastLayer.status === "blocked") {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; shot.in.status = finalDamage > 0 ? "splash" : "blocked";
shot.damage = finalDamage; shot.damage = finalDamage;
} else { } else {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} }
} else { } else {
if (lastLayer.status === 'blocked') { if (lastLayer.status === "blocked") {
shot.in.status = 'blocked'; shot.in.status = "blocked";
shot.damage = 0; shot.damage = 0;
} else if (lastLayer.status === 'penetration') { } else if (lastLayer.status === "penetration") {
shot.in.status = 'penetration'; shot.in.status = "penetration";
shot.damage = shell.armor_damage; shot.damage = shell.armor_damage;
} else { } else {
const caster = new Raycaster(); const caster = new Raycaster();
@@ -331,29 +330,29 @@ export function SpacedArmorSceneComponent({
.sub( .sub(
lastLayer.surfaceNormal lastLayer.surfaceNormal
.clone() .clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)), .multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal))
); );
caster.set(lastLayer.point, ricochetNormal); caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects( const ricochetIntersections = caster.intersectObjects(
props.scene.children, props.scene.children,
true, true
); );
const outShot = await shoot( const outShot = shoot(
lastLayer.point, lastLayer.point,
ricochetIntersections, ricochetIntersections,
false, false,
remainingPenetration, remainingPenetration
); );
shot.in.status = 'ricochet'; shot.in.status = "ricochet";
if (outShot === null) { if (outShot === null) {
shot.damage = 0; shot.damage = 0;
shot.out = { shot.out = {
surfaceNormal: ricochetNormal, surfaceNormal: ricochetNormal,
layers: [], layers: [],
status: 'ricochet', status: "ricochet",
}; };
} else { } else {
shot.damage = outShot.damage; shot.damage = outShot.damage;
@@ -366,7 +365,7 @@ export function SpacedArmorSceneComponent({
return shot; return shot;
}, },
[camera], [camera]
); );
return ( return (
@@ -386,14 +385,14 @@ export function SpacedArmorSceneComponent({
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
onClick={async (event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
const shot = (await shoot(
event.point, const shot = shoot(event.point, event.intersections, true)!;
event.intersections,
true, Tankopedia.mutate((draft) => {
))!; draft.shot = shot;
tankopediaEphemeralStore.setState({ shot }); });
}} }}
/> />
); );
@@ -1,22 +1,22 @@
import { useThree, type ThreeEvent } from '@react-three/fiber'; import { useThree, type ThreeEvent } from "@react-three/fiber";
import { memo, useRef } from 'react'; import { memo, useRef } from "react";
import { Group, Plane, Vector2, Vector3 } from 'three'; import { Group, Plane, Vector2, Vector3 } from "three";
import { applyPitchYawLimits } from '../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../core/blitz/applyPitchYawLimits";
import { correctZYTuple } from '../../../core/blitz/correctZYTuple'; import { correctZYTuple } from "../../../core/blitz/correctZYTuple";
import { hasEquipment } from '../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../core/blitzkit/hasEquipment";
import { modelTransformEvent } from '../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from "../../../core/blitzkit/modelTransform";
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId'; import { nameToArmorId } from "../../../core/blitzkit/nameToArmorId";
import { resolveArmor } from '../../../core/blitzkit/resolveThickness'; import { resolveArmor } from "../../../core/blitzkit/resolveThickness";
import { useArmor } from '../../../hooks/useArmor'; import { useArmor } from "../../../hooks/useArmor";
import { useModel } from '../../../hooks/useModel'; import { useModel } from "../../../hooks/useModel";
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../hooks/useTankModelDefinition";
import { useTankTransform } from '../../../hooks/useTankTransform'; import { useTankTransform } from "../../../hooks/useTankTransform";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { ModelTankWrapper } from './ModelTankWrapper'; import { ModelTankWrapper } from "./ModelTankWrapper";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
import { StaticArmorSceneComponent } from './StaticArmorSceneComponent'; import { StaticArmorSceneComponent } from "./StaticArmorSceneComponent";
interface ArmorSceneProps { interface ArmorSceneProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
@@ -30,7 +30,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const wrapper = useRef<Group>(null); const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
@@ -46,25 +45,23 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const hullOrigin = correctZYTuple(trackModelDefinition.origin); const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
const mutateDuel = Duel.useMutation();
const canvas = useThree((state) => state.gl.domElement); const canvas = useThree((state) => state.gl.domElement);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const maskOrigin = const maskOrigin =
gunModelDefinition.mask === undefined gunModelDefinition.mask === undefined
? undefined ? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const showPrimaryArmor = TankopediaPersistent.use( const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.showPrimaryArmor, (state) => state.showPrimaryArmor
); );
const showSpacedArmor = TankopediaPersistent.use( const showSpacedArmor = TankopediaPersistent.use(
(state) => state.showSpacedArmor, (state) => state.showSpacedArmor
); );
const showExternalModules = TankopediaPersistent.use( const showExternalModules = TankopediaPersistent.use(
(state) => state.showExternalModules, (state) => state.showExternalModules
); );
const isDynamicArmorActive = Duel.use((state) => const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73), state.protagonist.consumables.includes(73)
); );
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
@@ -73,12 +70,12 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<ModelTankWrapper ref={wrapper}> <ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}> <group position={hullOrigin}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_'); const isHull = node.name.startsWith("hull_");
const isVisible = isHull; const isVisible = isHull;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor, tankModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -86,8 +83,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
@@ -106,8 +103,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
</group> </group>
{modelNodes.map((node) => { {modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_'); const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith('chassis_track_'); const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isWheel || isTrack; const isVisible = isWheel || isTrack;
if (!isVisible || !showExternalModules) return null; if (!isVisible || !showExternalModules) return null;
@@ -129,13 +126,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={turretContainer}> <group ref={turretContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith( const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`, `turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentTurret; const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor, turretModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -143,8 +140,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) { ) {
return null; return null;
} }
@@ -157,27 +154,27 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
@@ -191,16 +188,16 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -209,7 +206,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<StaticArmorSceneComponent <StaticArmorSceneComponent
name={node.name} name={node.name}
thicknessRange={thicknessRange} thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary} type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness} thickness={thickness}
node={node} node={node}
@@ -223,13 +219,13 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<group ref={gunContainer}> <group ref={gunContainer}>
{armorNodes.map((node) => { {armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith( const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`, `gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`
); );
const isVisible = isCurrentGun; const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name); const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor( const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor, gunModelDefinition.armor,
armorId, armorId
); );
if ( if (
@@ -237,8 +233,8 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness === undefined || thickness === undefined ||
(!showPrimaryArmor && !spaced) || (!showPrimaryArmor && !spaced) ||
(!showSpacedArmor && spaced) || (!showSpacedArmor && spaced) ||
(isDynamicArmorActive && node.name.includes('state_00')) || (isDynamicArmorActive && node.name.includes("state_00")) ||
(!isDynamicArmorActive && node.name.includes('state_01')) (!isDynamicArmorActive && node.name.includes("state_01"))
) )
return null; return null;
@@ -248,30 +244,30 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
@@ -285,16 +281,16 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -316,7 +312,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
{modelNodes.map((node) => { {modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model_id const gunString = `gun_${gunModelDefinition.model_id
.toString() .toString()
.padStart(2, '0')}`; .padStart(2, "0")}`;
const isCurrentGun = gunModelDefinition.mask const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString) ? node.name.startsWith(gunString)
: node.name === gunString; : node.name === gunString;
@@ -330,29 +326,29 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
@@ -366,17 +362,17 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return ( return (
@@ -1,8 +1,8 @@
import { I_HAT, J_HAT } from '@blitzkit/core'; import { I_HAT, J_HAT } from "@blitzkit/core";
import { type MeshProps, useThree } from '@react-three/fiber'; import { type MeshProps, useThree } from "@react-three/fiber";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import type { QuicklimeEvent } from 'quicklime'; import type { QuicklimeEvent } from "quicklime";
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from "react";
import { import {
Box3, Box3,
Color, Color,
@@ -10,33 +10,33 @@ import {
FrontSide, FrontSide,
LineBasicMaterial, LineBasicMaterial,
Mesh, Mesh,
MeshStandardMaterial, MeshBasicMaterial,
Object3D, Object3D,
Plane, Plane,
Quaternion, Quaternion,
Vector3, Vector3,
} from 'three'; } from "three";
import { unrotateDavaVector } from '../../../core/blitz/unrotateDavaVector'; import { unrotateDavaVector } from "../../../core/blitz/unrotateDavaVector";
import { jsxTree } from '../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../core/blitzkit/jsxTree";
import { import {
type ModelTransformEventData, type ModelTransformEventData,
modelTransformEvent, modelTransformEvent,
} from '../../../core/blitzkit/modelTransform'; } from "../../../core/blitzkit/modelTransform";
import { discardClippingPlane } from '../../../core/three/discardClippingPlane'; import { discardClippingPlane } from "../../../core/three/discardClippingPlane";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import { ArmorType } from './SpacedArmorScene'; import { ArmorType } from "./SpacedArmorScene";
import type { import type {
ArmorUserData, ArmorUserData,
ExternalModuleVariant, ExternalModuleVariant,
} from './SpacedArmorSceneComponent'; } from "./SpacedArmorSceneComponent";
import type { ThicknessRange } from './StaticArmor'; import type { ThicknessRange } from "./StaticArmor";
type StaticArmorSceneComponentProps = { type StaticArmorSceneComponentProps = {
name: string; name: string;
thickness: number; thickness: number;
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
node: Object3D; node: Object3D;
onPointerDown?: MeshProps['onPointerDown']; onPointerDown?: MeshProps["onPointerDown"];
} & ( } & (
| { | {
type: Exclude<ArmorType, ArmorType.External>; type: Exclude<ArmorType, ArmorType.External>;
@@ -69,8 +69,6 @@ export function StaticArmorSceneComponent({
onPointerDown, onPointerDown,
...props ...props
}: StaticArmorSceneComponentProps) { }: StaticArmorSceneComponentProps) {
const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const x = thickness / thicknessRange.value; const x = thickness / thicknessRange.value;
const xClamped = clamp(x, 0, 1); const xClamped = clamp(x, 0, 1);
@@ -92,7 +90,7 @@ export function StaticArmorSceneComponent({
color = new Color( color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1), clamp(1 - (7 / 8) * xClamped, 0, 1),
0, 0,
clamp(1 - (1 / 8) * xClamped, 0, 1), clamp(1 - (1 / 8) * xClamped, 0, 1)
); );
opacity = clamp(x + 1 / 2, 0, 1); opacity = clamp(x + 1 / 2, 0, 1);
break; break;
@@ -108,14 +106,14 @@ export function StaticArmorSceneComponent({
const surfaceMaterial = useMemo( const surfaceMaterial = useMemo(
() => () =>
new MeshStandardMaterial({ new MeshBasicMaterial({
color, color,
opacity, opacity,
transparent: opacity < 1, transparent: opacity < 1,
depthWrite, depthWrite,
...(props.clip ? { clippingPlanes: [props.clip] } : {}), ...(props.clip ? { clippingPlanes: [props.clip] } : {}),
}), }),
[thickness], [thickness]
); );
const outlineMaterial = useMemo( const outlineMaterial = useMemo(
() => () =>
@@ -124,7 +122,7 @@ export function StaticArmorSceneComponent({
.clone() .clone()
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1), .multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
}), }),
[thickness], [thickness]
); );
useEffect(() => { useEffect(() => {
@@ -139,11 +137,11 @@ export function StaticArmorSceneComponent({
outlineMaterial.visible = true; outlineMaterial.visible = true;
} else if ( } else if (
selectedName === name || selectedName === name ||
(name.startsWith('chassis_') && selectedName.startsWith('chassis_')) || (name.startsWith("chassis_") && selectedName.startsWith("chassis_")) ||
(name.startsWith('gun_') && (name.startsWith("gun_") &&
selectedName.startsWith('gun_') && selectedName.startsWith("gun_") &&
!name.includes('_armor_') && !name.includes("_armor_") &&
!selectedName.includes('_armor_')) !selectedName.includes("_armor_"))
) { ) {
// this selected, stand out! // this selected, stand out!
surfaceMaterial.opacity = 1; surfaceMaterial.opacity = 1;
@@ -166,9 +164,9 @@ export function StaticArmorSceneComponent({
} }
const unsubscribes = [ const unsubscribes = [
tankopediaEphemeralStore.subscribe( Tankopedia.on(
(state) => state.highlightArmor?.name, (state) => state.highlightArmor?.name,
handleHighlightArmor, handleHighlightArmor
), ),
]; ];
@@ -181,14 +179,14 @@ export function StaticArmorSceneComponent({
const { clip } = props; const { clip } = props;
const gunOrigin = unrotateDavaVector(props.gunOrigin.clone()); const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add( const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
unrotateDavaVector(props.turretOrigin.clone()), unrotateDavaVector(props.turretOrigin.clone())
); );
const barrelOrigin = neckOrigin.clone().add(gunOrigin); const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin); const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3(); const point = new Vector3();
function handleModelTransform( function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>, event: QuicklimeEvent<ModelTransformEventData>
) { ) {
clip.normal clip.normal
.set(0, 0, -1) .set(0, 0, -1)
@@ -229,7 +227,7 @@ export function StaticArmorSceneComponent({
{ {
type: props.type, type: props.type,
variant: variant:
props.type === ArmorType.External ? props.variant : 'gun', props.type === ArmorType.External ? props.variant : "gun",
thickness, thickness,
} satisfies ArmorUserData } satisfies ArmorUserData
} }
@@ -238,7 +236,7 @@ export function StaticArmorSceneComponent({
event.stopPropagation(); event.stopPropagation();
const { editStatic } = tankopediaEphemeralStore.getState(); const { editStatic } = Tankopedia.state;
const bounds = new Box3().setFromObject(event.object); const bounds = new Box3().setFromObject(event.object);
const point = bounds.min const point = bounds.min
@@ -253,13 +251,13 @@ export function StaticArmorSceneComponent({
const surfaceNormal = event const surfaceNormal = event
.normal!.clone() .normal!.clone()
.applyQuaternion( .applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()), event.object.getWorldQuaternion(new Quaternion())
); );
const angle = surfaceNormal.angleTo(cameraNormal); const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled = const thicknessAngled =
thickness / Math.sin(Math.PI / 2 - angle); thickness / Math.sin(Math.PI / 2 - angle);
mutateTankopediaEphemeralStore((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = { draft.highlightArmor = {
type: props.type, type: props.type,
name, name,
@@ -1,11 +1,11 @@
import { asset } from '@blitzkit/core'; import { asset } from "@blitzkit/core";
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes'; import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { CompareRow } from './CompareRow'; import { CompareRow } from "./CompareRow";
import { CompareSectionTitle } from './CompareSectionTitle'; import { CompareSectionTitle } from "./CompareSectionTitle";
interface BodyProps { interface BodyProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -14,10 +14,9 @@ interface BodyProps {
export function Body({ stats }: BodyProps) { export function Body({ stats }: BodyProps) {
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const hasNonRegularGun = members.some( const hasNonRegularGun = members.some(
({ gun }) => gun.gun_type!.$case !== 'regular', ({ gun }) => gun.gun_type!.$case !== "regular"
); );
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<> <>
@@ -35,7 +34,7 @@ export function Body({ stats }: BodyProps) {
<Flex> <Flex>
{gun.shells.map((thisShell, shellIndex) => ( {gun.shells.map((thisShell, shellIndex) => (
<IconButton <IconButton
color={thisShell.id === shell.id ? undefined : 'gray'} color={thisShell.id === shell.id ? undefined : "gray"}
variant="soft" variant="soft"
key={thisShell.id} key={thisShell.id}
style={{ style={{
@@ -49,7 +48,7 @@ export function Body({ stats }: BodyProps) {
marginLeft: shellIndex === 0 ? 0 : -1, marginLeft: shellIndex === 0 ? 0 : -1,
}} }}
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].shell = thisShell; draft.members[index].shell = thisShell;
}); });
}} }}
@@ -88,7 +87,7 @@ export function Body({ stats }: BodyProps) {
} }
display={(stats) => display={(stats) =>
stats.shellReload?.toFixed(2) ?? stats.shellReload?.toFixed(2) ??
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ') stats.shellReloads!.map((reload) => reload.toFixed(2)).join(", ")
} }
deltaNominalDisplay={(delta) => delta.toFixed(2)} deltaNominalDisplay={(delta) => delta.toFixed(2)}
/> />
@@ -2,21 +2,21 @@ import {
CaretLeftIcon, CaretLeftIcon,
CaretRightIcon, CaretRightIcon,
CaretSortIcon, CaretSortIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { Flex, IconButton, Table, Text } from '@radix-ui/themes'; import { Flex, IconButton, Table, Text } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { ComparePersistent } from '../../stores/comparePersistent'; import { ComparePersistent } from "../../stores/comparePersistent";
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from "../StickyRowHeaderCell";
type CompareRowProps = { type CompareRowProps = {
display?: ( display?: (
member: Awaited<TankCharacteristics>, member: Awaited<TankCharacteristics>
) => number | string | undefined; ) => number | string | undefined;
deltaType?: 'higherIsBetter' | 'lowerIsBetter'; deltaType?: "higherIsBetter" | "lowerIsBetter";
decimals?: number; decimals?: number;
deltaNominalDisplay?: (delta: number) => string | number; deltaNominalDisplay?: (delta: number) => string | number;
indent?: boolean; indent?: boolean;
@@ -33,7 +33,7 @@ type CompareRowProps = {
export function CompareRow({ export function CompareRow({
display, display,
deltaType = 'higherIsBetter', deltaType = "higherIsBetter",
decimals, decimals,
deltaNominalDisplay, deltaNominalDisplay,
indent, indent,
@@ -41,17 +41,16 @@ export function CompareRow({
...props ...props
}: CompareRowProps) { }: CompareRowProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const sorting = CompareEphemeral.use((state) => state.sorting); const sorting = CompareEphemeral.use((state) => state.sorting);
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const values = stats.map((stat) => const values = stats.map((stat) =>
typeof props.value === 'function' typeof props.value === "function"
? props.value(stat)! ? props.value(stat)!
: (stat[props.value] as number), : (stat[props.value] as number)
); );
const id = useRef(Math.random()); const id = useRef(Math.random());
const name = const name =
typeof props.value === 'string' typeof props.value === "string"
? strings.website.tools.tankopedia.characteristics.values[props.value] ? strings.website.tools.tankopedia.characteristics.values[props.value]
: props.name; : props.name;
@@ -60,21 +59,21 @@ export function CompareRow({
<StickyRowHeaderCell> <StickyRowHeaderCell>
<Flex <Flex
align="center" align="center"
style={{ whiteSpace: 'nowrap' }} style={{ whiteSpace: "nowrap" }}
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }} gap={indent ? { initial: "4", sm: "6" } : { initial: "1", sm: "2" }}
> >
<IconButton <IconButton
color={sorting?.by === id.current ? undefined : 'gray'} color={sorting?.by === id.current ? undefined : "gray"}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.sort((memberA, memberB) => { draft.members.sort((memberA, memberB) => {
const indexA = draft.members.indexOf(memberA); const indexA = draft.members.indexOf(memberA);
const indexB = draft.members.indexOf(memberB); const indexB = draft.members.indexOf(memberB);
const valueA = values[indexA]; const valueA = values[indexA];
const valueB = values[indexB]; const valueB = values[indexB];
return draft.sorting?.direction === 'ascending' && return draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? valueA - valueB ? valueA - valueB
: valueB - valueA; : valueB - valueA;
@@ -83,26 +82,26 @@ export function CompareRow({
draft.sorting = { draft.sorting = {
by: id.current, by: id.current,
direction: direction:
draft.sorting?.direction === 'ascending' && draft.sorting?.direction === "ascending" &&
draft.sorting.by === id.current draft.sorting.by === id.current
? 'descending' ? "descending"
: 'ascending', : "ascending",
}; };
}); });
}} }}
> >
{(sorting === undefined || sorting.by !== id.current) && ( {(sorting === undefined || sorting.by !== id.current) && (
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} /> <CaretSortIcon style={{ transform: "rotate(90deg)" }} />
)} )}
{sorting?.by === id.current && ( {sorting?.by === id.current && (
<> <>
{sorting.direction === 'ascending' && <CaretRightIcon />} {sorting.direction === "ascending" && <CaretRightIcon />}
{sorting.direction === 'descending' && <CaretLeftIcon />} {sorting.direction === "descending" && <CaretLeftIcon />}
</> </>
)} )}
</IconButton> </IconButton>
<Text size={{ initial: '1', sm: '2' }}>{name}</Text> <Text size={{ initial: "1", sm: "2" }}>{name}</Text>
</Flex> </Flex>
</StickyRowHeaderCell> </StickyRowHeaderCell>
@@ -110,7 +109,7 @@ export function CompareRow({
const delta = value - values[0]; const delta = value - values[0];
const deltaPercentage = value / values[0] - 1; const deltaPercentage = value / values[0] - 1;
const normalizedDeltaPercentage = Math.round( const normalizedDeltaPercentage = Math.round(
Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25), Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25)
); );
const resolvedDisplayValue = display const resolvedDisplayValue = display
? display(stats[index]) ? display(stats[index])
@@ -129,12 +128,12 @@ export function CompareRow({
value === values[0] value === values[0]
? undefined ? undefined
: ( : (
deltaType === 'higherIsBetter' deltaType === "higherIsBetter"
? value > values[0] ? value > values[0]
: value < values[0] : value < values[0]
) )
? `color-mix(in srgb, ${Var('green-7')} ${normalizedDeltaPercentage}%, ${Var('green-3')})` ? `color-mix(in srgb, ${Var("green-7")} ${normalizedDeltaPercentage}%, ${Var("green-3")})`
: `color-mix(in srgb, ${Var('red-7')} ${normalizedDeltaPercentage}%, ${Var('red-3')})`, : `color-mix(in srgb, ${Var("red-7")} ${normalizedDeltaPercentage}%, ${Var("red-3")})`,
}} }}
> >
<Flex <Flex
@@ -142,11 +141,11 @@ export function CompareRow({
justify="center" justify="center"
gap="2" gap="2"
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
> >
<Text style={{ textAlign: 'center' }} wrap="nowrap"> <Text style={{ textAlign: "center" }} wrap="nowrap">
{resolvedDisplayValue} {resolvedDisplayValue}
</Text> </Text>
@@ -154,10 +153,10 @@ export function CompareRow({
resolvedDisplayValue !== undefined && resolvedDisplayValue !== undefined &&
values[0] !== undefined && ( values[0] !== undefined && (
<> <>
{deltaMode === 'absolute' && ( {deltaMode === "absolute" && (
<Text color="gray"> <Text color="gray">
( (
{`${delta > 0 ? '+' : ''}${ {`${delta > 0 ? "+" : ""}${
deltaNominalDisplay deltaNominalDisplay
? deltaNominalDisplay(delta) ? deltaNominalDisplay(delta)
: decimals === undefined : decimals === undefined
@@ -168,11 +167,11 @@ export function CompareRow({
</Text> </Text>
)} )}
{deltaMode === 'percentage' && ( {deltaMode === "percentage" && (
<Text color="gray"> <Text color="gray">
( (
{`${deltaPercentage > 0 ? '+' : ''}${Math.round( {`${deltaPercentage > 0 ? "+" : ""}${Math.round(
deltaPercentage * 100, deltaPercentage * 100
)}%`} )}%`}
) )
</Text> </Text>
@@ -1,28 +1,28 @@
import { asset, availableProvisions, permanentSkills } from '@blitzkit/core'; import { asset, availableProvisions, permanentSkills } from "@blitzkit/core";
import { checkConsumableProvisionInclusivity } from '@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons'; import { ComponentPlaceholderIcon } from "@radix-ui/react-icons";
import { Button, Flex, IconButton, Popover, Table } from '@radix-ui/themes'; import { Button, Flex, IconButton, Popover, Table } from "@radix-ui/themes";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { awaitableConsumableDefinitions } from '../../core/awaitables/consumableDefinitions'; import { awaitableConsumableDefinitions } from "../../core/awaitables/consumableDefinitions";
import { awaitableEquipmentDefinitions } from '../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../core/awaitables/equipmentDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from '../../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from "../../core/awaitables/skillDefinitions";
import type { TankCharacteristics } from '../../core/blitzkit/tankCharacteristics'; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import type { EquipmentMatrix } from '../../stores/duel'; import type { EquipmentMatrix } from "../../stores/duel";
import { BlitzkitButtonGrayIcon } from '../BlitzkitButtonGrayIcon'; import { BlitzkitButtonGrayIcon } from "../BlitzkitButtonGrayIcon";
import { ConsumablesManager } from '../ConsumablesManager'; import { ConsumablesManager } from "../ConsumablesManager";
import { CrewSkillManager } from '../CrewSkillManager'; import { CrewSkillManager } from "../CrewSkillManager";
import { EquipmentManager } from '../EquipmentManager'; import { EquipmentManager } from "../EquipmentManager";
import { ModuleManager } from '../ModuleManager'; import { ModuleManager } from "../ModuleManager";
import { ProvisionsManager } from '../ProvisionsManager'; import { ProvisionsManager } from "../ProvisionsManager";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { StickyTableRoot } from '../StickyTableRoot'; import { StickyTableRoot } from "../StickyTableRoot";
import { Body } from './Body'; import { Body } from "./Body";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { TankCard } from './TankCard'; import { TankCard } from "./TankCard";
interface CompareTableProps { interface CompareTableProps {
stats: TankCharacteristics[]; stats: TankCharacteristics[];
@@ -43,7 +43,6 @@ const [
export function CompareTable({ stats }: CompareTableProps) { export function CompareTable({ stats }: CompareTableProps) {
const crewSkills = CompareEphemeral.use((state) => state.crewSkills); const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const members = CompareEphemeral.use((state) => state.members); const members = CompareEphemeral.use((state) => state.members);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
return ( return (
@@ -51,20 +50,20 @@ export function CompareTable({ stats }: CompareTableProps) {
size="1" size="1"
variant="surface" variant="surface"
style={{ style={{
position: 'absolute', position: "absolute",
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
height: '100%', height: "100%",
// height: 'calc(100vh - 128px)' // height: 'calc(100vh - 128px)'
}} }}
> >
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<StickyColumnHeaderCell width="0" style={{ height: '100%' }}> <StickyColumnHeaderCell width="0" style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -82,11 +81,11 @@ export function CompareTable({ stats }: CompareTableProps) {
<Table.Body> <Table.Body>
<Table.Row> <Table.Row>
<Table.Cell style={{ height: '100%' }}> <Table.Cell style={{ height: "100%" }}>
<Flex <Flex
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
}} }}
align="center" align="center"
justify="center" justify="center"
@@ -107,15 +106,15 @@ export function CompareTable({ stats }: CompareTableProps) {
borderRadius: 2, borderRadius: 2,
backgroundColor: backgroundColor:
crewSkills[skill] === 0 crewSkills[skill] === 0
? Var('gray-11') ? Var("gray-11")
: permanentSkills.includes(skill) : permanentSkills.includes(skill)
? Var('crimson-11') ? Var("crimson-11")
: Var('amber-11'), : Var("amber-11"),
}} }}
/> />
))} ))}
</Flex> </Flex>
), )
)} )}
</Flex> </Flex>
</Button> </Button>
@@ -125,7 +124,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<CrewSkillManager <CrewSkillManager
skillLevels={crewSkills} skillLevels={crewSkills}
onChange={(skills) => { onChange={(skills) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.crewSkills = skills; draft.crewSkills = skills;
}); });
}} }}
@@ -135,7 +134,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 0; draft.crewSkills[skill] = 0;
}); });
@@ -147,7 +146,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
Object.keys(draft.crewSkills).forEach((skill) => { Object.keys(draft.crewSkills).forEach((skill) => {
draft.crewSkills[skill] = 7; draft.crewSkills[skill] = 7;
}); });
@@ -177,19 +176,19 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables, consumables,
camouflage, camouflage,
}, },
index, index
) => { ) => {
const equipmentPreset = const equipmentPreset =
equipmentDefinitions.presets[tank.equipment_preset]; equipmentDefinitions.presets[tank.equipment_preset];
const provisionsList = availableProvisions( const provisionsList = availableProvisions(
tank, tank,
gun, gun,
provisionDefinitions, provisionDefinitions
); );
const consumablesList = Object.values( const consumablesList = Object.values(
consumableDefinitions.consumables, consumableDefinitions.consumables
).filter((consumable) => ).filter((consumable) =>
checkConsumableProvisionInclusivity(consumable, tank, gun), checkConsumableProvisionInclusivity(consumable, tank, gun)
); );
return ( return (
@@ -205,7 +204,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button variant="ghost"> <Button variant="ghost">
<Flex align="center"> <Flex align="center">
{( {(
['turret', 'gun', 'engine', 'chassis'] as const ["turret", "gun", "engine", "chassis"] as const
).map((module, index) => ( ).map((module, index) => (
<img <img
key={module} key={module}
@@ -215,7 +214,7 @@ export function CompareTable({ stats }: CompareTableProps) {
src={asset(`icons/modules/${module}.webp`)} src={asset(`icons/modules/${module}.webp`)}
style={{ style={{
marginLeft: index > 0 ? -8 : 0, marginLeft: index > 0 ? -8 : 0,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -232,7 +231,7 @@ export function CompareTable({ stats }: CompareTableProps) {
engine={engine} engine={engine}
track={track} track={track}
onChange={(modules) => { onChange={(modules) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index] = { draft.members[index] = {
...draft.members[index], ...draft.members[index],
...modules, ...modules,
@@ -245,7 +244,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets[0]; member.turret = member.tank.turrets[0];
@@ -261,7 +260,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const member = draft.members[index]; const member = draft.members[index];
member.turret = member.tank.turrets.at(-1)!; member.turret = member.tank.turrets.at(-1)!;
@@ -288,9 +287,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -298,14 +297,14 @@ export function CompareTable({ stats }: CompareTableProps) {
<img <img
alt={unwrap( alt={unwrap(
provisionDefinitions.provisions[provision] provisionDefinitions.provisions[provision]
.name, .name
)} )}
key={provision} key={provision}
src={asset( src={asset(
`/icons/provisions/${provision}.webp`, `/icons/provisions/${provision}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -320,11 +319,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (provisions.length - 1) : index / (provisions.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -339,12 +338,12 @@ export function CompareTable({ stats }: CompareTableProps) {
<Popover.Content> <Popover.Content>
<ProvisionsManager <ProvisionsManager
provisions={provisionsList.map( provisions={provisionsList.map(
(provision) => provision.id, (provision) => provision.id
)} )}
selected={provisions} selected={provisions}
disabled={tank.max_provisions === provisions.length} disabled={tank.max_provisions === provisions.length}
onChange={(provisions) => { onChange={(provisions) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = provisions; draft.members[index].provisions = provisions;
}); });
}} }}
@@ -353,7 +352,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].provisions = []; draft.members[index].provisions = [];
}); });
}} }}
@@ -394,8 +393,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === -1 equipment === -1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -405,8 +404,8 @@ export function CompareTable({ stats }: CompareTableProps) {
height: 8, height: 8,
backgroundColor: backgroundColor:
equipment === 1 equipment === 1
? Var('crimson-11') ? Var("crimson-11")
: Var('gray-11'), : Var("gray-11"),
borderRadius: 2, borderRadius: 2,
}} }}
/> />
@@ -424,7 +423,7 @@ export function CompareTable({ stats }: CompareTableProps) {
matrix={equipmentMatrix} matrix={equipmentMatrix}
preset={equipmentPreset} preset={equipmentPreset}
onChange={(matrix) => { onChange={(matrix) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = matrix; draft.members[index].equipmentMatrix = matrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -436,10 +435,10 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].equipmentMatrix = times( draft.members[index].equipmentMatrix = times(
3, 3,
() => times(3, () => 0), () => times(3, () => 0)
) as EquipmentMatrix; ) as EquipmentMatrix;
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -453,7 +452,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.forEach((member) => { draft.members.forEach((member) => {
member.equipmentMatrix = member.equipmentMatrix =
draft.members[index].equipmentMatrix; draft.members[index].equipmentMatrix;
@@ -477,9 +476,9 @@ export function CompareTable({ stats }: CompareTableProps) {
variant="ghost" variant="ghost"
radius="large" radius="large"
style={{ style={{
height: '100%', height: "100%",
width: 12, width: 12,
position: 'relative', position: "relative",
}} }}
> >
<Flex direction="column" justify="center"> <Flex direction="column" justify="center">
@@ -488,14 +487,14 @@ export function CompareTable({ stats }: CompareTableProps) {
alt={unwrap( alt={unwrap(
consumableDefinitions.consumables[ consumableDefinitions.consumables[
consumable consumable
].name, ].name
)} )}
key={consumable} key={consumable}
src={asset( src={asset(
`/icons/consumables/${consumable}.webp`, `/icons/consumables/${consumable}.webp`
)} )}
style={{ style={{
left: '50%', left: "50%",
/** /**
* max = 100% - 16px - 4px * max = 100% - 16px - 4px
* min = 4px * min = 4px
@@ -510,11 +509,11 @@ export function CompareTable({ stats }: CompareTableProps) {
? 0.5 ? 0.5
: index / (consumables.length - 1) : index / (consumables.length - 1)
} * (100% - 24px))`, } * (100% - 24px))`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
position: 'absolute', position: "absolute",
width: 16, width: 16,
height: 16, height: 16,
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
))} ))}
@@ -531,7 +530,7 @@ export function CompareTable({ stats }: CompareTableProps) {
consumables={consumablesList} consumables={consumablesList}
selected={consumables} selected={consumables}
onConsumablesChange={(consumables) => { onConsumablesChange={(consumables) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = consumables; draft.members[index].consumables = consumables;
}); });
}} }}
@@ -544,7 +543,7 @@ export function CompareTable({ stats }: CompareTableProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].consumables = []; draft.members[index].consumables = [];
}); });
}} }}
@@ -562,18 +561,18 @@ export function CompareTable({ stats }: CompareTableProps) {
{!tank.fixed_camouflage && ( {!tank.fixed_camouflage && (
<IconButton <IconButton
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].camouflage = !camouflage; draft.members[index].camouflage = !camouflage;
}); });
}} }}
variant={camouflage ? 'solid' : 'outline'} variant={camouflage ? "solid" : "outline"}
> >
<img <img
src={asset('icons/camo.webp')} src={asset("icons/camo.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
transform: 'scale(2) translate(15%, 15%)', transform: "scale(2) translate(15%, 15%)",
}} }}
/> />
</IconButton> </IconButton>
@@ -581,7 +580,7 @@ export function CompareTable({ stats }: CompareTableProps) {
</Flex> </Flex>
</Table.Cell> </Table.Cell>
); );
}, }
)} )}
</Table.Row> </Table.Row>
</Table.Body> </Table.Body>
@@ -1,33 +1,27 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons'; import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes'; import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral';
import { import {
ComparePersistent, ComparePersistent,
type DeltaMode, type DeltaMode,
} from '../../stores/comparePersistent'; } from "../../stores/comparePersistent";
import { TankSearch } from '../TankSearch'; import { TankSearch } from "../TankSearch";
interface ControlsProps { interface ControlsProps {
addTankDialogOpen: boolean; addTankDialogOpen: boolean;
onAddTankDialogOpenChange: (open: boolean) => void; onAddTankDialogOpenChange: (open: boolean) => void;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function Controls({ export function Controls({
addTankDialogOpen, addTankDialogOpen,
onAddTankDialogOpenChange, onAddTankDialogOpenChange,
}: ControlsProps) { }: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const mutateComparePersistent = ComparePersistent.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -60,28 +54,20 @@ export function Controls({
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members.push( draft.members.push(
tankToCompareMember(tank, model, provisionDefinitions), tankToCompareMember(tank, provisionDefinitions)
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
onAddTankDialogOpenChange(false); onAddTankDialogOpenChange(false);
}} }}
onSelectAll={(tanks) => { onSelectAll={(tanks) => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.push( draft.members.push(
...tanks.map((tank) => { ...tanks.map((tank) => {
const model = modelDefinitions.models[tank.id]; return tankToCompareMember(tank, provisionDefinitions);
})
return tankToCompareMember(
tank,
model,
provisionDefinitions,
);
}),
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -97,7 +83,7 @@ export function Controls({
variant="soft" variant="soft"
color="red" color="red"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members = []; draft.members = [];
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -110,18 +96,18 @@ export function Controls({
variant="classic" variant="classic"
value={deltaMode} value={deltaMode}
onValueChange={(value) => { onValueChange={(value) => {
mutateComparePersistent((draft) => { ComparePersistent.mutate((draft) => {
draft.deltaMode = value as DeltaMode; draft.deltaMode = value as DeltaMode;
}); });
}} }}
> >
<SegmentedControl.Item value={'none' satisfies DeltaMode}> <SegmentedControl.Item value={"none" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.none} {strings.website.tools.compare.actions.deltas.none}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'percentage' satisfies DeltaMode}> <SegmentedControl.Item value={"percentage" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.percentage} {strings.website.tools.compare.actions.deltas.percentage}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={'absolute' satisfies DeltaMode}> <SegmentedControl.Item value={"absolute" satisfies DeltaMode}>
{strings.website.tools.compare.actions.deltas.absolute} {strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item> </SegmentedControl.Item>
</SegmentedControl.Root> </SegmentedControl.Root>
@@ -1,13 +1,13 @@
import { type TankDefinition, tankIcon } from '@blitzkit/core'; import { type TankDefinition, tankIcon } from "@blitzkit/core";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Vector2 } from 'three'; import { Vector2 } from "three";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { StickyColumnHeaderCell } from '../StickyColumnHeaderCell'; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { InsertionMarker } from './IntersectionMarker'; import { InsertionMarker } from "./IntersectionMarker";
import { insertionMarkers } from './IntersectionMarker/constants'; import { insertionMarkers } from "./IntersectionMarker/constants";
import { TankControl } from './TankControl'; import { TankControl } from "./TankControl";
interface TankCardProps { interface TankCardProps {
index: number; index: number;
@@ -16,7 +16,6 @@ interface TankCardProps {
export function TankCard({ index, tank }: TankCardProps) { export function TankCard({ index, tank }: TankCardProps) {
const draggable = useRef<HTMLDivElement>(null); const draggable = useRef<HTMLDivElement>(null);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
const { unwrap } = useLocale(); const { unwrap } = useLocale();
useEffect(() => { useEffect(() => {
@@ -34,8 +33,8 @@ export function TankCard({ index, tank }: TankCardProps) {
delta.set(0, 0); delta.set(0, 0);
current.copy(initial.set(event.clientX, event.clientY)); current.copy(initial.set(event.clientX, event.clientY));
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
@@ -43,7 +42,7 @@ export function TankCard({ index, tank }: TankCardProps) {
current.set(event.clientX, event.clientY); current.set(event.clientX, event.clientY);
delta.copy(current).sub(initial); delta.copy(current).sub(initial);
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
const distances = insertionMarkers.map((insertionMarker) => { const distances = insertionMarkers.map((insertionMarker) => {
@@ -56,34 +55,34 @@ export function TankCard({ index, tank }: TankCardProps) {
}; };
}); });
const closest = distances.reduce((a, b) => const closest = distances.reduce((a, b) =>
a.distance < b.distance ? a : b, a.distance < b.distance ? a : b
); );
dropIndex = closest.index; dropIndex = closest.index;
closest.element.style.opacity = '1'; closest.element.style.opacity = "1";
draggable.current!.style.position = 'fixed'; draggable.current!.style.position = "fixed";
draggable.current!.style.cursor = 'grabbing'; draggable.current!.style.cursor = "grabbing";
draggable.current!.style.zIndex = '1'; draggable.current!.style.zIndex = "1";
draggable.current!.style.left = `${initialRect.left + delta.x}px`; draggable.current!.style.left = `${initialRect.left + delta.x}px`;
draggable.current!.style.top = `${initialRect.top + delta.y}px`; draggable.current!.style.top = `${initialRect.top + delta.y}px`;
} }
function handlePointerUp(event: PointerEvent) { function handlePointerUp(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
draggable.current!.style.position = 'static'; draggable.current!.style.position = "static";
draggable.current!.style.zIndex = 'unset'; draggable.current!.style.zIndex = "unset";
draggable.current!.style.cursor = 'grab'; draggable.current!.style.cursor = "grab";
insertionMarkers.forEach(({ element }) => { insertionMarkers.forEach(({ element }) => {
element.style.opacity = '0'; element.style.opacity = "0";
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
if (dropIndex === -1) return; if (dropIndex === -1) return;
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
const fromIndex = index; const fromIndex = index;
const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex; const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
@@ -95,10 +94,10 @@ export function TankCard({ index, tank }: TankCardProps) {
dropIndex = -1; dropIndex = -1;
} }
draggable.current?.addEventListener('pointerdown', handlePointerDown); draggable.current?.addEventListener("pointerdown", handlePointerDown);
return () => { return () => {
draggable.current?.removeEventListener('pointerdown', handlePointerDown); draggable.current?.removeEventListener("pointerdown", handlePointerDown);
}; };
}, []); }, []);
@@ -114,9 +113,9 @@ export function TankCard({ index, tank }: TankCardProps) {
justify="between" justify="between"
gap="2" gap="2"
style={{ style={{
userSelect: 'none', userSelect: "none",
touchAction: 'none', touchAction: "none",
cursor: 'grab', cursor: "grab",
}} }}
> >
<img <img
@@ -126,16 +125,16 @@ export function TankCard({ index, tank }: TankCardProps) {
height={64} height={64}
draggable={false} draggable={false}
style={{ style={{
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
<Text <Text
style={{ style={{
whiteSpace: 'nowrap', whiteSpace: "nowrap",
maxWidth: 128, maxWidth: 128,
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
}} }}
> >
{unwrap(tank.name)} {unwrap(tank.name)}
@@ -1,35 +1,30 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons'; import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
import { Dialog, Flex, IconButton } from '@radix-ui/themes'; import { Dialog, Flex, IconButton } from "@radix-ui/themes";
import { useState } from 'react'; import { useState } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral } from '../../stores/compareEphemeral'; import { LinkI18n } from "../LinkI18n";
import { LinkI18n } from '../LinkI18n'; import { TankSearch } from "../TankSearch";
import { TankSearch } from '../TankSearch';
interface TankControlProps { interface TankControlProps {
index: number; index: number;
slug: string; slug: string;
} }
const [provisionDefinitions, modelDefinitions] = await Promise.all([ const provisionDefinitions = await awaitableProvisionDefinitions;
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function TankControl({ index, slug }: TankControlProps) { export function TankControl({ index, slug }: TankControlProps) {
const { locale } = useLocale(); const { locale } = useLocale();
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false); const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
return ( return (
<Flex gap="3" justify="center" style={{ width: '100%' }}> <Flex gap="3" justify="center" style={{ width: "100%" }}>
<IconButton <IconButton
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members.splice(index, 1); draft.members.splice(index, 1);
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -61,13 +56,10 @@ export function TankControl({ index, slug }: TankControlProps) {
<TankSearch <TankSearch
compact compact
onSelect={(tank) => { onSelect={(tank) => {
const model = modelDefinitions.models[tank.id]; CompareEphemeral.mutate((draft) => {
mutateCompareEphemeral((draft) => {
draft.members[index] = tankToCompareMember( draft.members[index] = tankToCompareMember(
tank, tank,
model, provisionDefinitions
provisionDefinitions,
); );
draft.sorting = undefined; draft.sorting = undefined;
}); });
@@ -7,17 +7,17 @@ import {
idToRegion, idToRegion,
sumCompositeStats, sumCompositeStats,
type IndividualTankStats, type IndividualTankStats,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { STRINGS } from '@blitzkit/i18n'; import { STRINGS } from "@blitzkit/i18n";
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from "@radix-ui/react-icons";
import { ContextMenu } from '@radix-ui/themes'; import { ContextMenu } from "@radix-ui/themes";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { breakdownConfig } from '../../constants/embeds'; import { breakdownConfig } from "../../constants/embeds";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { EmbedBreakdownPersistent } from '../../stores/embedBreakdownPersistent'; import { EmbedBreakdown } from "../../stores/embedBreakdown";
import { useEmbedStateCurry } from '../../stores/embedState/utilities'; import { useEmbedStateCurry } from "../../stores/embedState/utilities";
import { BreakdownEmbedCard, BreakdownEmbedWrapper } from '../TanksEmbed'; import { BreakdownEmbedCard, BreakdownEmbedWrapper } from "../TanksEmbed";
export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({ export const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
value, value,
@@ -55,7 +55,7 @@ export const fakeCompositeStats = compositeStats(
wins: 30, wins: 30,
xp: 1230 * 50, xp: 1230 * 50,
}, },
averageDefinitions.averages[7297].mu, averageDefinitions.averages[7297].mu
); );
export function BreakdownPreview() { export function BreakdownPreview() {
@@ -64,11 +64,11 @@ export function BreakdownPreview() {
return ( return (
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={fakeCompositeStats} tank={null} /> <BreakdownEmbedCard composite={fakeCompositeStats} tank={null} />
)} )}
{tanks.slice(0, useEmbedState('listMaxTanks')).map((tank) => ( {tanks.slice(0, useEmbedState("listMaxTanks")).map((tank) => (
<BreakdownEmbedCard <BreakdownEmbedCard
composite={fakeCompositeStats} composite={fakeCompositeStats}
key={tank.id} key={tank.id}
@@ -80,26 +80,17 @@ export function BreakdownPreview() {
} }
export function BreakdownRenderer() { export function BreakdownRenderer() {
return (
<EmbedBreakdownPersistent.Provider>
<BreakdownRendererContent />
</EmbedBreakdownPersistent.Provider>
);
}
function BreakdownRendererContent() {
const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>(); const { useEmbedState } = useEmbedStateCurry<typeof breakdownConfig>();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const mutateEmbedBreakdownPersistent = EmbedBreakdownPersistent.useMutation(); const id = Number(params.get("id"));
const id = Number(params.get('id')); const tankStatsA = EmbedBreakdown.use((state) => state[id] ?? []);
const tankStatsA = EmbedBreakdownPersistent.use((state) => state[id] ?? []);
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]); const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[]>([]);
const diff = useMemo( const diff = useMemo(
() => () =>
deltaTankStats(tankStatsA, tankStatsB).sort( deltaTankStats(tankStatsA, tankStatsB).sort(
(a, b) => b.last_battle_time - a.last_battle_time, (a, b) => b.last_battle_time - a.last_battle_time
), ),
[tankStatsB], [tankStatsB]
); );
const compositeStats = diff const compositeStats = diff
.map((diff) => { .map((diff) => {
@@ -109,14 +100,14 @@ function BreakdownRendererContent() {
const compositeStats = calculateCompositeStats( const compositeStats = calculateCompositeStats(
{ battle_life_time: diff.battle_life_time, ...diff.all }, { battle_life_time: diff.battle_life_time, ...diff.all },
average.mu, average.mu
); );
return { id: diff.tank_id, compositeStats }; return { id: diff.tank_id, compositeStats };
}) })
.filter((stats) => stats !== null); .filter((stats) => stats !== null);
const sum = sumCompositeStats( const sum = sumCompositeStats(
compositeStats.map(({ compositeStats }) => compositeStats), compositeStats.map(({ compositeStats }) => compositeStats)
); );
useEffect(() => { useEffect(() => {
@@ -135,12 +126,12 @@ function BreakdownRendererContent() {
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger> <ContextMenu.Trigger>
<BreakdownEmbedWrapper> <BreakdownEmbedWrapper>
{useEmbedState('showTotal') && ( {useEmbedState("showTotal") && (
<BreakdownEmbedCard composite={sum} tank={null} /> <BreakdownEmbedCard composite={sum} tank={null} />
)} )}
{compositeStats {compositeStats
.slice(0, useEmbedState('listMaxTanks')) .slice(0, useEmbedState("listMaxTanks"))
.map(({ id, compositeStats }) => { .map(({ id, compositeStats }) => {
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
@@ -163,7 +154,7 @@ function BreakdownRendererContent() {
if (newA === null) return; if (newA === null) return;
mutateEmbedBreakdownPersistent((draft) => { EmbedBreakdown.mutate((draft) => {
draft[id] = newA; draft[id] = newA;
}); });
}} }}
@@ -1,15 +1,15 @@
import locales from '@blitzkit/i18n/locales.json'; import locales from "@blitzkit/i18n/locales.json";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Text } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { awaitableGallery } from '../../core/awaitables/gallery'; import { awaitableGallery } from "../../core/awaitables/gallery";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from '../InlineSkeleton'; import { InlineSkeleton } from "../InlineSkeleton";
import { GalleryCard } from './Card'; import { GalleryCard } from "./Card";
export interface Avatar { export interface Avatar {
name: string; name: string;
@@ -22,7 +22,7 @@ const DEFAULT_LOADED = 42;
const PREVIEW_COUNT = 28; const PREVIEW_COUNT = 28;
export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) { export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
const search = GalleryEphemeral.use((state) => state.search); const search = Gallery.use((state) => state.search);
const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED); const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED);
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -35,9 +35,9 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
.go(search, gallery.avatars, { .go(search, gallery.avatars, {
keys: [ keys: [
...locales.supported.map( ...locales.supported.map(
(supported) => `name.locales.${supported.locale}`, (supported) => `name.locales.${supported.locale}`
), ),
'id', "id",
], ],
}) })
.map((result) => result.obj); .map((result) => result.obj);
@@ -72,7 +72,7 @@ export function GalleryList({ skeleton }: MaybeSkeletonComponentProps) {
skeleton skeleton
onIntersection={() => setLoadedCards((state) => state + 2)} onIntersection={() => setLoadedCards((state) => state + 2)}
/> />
), )
)} )}
</Flex> </Flex>
</> </>
@@ -1,13 +1,12 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { TextField } from '@radix-ui/themes'; import { TextField } from "@radix-ui/themes";
import { useRef } from 'react'; import { useRef } from "react";
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from "../../hooks/useLocale";
import { GalleryEphemeral } from '../../stores/galleryEphemeral'; import { Gallery } from "../../stores/gallery";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) { export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const mutateGalleryEphemeral = GalleryEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -17,7 +16,7 @@ export function GallerySearch({ skeleton }: MaybeSkeletonComponentProps) {
placeholder={strings.website.tools.gallery.search.hint} placeholder={strings.website.tools.gallery.search.hint}
ref={input} ref={input}
onChange={(event) => { onChange={(event) => {
mutateGalleryEphemeral((draft) => { Gallery.mutate((draft) => {
const trimmed = event.target.value.trim(); const trimmed = event.target.value.trim();
draft.search = trimmed.length === 0 ? undefined : trimmed; draft.search = trimmed.length === 0 ? undefined : trimmed;
}); });
@@ -1,13 +1,13 @@
import { Box } from '@radix-ui/themes'; import { Box } from "@radix-ui/themes";
import { useEffect } from 'react'; import { useEffect } from "react";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../core/awaitables/provisionDefinitions";
import { tankToDuelMember } from '../../core/blitzkit/tankToDuelMember'; import { tankToDuelMember } from "../../core/blitzkit/tankToDuelMember";
import { Var } from '../../core/radix/var'; import { Var } from "../../core/radix/var";
import { Duel } from '../../stores/duel'; import { Duel } from "../../stores/duel";
import { GuessEphemeral } from '../../stores/guessEphemeral'; import { Guess, GuessState } from "../../stores/guess";
import { TankopediaEphemeral } from '../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../stores/tankopedia";
import './index.css'; import "./index.css";
const [modelDefinitions, provisionDefinitions] = await Promise.all([ const [modelDefinitions, provisionDefinitions] = await Promise.all([
awaitableModelDefinitions, awaitableModelDefinitions,
@@ -15,17 +15,15 @@ const [modelDefinitions, provisionDefinitions] = await Promise.all([
]); ]);
export function GuessBackground() { export function GuessBackground() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
useEffect(() => { useEffect(() => {
mutateDuel((state) => { mutateDuel((state) => {
const model = modelDefinitions.models[tank.id]; state.protagonist = tankToDuelMember(tank, provisionDefinitions);
state.protagonist = tankToDuelMember(tank, model, provisionDefinitions);
}); });
mutateTankopediaEphemeral((state) => { Tankopedia.mutate((state) => {
state.model = modelDefinitions.models[tank.id]; state.model = modelDefinitions.models[tank.id];
}); });
}, [tank]); }, [tank]);
@@ -39,13 +37,13 @@ export function GuessBackground() {
top="0" top="0"
left="0" left="0"
style={{ style={{
transitionDuration: '2s', transitionDuration: "2s",
backgroundColor: Var( backgroundColor: Var(
guessState === null guessState === GuessState.NotGuessed
? 'accent-2' ? "accent-2"
: guessState : guessState === GuessState.Correct
? 'jade-3' ? "jade-3"
: 'tomato-3', : "tomato-3"
), ),
}} }}
> >
@@ -53,7 +51,7 @@ export function GuessBackground() {
width="100%" width="100%"
height="100%" height="100%"
style={{ style={{
background: `radial-gradient(circle, ${Var('color-panel-translucent')}, ${Var('color-panel-solid')})`, background: `radial-gradient(circle, ${Var("color-panel-translucent")}, ${Var("color-panel-solid")})`,
}} }}
/> />
</Box> </Box>
@@ -67,13 +65,13 @@ export function GuessBackground() {
left="0" left="0"
style={{ style={{
backgroundImage: ` backgroundImage: `
radial-gradient(circle, ${Var('white-a1')} 20%, transparent 20%), radial-gradient(circle, ${Var("white-a1")} 20%, transparent 20%),
radial-gradient(circle at 0 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 0, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 100% 0, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 0 100%, ${Var('white-a1')} 10%, transparent 10%), radial-gradient(circle at 0 100%, ${Var("white-a1")} 10%, transparent 10%),
radial-gradient(circle at 100% 100%, ${Var('white-a1')} 10%, transparent 10%) radial-gradient(circle at 100% 100%, ${Var("white-a1")} 10%, transparent 10%)
`, `,
backgroundSize: '4rem 4rem', backgroundSize: "4rem 4rem",
}} }}
/> />
</> </>
@@ -1,29 +1,27 @@
import { GuessEphemeral } from '../stores/guessEphemeral'; import { Guess, GuessState } from "../stores/guess";
import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants'; import { SmartCanvas } from "./SmartCanvas";
import { SmartCanvas } from './SmartCanvas'; import { Controls } from "./Tankopedia/HeroSection/components/TankSandbox/components/Control";
import { Controls } from './Tankopedia/HeroSection/components/TankSandbox/components/Control'; import { Lighting } from "./Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { Lighting } from './Tankopedia/HeroSection/components/TankSandbox/components/Lighting'; import { TankModel } from "./Tankopedia/HeroSection/components/TankSandbox/components/TankModel";
import { TankModel } from './Tankopedia/HeroSection/components/TankSandbox/components/TankModel';
const BRIGHTNESS = 2 ** -5; const BRIGHTNESS = 2 ** -5.5;
const CONTRAST = 2 ** 2; const CONTRAST = 2 ** 2;
export function GuessRenderer() { export function GuessRenderer() {
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const isRevealed = guessState !== null; const isRevealed = guessState !== GuessState.NotGuessed;
return ( return (
<SmartCanvas <SmartCanvas
style={{ style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`, filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
// filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`, transitionDuration: isRevealed ? "2s" : undefined,
transitionDuration: isRevealed ? '2s' : undefined, transitionProperty: "filter",
transitionProperty: 'filter',
}} }}
camera={{ position: [-5, 5, -5] }} camera={{ position: [-5, 5, -5] }}
> >
<Controls distanceScale={2} zoomable={false} /> <Controls zoomable={false} />
<Lighting display={TankopediaDisplay.Model} /> <Lighting />
<TankModel /> <TankModel />
</SmartCanvas> </SmartCanvas>
); );
+39 -33
View File
@@ -2,13 +2,13 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
TankDefinition, TankDefinition,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n'; import { literals } from "@blitzkit/i18n";
import { import {
ArrowRightIcon, ArrowRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PaperPlaneIcon, PaperPlaneIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { import {
Box, Box,
Button, Button,
@@ -17,16 +17,16 @@ import {
Spinner, Spinner,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from "react";
import { awaitableTankDefinitions } from '../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { awaitableTankNames } from '../core/awaitables/tankNames'; import { awaitableTankNames } from "../core/awaitables/tankNames";
import { useLocale } from '../hooks/useLocale'; import { useLocale } from "../hooks/useLocale";
import { GuessEphemeral } from '../stores/guessEphemeral'; import { Guess, GuessState } from "../stores/guess";
import { classIcons } from './ClassIcon'; import { classIcons } from "./ClassIcon";
import { SearchResults } from './SearchResults'; import { SearchResults } from "./SearchResults";
const { go } = fuzzysort; const { go } = fuzzysort;
@@ -38,16 +38,15 @@ const [tankNames, tankDefinitions] = await Promise.all([
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
export function Guesser() { export function Guesser() {
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const correctGuesses = GuessEphemeral.use((state) => state.correctGuesses); const correctGuesses = Guess.use((state) => state.correctGuesses);
const totalGuesses = GuessEphemeral.use((state) => state.totalGuesses); const totalGuesses = Guess.use((state) => state.totalGuesses);
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null); const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null); const [selected, setSelected] = useState<TankDefinition | null>(null);
const mutateGuessEphemeral = GuessEphemeral.useMutation();
const requestSearch = useCallback(() => { const requestSearch = useCallback(() => {
setSelected(null); setSelected(null);
@@ -72,14 +71,14 @@ export function Guesser() {
}); });
setResults( setResults(
searchResults.map((result) => tankDefinitions.tanks[result.obj.id]), searchResults.map((result) => tankDefinitions.tanks[result.obj.id])
); );
}, 500), }, 500),
[], []
); );
useEffect(() => { useEffect(() => {
if (guessState !== null) setSelected(null); if (guessState !== GuessState.NotGuessed) setSelected(null);
}, [guessState]); }, [guessState]);
return ( return (
@@ -88,7 +87,7 @@ export function Guesser() {
position="absolute" position="absolute"
bottom="0" bottom="0"
left="50%" left="50%"
style={{ transform: 'translateX(-50%)' }} style={{ transform: "translateX(-50%)" }}
width="100%" width="100%"
p="4" p="4"
maxWidth="25rem" maxWidth="25rem"
@@ -138,7 +137,7 @@ export function Guesser() {
</Card> </Card>
)} )}
<Flex justify="center"> <Flex justify="center" style={{ userSelect: "none" }}>
<Text> <Text>
{literals(strings.website.tools.guess.stats, [ {literals(strings.website.tools.guess.stats, [
`${correctGuesses}`, `${correctGuesses}`,
@@ -149,7 +148,7 @@ export function Guesser() {
<Flex gap="3"> <Flex gap="3">
<TextField.Root <TextField.Root
disabled={guessState !== null} disabled={guessState !== GuessState.NotGuessed}
ref={input} ref={input}
onChange={requestSearch} onChange={requestSearch}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -164,13 +163,19 @@ export function Guesser() {
<Button <Button
size="3" size="3"
color={guessState === null && selected === null ? 'red' : undefined} color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
onClick={() => { onClick={() => {
if (guessState === null) { if (guessState === GuessState.NotGuessed) {
const correct = selected !== null && selected.id === tank.id; const correct = selected !== null && selected.id === tank.id;
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
draft.guessState = correct; draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
draft.totalGuesses++; draft.totalGuesses++;
draft.correctGuesses += correct ? 1 : 0; draft.correctGuesses += correct ? 1 : 0;
}); });
@@ -178,21 +183,22 @@ export function Guesser() {
const id = Number(ids[Math.floor(Math.random() * ids.length)]); const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
mutateGuessEphemeral((draft) => { Guess.mutate((draft) => {
if (!input.current) return; if (!input.current) return;
draft.tank = tank; draft.tank = tank;
draft.guessState = null; draft.guessState = GuessState.NotGuessed;
input.current.value = '';
input.current.value = "";
}); });
} }
}} }}
> >
{guessState === null ? ( {guessState === GuessState.NotGuessed ? (
<> <>
{ {
strings.website.tools.guess.search[ strings.website.tools.guess.search[
selected === null ? 'skip' : 'guess' selected === null ? "skip" : "guess"
] ]
} }
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />} {selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
@@ -0,0 +1,21 @@
import type { SpotLightProps } from '@react-three/fiber';
import { memo } from 'react';
interface Props extends SpotLightProps {
debug?: boolean;
}
export const HelpingSpotLight = memo<Props>(({ debug, ...props }) => {
return (
<>
{debug && (
<mesh position={props.position}>
<icosahedronGeometry args={[0.1, 2]} />
<meshBasicMaterial color={props.color} />
</mesh>
)}
<spotLight {...props} />
</>
);
});
@@ -0,0 +1,125 @@
import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { jsxTree } from "../../../core/blitzkit/jsxTree";
import { useModel } from "../../../hooks/useModel";
import { Mixer } from "../../../stores/mixer";
const modelDefinitions = await awaitableModelDefinitions;
export function Model() {
const hull = Mixer.use((state) => state.hull);
const turret = Mixer.use((state) => state.turret);
const gun = Mixer.use((state) => state.gun);
const hullGltf = useModel(hull.id);
const turretGltf = useModel(turret.tank.id);
const gunGltf = useModel(gun.tank.id);
const hullNodes = Object.values(hullGltf.gltf.nodes);
const turretNodes = Object.values(turretGltf.gltf.nodes);
const gunNodes = Object.values(gunGltf.gltf.nodes);
const tankModel = modelDefinitions.models[hull.id];
const trackModel = tankModel.tracks[hull.tracks.at(-1)!.id];
const turretTankModel = modelDefinitions.models[turret.tank.id];
const turretTrackModel =
turretTankModel.tracks[turret.tank.tracks.at(-1)!.id];
const turretModel = turretTankModel.turrets[turret.turret.id];
const gunTankModel = modelDefinitions.models[gun.tank.id];
const gunTrackModel = gunTankModel.tracks[gun.tank.tracks.at(-1)!.id];
const gunTurretModel = gunTankModel.turrets[gun.turret.id];
const gunModel = gunTurretModel.guns[gun.gun.id];
return (
<group rotation={[-Math.PI / 2, 0, 0]}>
{hullNodes.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;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<group
position={[
tankModel.turret_origin.x +
trackModel.origin.x -
turretTankModel.turret_origin.x -
turretTrackModel.origin.x,
tankModel.turret_origin.z +
trackModel.origin.z -
turretTankModel.turret_origin.z -
turretTrackModel.origin.z,
tankModel.turret_origin.y +
trackModel.origin.y -
turretTankModel.turret_origin.y -
turretTrackModel.origin.y,
]}
>
{turretNodes.map((node) => {
const isCurrentTurret =
node.name ===
`turret_${turretModel.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentTurret;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<group
position={[
turretTankModel.turret_origin.x +
turretTrackModel.origin.x +
turretModel.gun_origin.x -
gunTankModel.turret_origin.x -
gunTrackModel.origin.x -
gunTurretModel.gun_origin.x,
turretTankModel.turret_origin.z +
turretTrackModel.origin.z +
turretModel.gun_origin.z -
gunTankModel.turret_origin.z -
gunTrackModel.origin.z -
gunTurretModel.gun_origin.z,
turretTankModel.turret_origin.y +
turretTrackModel.origin.y +
turretModel.gun_origin.y -
gunTankModel.turret_origin.y -
gunTrackModel.origin.y -
gunTurretModel.gun_origin.y,
]}
>
{gunNodes.map((node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, "0")}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
</group>
</group>
</group>
);
}
@@ -0,0 +1,25 @@
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { Suspense } from "react";
import { Lighting } from "../Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { Model } from "./components/Model";
export function MixerScene() {
return (
<Canvas
shadows="soft"
camera={{
fov: 45,
position: [5, 5, -5],
}}
>
<OrbitControls />
<Lighting />
<Suspense fallback={null}>
<Model />
</Suspense>
</Canvas>
);
}
@@ -1,15 +1,9 @@
import { Flex, Select, Text } from '@radix-ui/themes'; import { Flex, Select, Text } from '@radix-ui/themes';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { import { Performance, type PlayerCountPeriod } from '../../stores/performance';
TankPerformanceEphemeral,
type PlayerCountPeriod,
} from '../../stores/tankPerformanceEphemeral';
export function PlayerCountControl() { export function PlayerCountControl() {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use((state) => state.playerCountPeriod);
(state) => state.playerCountPeriod,
);
const mutateTankPerformanceSort = TankPerformanceEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -18,7 +12,7 @@ export function PlayerCountControl() {
<Select.Root <Select.Root
value={playerCountPeriod} value={playerCountPeriod}
onValueChange={(period) => { onValueChange={(period) => {
mutateTankPerformanceSort((draft) => { Performance.mutate((draft) => {
draft.playerCountPeriod = period as PlayerCountPeriod; draft.playerCountPeriod = period as PlayerCountPeriod;
}); });
}} }}
@@ -4,7 +4,7 @@ import { memo } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { TankRowHeaderCell } from '../TankRowHeaderCell'; import { TankRowHeaderCell } from '../TankRowHeaderCell';
interface TankRowProps { interface TankRowProps {
@@ -17,7 +17,7 @@ export const TankRow = memo<TankRowProps>(
({ tank }) => { ({ tank }) => {
const averages = averageDefinitions.averages[tank.id]; const averages = averageDefinitions.averages[tank.id];
const ratio = useAveragesExclusionRatio(); const ratio = useAveragesExclusionRatio();
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { locale } = useLocale(); const { locale } = useLocale();
@@ -1,17 +1,16 @@
import { useStore } from '@nanostores/react'; import { Table } from "@radix-ui/themes";
import { Table } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { Fragment, Suspense, useEffect, useMemo, useState } from "react";
import { Fragment, Suspense, useEffect, useMemo, useState } from 'react'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { Performance } from "../../stores/performance";
import { $tankFilters } from '../../stores/tankFilters'; import { TankFilters } from "../../stores/tankFilters";
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { RowLoader } from "./RowLoader";
import { RowLoader } from './RowLoader'; import { TankRow } from "./TankRow";
import { TankRow } from './TankRow'; import { Total } from "./Total";
import { Total } from './Total';
const PREVIEW_COUNT = 10; const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25; const DEFAULT_LOADED_ROWS = 25;
@@ -25,7 +24,7 @@ const averageDefinitionsArray = Object.entries(averageDefinitions.averages).map(
([id, average]) => ({ ([id, average]) => ({
id: Number(id), id: Number(id),
...average, ...average,
}), })
); );
export function Tanks({ skeleton }: MaybeSkeletonComponentProps) { export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
@@ -40,98 +39,97 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
} }
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const tankPerformancePersistentStore = TankPerformanceEphemeral.useStore();
const filters = useStore($tankFilters);
const tanksSorted = useMemo(() => { const tanksSorted = useMemo(() => {
const { playerCountPeriod } = tankPerformancePersistentStore.getState(); const { playerCountPeriod } = Performance.state;
switch (sort.type) { switch (sort.type) {
case 'accuracy': case "accuracy":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots), sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots)
); );
case 'capture_points': case "capture_points":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.capture_points / a.mu.battles - (a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles), b.mu.capture_points / b.mu.battles)
); );
case 'damage': case "damage":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.battles - (a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles), b.mu.damage_dealt / b.mu.battles)
); );
case 'damage_ratio': case "damage_ratio":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.damage_received - (a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received), b.mu.damage_dealt / b.mu.damage_received)
); );
case 'damage_taken': case "damage_taken":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_received / a.mu.battles - (a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles), b.mu.damage_received / b.mu.battles)
); );
case 'hits': case "hits":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles), (a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles)
); );
case 'kills': case "kills":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles), (a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles)
); );
case 'shots': case "shots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles), (a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles)
); );
case 'players': case "players":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]), (a.samples[playerCountPeriod] - b.samples[playerCountPeriod])
); );
case 'spots': case "spots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles), (a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles)
); );
case 'survival': case "survival":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.survived_battles / a.mu.battles - (a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles), b.mu.survived_battles / b.mu.battles)
); );
case 'winrate': case "winrate":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles), (a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles)
); );
case 'xp': case "xp":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles), sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles)
); );
} }
}, [sort]); }, [sort]);
const filters = TankFilters.use();
const tanks = useMemo(() => { const tanks = useMemo(() => {
return tanksSorted.filter((tank) => return tanksSorted.filter((tank) =>
filterTank(filters, tankDefinitions.tanks[tank.id]), filterTank(filters, tankDefinitions.tanks[tank.id])
); );
}, [filters, sort]); }, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS); const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
@@ -8,7 +8,7 @@ import { memo, useCallback } from 'react';
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions';
import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio'; import { useAveragesExclusionRatio } from '../../hooks/useAveragesExclusionRatio';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../stores/tankPerformanceEphemeral'; import { Performance } from '../../stores/performance';
import { StickyRowHeaderCell } from '../StickyRowHeaderCell'; import { StickyRowHeaderCell } from '../StickyRowHeaderCell';
interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry { interface AverageDefinitionsEntryWithId extends AverageDefinitionsEntry {
@@ -23,7 +23,7 @@ const averageDefinitions = await awaitableAverageDefinitions;
export const Total = memo<TotalProps>( export const Total = memo<TotalProps>(
({ tanks }) => { ({ tanks }) => {
const playerCountPeriod = TankPerformanceEphemeral.use( const playerCountPeriod = Performance.use(
(state) => state.playerCountPeriod, (state) => state.playerCountPeriod,
); );
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
@@ -4,11 +4,10 @@ import {
unionBoundingBox, unionBoundingBox,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions';
import { resolveReload } from '../../../core/blitzkit/resolveReload'; import { resolveReload } from '../../../core/blitzkit/resolveReload';
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankSort } from '../../../stores/tankopediaSort';
import { TankCard } from '../../TankCard'; import { TankCard } from '../../TankCard';
interface TankSearchCardProps { interface TankSearchCardProps {
@@ -19,9 +18,9 @@ interface TankSearchCardProps {
const modelDefinitions = await awaitableModelDefinitions; const modelDefinitions = await awaitableModelDefinitions;
export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) { export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const tankopediaSort = useStore($tankopediaSort); const by = TankSort.use((state) => state.by);
const discriminator = useMemo(() => { const discriminator = useMemo(() => {
if (tankopediaSort.by.startsWith('meta')) return undefined; if (by.startsWith('meta')) return undefined;
const turret = tank.turrets.at(-1)!; const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!; const gun = turret.guns.at(-1)!;
@@ -34,7 +33,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id]; const gunModelDefinition = turretModelDefinition.guns[gun.id];
switch (tankopediaSort.by) { switch (by) {
case 'fire.aimTime': case 'fire.aimTime':
return gun.aim_time.toFixed(2); return gun.aim_time.toFixed(2);
case 'fire.caliber': case 'fire.caliber':
@@ -133,7 +132,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0); return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
} }
} }
}, [tankopediaSort.by]); }, [by]);
return ( return (
<TankCard <TankCard
@@ -4,8 +4,7 @@ import {
TANK_CLASSES, TANK_CLASSES,
TankType, TankType,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useStore } from '@nanostores/react';
import { import {
Box, Box,
Flex, Flex,
@@ -14,29 +13,22 @@ import {
Link, Link,
Popover, Popover,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { isEqual, times } from 'lodash-es'; import { isEqual, times } from "lodash-es";
import { awaitableGameDefinitions } from '../../../core/awaitables/gameDefinitions'; import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions";
import { Var } from '../../../core/radix/var'; import { Var } from "../../../core/radix/var";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { import { TankFilters } from "../../../stores/tankFilters";
$tankFilters, import { TankSort } from "../../../stores/tankopediaSort";
initialTankFilters, import { classIcons } from "../../ClassIcon";
type TankFilters, import { ExperimentIcon } from "../../ExperimentIcon";
} from '../../../stores/tankFilters'; import { GunAutoloaderIcon } from "../../GunAutoloaderIcon";
import { import { GunAutoreloaderIcon } from "../../GunAutoreloaderIcon";
$tankopediaSort, import { GunRegularIcon } from "../../GunRegularIcon";
initialTankopediaSort, import { MissingShellIcon } from "../../MissingShellIcon";
} from '../../../stores/tankopediaSort'; import { ResearchedIcon } from "../../ResearchedIcon";
import { classIcons } from '../../ClassIcon'; import { ScienceIcon } from "../../ScienceIcon";
import { ExperimentIcon } from '../../ExperimentIcon'; import { ScienceOffIcon } from "../../ScienceOffIcon";
import { GunAutoloaderIcon } from '../../GunAutoloaderIcon';
import { GunAutoreloaderIcon } from '../../GunAutoreloaderIcon';
import { GunRegularIcon } from '../../GunRegularIcon';
import { MissingShellIcon } from '../../MissingShellIcon';
import { ResearchedIcon } from '../../ResearchedIcon';
import { ScienceIcon } from '../../ScienceIcon';
import { ScienceOffIcon } from '../../ScienceOffIcon';
interface FilterControlProps { interface FilterControlProps {
compact?: boolean; compact?: boolean;
@@ -45,29 +37,29 @@ interface FilterControlProps {
const gameDefinitions = await awaitableGameDefinitions; const gameDefinitions = await awaitableGameDefinitions;
const shellTypeIcons: Record<ShellType, string> = { const shellTypeIcons: Record<ShellType, string> = {
[ShellType.AP]: 'ap', [ShellType.AP]: "ap",
[ShellType.APCR]: 'ap_cr', [ShellType.APCR]: "ap_cr",
[ShellType.HE]: 'he', [ShellType.HE]: "he",
[ShellType.HEAT]: 'hc', [ShellType.HEAT]: "hc",
}; };
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) { function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const tankFilters = useStore($tankFilters); const shells = TankFilters.use((state) => state.shells);
return ( return (
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<IconButton variant="soft" radius="none" color="gray" highContrast> <IconButton variant="soft" radius="none" color="gray" highContrast>
{tankFilters.shells[index] === null && ( {shells[index] === null && (
<Text color="gray" style={{ display: 'contents' }}> <Text color="gray" style={{ display: "contents" }}>
<MissingShellIcon width="1em" height="1em" /> <MissingShellIcon width="1em" height="1em" />
</Text> </Text>
)} )}
{tankFilters.shells[index] !== null && ( {shells[index] !== null && (
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset( src={asset(
`icons/shells/${shellTypeIcons[tankFilters.shells[index]]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shells[index]]}${premium ? "_premium" : ""}.webp`
)} )}
/> />
)} )}
@@ -78,35 +70,33 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
<Inset> <Inset>
<Flex> <Flex>
{Object.values(ShellType).map((shellType) => { {Object.values(ShellType).map((shellType) => {
if (typeof shellType === 'string') return null; if (typeof shellType === "string") return null;
const selected = tankFilters.shells[index] === shellType; const selected = shells[index] === shellType;
return ( return (
<IconButton <IconButton
key={shellType} key={shellType}
value={`${shellType}`} value={`${shellType}`}
radius="none" radius="none"
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
onClick={() => { onClick={() => {
const mutated = [ const mutated = [...shells] as TankFilters["shells"];
tankFilters.shells[0],
tankFilters.shells[1],
tankFilters.shells[2],
] as TankFilters['shells'];
mutated[index] = selected ? null : shellType; mutated[index] = selected ? null : shellType;
$tankFilters.setKey('shells', mutated); TankFilters.mutate((draft) => {
draft.shells = mutated;
});
}} }}
highContrast={selected} highContrast={selected}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
> >
<img <img
src={asset( src={asset(
`icons/shells/${shellTypeIcons[shellType]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shellType]}${premium ? "_premium" : ""}.webp`
)} )}
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
/> />
</IconButton> </IconButton>
); );
@@ -119,26 +109,26 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
} }
export function FilterControl({ compact }: FilterControlProps) { export function FilterControl({ compact }: FilterControlProps) {
const tankFilters = useStore($tankFilters); const tankFilters = TankFilters.use();
const { strings } = useLocale(); const { strings } = useLocale();
const clearable = !isEqual(tankFilters, initialTankFilters); const clearable = !isEqual(tankFilters, TankFilters.initial);
return ( return (
<Flex direction="column" align="center"> <Flex direction="column" align="center">
<Flex <Flex
height="fit-content" height="fit-content"
gap="2" gap="2"
align={{ initial: 'start', md: 'center' }} align={{ initial: "start", md: "center" }}
justify="center" justify="center"
> >
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 1; const tier = index + 1;
@@ -147,21 +137,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -172,15 +160,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 6; const tier = index + 6;
@@ -189,21 +177,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -216,12 +202,12 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(0, Math.ceil(gameDefinitions.nations.length / 2)) .slice(0, Math.ceil(gameDefinitions.nations.length / 2))
@@ -231,26 +217,24 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={nation} key={nation}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -259,15 +243,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(Math.ceil(gameDefinitions.nations.length / 2)) .slice(Math.ceil(gameDefinitions.nations.length / 2))
@@ -278,26 +262,24 @@ export function FilterControl({ compact }: FilterControlProps) {
<IconButton <IconButton
key={nation} key={nation}
style={{ flex: 1 }} style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -307,15 +289,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Flex <Flex
direction={{ initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
align="start" align="start"
gap={{ initial: '2', md: '1' }} gap={{ initial: "2", md: "1" }}
> >
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{TANK_CLASSES.map((tankClass) => { {TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass]; const Icon = classIcons[tankClass];
@@ -324,25 +306,23 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tankClass} key={tankClass}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.classes.includes(tankClass)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.classes.includes(tankClass)) {
'classes', draft.classes = draft.classes.filter(
tankFilters.classes.filter((c) => c !== tankClass), (c) => c !== tankClass
); );
} else { } else {
$tankFilters.setKey('classes', [ draft.classes = [...draft.classes, tankClass];
...tankFilters.classes, }
tankClass, });
]);
}
}} }}
> >
<Icon style={{ width: '1em', height: '1em' }} /> <Icon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
); );
})} })}
@@ -350,96 +330,90 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('regular') ? 'solid' : 'soft' tankFilters.gunType.includes("regular") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('regular') ? undefined : 'gray' tankFilters.gunType.includes("regular") ? undefined : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('regular')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("regular")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'regular'), (t) => t !== "regular"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "regular"];
...tankFilters.gunType, }
'regular', });
]);
}
}} }}
> >
<GunRegularIcon style={{ width: '1em', height: '1em' }} /> <GunRegularIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_loader') ? 'solid' : 'soft' tankFilters.gunType.includes("auto_loader") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_loader') tankFilters.gunType.includes("auto_loader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_loader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_loader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_loader'), (t) => t !== "auto_loader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_loader"];
...tankFilters.gunType, }
'auto_loader', });
]);
}
}} }}
> >
<GunAutoloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_reloader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_reloader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_reloader'), (t) => t !== "auto_reloader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_reloader"];
...tankFilters.gunType, }
'auto_reloader', });
]);
}
}} }}
> >
<GunAutoreloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoreloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<ShellFilter index={0} /> <ShellFilter index={0} />
<ShellFilter index={1} premium /> <ShellFilter index={1} premium />
@@ -450,160 +424,158 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.RESEARCHABLE)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter( (t) => t !== TankType.RESEARCHABLE
(t) => t !== TankType.RESEARCHABLE, );
), } else {
); draft.types = [...draft.types, TankType.RESEARCHABLE];
} else { }
$tankFilters.setKey('types', [ });
...tankFilters.types,
TankType.RESEARCHABLE,
]);
}
}} }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'amber' ? "amber"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.PREMIUM)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.PREMIUM)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.PREMIUM), (t) => t !== TankType.PREMIUM
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.PREMIUM];
...tankFilters.types, }
TankType.PREMIUM, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? undefined ? undefined
: 'amber' : "amber"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'blue' ? "blue"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.COLLECTOR)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.COLLECTOR)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.COLLECTOR), (t) => t !== TankType.COLLECTOR
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.COLLECTOR];
...tankFilters.types, }
TankType.COLLECTOR, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? undefined ? undefined
: 'blue' : "blue"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={tankFilters.testing === 'include' ? 'solid' : 'soft'} variant={tankFilters.testing === "include" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'include' ? undefined : 'gray'} color={tankFilters.testing === "include" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'include'); TankFilters.mutate((draft) => {
draft.testing = "include";
});
}} }}
> >
<ExperimentIcon <ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'exclude' ? 'solid' : 'soft'} variant={tankFilters.testing === "exclude" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'exclude' ? undefined : 'gray'} color={tankFilters.testing === "exclude" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'exclude'); TankFilters.mutate((draft) => {
draft.testing = "exclude";
});
}} }}
> >
<ScienceOffIcon <ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'only' ? 'solid' : 'soft'} variant={tankFilters.testing === "only" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'only' ? undefined : 'gray'} color={tankFilters.testing === "only" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'only'); TankFilters.mutate((draft) => {
draft.testing = "only";
});
}} }}
> >
<ScienceIcon <ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
</Flex> </Flex>
@@ -619,8 +591,8 @@ export function FilterControl({ compact }: FilterControlProps) {
href="#" href="#"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
$tankopediaSort.set(initialTankopediaSort); TankSort.set(TankSort.initial);
}} }}
> >
{strings.website.common.tank_search.clear_filters} {strings.website.common.tank_search.clear_filters}
@@ -1,27 +1,27 @@
import { Flex, Link, Text } from '@radix-ui/themes'; import { Flex, Link, Text } from "@radix-ui/themes";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankFilters } from "../../../stores/tankFilters";
interface NoResultsProps { interface NoResultsProps {
type?: 'filters' | 'search'; type?: "filters" | "search";
} }
export function NoResults({ type = 'filters' }: NoResultsProps) { export function NoResults({ type = "filters" }: NoResultsProps) {
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
<Flex flexGrow="1" align="center" justify="center"> <Flex flexGrow="1" align="center" justify="center">
<Text color="gray"> <Text color="gray">
{strings.website.common.tank_search.no_tanks_found.body}{' '} {strings.website.common.tank_search.no_tanks_found.body}{" "}
<Link <Link
href="#" href="#"
underline="always" underline="always"
color="red" color="red"
onClick={() => { onClick={() => {
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
}} }}
> >
{type === 'filters' {type === "filters"
? strings.website.common.tank_search.no_tanks_found.clear_filters ? strings.website.common.tank_search.no_tanks_found.clear_filters
: strings.website.common.tank_search.no_tanks_found.search_again} : strings.website.common.tank_search.no_tanks_found.search_again}
</Link> </Link>
@@ -1,20 +1,20 @@
import { type TankDefinition } from '@blitzkit/core'; import { type TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { CaretRightIcon } from "@radix-ui/react-icons";
import { CaretRightIcon } from '@radix-ui/react-icons'; import { Button, TextField } from "@radix-ui/themes";
import { Button, TextField } from '@radix-ui/themes'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { LinkI18n } from "../../LinkI18n";
import { LinkI18n } from '../../LinkI18n';
interface QuickLinkProps { interface QuickLinkProps {
topResult?: TankDefinition; topResult?: TankDefinition;
} }
export function QuickLink({ topResult }: QuickLinkProps) { export function QuickLink({ topResult }: QuickLinkProps) {
const tankFilters = useStore($tankFilters);
const { locale, unwrap } = useLocale(); const { locale, unwrap } = useLocale();
const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
if (!tankFilters.search || !topResult || tankFilters.searching) return null; if (!search || !topResult || searching) return null;
return ( return (
<TextField.Slot> <TextField.Slot>
@@ -1,38 +1,37 @@
import { useStore } from '@nanostores/react'; import { Flex, Separator, Text } from "@radix-ui/themes";
import { Flex, Separator, Text } from '@radix-ui/themes'; import { isEqual } from "lodash-es";
import { isEqual } from 'lodash-es'; import { useMemo } from "react";
import { useMemo } from 'react'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankSort } from "../../../stores/tankopediaSort";
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankCard } from "../../TankCard";
import { TankCard } from '../../TankCard'; import { TankCardWrapper } from "./TankCardWrapper";
import { TankCardWrapper } from './TankCardWrapper';
const tankDefinitions = await awaitableTankDefinitions; const tankDefinitions = await awaitableTankDefinitions;
export function RecentlyViewed() { export function RecentlyViewed() {
const tankopediaPersistentStore = TankopediaPersistent.useStore(); const tankopediaPersistentStore = TankopediaPersistent.useStore();
const filters = useStore($tankFilters); const filters = TankFilters.use();
// non-reactive because it is a little weird that it updates instantly even before the page loads // non-reactive because it is a little weird that it updates instantly even before the page loads
const recentlyViewed = tankopediaPersistentStore const recentlyViewed = tankopediaPersistentStore
.getState() .getState()
.recentlyViewed.filter((id) => id in tankDefinitions.tanks); .recentlyViewed.filter((id) => id in tankDefinitions.tanks);
const sort = useStore($tankopediaSort);
const hasFilters = useMemo( const hasFilters = useMemo(
() => () =>
Object.entries(filters).some(([key, value]) => { Object.entries(filters).some(([key, value]) => {
return !isEqual( return !isEqual(
value, value,
initialTankFilters[key as keyof typeof initialTankFilters], TankFilters.initial[key as keyof typeof TankFilters.initial]
); );
}), }),
[filters], [filters]
); );
const { strings } = useLocale(); const { strings } = useLocale();
const by = TankSort.use((state) => state.by);
if (recentlyViewed.length === 0 || sort.by !== 'meta.none' || hasFilters) { if (recentlyViewed.length === 0 || by !== "meta.none" || hasFilters) {
return null; return null;
} }
@@ -1,14 +1,13 @@
import type { TankDefinition } from '@blitzkit/core'; import type { TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { Flex, Spinner, TextField } from "@radix-ui/themes";
import { Flex, Spinner, TextField } from '@radix-ui/themes'; import { debounce } from "lodash-es";
import { debounce } from 'lodash-es'; import { type KeyboardEventHandler, useCallback, useRef } from "react";
import { type KeyboardEventHandler, useCallback, useRef } from 'react'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import { QuickLink } from "./QuickLink";
import { QuickLink } from './QuickLink'; import { Sort } from "./Sort";
import { Sort } from './Sort';
type SearchBarProps = MaybeSkeletonComponentProps & { type SearchBarProps = MaybeSkeletonComponentProps & {
topResult?: TankDefinition; topResult?: TankDefinition;
@@ -17,30 +16,37 @@ type SearchBarProps = MaybeSkeletonComponentProps & {
export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) { export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const tankFilters = useStore($tankFilters); const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const performSearch = useCallback( const performSearch = useCallback(
debounce(() => { debounce(() => {
$tankFilters.setKey('searching', false); TankFilters.mutate((draft) => {
draft.searching = false;
});
if (!input.current) return; if (!input.current) return;
const sanitized = input.current.value.trim(); const sanitized = input.current.value.trim();
$tankFilters.setKey('search', sanitized.length === 0 ? null : sanitized); TankFilters.mutate((draft) => {
draft.search = sanitized.length === 0 ? null : sanitized;
});
}, 500), }, 500),
[], []
); );
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!tankFilters.searching) { if (!searching) {
$tankFilters.setKey('searching', true); TankFilters.mutate((draft) => {
draft.searching = true;
});
} }
performSearch(); performSearch();
}, [tankFilters]); }, [searching]);
const handleKeyDown = useCallback<KeyboardEventHandler>( const handleKeyDown = useCallback<KeyboardEventHandler>(
(event) => { (event) => {
if (event.key !== 'Enter' || !topResult || tankFilters.searching) return; if (event.key !== "Enter" || !topResult || searching) return;
event.preventDefault(); event.preventDefault();
@@ -50,7 +56,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
window.location.href = `/tanks/${topResult.slug}`; window.location.href = `/tanks/${topResult.slug}`;
} }
}, },
[topResult], [topResult]
); );
return ( return (
@@ -59,7 +65,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
<TextField.Root <TextField.Root
variant="classic" variant="classic"
disabled={skeleton} disabled={skeleton}
defaultValue={tankFilters.search ?? undefined} defaultValue={search ?? undefined}
style={{ flex: 1 }} style={{ flex: 1 }}
ref={input} ref={input}
placeholder={strings.website.common.tank_search.search_bar_hint} placeholder={strings.website.common.tank_search.search_bar_hint}
@@ -67,7 +73,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<TextField.Slot> <TextField.Slot>
{tankFilters.searching ? <Spinner /> : <MagnifyingGlassIcon />} {searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot> </TextField.Slot>
<QuickLink topResult={topResult} /> <QuickLink topResult={topResult} />
@@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react';
import { CaretDownIcon } from '@radix-ui/react-icons'; import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes'; import { Button, DropdownMenu } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from '../../../hooks/useLocale';
@@ -6,7 +5,7 @@ import type {
TankopediaSortBy, TankopediaSortBy,
TankopediaSortDirection, TankopediaSortDirection,
} from '../../../stores/tankopediaPersistent'; } from '../../../stores/tankopediaPersistent';
import { $tankopediaSort } from '../../../stores/tankopediaSort'; import { TankSort } from '../../../stores/tankopediaSort';
interface ItemProps { interface ItemProps {
by: TankopediaSortBy; by: TankopediaSortBy;
@@ -18,13 +17,15 @@ interface ItemProps {
function Item({ by }: ItemProps) { function Item({ by }: ItemProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const tankopediaSort = useStore($tankopediaSort); const _by = TankSort.use((state) => state.by);
return ( return (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={tankopediaSort.by === by} checked={_by === by}
onClick={() => { onClick={() => {
$tankopediaSort.setKey('by', by); TankSort.mutate((draft) => {
draft.by = by;
});
}} }}
> >
{strings.website.common.tank_search.sort[by]} {strings.website.common.tank_search.sort[by]}
@@ -33,7 +34,7 @@ function Item({ by }: ItemProps) {
} }
export function Sort() { export function Sort() {
const tankopediaSort = useStore($tankopediaSort); const direction = TankSort.use((state) => state.direction);
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -119,12 +120,11 @@ export function Sort() {
} }
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={tankopediaSort.direction} value={direction}
onValueChange={(value) => { onValueChange={(value) => {
$tankopediaSort.setKey( TankSort.mutate((draft) => {
'direction', draft.direction = value as TankopediaSortDirection;
value as TankopediaSortDirection, });
);
}} }}
> >
<DropdownMenu.RadioItem value="ascending"> <DropdownMenu.RadioItem value="ascending">
@@ -1,44 +1,43 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core'; import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { Flex, IconButton, Text } from "@radix-ui/themes";
import { Flex, IconButton, Text } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { memo } from "react";
import { memo } from 'react'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { TankSort } from "../../../stores/tankopediaSort";
import { $tankopediaSort } from '../../../stores/tankopediaSort';
export const TierFilter = memo(() => { export const TierFilter = memo(() => {
const tankFilters = useStore($tankFilters); const by = TankSort.use((state) => state.by);
const tankopediaSort = useStore($tankopediaSort); const search = TankFilters.use((state) => state.search);
const tiers = TankFilters.use((state) => state.tiers);
if (tankopediaSort.by !== 'meta.none' || tankFilters.search) return null; if (by !== "meta.none" || search) return null;
return ( return (
<Flex justify="center"> <Flex justify="center">
<Flex <Flex
direction="row" direction="row"
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
> >
{times(10, (index) => { {times(10, (index) => {
const tier = 10 - index; const tier = 10 - index;
const selected = tankFilters.tiers.includes(tier); const selected = tiers.includes(tier);
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tiers.includes(tier)) {
'tiers', draft.tiers = tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), } else {
); draft.tiers = [...tiers, tier];
} else { }
$tankFilters.setKey('tiers', [...tankFilters.tiers, tier]); });
}
}} }}
> >
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> <Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
@@ -5,37 +5,36 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
unionBoundingBox, unionBoundingBox,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { useStore } from '@nanostores/react'; import { Callout, Flex, Link, Text, type FlexProps } from "@radix-ui/themes";
import { Callout, Flex, Link, Text, type FlexProps } from '@radix-ui/themes'; import fuzzysort from "fuzzysort";
import fuzzysort from 'fuzzysort'; import { times, uniq } from "lodash-es";
import { times, uniq } from 'lodash-es'; import { memo, useEffect, useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState } from 'react'; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions";
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankNames } from "../../core/awaitables/tankNames";
import { awaitableTankNames } from '../../core/awaitables/tankNames'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { resolveReload } from "../../core/blitzkit/resolveReload";
import { resolveReload } from '../../core/blitzkit/resolveReload'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../stores/tankopediaPersistent'; import { SORT_UNITS } from "../../stores/tankopediaPersistent/constants";
import { SORT_UNITS } from '../../stores/tankopediaPersistent/constants'; import { TankSort } from "../../stores/tankopediaSort";
import { $tankopediaSort } from '../../stores/tankopediaSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { ExperimentIcon } from "../ExperimentIcon";
import { ExperimentIcon } from '../ExperimentIcon'; import { TankSearchCard } from "./components/Card";
import { TankSearchCard } from './components/Card'; import { FilterControl } from "./components/FilterControl";
import { FilterControl } from './components/FilterControl'; import { NoResults } from "./components/NoResults";
import { NoResults } from './components/NoResults'; import { RecentlyViewed } from "./components/RecentlyViewed";
import { RecentlyViewed } from './components/RecentlyViewed'; import { SearchBar } from "./components/SearchBar";
import { SearchBar } from './components/SearchBar'; import { SkeletonTankCard } from "./components/SkeletonTankCard";
import { SkeletonTankCard } from './components/SkeletonTankCard'; import { TankCardWrapper } from "./components/TankCardWrapper";
import { TankCardWrapper } from './components/TankCardWrapper'; import { MAX_RECENTLY_VIEWED } from "./constants";
import { MAX_RECENTLY_VIEWED } from './constants';
type TankSearchProps = MaybeSkeletonComponentProps & type TankSearchProps = MaybeSkeletonComponentProps &
Omit<FlexProps, 'onSelect'> & { Omit<FlexProps, "onSelect"> & {
compact?: boolean; compact?: boolean;
onSelect?: (tank: TankDefinition) => void; onSelect?: (tank: TankDefinition) => void;
onSelectAll?: (tanks: TankDefinition[]) => void; onSelectAll?: (tanks: TankDefinition[]) => void;
@@ -57,21 +56,21 @@ export const TankSearch = memo<TankSearchProps>(
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks); const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks);
const tankFilters = useStore($tankFilters); const tankopediaSort = TankSort.use();
const tankopediaSort = useStore($tankopediaSort); const tankFilters = TankFilters.use();
const tanksFiltered = useMemo(() => { const tanksFiltered = useMemo(() => {
if (tankFilters.search === null) { if (tankFilters.search === null) {
const filtered = awaitedTanksDefinitionsArray.filter((tank) => const filtered = awaitedTanksDefinitionsArray.filter((tank) =>
filterTank(tankFilters, tank), filterTank(tankFilters, tank)
); );
let sorted: TankDefinition[]; let sorted: TankDefinition[];
switch (tankopediaSort.by) { switch (tankopediaSort.by) {
case 'meta.none': case "meta.none":
sorted = metaSortTank(filtered, gameDefinitions); sorted = metaSortTank(filtered, gameDefinitions);
break; break;
case 'survivability.health': { case "survivability.health": {
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aHealth = a.health + a.turrets.at(-1)!.health; const aHealth = a.health + a.turrets.at(-1)!.health;
const bHealth = b.health + b.turrets.at(-1)!.health; const bHealth = b.health + b.turrets.at(-1)!.health;
@@ -81,36 +80,36 @@ export const TankSearch = memo<TankSearchProps>(
break; break;
} }
case 'survivability.viewRange': case "survivability.viewRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range, a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range
); );
break; break;
case 'survivability.camouflageStill': case "survivability.camouflageStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_still - b.camouflage_still, (a, b) => a.camouflage_still - b.camouflage_still
); );
break; break;
case 'survivability.camouflageMoving': case "survivability.camouflageMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_moving - b.camouflage_moving, (a, b) => a.camouflage_moving - b.camouflage_moving
); );
break; break;
case 'survivability.camouflageShooting': case "survivability.camouflageShooting":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.camouflage_still * a.camouflage_still *
a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss - a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss -
b.camouflage_still * b.camouflage_still *
b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss, b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss
); );
break; break;
case 'survivability.volume': case "survivability.volume":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -121,14 +120,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aVolume = aSize.x * aSize.y * aSize.z; const aVolume = aSize.x * aSize.y * aSize.z;
const bVolume = bSize.x * bSize.y * bSize.z; const bVolume = bSize.x * bSize.y * bSize.z;
@@ -137,7 +136,7 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'survivability.length': case "survivability.length":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -148,14 +147,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aLength = Math.max(aSize.x, aSize.y, aSize.z); const aLength = Math.max(aSize.x, aSize.y, aSize.z);
const bLength = Math.max(bSize.x, bSize.y, bSize.z); const bLength = Math.max(bSize.x, bSize.y, bSize.z);
@@ -164,147 +163,147 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'fire.dpm': case "fire.dpm":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveDpm( resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[0], a.turrets.at(-1)!.guns.at(-1)!.shells[0]
) - ) -
resolveDpm( resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[0], b.turrets.at(-1)!.guns.at(-1)!.shells[0]
), )
); );
break; break;
case 'fire.dpmPremium': case "fire.dpmPremium":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1] (a.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[1], a.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0) - : 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1] (b.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[1], b.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0), : 0)
); );
break; break;
case 'fire.reload': case "fire.reload":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) - resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
resolveReload(b.turrets.at(-1)!.guns.at(-1)!), resolveReload(b.turrets.at(-1)!.guns.at(-1)!)
); );
break; break;
case 'fire.caliber': case "fire.caliber":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber - a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber, b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber
); );
break; break;
case 'fire.standardPenetration': case "fire.standardPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near - a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near, b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near
); );
break; break;
case 'fire.premiumPenetration': case "fire.premiumPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.tertiaryPenetration': case "fire.tertiaryPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.damage': case "fire.damage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage
); );
break; break;
case 'fire.moduleDamage': case "fire.moduleDamage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage
); );
break; break;
case 'fire.shellVelocity': case "fire.shellVelocity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity - a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity, b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity
); );
break; break;
case 'fire.shellRange': case "fire.shellRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].range - a.turrets.at(-1)!.guns.at(-1)!.shells[0].range -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].range, b.turrets.at(-1)!.guns.at(-1)!.shells[0].range
); );
break; break;
case 'fire.shellCapacity': case "fire.shellCapacity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shell_capacity - a.turrets.at(-1)!.guns.at(-1)!.shell_capacity -
b.turrets.at(-1)!.guns.at(-1)!.shell_capacity, b.turrets.at(-1)!.guns.at(-1)!.shell_capacity
); );
break; break;
case 'fire.aimTime': case "fire.aimTime":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.aim_time - a.turrets.at(-1)!.guns.at(-1)!.aim_time -
b.turrets.at(-1)!.guns.at(-1)!.aim_time, b.turrets.at(-1)!.guns.at(-1)!.aim_time
); );
break; break;
case 'fire.dispersionStill': case "fire.dispersionStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.dispersion_base - a.turrets.at(-1)!.guns.at(-1)!.dispersion_base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion_base, b.turrets.at(-1)!.guns.at(-1)!.dispersion_base
); );
break; break;
case 'fire.dispersionMoving': case "fire.dispersionMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.dispersion_move - a.tracks.at(-1)!.dispersion_move -
b.tracks.at(-1)!.dispersion_move, b.tracks.at(-1)!.dispersion_move
); );
break; break;
case 'fire.gunDepression': case "fire.gunDepression":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
@@ -314,11 +313,11 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max - .guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max -
(modelDefinitions.models[b.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[b.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'fire.gunElevation': case "fire.gunElevation":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
@@ -328,29 +327,29 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min - .guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min -
(modelDefinitions.models[a.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[a.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'maneuverability.forwardsSpeed': case "maneuverability.forwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_forwards - b.speed_forwards, (a, b) => a.speed_forwards - b.speed_forwards
); );
break; break;
case 'maneuverability.backwardsSpeed': case "maneuverability.backwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_backwards - b.speed_backwards, (a, b) => a.speed_backwards - b.speed_backwards
); );
break; break;
case 'maneuverability.power': case "maneuverability.power":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power, (a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power
); );
break; break;
case 'maneuverability.powerToWeight': case "maneuverability.powerToWeight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.engines.at(-1)!.power / a.engines.at(-1)!.power /
@@ -364,11 +363,11 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.weight': case "maneuverability.weight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.weight + a.weight +
@@ -380,20 +379,20 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.traverseSpeed': case "maneuverability.traverseSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.traverse_speed - a.tracks.at(-1)!.traverse_speed -
b.tracks.at(-1)!.traverse_speed, b.tracks.at(-1)!.traverse_speed
); );
break; break;
} }
return tankopediaSort.direction === 'ascending' return tankopediaSort.direction === "ascending"
? sorted ? sorted
: sorted.reverse(); : sorted.reverse();
} else { } else {
@@ -401,7 +400,7 @@ export const TankSearch = memo<TankSearchProps>(
keys: SEARCH_KEYS, keys: SEARCH_KEYS,
}); });
const searchedTanks = searchedRaw.map( const searchedTanks = searchedRaw.map(
(result) => tankDefinitions.tanks[result.obj.id], (result) => tankDefinitions.tanks[result.obj.id]
); );
return searchedTanks; return searchedTanks;
} }
@@ -459,24 +458,24 @@ export const TankSearch = memo<TankSearchProps>(
)} )}
</Flex> </Flex>
{tankopediaSort.by !== 'meta.none' && ( {tankopediaSort.by !== "meta.none" && (
<Text color="gray"> <Text color="gray">
{literals(strings.website.common.tank_search.sorting_by, [ {literals(strings.website.common.tank_search.sorting_by, [
strings.website.common.tank_search.sort[tankopediaSort.by], strings.website.common.tank_search.sort[tankopediaSort.by],
])} ])}
{SORT_UNITS[tankopediaSort.by] === undefined {SORT_UNITS[tankopediaSort.by] === undefined
? '' ? ""
: ` (${SORT_UNITS[tankopediaSort.by]})`} : ` (${SORT_UNITS[tankopediaSort.by]})`}
, {tankopediaSort.direction} , {tankopediaSort.direction}
</Text> </Text>
)} )}
</Flex> </Flex>
{tankFilters.testing === 'only' && ( {tankFilters.testing === "only" && (
<Flex justify="center" mt="4"> <Flex justify="center" mt="4">
<Callout.Root color="amber"> <Callout.Root color="amber">
<Callout.Icon> <Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: "1em", height: "1em" }} />
</Callout.Icon> </Callout.Icon>
<Callout.Text> <Callout.Text>
{strings.website.common.warnings.test} {strings.website.common.warnings.test}
@@ -505,12 +504,12 @@ export const TankSearch = memo<TankSearchProps>(
key={index} key={index}
onIntersection={() => { onIntersection={() => {
setLoadedCards((state) => setLoadedCards((state) =>
Math.min(state + 2, tanksFiltered.length), Math.min(state + 2, tanksFiltered.length)
); );
}} }}
/> />
); );
}, }
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
@@ -523,15 +522,15 @@ export const TankSearch = memo<TankSearchProps>(
<TankCardWrapper> <TankCardWrapper>
{times( {times(
Math.round( Math.round(
skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random(), skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random()
), ),
(index) => ( (index) => (
<SkeletonTankCard key={index} /> <SkeletonTankCard key={index} />
), )
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
</Flex> </Flex>
); );
}, }
); );
@@ -1,19 +1,16 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core'; import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { Flex, SegmentedControl, Text, type FlexProps } from '@radix-ui/themes'; import { Flex, SegmentedControl, Text, type FlexProps } from "@radix-ui/themes";
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { import {
TankopediaEphemeral, Tankopedia,
TankopediaRelativeAgainst, TankopediaRelativeAgainst,
} from '../../../../../../stores/tankopediaEphemeral'; } from "../../../../../../stores/tankopedia";
export function CompareOptions(props: FlexProps) { export function CompareOptions(props: FlexProps) {
const relativeAgainst = TankopediaEphemeral.use( const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst);
(state) => state.relativeAgainst,
);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -21,7 +18,7 @@ export function CompareOptions(props: FlexProps) {
align="center" align="center"
gap="2" gap="2"
justify="center" justify="center"
direction={{ initial: 'column', sm: 'row' }} direction={{ initial: "column", sm: "row" }}
{...props} {...props}
> >
<Text color="gray"> <Text color="gray">
@@ -33,7 +30,7 @@ export function CompareOptions(props: FlexProps) {
size="1" size="1"
value={`${relativeAgainst}`} value={`${relativeAgainst}`}
onValueChange={(value) => { onValueChange={(value) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst; draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst;
}); });
}} }}
@@ -44,13 +41,13 @@ export function CompareOptions(props: FlexProps) {
[ [
TIER_ROMAN_NUMERALS[tank.tier], TIER_ROMAN_NUMERALS[tank.tier],
strings.common.tank_class_short_plural_lowercase[tank.class], strings.common.tank_class_short_plural_lowercase[tank.class],
], ]
)} )}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.Tier}`}> <SegmentedControl.Item value={`${TankopediaRelativeAgainst.Tier}`}>
{literals( {literals(
strings.website.tools.tankopedia.characteristics.compare.tier, strings.website.tools.tankopedia.characteristics.compare.tier,
[TIER_ROMAN_NUMERALS[tank.tier]], [TIER_ROMAN_NUMERALS[tank.tier]]
)} )}
</SegmentedControl.Item> </SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaRelativeAgainst.All}`}> <SegmentedControl.Item value={`${TankopediaRelativeAgainst.All}`}>
@@ -1,14 +1,6 @@
import { import { asset, isExplosive } from '@blitzkit/core';
asset,
coefficient,
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n'; import { literals } from '@blitzkit/i18n';
import { Flex, Heading, IconButton, Tooltip } from '@radix-ui/themes'; import { Flex, Heading, IconButton, Tooltip } from '@radix-ui/themes';
import { debounce } from 'lodash-es';
import { useEffect, useRef, useState } from 'react';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from '../../../../../../hooks/useLocale';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import { Duel } from '../../../../../../stores/duel'; import { Duel } from '../../../../../../stores/duel';
@@ -28,34 +20,13 @@ export function Firepower({
}: StatsAcceptorProps & MaybeSkeletonComponentProps) { }: StatsAcceptorProps & MaybeSkeletonComponentProps) {
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun); const gun = Duel.use((state) => state.protagonist.gun);
const shell = Duel.use((state) => state.protagonist.shell); const shell = Duel.use((state) => state.protagonist.shell);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const hasSupercharger = useEquipment(107);
const hasCalibratedShells = useEquipment(103);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.6,
]);
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id]; const gunModelDefinition = turretModelDefinition.guns[gun.id];
useEffect(() => {
if (penetrationDistanceInput.current) {
penetrationDistanceInput.current.value = `${penetrationDistance}`;
}
}, [penetrationDistance]);
return ( return (
<StatsTableWrapper> <StatsTableWrapper>
<Flex align="center" gap="4"> <Flex align="center" gap="4">
@@ -1,10 +1,10 @@
import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons'; import { CaretDownIcon, CaretUpIcon } from "@radix-ui/react-icons";
import { Box, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from "@radix-ui/themes";
import fuzzysort from 'fuzzysort'; import fuzzysort from "fuzzysort";
import { Quicklime } from 'quicklime'; import { Quicklime } from "quicklime";
import { useEffect, useRef, type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from "react";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { Mark } from '../../../../../Mark'; import { Mark } from "../../../../../Mark";
export const highlightedRows = new Set<HTMLDivElement>(); export const highlightedRows = new Set<HTMLDivElement>();
export const highlightedRowsUpdate = new Quicklime(); export const highlightedRowsUpdate = new Quicklime();
@@ -16,7 +16,7 @@ export interface InfoProps {
delta?: number; delta?: number;
decimals?: number; decimals?: number;
prefix?: string; prefix?: string;
deltaType?: 'higherIsBetter' | 'lowerIsBetter'; deltaType?: "higherIsBetter" | "lowerIsBetter";
} }
export function Info({ export function Info({
@@ -26,15 +26,15 @@ export function Info({
delta, delta,
decimals, decimals,
prefix, prefix,
deltaType = 'higherIsBetter', deltaType = "higherIsBetter",
}: InfoProps) { }: InfoProps) {
const statSearch = TankopediaEphemeral.use((state) => state.statSearch); const statSearch = Tankopedia.use((state) => state.statSearch);
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
let label = name; let label = name;
let highlighted = false; let highlighted = false;
if (statSearch !== undefined && typeof name === 'string') { if (statSearch !== undefined && typeof name === "string") {
const result = fuzzysort.single(statSearch, name); const result = fuzzysort.single(statSearch, name);
if (result) { if (result) {
@@ -62,7 +62,7 @@ export function Info({
return ( return (
<Flex <Flex
align="center" align="center"
pl={indent ? '2' : '0'} pl={indent ? "2" : "0"}
width="100%" width="100%"
gap="4" gap="4"
ref={container} ref={container}
@@ -77,9 +77,9 @@ export function Info({
<> <>
<Text <Text
color={ color={
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0 delta * (deltaType === "lowerIsBetter" ? -1 : 1) > 0
? 'green' ? "green"
: 'tomato' : "tomato"
} }
> >
{decimals !== undefined {decimals !== undefined
@@ -88,9 +88,9 @@ export function Info({
</Text> </Text>
<Text <Text
color={ color={
delta * (deltaType === 'lowerIsBetter' ? -1 : 1) > 0 delta * (deltaType === "lowerIsBetter" ? -1 : 1) > 0
? 'green' ? "green"
: 'tomato' : "tomato"
} }
> >
{delta > 0 ? <CaretUpIcon /> : <CaretDownIcon />} {delta > 0 ? <CaretUpIcon /> : <CaretDownIcon />}
@@ -105,7 +105,7 @@ export function Info({
</Text> </Text>
)} )}
{decimals !== undefined && typeof children === 'number' {decimals !== undefined && typeof children === "number"
? children.toFixed(decimals) ? children.toFixed(decimals)
: children} : children}
</Text> </Text>
@@ -1,27 +1,27 @@
import { createDefaultSkills } from '@blitzkit/core'; import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Progress, Text } from '@radix-ui/themes'; import { Flex, Progress, Text } from "@radix-ui/themes";
import { clamp } from 'lodash-es'; import { clamp } from "lodash-es";
import { memo, useMemo, type ComponentProps, type ReactNode } from 'react'; import { memo, useMemo, type ComponentProps, type ReactNode } from "react";
import { awaitableEquipmentDefinitions } from '../../../../../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../../../../../core/awaitables/equipmentDefinitions";
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../../../../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../../../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../../../../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from '../../../../../../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from "../../../../../../core/awaitables/skillDefinitions";
import { awaitableTankDefinitions } from '../../../../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../../../../core/awaitables/tankDefinitions";
import { import {
tankCharacteristics, tankCharacteristics,
type TankCharacteristics, type TankCharacteristics,
} from '../../../../../../core/blitzkit/tankCharacteristics'; } from "../../../../../../core/blitzkit/tankCharacteristics";
import { tankToDuelMember } from '../../../../../../core/blitzkit/tankToDuelMember'; import { tankToDuelMember } from "../../../../../../core/blitzkit/tankToDuelMember";
import { useDelta } from '../../../../../../hooks/useDelta'; import { useDelta } from "../../../../../../hooks/useDelta";
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { import {
TankopediaEphemeral, Tankopedia,
TankopediaRelativeAgainst, TankopediaRelativeAgainst,
} from '../../../../../../stores/tankopediaEphemeral'; } from "../../../../../../stores/tankopedia";
import { Info, type InfoProps } from './Info'; import { Info, type InfoProps } from "./Info";
type InfoWithDeltaProps = Omit<InfoProps, 'name'> & { type InfoWithDeltaProps = Omit<InfoProps, "name"> & {
stats: TankCharacteristics; stats: TankCharacteristics;
noRanking?: boolean; noRanking?: boolean;
} & ( } & (
@@ -51,20 +51,18 @@ const [
export const InfoWithDelta = memo<InfoWithDeltaProps>( export const InfoWithDelta = memo<InfoWithDeltaProps>(
({ stats, indent, noRanking, deltaType, ...props }) => { ({ stats, indent, noRanking, deltaType, ...props }) => {
const { strings } = useLocale(); const { strings } = useLocale();
const relativeAgainst = TankopediaEphemeral.use( const relativeAgainst = Tankopedia.use((state) => state.relativeAgainst);
(state) => state.relativeAgainst,
);
const uhWhatDoICallThisVariable = const uhWhatDoICallThisVariable =
typeof props.value === 'function' typeof props.value === "function"
? props.value(stats)! ? props.value(stats)!
: (stats[props.value] as number); : (stats[props.value] as number);
const delta = useDelta(uhWhatDoICallThisVariable); const delta = useDelta(uhWhatDoICallThisVariable);
const protagonistTank = Duel.use((state) => state.protagonist.tank); const protagonistTank = Duel.use((state) => state.protagonist.tank);
const shellIndex = Duel.use((state) => const shellIndex = Duel.use((state) =>
state.protagonist.gun.shells.indexOf(state.protagonist.shell), state.protagonist.gun.shells.indexOf(state.protagonist.shell)
); );
const equipmentMatrix = Duel.use( const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix
); );
const others = useMemo(() => { const others = useMemo(() => {
const defaultSkills = createDefaultSkills(skillDefinitions); const defaultSkills = createDefaultSkills(skillDefinitions);
@@ -77,15 +75,10 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
tank.class === protagonistTank.class) || tank.class === protagonistTank.class) ||
(relativeAgainst === TankopediaRelativeAgainst.Tier && (relativeAgainst === TankopediaRelativeAgainst.Tier &&
tank.tier === protagonistTank.tier) || tank.tier === protagonistTank.tier) ||
relativeAgainst === TankopediaRelativeAgainst.All, relativeAgainst === TankopediaRelativeAgainst.All
) )
.map((tank) => { .map((tank) => {
const thisTankModel = modelDefinitions.models[tank.id]; const member = tankToDuelMember(tank, provisionDefinitions);
const member = tankToDuelMember(
tank,
thisTankModel,
provisionDefinitions,
);
if (member.gun.shells[shellIndex] === undefined) { if (member.gun.shells[shellIndex] === undefined) {
return undefined; return undefined;
@@ -117,14 +110,14 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
equipmentDefinitions, equipmentDefinitions,
provisionDefinitions, provisionDefinitions,
tankModelDefinition: modelDefinitions.models[tank.id], tankModelDefinition: modelDefinitions.models[tank.id],
}, }
); );
}) })
.filter((tank) => { .filter((tank) => {
if (tank === undefined) return false; if (tank === undefined) return false;
const othersValue = const othersValue =
typeof props.value === 'function' typeof props.value === "function"
? props.value(tank)! ? props.value(tank)!
: (tank[props.value] as number); : (tank[props.value] as number);
@@ -133,22 +126,22 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
}, [relativeAgainst, shellIndex, equipmentMatrix]); }, [relativeAgainst, shellIndex, equipmentMatrix]);
const betterTanks = others.filter((tank) => { const betterTanks = others.filter((tank) => {
const othersValue = const othersValue =
typeof props.value === 'function' typeof props.value === "function"
? props.value(tank)! ? props.value(tank)!
: (tank[props.value] as number); : (tank[props.value] as number);
if (othersValue === undefined) return false; if (othersValue === undefined) return false;
return deltaType === 'lowerIsBetter' return deltaType === "lowerIsBetter"
? othersValue < uhWhatDoICallThisVariable ? othersValue < uhWhatDoICallThisVariable
: othersValue > uhWhatDoICallThisVariable; : othersValue > uhWhatDoICallThisVariable;
}); });
const goodness = (others.length - betterTanks.length) / others.length; const goodness = (others.length - betterTanks.length) / others.length;
let color: ComponentProps<typeof Progress>['color']; let color: ComponentProps<typeof Progress>["color"];
if (goodness <= 0.25) color = 'red'; if (goodness <= 0.25) color = "red";
else if (goodness <= 0.5) color = 'orange'; else if (goodness <= 0.5) color = "orange";
else if (goodness <= 0.75) color = 'yellow'; else if (goodness <= 0.75) color = "yellow";
else color = 'green'; else color = "green";
return ( return (
<Flex direction="column"> <Flex direction="column">
@@ -156,7 +149,7 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
deltaType={deltaType} deltaType={deltaType}
indent={indent} indent={indent}
name={ name={
'name' in props "name" in props
? props.name ? props.name
: strings.website.tools.tankopedia.characteristics.values[ : strings.website.tools.tankopedia.characteristics.values[
props.value props.value
@@ -169,17 +162,17 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
</Info> </Info>
{!noRanking && ( {!noRanking && (
<Flex pl={indent ? '2' : '0'} align="center" gap="2"> <Flex pl={indent ? "2" : "0"} align="center" gap="2">
<Progress <Progress
variant="soft" variant="soft"
size="1" size="1"
value={goodness * 100} value={goodness * 100}
color={color} color={color}
style={{ height: '0.125rem', opacity: 0.5 }} style={{ height: "0.125rem", opacity: 0.5 }}
/> />
<Text color="gray" size="1"> <Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{' '} {clamp(betterTanks.length + 1, 1, others.length)} /{" "}
{others.length} {others.length}
</Text> </Text>
</Flex> </Flex>
@@ -189,6 +182,6 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
}, },
(a, b) => (a, b) =>
(typeof a.value === 'function' ? a.value(a.stats) : a.stats[a.value]) === (typeof a.value === "function" ? a.value(a.stats) : a.stats[a.value]) ===
(typeof b.value === 'function' ? b.value(b.stats) : b.stats[b.value]), (typeof b.value === "function" ? b.value(b.stats) : b.stats[b.value])
); );
@@ -1,8 +1,10 @@
import { Box, Code } from '@radix-ui/themes'; import { DoubleArrowRightIcon } from '@radix-ui/react-icons';
import { Box, Button, Code } from '@radix-ui/themes';
import { times } from 'lodash-es'; import { times } from 'lodash-es';
import { Quicklime, type QuicklimeCallback } from 'quicklime'; import { Quicklime, type QuicklimeCallback } from 'quicklime';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Var } from '../../../../../../../core/radix/var'; import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import type { StatsAcceptorProps } from '../TraverseVisualizer'; import type { StatsAcceptorProps } from '../TraverseVisualizer';
import { VisualizerCard } from '../VisualizerCard'; import { VisualizerCard } from '../VisualizerCard';
import { Shell } from './components/Shell'; import { Shell } from './components/Shell';
@@ -19,6 +21,8 @@ export interface ReloadUpdateData {
export const reloadUpdate = new Quicklime<ReloadUpdateData>(); export const reloadUpdate = new Quicklime<ReloadUpdateData>();
export function ReloadVisualizer({ stats }: StatsAcceptorProps) { export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
const { strings } = useLocale();
const reloadCircle = useRef<HTMLDivElement>(null); const reloadCircle = useRef<HTMLDivElement>(null);
const reloadCore = useRef<HTMLDivElement>(null); const reloadCore = useRef<HTMLDivElement>(null);
const reloadGlow = useRef<HTMLDivElement>(null); const reloadGlow = useRef<HTMLDivElement>(null);
@@ -246,7 +250,7 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
<Code <Code
ref={progressTime} ref={progressTime}
size="1" size="2"
variant="ghost" variant="ghost"
weight="bold" weight="bold"
color="gray" color="gray"
@@ -255,14 +259,14 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
right: 'calc(50% + var(--space-8))', right: 'calc(50% + var(--space-8))',
transform: 'translateX(50%)', transform: 'translate(50%, -50%)',
}} }}
> >
0.00 0.00
</Code> </Code>
<Code <Code
ref={totalTime} ref={totalTime}
size="1" size="2"
variant="ghost" variant="ghost"
weight="bold" weight="bold"
color="gray" color="gray"
@@ -270,7 +274,7 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
left: 'calc(50% + var(--space-8))', left: 'calc(50% + var(--space-8))',
transform: 'translateX(-50%)', transform: 'translate(-50%, -50%)',
}} }}
> >
0.00 0.00
@@ -290,6 +294,25 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
width="100%" width="100%"
height="100%" height="100%"
/> />
<Button
size="1"
variant="outline"
color="gray"
highContrast
style={{
position: 'absolute',
top: '68%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
onClick={() => {
state.current = Math.floor(state.current + 1);
}}
>
{strings.website.tools.tankopedia.visualizers.reload.skip}
<DoubleArrowRightIcon />
</Button>
</VisualizerCard> </VisualizerCard>
); );
} }
@@ -1,16 +1,15 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { Flex, Spinner, TextField, type FlexProps } from '@radix-ui/themes'; import { Flex, Spinner, TextField, type FlexProps } from "@radix-ui/themes";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from "react";
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from "../../../../../../hooks/useLocale";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { highlightedRows, highlightedRowsUpdate } from './Info'; import { highlightedRows, highlightedRowsUpdate } from "./Info";
export function StatSearch(props: FlexProps) { export function StatSearch(props: FlexProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const count = useRef<HTMLDivElement>(null); const count = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const search = useCallback(() => { const search = useCallback(() => {
@@ -20,7 +19,7 @@ export function StatSearch(props: FlexProps) {
setSearching(true); setSearching(true);
} else { } else {
setSearching(false); setSearching(false);
count.current.textContent = ''; count.current.textContent = "";
} }
requestUpdate(); requestUpdate();
@@ -31,21 +30,21 @@ export function StatSearch(props: FlexProps) {
if (!input.current || !count.current) return; if (!input.current || !count.current) return;
if (input.current.value.length === 0) { if (input.current.value.length === 0) {
count.current.textContent = ''; count.current.textContent = "";
setSearching(false); setSearching(false);
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.statSearch = undefined; draft.statSearch = undefined;
}); });
return; return;
} }
setSearching(false); setSearching(false);
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.statSearch = input.current!.value; draft.statSearch = input.current!.value;
}); });
}, 500), }, 500),
[], []
); );
useEffect(() => { useEffect(() => {
@@ -53,7 +52,7 @@ export function StatSearch(props: FlexProps) {
if (!count.current || !input.current) return; if (!count.current || !input.current) return;
count.current.textContent = count.current.textContent =
input.current.value.length === 0 ? '' : `0 / ${highlightedRows.size}`; input.current.value.length === 0 ? "" : `0 / ${highlightedRows.size}`;
}; };
highlightedRowsUpdate.on(handleHighlightedRowsUpdate); highlightedRowsUpdate.on(handleHighlightedRowsUpdate);
@@ -69,23 +68,23 @@ export function StatSearch(props: FlexProps) {
ref={input} ref={input}
placeholder={strings.website.tools.tankopedia.search} placeholder={strings.website.tools.tankopedia.search}
variant="classic" variant="classic"
style={{ width: '100%' }} style={{ width: "100%" }}
onChange={search} onChange={search}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!count.current) return; if (!count.current) return;
if (event.key === 'Enter') { if (event.key === "Enter") {
const newIndex = (index + 1) % highlightedRows.size; const newIndex = (index + 1) % highlightedRows.size;
count.current.textContent = `${newIndex + 1} / ${highlightedRows.size}`; count.current.textContent = `${newIndex + 1} / ${highlightedRows.size}`;
const array = [...highlightedRows]; const array = [...highlightedRows];
array.forEach((element) => element.classList.remove('focused')); array.forEach((element) => element.classList.remove("focused"));
if (array[newIndex]) { if (array[newIndex]) {
array[newIndex].classList.add('focused'); array[newIndex].classList.add("focused");
array[newIndex].scrollIntoView({ array[newIndex].scrollIntoView({
block: 'center', block: "center",
inline: 'center', inline: "center",
}); });
} }
@@ -1,19 +1,19 @@
import { Flex } from '@radix-ui/themes'; import { Flex } from "@radix-ui/themes";
import { useEffect } from 'react'; import { useEffect } from "react";
import { awaitableEquipmentDefinitions } from '../../../../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../../../../core/awaitables/equipmentDefinitions";
import { awaitableProvisionDefinitions } from '../../../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../../../../core/awaitables/provisionDefinitions";
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from '../../../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from "../../../../../core/blitzkit/modelTransform";
import { tankCharacteristics } from '../../../../../core/blitzkit/tankCharacteristics'; import { tankCharacteristics } from "../../../../../core/blitzkit/tankCharacteristics";
import { useEquipment } from '../../../../../hooks/useEquipment'; import { useEquipment } from "../../../../../hooks/useEquipment";
import { useTankModelDefinition } from '../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../../../hooks/useTankModelDefinition";
import { Duel } from '../../../../../stores/duel'; import { Duel } from "../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../../../../types/maybeSkeletonComponentProps";
import { Crew } from './components/Crew'; import { Crew } from "./components/Crew";
import { Firepower } from './components/Firepower'; import { Firepower } from "./components/Firepower";
import { Maneuverability } from './components/Maneuverability'; import { Maneuverability } from "./components/Maneuverability";
import { Survivability } from './components/Survivability'; import { Survivability } from "./components/Survivability";
const [equipmentDefinitions, provisionDefinitions] = await Promise.all([ const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
awaitableEquipmentDefinitions, awaitableEquipmentDefinitions,
@@ -21,19 +21,18 @@ const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
]); ]);
export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) { export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
const mutateDuel = Duel.useMutation(); const crewSkills = Tankopedia.use((state) => state.skills);
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const provisions = Duel.use((state) => state.protagonist.provisions); const provisions = Duel.use((state) => state.protagonist.provisions);
const consumables = Duel.use((state) => state.protagonist.consumables); const consumables = Duel.use((state) => state.protagonist.consumables);
const camouflage = Duel.use((state) => state.protagonist.camouflage); const camouflage = Duel.use((state) => state.protagonist.camouflage);
const assaultDistance = Duel.use( const assaultDistance = Duel.use(
(state) => state.protagonist.assaultDistance, (state) => state.protagonist.assaultDistance
); );
const equipmentMatrix = Duel.use( const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix, (state) => state.protagonist.equipmentMatrix
); );
const { tank, turret, gun, engine, track, shell } = Duel.use( const { tank, turret, gun, engine, track, shell } = Duel.use(
(state) => state.protagonist, (state) => state.protagonist
); );
const stockEngine = tank.engines[0]; const stockEngine = tank.engines[0];
const stockTrack = tank.tracks[0]; const stockTrack = tank.tracks[0];
@@ -68,7 +67,7 @@ export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
tankModelDefinition, tankModelDefinition,
equipmentDefinitions: equipmentDefinitions, equipmentDefinitions: equipmentDefinitions,
provisionDefinitions: provisionDefinitions, provisionDefinitions: provisionDefinitions,
}, }
); );
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
@@ -81,7 +80,7 @@ export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -90,25 +89,25 @@ export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
return ( return (
<Flex <Flex
gap="8" gap="8"
direction={{ initial: 'column', md: 'row' }} direction={{ initial: "column", md: "row" }}
align={{ initial: 'center', sm: 'start' }} align={{ initial: "center", sm: "start" }}
width={{ initial: '100%', sm: 'auto' }} width={{ initial: "100%", sm: "auto" }}
> >
<Firepower skeleton={skeleton} stats={stats} /> <Firepower skeleton={skeleton} stats={stats} />
<Flex <Flex
gap="8" gap="8"
direction={{ initial: 'column', lg: 'row' }} direction={{ initial: "column", lg: "row" }}
align={{ initial: 'center', sm: 'start' }} align={{ initial: "center", sm: "start" }}
width={{ initial: '100%', sm: 'auto' }} width={{ initial: "100%", sm: "auto" }}
> >
<Maneuverability stats={stats} /> <Maneuverability stats={stats} />
<Flex <Flex
gap="8" gap="8"
direction="column" direction="column"
align={{ initial: 'center', sm: 'start' }} align={{ initial: "center", sm: "start" }}
width={{ initial: '100%', sm: 'auto' }} width={{ initial: "100%", sm: "auto" }}
> >
<Survivability stats={stats} /> <Survivability stats={stats} />
<Crew stats={stats} /> <Crew stats={stats} />
@@ -1,4 +1,4 @@
import { InfoCircledIcon } from '@radix-ui/react-icons'; import { InfoCircledIcon } from "@radix-ui/react-icons";
import { import {
Button, Button,
Flex, Flex,
@@ -6,15 +6,14 @@ import {
IconButton, IconButton,
Popover, Popover,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { useLocale } from '../../../../hooks/useLocale'; import { useLocale } from "../../../../hooks/useLocale";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import { CrewSkillManager } from '../../../CrewSkillManager'; import { CrewSkillManager } from "../../../CrewSkillManager";
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper'; import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
export function Skills() { export function Skills() {
const skillLevels = TankopediaEphemeral.use((state) => state.skills); const skillLevels = Tankopedia.use((state) => state.skills);
const mutateTankopediaTemporary = TankopediaEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -46,7 +45,7 @@ export function Skills() {
variant="ghost" variant="ghost"
color="red" color="red"
onClick={() => { onClick={() => {
mutateTankopediaTemporary((draft) => { Tankopedia.mutate((draft) => {
Object.keys(draft.skills).forEach((skill) => { Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0; draft.skills[skill] = 0;
}); });
@@ -58,7 +57,7 @@ export function Skills() {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateTankopediaTemporary((draft) => { Tankopedia.mutate((draft) => {
Object.keys(draft.skills).forEach((skill) => { Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 7; draft.skills[skill] = 7;
}); });
@@ -72,7 +71,7 @@ export function Skills() {
<CrewSkillManager <CrewSkillManager
skillLevels={skillLevels} skillLevels={skillLevels}
onChange={(skills) => { onChange={(skills) => {
mutateTankopediaTemporary((draft) => { Tankopedia.mutate((draft) => {
draft.skills = skills; draft.skills = skills;
}); });
}} }}
@@ -1,5 +1,5 @@
import { ShellType } from '@blitzkit/core'; import { ShellType } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { import {
Button, Button,
Flex, Flex,
@@ -9,18 +9,17 @@ import {
Slider, Slider,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { throttle } from 'lodash-es'; import { throttle } from "lodash-es";
import { type ChangeEvent } from 'react'; import { type ChangeEvent } from "react";
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
export function CustomShellButton() { export function CustomShellButton() {
const customShell = TankopediaEphemeral.use((state) => state.customShell); const customShell = Tankopedia.use((state) => state.customShell);
const gun = Duel.use((state) => state.protagonist.gun); const gun = Duel.use((state) => state.protagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell); const antagonistShell = Duel.use((state) => state.antagonist.shell);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -28,24 +27,24 @@ export function CustomShellButton() {
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open || customShell) return; if (!open || customShell) return;
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell = antagonistShell; draft.customShell = antagonistShell;
}); });
}} }}
> >
<Popover.Trigger> <Popover.Trigger>
<IconButton <IconButton
color={customShell ? undefined : 'gray'} color={customShell ? undefined : "gray"}
variant="soft" variant="soft"
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
radius="none" radius="none"
> >
<img <img
alt="custom shell" alt="custom shell"
src="/assets/images/icons/custom-shell.png" src="/assets/images/icons/custom-shell.png"
style={{ style={{
width: '50%', width: "50%",
height: '50%', height: "50%",
}} }}
/> />
</IconButton> </IconButton>
@@ -91,13 +90,13 @@ export function CustomShellButton() {
modified.ricochet = undefined; modified.ricochet = undefined;
modified.explosion_radius = modified.explosion_radius =
gun.shells.find( gun.shells.find(
(shell) => shell.type === ShellType.HE, (shell) => shell.type === ShellType.HE
)?.explosion_radius ?? 1; )?.explosion_radius ?? 1;
break; break;
} }
} }
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell = modified; draft.customShell = modified;
}); });
}} }}
@@ -130,11 +129,11 @@ export function CustomShellButton() {
</Text> </Text>
<TextField.Root <TextField.Root
variant="classic" variant="classic"
style={{ maxWidth: '6rem' }} style={{ maxWidth: "6rem" }}
defaultValue={customShell.caliber} defaultValue={customShell.caliber}
type="number" type="number"
onChange={(event) => { onChange={(event) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell!.caliber = event.target.valueAsNumber; draft.customShell!.caliber = event.target.valueAsNumber;
}); });
}} }}
@@ -142,9 +141,9 @@ export function CustomShellButton() {
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation(); event.stopPropagation();
if (event.key === 'Enter') { if (event.key === "Enter") {
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
(event.target as HTMLInputElement).valueAsNumber = (event.target as HTMLInputElement).valueAsNumber =
customShell.caliber; customShell.caliber;
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
@@ -152,7 +151,7 @@ export function CustomShellButton() {
}} }}
> >
<TextField.Slot side="right"> <TextField.Slot side="right">
{literals(strings.common.units.mm, [''])} {literals(strings.common.units.mm, [""])}
</TextField.Slot> </TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
@@ -166,11 +165,11 @@ export function CustomShellButton() {
</Text> </Text>
<TextField.Root <TextField.Root
variant="classic" variant="classic"
style={{ maxWidth: '6rem' }} style={{ maxWidth: "6rem" }}
defaultValue={customShell.penetration.near} defaultValue={customShell.penetration.near}
type="number" type="number"
onChange={(event: ChangeEvent<HTMLInputElement>) => { onChange={(event: ChangeEvent<HTMLInputElement>) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell!.penetration.near = draft.customShell!.penetration.near =
event.target.valueAsNumber; event.target.valueAsNumber;
}); });
@@ -179,9 +178,9 @@ export function CustomShellButton() {
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation(); event.stopPropagation();
if (event.key === 'Enter') { if (event.key === "Enter") {
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
(event.target as HTMLInputElement).valueAsNumber = (event.target as HTMLInputElement).valueAsNumber =
customShell.penetration.near; customShell.penetration.near;
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
@@ -189,7 +188,7 @@ export function CustomShellButton() {
}} }}
> >
<TextField.Slot side="right"> <TextField.Slot side="right">
{literals(strings.common.units.mm, [''])} {literals(strings.common.units.mm, [""])}
</TextField.Slot> </TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
@@ -224,9 +223,9 @@ export function CustomShellButton() {
step={Number.EPSILON} step={Number.EPSILON}
defaultValue={[customShell.normalization ?? 0]} defaultValue={[customShell.normalization ?? 0]}
onValueChange={([value]) => { onValueChange={([value]) => {
console.log('asd'); console.log("asd");
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell!.normalization = value; draft.customShell!.normalization = value;
}); });
}} }}
@@ -255,7 +254,7 @@ export function CustomShellButton() {
step={Number.EPSILON} step={Number.EPSILON}
defaultValue={[customShell.ricochet ?? 90]} defaultValue={[customShell.ricochet ?? 90]}
onValueChange={throttle(([value]) => { onValueChange={throttle(([value]) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell!.ricochet = value; draft.customShell!.ricochet = value;
}); });
}, 1000)} }, 1000)}
@@ -270,11 +269,11 @@ export function CustomShellButton() {
<Text>Explosion radius</Text> <Text>Explosion radius</Text>
<TextField.Root <TextField.Root
variant="classic" variant="classic"
style={{ maxWidth: '6rem' }} style={{ maxWidth: "6rem" }}
defaultValue={customShell.explosion_radius ?? 0} defaultValue={customShell.explosion_radius ?? 0}
type="number" type="number"
onChange={(event: ChangeEvent<HTMLInputElement>) => { onChange={(event: ChangeEvent<HTMLInputElement>) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell!.explosion_radius = draft.customShell!.explosion_radius =
event.target.valueAsNumber; event.target.valueAsNumber;
}); });
@@ -283,9 +282,9 @@ export function CustomShellButton() {
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation(); event.stopPropagation();
if (event.key === 'Enter') { if (event.key === "Enter") {
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
} else if (event.key === 'Escape') { } else if (event.key === "Escape") {
(event.target as HTMLInputElement).valueAsNumber = (event.target as HTMLInputElement).valueAsNumber =
customShell.explosion_radius ?? 1; customShell.explosion_radius ?? 1;
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
@@ -310,7 +309,7 @@ export function CustomShellButton() {
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell = antagonistShell; draft.customShell = antagonistShell;
}); });
}} }}
@@ -1,15 +1,14 @@
import { asset } from '@blitzkit/core'; import { asset } from "@blitzkit/core";
import { IconButton } from '@radix-ui/themes'; import { IconButton } from "@radix-ui/themes";
import { useArmor } from '../../../../../../hooks/useArmor'; import { useArmor } from "../../../../../../hooks/useArmor";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
export function DynamicArmorSwitcher() { export function DynamicArmorSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const isDynamicArmorActive = Duel.use((state) => const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73), state.protagonist.consumables.includes(73)
); );
const { hasDynamicArmor } = useArmor(tank.id); const { hasDynamicArmor } = useArmor(tank.id);
@@ -17,9 +16,9 @@ export function DynamicArmorSwitcher() {
return ( return (
<IconButton <IconButton
color={isDynamicArmorActive ? undefined : 'gray'} color={isDynamicArmorActive ? undefined : "gray"}
variant="soft" variant="soft"
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
if (draft.protagonist.consumables.includes(73)) { if (draft.protagonist.consumables.includes(73)) {
@@ -33,17 +32,17 @@ export function DynamicArmorSwitcher() {
} }
} }
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
<img <img
alt="Dynamic Armor" alt="Dynamic Armor"
src={asset('icons/consumables/73.webp')} src={asset("icons/consumables/73.webp")}
style={{ style={{
width: '50%', width: "50%",
height: '50%', height: "50%",
}} }}
/> />
</IconButton> </IconButton>
@@ -14,7 +14,6 @@ import { Duel } from '../../../../../../../stores/duel';
import './index.css'; import './index.css';
export function QuickInputs() { export function QuickInputs() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist); const protagonist = Duel.use((state) => state.protagonist);
const yawInput = useRef<HTMLInputElement>(null); const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null); const pitchInput = useRef<HTMLInputElement>(null);
@@ -1,46 +1,44 @@
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { EyeClosedIcon, EyeOpenIcon, ReloadIcon } from '@radix-ui/react-icons'; import { EyeClosedIcon, EyeOpenIcon, ReloadIcon } from "@radix-ui/react-icons";
import { Button, Checkbox, Flex, Text } from '@radix-ui/themes'; import { Button, Checkbox, Flex, Text } from "@radix-ui/themes";
import { Suspense } from 'react'; import { Suspense } from "react";
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from "../../../../../../hooks/useLocale";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../../../stores/tankopediaPersistent";
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import type { MaybeSkeletonComponentProps } from '../../../../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../../../../../types/maybeSkeletonComponentProps";
import type { ThicknessRange } from '../../../../../Armor/components/StaticArmor'; import type { ThicknessRange } from "../../../../../Armor/components/StaticArmor";
import { DynamicArmorSwitcher } from './DynamicArmorSwitcher'; import { DynamicArmorSwitcher } from "./DynamicArmorSwitcher";
type ThicknessesProps = MaybeSkeletonComponentProps & { type ThicknessesProps = MaybeSkeletonComponentProps & {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
}; };
export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) { export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
const display = TankopediaEphemeral.use((state) => state.display); const display = Tankopedia.use((state) => state.display);
const showExternalModules = TankopediaPersistent.use( const showExternalModules = TankopediaPersistent.use(
(state) => state.showExternalModules, (state) => state.showExternalModules
); );
const showSpacedArmor = TankopediaPersistent.use( const showSpacedArmor = TankopediaPersistent.use(
(state) => state.showSpacedArmor, (state) => state.showSpacedArmor
); );
const showPrimaryArmor = TankopediaPersistent.use( const showPrimaryArmor = TankopediaPersistent.use(
(state) => state.showPrimaryArmor, (state) => state.showPrimaryArmor
); );
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const editStatic = Tankopedia.use((state) => state.editStatic);
const editStatic = TankopediaEphemeral.use((state) => state.editStatic);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
<Flex <Flex
position="absolute" position="absolute"
right={display === TankopediaDisplay.StaticArmor ? '0' : '-6rem'} right={display === TankopediaDisplay.StaticArmor ? "0" : "-6rem"}
top="50%" top="50%"
mr="4" mr="4"
style={{ style={{
transform: 'translateY(-50%)', transform: "translateY(-50%)",
userSelect: 'none', userSelect: "none",
transitionDuration: '200ms', transitionDuration: "200ms",
}} }}
direction="column" direction="column"
gap="5" gap="5"
@@ -50,12 +48,12 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
<Flex <Flex
height="64px" height="64px"
gap="2" gap="2"
style={{ opacity: showPrimaryArmor ? 1 : 0.5, cursor: 'pointer' }} style={{ opacity: showPrimaryArmor ? 1 : 0.5, cursor: "pointer" }}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.showPrimaryArmor = !draft.showPrimaryArmor; draft.showPrimaryArmor = !draft.showPrimaryArmor;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }}
@@ -70,7 +68,7 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
{strings.website.tools.tankopedia.sandbox.static.primary} {strings.website.tools.tankopedia.sandbox.static.primary}
</Text> </Text>
<Text color="gray" size="1"> <Text color="gray" size="1">
{literals(strings.common.units.mm, ['0'])} {literals(strings.common.units.mm, ["0"])}
</Text> </Text>
</Flex> </Flex>
@@ -78,10 +76,10 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
align="center" align="center"
width="1rem" width="1rem"
style={{ style={{
borderRadius: 'var(--radius-full)', borderRadius: "var(--radius-full)",
overflow: 'hidden', overflow: "hidden",
background: `linear-gradient(rgb(64, 0, 0), rgb(255, 0, 0) 33%, rgb(255, 255, 0) 66%, rgb(0, 255, 0))`, background: `linear-gradient(rgb(64, 0, 0), rgb(255, 0, 0) 33%, rgb(255, 255, 0) 66%, rgb(0, 255, 0))`,
color: 'black', color: "black",
}} }}
> >
{showPrimaryArmor ? <EyeOpenIcon /> : <EyeClosedIcon />} {showPrimaryArmor ? <EyeOpenIcon /> : <EyeClosedIcon />}
@@ -91,12 +89,12 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
<Flex <Flex
height="64px" height="64px"
gap="2" gap="2"
style={{ opacity: showSpacedArmor ? 1 : 0.5, cursor: 'pointer' }} style={{ opacity: showSpacedArmor ? 1 : 0.5, cursor: "pointer" }}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.showSpacedArmor = !draft.showSpacedArmor; draft.showSpacedArmor = !draft.showSpacedArmor;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }}
@@ -111,7 +109,7 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
{strings.website.tools.tankopedia.sandbox.static.spaced} {strings.website.tools.tankopedia.sandbox.static.spaced}
</Text> </Text>
<Text color="gray" size="1"> <Text color="gray" size="1">
{literals(strings.common.units.mm, ['0'])} {literals(strings.common.units.mm, ["0"])}
</Text> </Text>
</Flex> </Flex>
@@ -119,8 +117,8 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
align="center" align="center"
width="1rem" width="1rem"
style={{ style={{
borderRadius: 'var(--radius-full)', borderRadius: "var(--radius-full)",
overflow: 'hidden', overflow: "hidden",
background: `linear-gradient(rgb(32, 0, 225), rgb(255, 0, 255))`, background: `linear-gradient(rgb(32, 0, 225), rgb(255, 0, 255))`,
}} }}
> >
@@ -130,12 +128,12 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
<Flex <Flex
gap="2" gap="2"
style={{ opacity: showExternalModules ? 1 : 0.5, cursor: 'pointer' }} style={{ opacity: showExternalModules ? 1 : 0.5, cursor: "pointer" }}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.showExternalModules = !draft.showExternalModules; draft.showExternalModules = !draft.showExternalModules;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }}
@@ -151,10 +149,10 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
width="1rem" width="1rem"
height="1rem" height="1rem"
style={{ style={{
borderRadius: 'var(--radius-full)', borderRadius: "var(--radius-full)",
overflow: 'hidden', overflow: "hidden",
backgroundColor: '#c0c0c0', backgroundColor: "#c0c0c0",
color: 'black', color: "black",
}} }}
> >
{showExternalModules ? <EyeOpenIcon /> : <EyeClosedIcon />} {showExternalModules ? <EyeOpenIcon /> : <EyeClosedIcon />}
@@ -166,9 +164,9 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
<Flex align="center" gap="2"> <Flex align="center" gap="2">
<Flex <Flex
gap="2" gap="2"
style={{ cursor: 'pointer' }} style={{ cursor: "pointer" }}
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.editStatic = !draft.editStatic; draft.editStatic = !draft.editStatic;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
@@ -186,9 +184,9 @@ export function Thicknesses({ thicknessRange, skeleton }: ThicknessesProps) {
size="1" size="1"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.editStatic = false; draft.editStatic = false;
draft.model = tankopediaEphemeralStore.getInitialState().model; draft.model = Tankopedia.initial.model;
}); });
}} }}
> >
@@ -3,8 +3,8 @@ import {
resolvePenetrationCoefficient, resolvePenetrationCoefficient,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
uniqueGuns, uniqueGuns,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { import {
CameraIcon, CameraIcon,
CopyIcon, CopyIcon,
@@ -12,8 +12,7 @@ import {
EnterFullScreenIcon, EnterFullScreenIcon,
ExitFullScreenIcon, ExitFullScreenIcon,
EyeOpenIcon, EyeOpenIcon,
GearIcon, } from "@radix-ui/react-icons";
} from '@radix-ui/react-icons';
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -26,27 +25,27 @@ import {
Tabs, Tabs,
Text, Text,
Tooltip, Tooltip,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { Suspense, useState, type RefObject } from 'react'; import { Suspense, useState, type RefObject } from "react";
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose'; import { Pose, poseEvent } from "../../../../../core/blitzkit/pose";
import { useEquipment } from '../../../../../hooks/useEquipment'; import { useEquipment } from "../../../../../hooks/useEquipment";
import { useFullScreen } from '../../../../../hooks/useFullScreen'; import { useFullScreen } from "../../../../../hooks/useFullScreen";
import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAvailability'; import { useFullscreenAvailability } from "../../../../../hooks/useFullscreenAvailability";
import { useLocale } from '../../../../../hooks/useLocale'; import { useLocale } from "../../../../../hooks/useLocale";
import { App } from '../../../../../stores/app'; import { Duel } from "../../../../../stores/duel";
import { Duel } from '../../../../../stores/duel'; import { Tankopedia } from "../../../../../stores/tankopedia";
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral'; import { TankopediaPersistent } from "../../../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../../../stores/tankopediaPersistent'; import { TankopediaDisplay } from "../../../../../stores/tankopediaPersistent/constants";
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants'; import type { MaybeSkeletonComponentProps } from "../../../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps'; import type { ThicknessRange } from "../../../../Armor/components/StaticArmor";
import type { ThicknessRange } from '../../../../Armor/components/StaticArmor'; import { ModuleButton } from "../../../../ModuleButtons/ModuleButton";
import { ModuleButton } from '../../../../ModuleButtons/ModuleButton'; import { SmallTankIcon } from "../../../../SmallTankIcon";
import { SmallTankIcon } from '../../../../SmallTankIcon'; import { TankSearch } from "../../../../TankSearch";
import { TankSearch } from '../../../../TankSearch'; import { screenshotReadyEvent } from "../TankSandbox/components/SceneProps";
import { CustomShellButton } from './components/CustomShellButton'; import { CustomShellButton } from "./components/CustomShellButton";
import { DynamicArmorSwitcher } from './components/DynamicArmorSwitcher'; import { DynamicArmorSwitcher } from "./components/DynamicArmorSwitcher";
import { QuickInputs } from './components/QuickInputs'; import { QuickInputs } from "./components/QuickInputs";
import { Thicknesses } from './components/Thicknesses'; import { Thicknesses } from "./components/Thicknesses";
type OptionsProps = MaybeSkeletonComponentProps & { type OptionsProps = MaybeSkeletonComponentProps & {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
@@ -54,40 +53,29 @@ type OptionsProps = MaybeSkeletonComponentProps & {
}; };
export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
const hasCustomShell = TankopediaEphemeral.use( const hasCustomShell = Tankopedia.use(
(state) => state.customShell !== undefined, (state) => state.customShell !== undefined
); );
const display = TankopediaEphemeral.use((state) => state.display); const display = Tankopedia.use((state) => state.display);
const isFullScreen = useFullScreen(); const isFullScreen = useFullScreen();
const showGrid = TankopediaPersistent.use((state) => state.showGrid);
const greenPenetration = TankopediaPersistent.use(
(state) => state.greenPenetration,
);
const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor,
);
const advancedHighlighting = TankopediaPersistent.use( const advancedHighlighting = TankopediaPersistent.use(
(state) => state.advancedHighlighting, (state) => state.advancedHighlighting
); );
const wireframe = TankopediaPersistent.use((state) => state.wireframe);
const opaque = TankopediaPersistent.use((state) => state.opaque);
const fullScreenAvailable = useFullscreenAvailability(true); const fullScreenAvailable = useFullscreenAvailability(true);
const developerMode = App.use((state) => state.developerMode);
const protagonistTank = Duel.use((state) => state.protagonist.tank); const protagonistTank = Duel.use((state) => state.protagonist.tank);
const antagonistGun = Duel.use((state) => state.antagonist.gun); const antagonistGun = Duel.use((state) => state.antagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell); const antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false); const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = Duel.use((state) => state.antagonist.tank); const antagonistTank = Duel.use((state) => state.antagonist.tank);
const hasCalibratedShells = useEquipment(103, true); const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search'); const [tab, setTab] = useState("search");
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const hasEnhancedArmor = useEquipment(110); const hasEnhancedArmor = useEquipment(110);
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets); const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const revealed = TankopediaEphemeral.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
return ( return (
<> <>
@@ -99,21 +87,21 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
gap="2" gap="2"
direction="column" direction="column"
top="50%" top="50%"
right={display === TankopediaDisplay.DynamicArmor ? '3' : '-4rem'} right={display === TankopediaDisplay.DynamicArmor ? "3" : "-4rem"}
style={{ style={{
position: 'absolute', position: "absolute",
transform: 'translateY(-50%)', transform: "translateY(-50%)",
transitionDuration: '200ms', transitionDuration: "200ms",
}} }}
align="end" align="end"
> >
{!hasCustomShell && ( {!hasCustomShell && (
<Text color="gray" size={{ initial: '1', sm: '2' }}> <Text color="gray" size={{ initial: "1", sm: "2" }}>
{literals(strings.common.units.mm, [ {literals(strings.common.units.mm, [
( (
resolvePenetrationCoefficient( resolvePenetrationCoefficient(
hasCalibratedShells, hasCalibratedShells,
antagonistShell.type, antagonistShell.type
) * antagonistShell.penetration.near ) * antagonistShell.penetration.near
).toFixed(0), ).toFixed(0),
])} ])}
@@ -127,14 +115,14 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
radius="full" radius="full"
variant="soft" variant="soft"
color="gray" color="gray"
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
style={{ position: 'relative' }} style={{ position: "relative" }}
> >
<Text <Text
size="1" size="1"
color="gray" color="gray"
style={{ style={{
position: 'absolute', position: "absolute",
right: 0, right: 0,
bottom: 0, bottom: 0,
}} }}
@@ -146,11 +134,11 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<img <img
alt="Antagonist Gun" alt="Antagonist Gun"
src={asset('icons/modules/gun.webp')} src={asset("icons/modules/gun.webp")}
style={{ style={{
width: '65%', width: "65%",
height: '65%', height: "65%",
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
</IconButton> </IconButton>
@@ -172,7 +160,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
selected={gun.id === antagonistGun.id} selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]} discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
secondaryDiscriminator={ secondaryDiscriminator={
<Text style={{ fontSize: '0.75em' }}> <Text style={{ fontSize: "0.75em" }}>
{literals(strings.common.units.mm, [ {literals(strings.common.units.mm, [
gun.shells[0].caliber.toFixed(0), gun.shells[0].caliber.toFixed(0),
])} ])}
@@ -188,7 +176,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Flex <Flex
direction="column" direction="column"
style={{ style={{
borderRadius: 'var(--radius-full)', borderRadius: "var(--radius-full)",
}} }}
overflow="hidden" overflow="hidden"
> >
@@ -197,17 +185,17 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
color={ color={
thisShell.id === antagonistShell.id && !hasCustomShell thisShell.id === antagonistShell.id && !hasCustomShell
? undefined ? undefined
: 'gray' : "gray"
} }
variant="soft" variant="soft"
key={thisShell.id} key={thisShell.id}
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
radius="none" radius="none"
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist.shell = thisShell; draft.antagonist.shell = thisShell;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.customShell = undefined; draft.customShell = undefined;
}); });
@@ -217,8 +205,8 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
alt={unwrap(thisShell.name)} alt={unwrap(thisShell.name)}
src={asset(`icons/shells/${thisShell.icon}.webp`)} src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{ style={{
width: '50%', width: "50%",
height: '50%', height: "50%",
}} }}
/> />
</IconButton> </IconButton>
@@ -230,15 +218,15 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Flex <Flex
direction="column" direction="column"
style={{ style={{
pointerEvents: 'auto', pointerEvents: "auto",
borderRadius: 'var(--radius-full)', borderRadius: "var(--radius-full)",
}} }}
overflow="hidden" overflow="hidden"
> >
<IconButton <IconButton
color={hasCalibratedShells ? undefined : 'gray'} color={hasCalibratedShells ? undefined : "gray"}
variant="soft" variant="soft"
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
radius="none" radius="none"
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
@@ -246,24 +234,24 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
? 0 ? 0
: 1; : 1;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
<img <img
alt="Calibrated Shells" alt="Calibrated Shells"
src={asset('icons/equipment/103.webp')} src={asset("icons/equipment/103.webp")}
style={{ style={{
width: '50%', width: "50%",
height: '50%', height: "50%",
}} }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
color={hasEnhancedArmor ? undefined : 'gray'} color={hasEnhancedArmor ? undefined : "gray"}
variant="soft" variant="soft"
size={{ initial: '2', sm: '3' }} size={{ initial: "2", sm: "3" }}
radius="none" radius="none"
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
@@ -271,17 +259,17 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
? 0 ? 0
: -1; : -1;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
<img <img
alt="Enhanced Armor" alt="Enhanced Armor"
src={asset('icons/equipment/110.webp')} src={asset("icons/equipment/110.webp")}
style={{ style={{
width: '50%', width: "50%",
height: '50%', height: "50%",
}} }}
/> />
</IconButton> </IconButton>
@@ -298,22 +286,22 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
direction="column" direction="column"
align="center" align="center"
position="absolute" position="absolute"
bottom={revealed ? '4' : '-100%'} bottom={revealed ? "4" : "-100%"}
left="50%" left="50%"
style={{ transform: 'translateX(-50%)', transitionDuration: '200ms' }} style={{ transform: "translateX(-50%)", transitionDuration: "200ms" }}
> >
<Flex <Flex
direction="column" direction="column"
align="center" align="center"
style={{ transitionDuration: '200ms' }} style={{ transitionDuration: "200ms" }}
position="relative" position="relative"
bottom={display === TankopediaDisplay.DynamicArmor ? '0' : '-7rem'} bottom={display === TankopediaDisplay.DynamicArmor ? "0" : "-7rem"}
> >
<Flex <Flex
align="center" align="center"
gap="2" gap="2"
mb="1" mb="1"
style={{ cursor: 'pointer' }} style={{ cursor: "pointer" }}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.advancedHighlighting = !draft.advancedHighlighting; draft.advancedHighlighting = !draft.advancedHighlighting;
@@ -330,6 +318,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Text color="gray" size="2"> <Text color="gray" size="2">
{strings.website.tools.tankopedia.sandbox.dynamic.shooter} {strings.website.tools.tankopedia.sandbox.dynamic.shooter}
</Text> </Text>
<Dialog.Root <Dialog.Root
open={antagonistSelectorOpen} open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorOpen} onOpenChange={setAntagonistSelectorOpen}
@@ -349,7 +338,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Tabs.Root <Tabs.Root
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
style={{ position: 'relative' }} style={{ position: "relative" }}
> >
<TankSearch <TankSearch
compact compact
@@ -377,7 +366,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
variant="classic" variant="classic"
value={`${disturbed ? display : -1}`} value={`${disturbed ? display : -1}`}
onValueChange={(value) => { onValueChange={(value) => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.disturbed = true; draft.disturbed = true;
draft.display = Number(value); draft.display = Number(value);
}); });
@@ -390,7 +379,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Flex height="100%" align="center"> <Flex height="100%" align="center">
<img <img
src="/assets/images/icons/tankopedia-model.png" src="/assets/images/icons/tankopedia-model.png"
style={{ height: '1.25em' }} style={{ height: "1.25em" }}
/> />
</Flex> </Flex>
</Tooltip> </Tooltip>
@@ -402,7 +391,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Flex height="100%" align="center"> <Flex height="100%" align="center">
<img <img
src="/assets/images/icons/tankopedia-dynamic-armor.png" src="/assets/images/icons/tankopedia-dynamic-armor.png"
style={{ height: '1.25em' }} style={{ height: "1.25em" }}
/> />
</Flex> </Flex>
</Tooltip> </Tooltip>
@@ -414,7 +403,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Flex height="100%" align="center"> <Flex height="100%" align="center">
<img <img
src="/assets/images/icons/tankopedia-static-armor.png" src="/assets/images/icons/tankopedia-static-armor.png"
style={{ height: '1.25em' }} style={{ height: "1.25em" }}
/> />
</Flex> </Flex>
</Tooltip> </Tooltip>
@@ -451,106 +440,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{display === TankopediaDisplay.DynamicArmor && (
<>
<DropdownMenu.Label>
{
strings.website.tools.tankopedia.sandbox.settings
.armor
}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.greenPenetration = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.green_penetration
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={hideTankModelUnderArmor}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.hideTankModelUnderArmor = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.hide_model_under_armor
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.opaque = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.opaque
}
</DropdownMenu.CheckboxItem>
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.wireframe = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.dev_wireframe
}
</DropdownMenu.CheckboxItem>
)}
</>
)}
<DropdownMenu.Label>
{
strings.website.tools.tankopedia.sandbox.settings
.environment
}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.showGrid = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.show_grid
}
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<IconButton color="gray" variant="ghost"> <IconButton color="gray" variant="ghost">
@@ -563,19 +452,25 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Popover.Close> <Popover.Close>
<Button <Button
onClick={() => { onClick={() => {
if (!canvas.current) return; screenshotReadyEvent.dispatch(true);
const anchor = document.createElement('a'); requestAnimationFrame(() => {
if (!canvas.current) return;
anchor.setAttribute( const anchor = document.createElement("a");
'download',
`${protagonistTank.name}.png`, anchor.setAttribute(
); "download",
anchor.setAttribute( `${protagonistTank.name}.png`
'href', );
canvas.current.toDataURL('image/png'), anchor.setAttribute(
); "href",
anchor.click(); canvas.current.toDataURL("image/png")
);
anchor.click();
screenshotReadyEvent.dispatch(false);
});
}} }}
> >
<DownloadIcon /> <DownloadIcon />
@@ -589,14 +484,20 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (!canvas.current) return; screenshotReadyEvent.dispatch(true);
canvas.current.toBlob((blob) => { requestAnimationFrame(() => {
if (!blob) return; if (!canvas.current) return;
navigator.clipboard.write([ canvas.current.toBlob((blob) => {
new ClipboardItem({ 'image/png': blob }), if (!blob) return;
]);
navigator.clipboard.write([
new ClipboardItem({ "image/png": blob }),
]);
});
screenshotReadyEvent.dispatch(false);
}); });
}} }}
> >
@@ -1,10 +1,10 @@
import { Box } from '@radix-ui/themes'; import { Box } from "@radix-ui/themes";
import { Var } from '../../../../../core/radix/var'; import { Var } from "../../../../../core/radix/var";
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../stores/tankopedia";
import './index.css'; import "./index.css";
export function ScrollHint() { export function ScrollHint() {
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
if (disturbed) return null; if (disturbed) return null;
@@ -17,9 +17,9 @@ export function ScrollHint() {
height="2rem" height="2rem"
width="1rem" width="1rem"
style={{ style={{
borderRadius: '0.5rem', borderRadius: "0.5rem",
outline: `2px solid ${Var('gray-11')}`, outline: `2px solid ${Var("gray-11")}`,
transform: 'translateX(-50%)', transform: "translateX(-50%)",
}} }}
> >
<Box <Box
@@ -28,8 +28,8 @@ export function ScrollHint() {
height="0.75rem" height="0.75rem"
m="0.125rem" m="0.125rem"
style={{ style={{
borderRadius: '50%', borderRadius: "50%",
backgroundColor: Var('gray-12'), backgroundColor: Var("gray-12"),
}} }}
/> />
</Box> </Box>
@@ -13,10 +13,10 @@ export function Tracker({ fontSize }: Props) {
<Box <Box
position="absolute" position="absolute"
left="50%" left="50%"
bottom="0" top="50%"
width="min(100%, 16rem)" width="min(100%, 16rem)"
style={{ style={{
transform: `translate(-50%, calc(${fontSize} / 2))`, transform: `translate(-50%, calc(${fontSize} / 2 + var(--space-4)))`,
}} }}
> >
<Progress color="gray" value={progress} /> <Progress color="gray" value={progress} />
@@ -1,129 +1,54 @@
import { TankType } from '@blitzkit/core'; import { Flex, Heading } from "@radix-ui/themes";
import { Flex, Heading } from '@radix-ui/themes'; import { useLocale } from "../../../../../../hooks/useLocale";
import { Var } from '../../../../../../core/radix/var'; import { Duel } from "../../../../../../stores/duel";
import { useLocale } from '../../../../../../hooks/useLocale'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { Duel } from '../../../../../../stores/duel'; import { Tracker } from "./components/Tracker";
import type { RadixColor } from '../../../../../../stores/embedState';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { Tracker } from './components/Tracker';
interface TitleProps { export function Title() {
foreground?: boolean;
}
export const NATION_COLORS: Record<
string,
{
text: RadixColor;
background: RadixColor[];
tint: RadixColor;
}
> = {
france: {
text: 'mint',
tint: 'purple',
background: ['mint', 'cyan', 'jade'],
},
germany: {
text: 'sage',
tint: 'blue',
background: ['slate', 'sage', 'mauve'],
},
usa: {
text: 'gold',
tint: 'red',
background: ['brown', 'gold', 'gray'],
},
european: {
text: 'sky',
tint: 'violet',
background: ['cyan', 'blue', 'indigo'],
},
ussr: {
text: 'bronze',
tint: 'red',
background: ['bronze', 'tomato', 'bronze'],
},
uk: {
text: 'gold',
tint: 'red',
background: ['bronze', 'gold', 'brown'],
},
japan: {
text: 'mint',
tint: 'violet',
background: ['green', 'teal', 'jade'],
},
china: {
text: 'mint',
tint: 'pink',
background: ['green', 'teal', 'jade'],
},
other: {
text: 'amber',
tint: 'sky',
background: ['amber', 'yellow', 'gray'],
},
};
export function Title({ foreground }: TitleProps) {
const { unwrap } = useLocale(); const { unwrap } = useLocale();
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const revealed = Tankopedia.use((state) => state.revealed);
const revealed = TankopediaEphemeral.use((state) => state.revealed);
const nationColors = NATION_COLORS[protagonist.nation];
const color = Var(
disturbed
? protagonist.type === TankType.COLLECTOR
? 'blue-11'
: protagonist.type === TankType.PREMIUM
? 'amber-11'
: 'gray-12'
: `${nationColors.text}-12`,
);
const name = unwrap(protagonist.name); const name = unwrap(protagonist.name);
const fontSize = revealed const fontSize = revealed
? disturbed ? revealed
? '2rem' ? "2rem"
: `min(65vh, ${125 / name.length}vw)` : `min(16vh, ${125 / name.length}vw)`
: `min(48vh, ${75 / name.length}vw)`; : `min(12vh, ${75 / name.length}vw)`;
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
return ( return (
<Flex <Flex
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault(); event.preventDefault();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.disturbed = true; draft.revealed = true;
}); });
}} }}
direction="column" direction="column"
position="absolute" position="absolute"
align="center" align="center"
justify="center" justify="center"
top={disturbed ? '6rem' : '50%'} top={revealed ? "6rem" : "50%"}
left="50%" left="50%"
width="100%" width="100%"
height={disturbed ? fontSize : '100%'} height={revealed ? fontSize : "100%"}
style={{ style={{
pointerEvents: disturbed ? 'none' : undefined, pointerEvents: revealed ? "none" : undefined,
transitionDuration: '1s', transitionDuration: "1s",
transform: 'translate(-50%, -50%)', transform: "translate(-50%, -50%)",
}} }}
> >
<Heading <Heading
style={{ style={{
fontWeight: 900, fontWeight: 900,
userSelect: 'none', userSelect: "none",
pointerEvents: 'none', pointerEvents: "none",
fontSize, fontSize,
whiteSpace: 'nowrap', whiteSpace: "nowrap",
color: foreground ? Var('gray-a10') : color, opacity: revealed ? 1 : 0.5,
opacity: foreground && disturbed ? 0 : 1, letterSpacing: revealed || !revealed ? 0 : "-0.03em",
letterSpacing: disturbed || !revealed ? 0 : '-0.03em',
transition: ` transition: `
letter-spacing 1.5s ${disturbed ? '' : 'cubic-bezier(0.81, -2, 0.68, 1)'}, letter-spacing 1.5s ${revealed ? "" : "cubic-bezier(0.81, -2, 0.68, 1)"},
font-size 1s, font-size 1s,
-webkit-text-stroke 2s, -webkit-text-stroke 2s,
opacity 1s opacity 1s
@@ -1,15 +1,15 @@
import { I_HAT, J_HAT } from '@blitzkit/core'; import { I_HAT, J_HAT } from "@blitzkit/core";
import { OrbitControls } from '@react-three/drei'; import { OrbitControls } from "@react-three/drei";
import { invalidate, useFrame, useThree } from '@react-three/fiber'; import { invalidate, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { PerspectiveCamera, Vector3 } from 'three'; import { PerspectiveCamera, Vector3 } from "three";
import { OrbitControls as OrbitControlsClass } from 'three-stdlib'; import { OrbitControls as OrbitControlsClass } from "three-stdlib";
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../../../../../core/awaitables/modelDefinitions";
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose'; import { Pose, poseEvent } from "../../../../../../core/blitzkit/pose";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
const poseDistances: Record<Pose, number> = { const poseDistances: Record<Pose, number> = {
[Pose.HullDown]: 15, [Pose.HullDown]: 15,
@@ -24,7 +24,6 @@ interface ControlsProps {
autoRotate?: boolean; autoRotate?: boolean;
zoomable?: boolean; zoomable?: boolean;
enableRotate?: boolean; enableRotate?: boolean;
distanceScale?: number;
} }
export function Controls({ export function Controls({
@@ -32,11 +31,8 @@ export function Controls({
autoRotate = true, autoRotate = true,
zoomable = true, zoomable = true,
enableRotate = true, enableRotate = true,
distanceScale = 1,
}: ControlsProps) { }: ControlsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera as PerspectiveCamera); const camera = useThree((state) => state.camera as PerspectiveCamera);
const canvas = useThree((state) => state.gl.domElement); const canvas = useThree((state) => state.gl.domElement);
const orbitControls = useRef<OrbitControlsClass>(null); const orbitControls = useRef<OrbitControlsClass>(null);
@@ -60,23 +56,23 @@ export function Controls({
const protagonistHullOrigin = new Vector3( const protagonistHullOrigin = new Vector3(
protagonistTrackModelDefinition.origin.x, protagonistTrackModelDefinition.origin.x,
protagonistTrackModelDefinition.origin.y, protagonistTrackModelDefinition.origin.y,
-protagonistTrackModelDefinition.origin.z, -protagonistTrackModelDefinition.origin.z
); );
const protagonistTurretOrigin = new Vector3( const protagonistTurretOrigin = new Vector3(
protagonistModelDefinition.turret_origin.x, protagonistModelDefinition.turret_origin.x,
protagonistModelDefinition.turret_origin.y, protagonistModelDefinition.turret_origin.y,
-protagonistModelDefinition.turret_origin.z, -protagonistModelDefinition.turret_origin.z
); );
const protagonistGunOrigin = new Vector3( const protagonistGunOrigin = new Vector3(
protagonistTurretModelDefinition.gun_origin.x, protagonistTurretModelDefinition.gun_origin.x,
protagonistTurretModelDefinition.gun_origin.y, protagonistTurretModelDefinition.gun_origin.y,
-protagonistTurretModelDefinition.gun_origin.z, -protagonistTurretModelDefinition.gun_origin.z
); );
const antagonistGunHeight = const antagonistGunHeight =
protagonistTrackModelDefinition.origin.y + protagonistTrackModelDefinition.origin.y +
antagonistModelDefinition.turret_origin.y + antagonistModelDefinition.turret_origin.y +
antagonistTurretModelDefinition.gun_origin.y; antagonistTurretModelDefinition.gun_origin.y;
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const doAutoRotate = autoRotate && !disturbed; const doAutoRotate = autoRotate && !disturbed;
const gunHeight = const gunHeight =
protagonistHullOrigin.y + protagonistHullOrigin.y +
@@ -84,9 +80,9 @@ export function Controls({
protagonistGunOrigin.y; protagonistGunOrigin.y;
const inspectModeInitialPosition = new Vector3( const inspectModeInitialPosition = new Vector3(
-8, -8,
gunHeight + (naked ? 10 : 2), naked ? gunHeight + 10 : 2,
-13, -13
).multiplyScalar(distanceScale); );
const t0 = useRef(Date.now() / 1000); const t0 = useRef(Date.now() / 1000);
useFrame(() => { useFrame(() => {
@@ -104,24 +100,24 @@ export function Controls({
}); });
useEffect(() => { useEffect(() => {
const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe( const unsubscribeTankopediaEphemeral = Tankopedia.on(
(state) => state.controlsEnabled, (state) => state.controlsEnabled,
(enabled) => { (enabled) => {
if (orbitControls.current) orbitControls.current.enabled = enabled; if (orbitControls.current) orbitControls.current.enabled = enabled;
}, }
); );
async function handlePoseEvent(event: Pose) { function handlePoseEvent(event: Pose) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
switch (event) { switch (event) {
@@ -132,7 +128,7 @@ export function Controls({
protagonistGunModelDefinition.pitch, protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw, protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
camera.position camera.position
@@ -144,14 +140,14 @@ export function Controls({
new Vector3( new Vector3(
0, 0,
poseDistances[event] * Math.sin(pitch), poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch), poseDistances[event] * -Math.cos(pitch)
), )
); );
camera.lookAt( camera.lookAt(
protagonistHullOrigin protagonistHullOrigin
.clone() .clone()
.add(protagonistTurretOrigin) .add(protagonistTurretOrigin)
.add(protagonistGunOrigin), .add(protagonistGunOrigin)
); );
orbitControls.current?.target.set(0, antagonistGunHeight, 0); orbitControls.current?.target.set(0, antagonistGunHeight, 0);
@@ -165,7 +161,7 @@ export function Controls({
protagonistGunModelDefinition.pitch, protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw, protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
camera.position camera.position
@@ -177,8 +173,8 @@ export function Controls({
new Vector3( new Vector3(
0, 0,
poseDistances[event] * Math.sin(pitch), poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch), poseDistances[event] * -Math.cos(pitch)
), )
); );
orbitControls.current?.target orbitControls.current?.target
.set(0, 0, 0) .set(0, 0, 0)
@@ -189,8 +185,8 @@ export function Controls({
new Vector3( new Vector3(
0, 0,
0.5 * poseDistances[event] * Math.sin(pitch), 0.5 * poseDistances[event] * Math.sin(pitch),
0.5 * poseDistances[event] * -Math.cos(pitch), 0.5 * poseDistances[event] * -Math.cos(pitch)
), )
); );
break; break;
@@ -224,15 +220,15 @@ export function Controls({
} }
function disturb() { function disturb() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.disturbed = true; draft.disturbed = true;
}); });
} }
poseEvent.on(disturb); poseEvent.on(disturb);
canvas.addEventListener('pointerdown', disturb); canvas.addEventListener("pointerdown", disturb);
canvas.addEventListener('wheel', handleWheel); canvas.addEventListener("wheel", handleWheel);
document.body.addEventListener('scroll', handleScroll); document.body.addEventListener("scroll", handleScroll);
function updateCamera() { function updateCamera() {
if (!orbitControls.current) return; if (!orbitControls.current) return;
@@ -240,7 +236,11 @@ export function Controls({
orbitControls.current.rotateSpeed = 0.25; orbitControls.current.rotateSpeed = 0.25;
camera.position.copy(inspectModeInitialPosition); camera.position.copy(inspectModeInitialPosition);
orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0); orbitControls.current.target.set(
0,
gunHeight * (naked ? 1 / 4 : 3 / 4),
0
);
orbitControls.current.enablePan = true; orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true; orbitControls.current.enableZoom = true;
camera.fov = naked ? 20 : 25; camera.fov = naked ? 20 : 25;
@@ -251,10 +251,10 @@ export function Controls({
updateCamera(); updateCamera();
return () => { return () => {
canvas.removeEventListener('pointerdown', disturb); canvas.removeEventListener("pointerdown", disturb);
poseEvent.off(disturb); poseEvent.off(disturb);
canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener("wheel", handleWheel);
document.body.removeEventListener('scroll', handleScroll); document.body.removeEventListener("scroll", handleScroll);
}; };
}, []); }, []);
@@ -266,7 +266,7 @@ export function Controls({
enableZoom={zoomable} enableZoom={zoomable}
zoomSpeed={zoomable ? undefined : 0} zoomSpeed={zoomable ? undefined : 0}
ref={orbitControls} ref={orbitControls}
enabled={tankopediaEphemeralStore.getState().controlsEnabled} enabled={Tankopedia.state.controlsEnabled}
enableDamping={false} enableDamping={false}
autoRotateSpeed={1 / 4} autoRotateSpeed={1 / 4}
/> />
@@ -1,15 +1,14 @@
import { invalidate, useThree } from '@react-three/fiber'; import { invalidate, useThree } from "@react-three/fiber";
import { useEffect } from 'react'; import { useEffect } from "react";
import type { Fog } from 'three'; import type { Fog } from "three";
import { fogAnimationTime, fogFar0, fogFar1, forNear0, forNear1 } from '..'; import { fogAnimationTime, fogFar0, fogFar1, forNear0, forNear1 } from "..";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
export function InitialFogReveal() { export function InitialFogReveal() {
const fog = useThree((state) => state.scene.fog as Fog | null); const fog = useThree((state) => state.scene.fog as Fog | null);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
useEffect(() => { useEffect(() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.revealed = true; draft.revealed = true;
}); });
@@ -1,34 +0,0 @@
import { Environment } from '@react-three/drei';
import { Euler } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import { useModel } from '../../../../../../hooks/useModel';
import { Duel } from '../../../../../../stores/duel';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
const LIGHTS_COUNT = 5;
interface LightingProps {
display: TankopediaDisplay;
}
export function Lighting({ display }: LightingProps) {
const protagonist = Duel.use((state) => state.protagonist);
const { hasPbr } = useModel(protagonist.tank.id);
const isBrighterLighting =
!hasPbr && display !== TankopediaDisplay.StaticArmor;
return (
<>
{display !== TankopediaDisplay.StaticArmor && (
<Environment
files="https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/winter_river_1k.hdr"
environmentIntensity={isBrighterLighting ? 1.5 : 1.25}
environmentRotation={new Euler(0, degToRad(180), 0)}
/>
)}
{display === TankopediaDisplay.StaticArmor && (
<Environment preset="lobby" />
)}
</>
);
}
@@ -0,0 +1,49 @@
import { useHelper } from '@react-three/drei';
import { times } from 'lodash-es';
import { useRef } from 'react';
import { Color, SpotLight, SpotLightHelper } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import { isHalloween } from '../../../../../../../core/blitzkit/isHalloween';
import { HelpingSpotLight } from '../../../../../../HelpingSpotLight';
const LIGHTS_COUNT = 5;
const LIGHT_DISTANCE = 13;
const LIGHT_HEIGHT_0 = 4;
const LIGHT_HEIGHT_1 = 2;
const INTENSITY = 30;
export function Lighting() {
const topLight = useRef<SpotLight>(null!);
const frontLight = useRef<SpotLight>(null!);
useHelper(topLight, SpotLightHelper, 'red');
useHelper(frontLight, SpotLightHelper, 'green');
return (
<>
{times(LIGHTS_COUNT, (index) => {
const x = index / (LIGHTS_COUNT - 1);
const theta = 2 * Math.PI * (index / LIGHTS_COUNT) - degToRad(152);
const position = [
LIGHT_DISTANCE * Math.sin(theta),
LIGHT_HEIGHT_0 * (1 - x) + LIGHT_HEIGHT_1 * x,
LIGHT_DISTANCE * Math.cos(theta),
] as const;
const color = new Color();
if (isHalloween()) color.set(1, 0.25, 0.25);
return (
<HelpingSpotLight
position={position}
intensity={INTENSITY}
penumbra={1}
castShadow
decay={1}
color={color}
/>
);
})}
</>
);
}
@@ -1,240 +1,50 @@
import { asset, I_HAT, J_HAT } from '@blitzkit/core'; import { invalidate, useFrame } from '@react-three/fiber';
import { useFrame, useLoader } from '@react-three/fiber';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { useRef } from 'react'; import { Quicklime, type QuicklimeEvent } from 'quicklime';
import { import { useEffect, useRef } from 'react';
Group, import { Mesh, MeshStandardMaterial } from 'three';
MeshStandardMaterial,
PerspectiveCamera,
TextureLoader,
Vector2,
Vector3,
} from 'three';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';
import { degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { awaitableEquipmentDefinitions } from '../../../../../../../core/awaitables/equipmentDefinitions';
import { awaitableModelDefinitions } from '../../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../../core/awaitables/provisionDefinitions';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTransform';
import { tankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics';
import { Duel } from '../../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
import { aimTarget } from './constants';
const [modelDefinitions, equipmentDefinitions, provisionDefinitions] = const SIZE = 2 ** 5;
await Promise.all([
awaitableModelDefinitions,
awaitableEquipmentDefinitions,
awaitableProvisionDefinitions,
]);
const emptyVector = new Vector2(); export const screenshotReadyEvent = new Quicklime(false);
export function SceneProps() { export function SceneProps() {
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const mesh = useRef<Mesh>(null!);
const targetCircleWrapper = useRef<Group>(null);
const clientTargetCircle = useRef<HTMLDivElement>(null);
const serverTargetCircle = useRef<HTMLDivElement>(null);
const material = useRef<MeshStandardMaterial>(null); const material = useRef<MeshStandardMaterial>(null);
const playground = useRef<Group>(null);
const turret = Duel.use((state) => state.protagonist.turret);
const track = Duel.use((state) => state.protagonist.track);
const tank = Duel.use((state) => state.protagonist.tank);
const gun = Duel.use((state) => state.protagonist.gun);
const shell = Duel.use((state) => state.protagonist.shell);
const engine = Duel.use((state) => state.protagonist.engine);
const tankModelDefinition = modelDefinitions.models[tank.id];
const trackModelDefinition =
modelDefinitions.models[tank.id].tracks[track.id];
const gunModelDefinition =
tankModelDefinition.turrets[turret.id].guns[gun.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const hullOrigin = new Vector3(
trackModelDefinition.origin.x,
trackModelDefinition.origin.y,
-trackModelDefinition.origin.z,
);
const turretOrigin = new Vector3(
tankModelDefinition.turret_origin.x,
tankModelDefinition.turret_origin.y,
-tankModelDefinition.turret_origin.z,
);
const gunOriginOnlyY = new Vector3(0, turretModelDefinition.gun_origin.y, 0);
const shellOrigin = hullOrigin.clone().add(turretOrigin).add(gunOriginOnlyY);
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const consumables = Duel.use((state) => state.protagonist.consumables);
const provisions = Duel.use((state) => state.protagonist.provisions);
const assaultDistance = Duel.use(
(state) => state.protagonist.assaultDistance,
);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const characteristics = tankCharacteristics(
{
tank,
turret,
gun,
shell,
consumables,
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage,
crewSkills,
engine,
equipmentMatrix,
provisions,
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTurret: tank.turrets[0],
stockTrack: tank.tracks[0],
track,
assaultDistance,
},
{ equipmentDefinitions, provisionDefinitions, tankModelDefinition },
);
let { dispersion } = characteristics;
const gunRotationSpeed = degToRad(characteristics.gunTraverseSpeed);
const mockTank = useLoader(GLTFLoader, asset(`3d/tanks/models/6929.glb`));
const texture = useLoader(TextureLoader, '/assets/images/3d/grid.png');
const path = new Vector3();
const direction = new Vector3();
texture.anisotropy = 2;
useFrame(({ camera }) => { useFrame(({ camera }) => {
if (!material.current) return; if (!material.current) return;
material.current.opacity = clamp(0.5 * camera.position.y, 0, 1); material.current.opacity = clamp(0.5 * camera.position.y, 0, 1);
}); });
let lastTime = 0; useEffect(() => {
let DEBUG_disable = false; function handleScreenshotReady(event: QuicklimeEvent<boolean>) {
mesh.current.visible = !event.data;
useFrame(({ raycaster, camera, clock, gl }) => { invalidate();
if (
!playground.current ||
!targetCircleWrapper.current ||
DEBUG_disable ||
!clientTargetCircle.current ||
!serverTargetCircle.current
) {
return;
} }
raycaster.setFromCamera(emptyVector, camera); screenshotReadyEvent.on(handleScreenshotReady);
const cameraIntersections = raycaster.intersectObjects( return () => {
playground.current.children, screenshotReadyEvent.off(handleScreenshotReady);
true, };
); }, []);
let length: number;
if (cameraIntersections.length === 0) {
length = characteristics.shellRange;
direction.set(0, 0, -1).applyQuaternion(camera.quaternion);
aimTarget.copy(shellOrigin).addScaledVector(direction, length);
} else {
aimTarget.copy(cameraIntersections[0].point);
path.copy(aimTarget).sub(shellOrigin);
length = path.length();
direction.copy(path).normalize();
}
const deltaT = clock.elapsedTime - lastTime;
lastTime = clock.elapsedTime;
const desiredPitch = Math.asin(direction.y);
const desiredYaw = Math.atan2(-direction.x, -direction.z);
const deltaPitch = desiredPitch - modelTransformEvent.last!.pitch;
const deltaYaw = desiredYaw - modelTransformEvent.last!.yaw;
const deltaAngle = Math.sqrt(deltaPitch ** 2 + deltaYaw ** 2);
const maxPossibleRotationInTime = gunRotationSpeed * deltaT;
const clampedDeltaAngle = Math.min(maxPossibleRotationInTime, deltaAngle);
const clampedDeltaPitch =
deltaAngle === 0 ? 0 : deltaPitch * (clampedDeltaAngle / deltaAngle);
const clampedDeltaYaw =
deltaAngle === 0 ? 0 : deltaYaw * (clampedDeltaAngle / deltaAngle);
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch + clampedDeltaPitch,
modelTransformEvent.last!.yaw + clampedDeltaYaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
);
const actualDeltaPitch = pitch - modelTransformEvent.last!.pitch;
const actualDeltaYaw = yaw - modelTransformEvent.last!.yaw;
const deltaRotation = Math.abs(actualDeltaPitch) + Math.abs(actualDeltaYaw);
const rotationSpeed = deltaRotation / deltaT;
const turretTraversePenalty =
characteristics.dispersionTurretTraversing * radToDeg(rotationSpeed);
const penalty = Math.sqrt(1 + turretTraversePenalty ** 2);
if (penalty === 1) {
const dispersionMod = dispersion - characteristics.dispersion;
const dispersionModDischarged =
dispersionMod * Math.exp(-deltaT / characteristics.aimTime) -
dispersionMod;
dispersion += dispersionModDischarged;
} else {
dispersion = Math.max(characteristics.dispersion * penalty, dispersion);
}
modelTransformEvent.dispatch({ pitch, yaw });
const gunDirection = new Vector3(0, 0, -1)
.applyAxisAngle(I_HAT, pitch)
.applyAxisAngle(J_HAT, yaw);
raycaster.set(shellOrigin, gunDirection);
const gunIntersections = raycaster.intersectObjects(
playground.current.children,
true,
);
let gunTarget: Vector3;
if (gunIntersections.length === 0) {
gunTarget = shellOrigin
.clone()
.add(gunDirection.multiplyScalar(characteristics.shellRange));
} else {
gunTarget = gunIntersections[0].point;
}
targetCircleWrapper.current.position.copy(gunTarget);
const distanceToTarget = gunTarget.distanceTo(shellOrigin);
const dispersionRadius = dispersion * (distanceToTarget / 100);
const dispersionSize = 2 * dispersionRadius;
const targetDistanceFromCamera = gunTarget.distanceTo(camera.position);
const angularSize =
2 * Math.atan(dispersionSize / (2 * targetDistanceFromCamera));
const verticalScreenSize =
2 * Math.tan(degToRad((camera as PerspectiveCamera).fov) / 2);
const screenSpaceSize =
gl.domElement.clientHeight * (angularSize / verticalScreenSize);
const targetCircleDotsSize = `${screenSpaceSize}px`;
clientTargetCircle.current.style.width = targetCircleDotsSize;
clientTargetCircle.current.style.height = targetCircleDotsSize;
serverTargetCircle.current.style.width = targetCircleDotsSize;
serverTargetCircle.current.style.height = targetCircleDotsSize;
});
if (!disturbed) return null;
return ( return (
<mesh rotation={[-Math.PI / 2, 0, 0]}> <mesh
<planeGeometry args={[10, 10]} /> rotation={[-Math.PI / 2, 0, 0]}
<meshStandardMaterial ref={material} map={texture} transparent /> receiveShadow
visible={!screenshotReadyEvent.last!}
ref={mesh}
>
<planeGeometry args={[SIZE, SIZE]} />
<meshStandardMaterial
transparent
ref={material}
color="black"
roughness={1}
metalness={1}
/>
</mesh> </mesh>
); );
} }
@@ -1,30 +1,27 @@
import { invalidate, useThree, type ThreeEvent } from '@react-three/fiber'; import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from 'react'; import { useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from 'three'; import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform";
import { useModel } from '../../../../../../hooks/useModel'; import { useModel } from "../../../../../../hooks/useModel";
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from '../../../../../../hooks/useTankTransform'; import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from '../../../../../../stores/duel'; import { Duel } from "../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from '../../../../../Armor/components/ModelTankWrapper'; import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
export function TankModel() { export function TankModel() {
const mutateDuel = Duel.useMutation();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const protagonist = Duel.use((draft) => draft.protagonist); const protagonist = Duel.use((draft) => draft.protagonist);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const canvas = useThree((state) => state.gl.domElement); const canvas = useThree((state) => state.gl.domElement);
const hullContainer = useRef<Group>(null); const hullContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id]; tankModelDefinition.turrets[protagonist.turret.id];
@@ -37,9 +34,9 @@ export function TankModel() {
return ( return (
<ModelTankWrapper ref={hullContainer}> <ModelTankWrapper ref={hullContainer}>
{nodes.map((node) => { {nodes.map((node) => {
const isHull = node.name === 'hull'; const isHull = node.name === "hull";
const isWheel = node.name.startsWith('chassis_wheel_'); const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith('chassis_track_'); const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isHull || isWheel || isTrack; const isVisible = isHull || isWheel || isTrack;
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
@@ -62,20 +59,16 @@ export function TankModel() {
} }
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
if ( if (isTrack && Tankopedia.state.display === TankopediaDisplay.Model) {
isTrack &&
tankopediaEphemeralStore.getState().display ===
TankopediaDisplay.Model
) {
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} else { } else {
event.stopPropagation(); event.stopPropagation();
} }
@@ -89,12 +82,12 @@ export function TankModel() {
translateTexture(deltaX + deltaY); translateTexture(deltaX + deltaY);
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return jsxTree(node, { return jsxTree(node, {
@@ -105,6 +98,7 @@ export function TankModel() {
key={key} key={key}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
castShadow castShadow
receiveShadow
/> />
); );
}, },
@@ -113,10 +107,10 @@ export function TankModel() {
<group ref={turretContainer}> <group ref={turretContainer}>
{nodes.map((node) => { {nodes.map((node) => {
const isTurret = node.name.startsWith('turret_'); const isTurret = node.name.startsWith("turret_");
const isCurrentTurret = const isCurrentTurret =
node.name === node.name ===
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`; `turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentTurret; const isVisible = isCurrentTurret;
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
@@ -130,27 +124,27 @@ export function TankModel() {
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
@@ -164,16 +158,16 @@ export function TankModel() {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return jsxTree(node, { return jsxTree(node, {
@@ -184,6 +178,7 @@ export function TankModel() {
key={key} key={key}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
castShadow castShadow
receiveShadow
/> />
); );
}, },
@@ -196,10 +191,10 @@ export function TankModel() {
node.name === node.name ===
`gun_${gunModelDefinition.model_id `gun_${gunModelDefinition.model_id
.toString() .toString()
.padStart(2, '0')}_mask`; .padStart(2, "0")}_mask`;
const isCurrentGun = const isCurrentGun =
node.name === node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`; `gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet; const isVisible = isCurrentGun || isCurrentMantlet;
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
@@ -212,10 +207,10 @@ export function TankModel() {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
@@ -224,20 +219,20 @@ export function TankModel() {
pitch = modelTransformEvent.last!.pitch; pitch = modelTransformEvent.last!.pitch;
yaw = modelTransformEvent.last!.yaw; yaw = modelTransformEvent.last!.yaw;
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState(); const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment( const hasImprovedVerticalStabilizer = hasEquipment(
122, 122,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const hasDownImprovedVerticalStabilizer = await hasEquipment( const hasDownImprovedVerticalStabilizer = hasEquipment(
124, 124,
duel.protagonist.tank.equipment_preset, duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix, duel.protagonist.equipmentMatrix
); );
const boundingRect = canvas.getBoundingClientRect(); const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
@@ -249,16 +244,16 @@ export function TankModel() {
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return jsxTree(node, { return jsxTree(node, {
@@ -269,6 +264,7 @@ export function TankModel() {
key={key} key={key}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
castShadow castShadow
receiveShadow
/> />
); );
}, },
@@ -1,14 +1,14 @@
import { useRef } from 'react'; import { useRef } from "react";
import { Group, ShaderMaterial, UniformsLib, UniformsUtils } from 'three'; import { Group, ShaderMaterial, UniformsLib, UniformsUtils } from "three";
import { jsxTree } from '../../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from "../../../../../../../core/blitzkit/jsxTree";
import { useModel } from '../../../../../../../hooks/useModel'; import { useModel } from "../../../../../../../hooks/useModel";
import { useTankModelDefinition } from '../../../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from '../../../../../../../hooks/useTankTransform'; import { useTankTransform } from "../../../../../../../hooks/useTankTransform";
import { Duel } from '../../../../../../../stores/duel'; import { Duel } from "../../../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../../../stores/tankopedia";
import { ModelTankWrapper } from '../../../../../../Armor/components/ModelTankWrapper'; import { ModelTankWrapper } from "../../../../../../Armor/components/ModelTankWrapper";
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from "./shaders/fragment.glsl?raw";
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from "./shaders/vertex.glsl?raw";
const skeletonMaterial = new ShaderMaterial({ const skeletonMaterial = new ShaderMaterial({
vertexShader, vertexShader,
@@ -22,7 +22,6 @@ const skeletonMaterial = new ShaderMaterial({
export function TransitionSkeleton() { export function TransitionSkeleton() {
const protagonist = Duel.use((draft) => draft.protagonist); const protagonist = Duel.use((draft) => draft.protagonist);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const hullContainer = useRef<Group>(null); const hullContainer = useRef<Group>(null);
@@ -34,16 +33,16 @@ export function TransitionSkeleton() {
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id); const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes); const nodes = Object.values(gltf.nodes);
const revealed = tankopediaEphemeralStore((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
return ( return (
<ModelTankWrapper ref={hullContainer} visible={revealed}> <ModelTankWrapper ref={hullContainer} visible={revealed}>
{nodes.map((node) => { {nodes.map((node) => {
const isHull = node.name === 'hull'; const isHull = node.name === "hull";
const isWheel = node.name.startsWith('chassis_wheel_'); const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith('chassis_track_'); const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isHull || isWheel || isTrack; const isVisible = isHull || isWheel || isTrack;
if (!isVisible) return null; if (!isVisible) return null;
@@ -59,7 +58,7 @@ export function TransitionSkeleton() {
{nodes.map((node) => { {nodes.map((node) => {
const isCurrentTurret = const isCurrentTurret =
node.name === node.name ===
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`; `turret_${turretModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentTurret; const isVisible = isCurrentTurret;
if (!isVisible) return null; if (!isVisible) return null;
@@ -77,10 +76,10 @@ export function TransitionSkeleton() {
node.name === node.name ===
`gun_${gunModelDefinition.model_id `gun_${gunModelDefinition.model_id
.toString() .toString()
.padStart(2, '0')}_mask`; .padStart(2, "0")}_mask`;
const isCurrentGun = const isCurrentGun =
node.name === node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`; `gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet; const isVisible = isCurrentGun || isCurrentMantlet;
if (!isVisible) return null; if (!isVisible) return null;
@@ -1,5 +1,5 @@
import { BLITZKIT_TANK_ICON_SIZE } from '@blitzkit/core'; import { BLITZKIT_TANK_ICON_SIZE } from "@blitzkit/core";
import { invalidate } from '@react-three/fiber'; import { invalidate } from "@react-three/fiber";
import { import {
forwardRef, forwardRef,
Suspense, Suspense,
@@ -7,33 +7,33 @@ import {
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useState, useState,
} from 'react'; } from "react";
import { Fog } from 'three'; import { Fog } from "three";
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from "../../../../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from '../../../../../core/blitzkit/modelTransform'; import { modelTransformEvent } from "../../../../../core/blitzkit/modelTransform";
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose'; import { Pose, poseEvent } from "../../../../../core/blitzkit/pose";
import { useEquipment } from '../../../../../hooks/useEquipment'; import { useEquipment } from "../../../../../hooks/useEquipment";
import { useTankModelDefinition } from '../../../../../hooks/useTankModelDefinition'; import { useTankModelDefinition } from "../../../../../hooks/useTankModelDefinition";
import { Duel } from '../../../../../stores/duel'; import { Duel } from "../../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../../stores/tankopediaPersistent";
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from "../../../../../stores/tankopediaPersistent/constants";
import { Armor } from '../../../../Armor'; import { Armor } from "../../../../Armor";
import { ArmorPlateDisplay } from '../../../../Armor/components/ArmorPlateDisplay'; import { ArmorPlateDisplay } from "../../../../Armor/components/ArmorPlateDisplay";
import { ShotDisplay } from '../../../../Armor/components/ShotDisplay'; import { ShotDisplay } from "../../../../Armor/components/ShotDisplay";
import { import {
StaticArmor, StaticArmor,
type ThicknessRange, type ThicknessRange,
} from '../../../../Armor/components/StaticArmor'; } from "../../../../Armor/components/StaticArmor";
import { SmartCanvas } from '../../../../SmartCanvas'; import { SmartCanvas } from "../../../../SmartCanvas";
import { AutoClear } from './components/AutoClear'; import { AutoClear } from "./components/AutoClear";
import { Controls } from './components/Control'; import { Controls } from "./components/Control";
import { InitialAligner } from './components/InitialAligner'; import { InitialAligner } from "./components/InitialAligner";
import { InitialFogReveal } from './components/InitialFogReveal'; import { InitialFogReveal } from "./components/InitialFogReveal";
import { Lighting } from './components/Lighting'; import { Lighting } from "./components/Lighting";
import { SceneProps } from './components/SceneProps'; import { SceneProps } from "./components/SceneProps";
import { TankModel } from './components/TankModel'; import { TankModel } from "./components/TankModel";
import { TransitionSkeleton } from './components/TransitionSkeleton'; import { TransitionSkeleton } from "./components/TransitionSkeleton";
interface TankSandboxProps { interface TankSandboxProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
@@ -48,22 +48,20 @@ export const fogAnimationTime = 2.5;
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>( export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
({ thicknessRange, naked }, ref) => { ({ thicknessRange, naked }, ref) => {
const fog = useRef(new Fog('black', forNear1, fogFar1)); const fog = useRef(new Fog("black", forNear1, fogFar1));
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(null); const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124); const hasDownImprovedVerticalStabilizer = useEquipment(124);
const protagonistGun = Duel.use((state) => state.protagonist.gun); const protagonistGun = Duel.use((state) => state.protagonist.gun);
const protagonistTurret = Duel.use((state) => state.protagonist.turret); const protagonistTurret = Duel.use((state) => state.protagonist.turret);
const mutateDuel = Duel.useMutation();
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = const turretModelDefinition =
tankModelDefinition.turrets[protagonistTurret.id]; tankModelDefinition.turrets[protagonistTurret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonistGun.id]; const gunModelDefinition = turretModelDefinition.guns[protagonistGun.id];
const rawDisplay = TankopediaEphemeral.use((state) => state.display); const rawDisplay = Tankopedia.use((state) => state.display);
const [display, setDisplay] = useState(rawDisplay); const [display, setDisplay] = useState(rawDisplay);
const hideTankModelUnderArmor = TankopediaPersistent.use( const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor, (state) => state.hideTankModelUnderArmor
); );
useEffect(() => { useEffect(() => {
@@ -97,8 +95,8 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
useImperativeHandle(ref, () => canvas.current!, []); useImperativeHandle(ref, () => canvas.current!, []);
function handlePointerDown() { function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
@@ -106,8 +104,8 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
function handlePointerUp(event: PointerEvent) { function handlePointerUp(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
useEffect(() => { useEffect(() => {
@@ -120,7 +118,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -134,7 +132,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
0, 0,
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -148,7 +146,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
0, 0,
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -170,7 +168,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
modelTransformEvent.last!.yaw, modelTransformEvent.last!.yaw,
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -178,13 +176,13 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
useEffect(() => { useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) { if (display !== TankopediaDisplay.DynamicArmor) {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
} }
if (display !== TankopediaDisplay.StaticArmor) { if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
} }
@@ -199,19 +197,19 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
localClippingEnabled: true, localClippingEnabled: true,
preserveDrawingBuffer: true, preserveDrawingBuffer: true,
}} }}
shadows shadows="soft"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMissed={() => { onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.shot = undefined; draft.shot = undefined;
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }}
style={{ style={{
userSelect: 'none', userSelect: "none",
width: naked ? BLITZKIT_TANK_ICON_SIZE.width : '100%', width: naked ? BLITZKIT_TANK_ICON_SIZE.width : "100%",
height: naked ? BLITZKIT_TANK_ICON_SIZE.height : '100%', height: naked ? BLITZKIT_TANK_ICON_SIZE.height : "100%",
outline: naked ? '1rem red solid' : undefined, outline: naked ? "1rem red solid" : undefined,
}} }}
> >
{!naked && <SceneProps />} {!naked && <SceneProps />}
@@ -232,11 +230,11 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
{display === TankopediaDisplay.StaticArmor && ( {display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} /> <StaticArmor thicknessRange={thicknessRange} />
)} )}
<Lighting display={display} /> <Lighting />
</Suspense> </Suspense>
<InitialAligner /> <InitialAligner />
</SmartCanvas> </SmartCanvas>
); );
}, }
); );
@@ -1,29 +1,28 @@
import { Box, Flex } from '@radix-ui/themes'; import { Box, Flex } from "@radix-ui/themes";
import { times } from 'lodash-es'; import { times } from "lodash-es";
import { Suspense, useEffect, useMemo, useRef } from 'react'; import { Suspense, useEffect, useMemo, useRef } from "react";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { Var } from '../../../core/radix/var'; import { useFullScreen } from "../../../hooks/useFullScreen";
import { useFullScreen } from '../../../hooks/useFullScreen'; import { Duel } from "../../../stores/duel";
import { Duel } from '../../../stores/duel'; import { Tankopedia } from "../../../stores/tankopedia";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import type { ThicknessRange } from "../../Armor/components/StaticArmor";
import type { ThicknessRange } from '../../Armor/components/StaticArmor'; import { Options } from "./components/Options";
import { Options } from './components/Options'; import { TankSandbox } from "./components/TankSandbox";
import { TankSandbox } from './components/TankSandbox'; import { Title } from "./components/TankSandbox/Title";
import { NATION_COLORS, Title } from './components/TankSandbox/Title';
const tankDefinitions = await awaitableTankDefinitions; const tankDefinitions = await awaitableTankDefinitions;
export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) { export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const revealed = TankopediaEphemeral.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = TankopediaEphemeral.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const canvas = useRef<HTMLCanvasElement>(null); const canvas = useRef<HTMLCanvasElement>(null);
const isFullScreen = useFullScreen(); const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const thicknessRange = useMemo(() => { const thicknessRange = useMemo(() => {
const entries = Object.values(tankDefinitions.tanks); const entries = Object.values(tankDefinitions.tanks);
const filtered = entries.filter( const filtered = entries.filter(
(thisTank) => thisTank.tier === protagonist.tier, (thisTank) => thisTank.tier === protagonist.tier
); );
const value = const value =
(filtered.reduce((accumulator, thisTank) => { (filtered.reduce((accumulator, thisTank) => {
@@ -39,12 +38,10 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
}, [protagonist]); }, [protagonist]);
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const nationColors = NATION_COLORS[protagonist.nation];
useEffect(() => { useEffect(() => {
if (disturbed) { if (disturbed) {
document.body.classList.remove('no-navbar'); document.body.classList.remove("no-navbar");
} }
}, [disturbed]); }, [disturbed]);
@@ -57,15 +54,15 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist.shell = shells[index]; draft.antagonist.shell = shells[index];
}); });
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.customShell = undefined; draft.customShell = undefined;
}); });
} }
}); });
} }
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, []); }, []);
return ( return (
@@ -73,7 +70,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
overflow="hidden" overflow="hidden"
justify="center" justify="center"
style={{ style={{
backgroundColor: 'black', backgroundColor: "black",
}} }}
position="relative" position="relative"
> >
@@ -85,30 +82,36 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
left="0" left="0"
style={{ style={{
opacity: disturbed ? 0 : 1, opacity: disturbed ? 0 : 1,
background: `linear-gradient(${nationColors.background background: "black",
.map((color) => Var(`${color}-2`)) transitionDuration: "1s",
.join(',')})`,
transitionDuration: '1s',
}} }}
/> />
{/* <ScrollHint /> */} {/* <ScrollHint /> */}
<Flex <Flex
direction={{ initial: 'column-reverse', md: 'row' }} direction={{ initial: "column-reverse", md: "row" }}
style={{ style={{
zIndex: isFullScreen ? 2 : undefined, zIndex: isFullScreen ? 2 : undefined,
transitionDuration: '1s', transitionDuration: "1s",
background: isFullScreen ? 'black' : undefined, background: isFullScreen ? "black" : undefined,
}} }}
height={!isFullScreen ? 'calc(100svh - 6rem)' : '100vh'} height={
maxHeight={isFullScreen ? undefined : '60rem'} isFullScreen
maxWidth={isFullScreen ? undefined : '120rem'} ? "100vh"
: disturbed
? "calc(100svh - 6rem)"
: {
initial: "28rem",
md: "calc(100svh - 12rem)",
}
}
maxWidth={isFullScreen ? undefined : "120rem"}
flexGrow="1" flexGrow="1"
width={isFullScreen ? '100vw' : undefined} width={isFullScreen ? "100vw" : undefined}
position={isFullScreen ? 'fixed' : 'relative'} position={isFullScreen ? "fixed" : "relative"}
top={isFullScreen ? '0' : undefined} top={isFullScreen ? "0" : undefined}
left={isFullScreen ? '0' : undefined} left={isFullScreen ? "0" : undefined}
> >
<Box <Box
className="tank-sandbox-container" className="tank-sandbox-container"
@@ -118,21 +121,19 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
position="relative" position="relative"
> >
<Box position="absolute" width="100%" height="100%" overflow="hidden"> <Box position="absolute" width="100%" height="100%" overflow="hidden">
<Title />
<Box <Box
width="100%" width="100%"
height="100%" height="100%"
position="relative" position="relative"
style={{ style={{
transitionDuration: '1s', transitionDuration: "1s",
}} }}
> >
<Box <Box
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
transitionDuration: '2s', transitionDuration: "2s",
opacity: revealed ? 1 : 0, opacity: revealed ? 1 : 0,
filter: disturbed filter: disturbed
? undefined ? undefined
@@ -145,7 +146,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
</Box> </Box>
</Box> </Box>
<Title foreground /> <Title />
<Options <Options
skeleton={skeleton} skeleton={skeleton}
@@ -1,12 +1,12 @@
import { asset, formatCompact, TIER_ROMAN_NUMERALS } from '@blitzkit/core'; import { asset, formatCompact, TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { Box, Flex, Skeleton, Text } from '@radix-ui/themes'; import { Box, Flex, Skeleton, Text } from "@radix-ui/themes";
import { awaitableAverageDefinitions } from '../../../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from "../../../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions";
import { useLocale } from '../../../../hooks/useLocale'; import { useLocale } from "../../../../hooks/useLocale";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from '../../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
import { LinkI18n } from '../../../LinkI18n'; import { LinkI18n } from "../../../LinkI18n";
type NodeProps = MaybeSkeletonComponentProps & { type NodeProps = MaybeSkeletonComponentProps & {
id: number; id: number;
@@ -21,7 +21,7 @@ const [tankDefinitions, averageDefinitions] = await Promise.all([
export function Node({ id, highlight, nextIds, skeleton }: NodeProps) { export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
const { locale, strings, unwrap } = useLocale(); const { locale, strings, unwrap } = useLocale();
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier); const xpMultiplier = Tankopedia.use((state) => state.xpMultiplier);
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
const nextTanks = nextIds?.map((id) => tankDefinitions.tanks[id]); const nextTanks = nextIds?.map((id) => tankDefinitions.tanks[id]);
const thisTankXp = const thisTankXp =
@@ -36,7 +36,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
? 0 ? 0
: (tank.research_cost.research_cost_type!.value as number) / : (tank.research_cost.research_cost_type!.value as number) /
xpMultiplier), xpMultiplier),
0, 0
); );
const averages = averageDefinitions.averages[tank.id]?.mu; const averages = averageDefinitions.averages[tank.id]?.mu;
const averageXp = averages ? averages.xp / averages.battles : undefined; const averageXp = averages ? averages.xp / averages.battles : undefined;
@@ -49,32 +49,32 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
href={`/tanks/${tankDefinitions.tanks[id].slug}`} href={`/tanks/${tankDefinitions.tanks[id].slug}`}
key={id} key={id}
style={{ style={{
textDecoration: 'none', textDecoration: "none",
color: 'inherit', color: "inherit",
position: 'relative', position: "relative",
}} }}
> >
<Box <Box
mx={highlight ? '2' : '0'} mx={highlight ? "2" : "0"}
style={{ style={{
background: highlight background: highlight
? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})` ? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})`
: undefined, : undefined,
backgroundPosition: 'center', backgroundPosition: "center",
backgroundSize: 'cover', backgroundSize: "cover",
boxShadow: highlight ? 'var(--shadow-4)' : undefined, boxShadow: highlight ? "var(--shadow-4)" : undefined,
borderRadius: 'var(--radius-4)', borderRadius: "var(--radius-4)",
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<Flex <Flex
direction="column" direction="column"
align="center" align="center"
py="4" py="4"
px={highlight ? '4' : '2'} px={highlight ? "4" : "2"}
style={{ style={{
backdropFilter: highlight ? 'brightness(0.25)' : undefined, backdropFilter: highlight ? "brightness(0.25)" : undefined,
WebkitBackdropFilter: highlight ? 'brightness(0.25)' : undefined, WebkitBackdropFilter: highlight ? "brightness(0.25)" : undefined,
}} }}
> >
<img <img
@@ -83,7 +83,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
width={64} width={64}
height={64} height={64}
style={{ style={{
objectFit: 'contain', objectFit: "contain",
}} }}
/> />
@@ -99,12 +99,12 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
<Flex gap="1" align="center"> <Flex gap="1" align="center">
<img <img
alt="XP" alt="XP"
src={asset('icons/currencies/xp.webp')} src={asset("icons/currencies/xp.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
objectFit: 'contain', objectFit: "contain",
objectPosition: 'center', objectPosition: "center",
}} }}
/> />
{formatCompact(locale, thisTankXp)} {formatCompact(locale, thisTankXp)}
@@ -114,12 +114,12 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
<Flex gap="1" align="center"> <Flex gap="1" align="center">
<img <img
alt="Silver" alt="Silver"
src={asset('icons/currencies/silver.webp')} src={asset("icons/currencies/silver.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
objectFit: 'contain', objectFit: "contain",
objectPosition: 'center', objectPosition: "center",
}} }}
/> />
{formatCompact(locale, tank.price.value)} {formatCompact(locale, tank.price.value)}
@@ -135,7 +135,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
? strings.website.tools.tankopedia.tech_tree.battle ? strings.website.tools.tankopedia.tech_tree.battle
: literals( : literals(
strings.website.tools.tankopedia.tech_tree.battles, strings.website.tools.tankopedia.tech_tree.battles,
[`${games}`], [`${games}`]
))} ))}
</Text> </Text>
)} )}
@@ -1,15 +1,15 @@
import { asset, TankType } from '@blitzkit/core'; import { asset, TankType } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons'; import { CaretLeftIcon, CaretRightIcon, PlusIcon } from "@radix-ui/react-icons";
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes'; import { Flex, Heading, IconButton, ScrollArea, Text } from "@radix-ui/themes";
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import { Arrow } from './components/Arrow'; import { Arrow } from "./components/Arrow";
import { Node } from './components/Node'; import { Node } from "./components/Node";
type Line = number[]; type Line = number[];
@@ -20,8 +20,7 @@ export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) { export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
const { locale, strings } = useLocale(); const { locale, strings } = useLocale();
const master = Duel.use((state) => state.protagonist.tank); const master = Duel.use((state) => state.protagonist.tank);
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier); const xpMultiplier = Tankopedia.use((state) => state.xpMultiplier);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const lines = useMemo(() => { const lines = useMemo(() => {
function extend(line: Line): Line[] { function extend(line: Line): Line[] {
@@ -33,8 +32,8 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
if (root.ancestors.length === 1 || root.tier === 2) { if (root.ancestors.length === 1 || root.tier === 2) {
line.push( line.push(
root.ancestors.find( root.ancestors.find(
(ancestor) => !tankDefinitions.tanks[ancestor].deprecated, (ancestor) => !tankDefinitions.tanks[ancestor].deprecated
) ?? root.ancestors[0], ) ?? root.ancestors[0]
); );
return extend(line); return extend(line);
} else { } else {
@@ -53,7 +52,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
const [lineIndex, setLineIndex] = useState(0); const [lineIndex, setLineIndex] = useState(0);
const line = useMemo( const line = useMemo(
() => (lines.length === 0 ? [master.id] : [...lines[lineIndex]].reverse()), () => (lines.length === 0 ? [master.id] : [...lines[lineIndex]].reverse()),
[master, lineIndex], [master, lineIndex]
); );
const totalXp = line.reduce( const totalXp = line.reduce(
(xp, id) => (xp, id) =>
@@ -63,11 +62,11 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
? 0 ? 0
: (tankDefinitions.tanks[id].research_cost.research_cost_type! : (tankDefinitions.tanks[id].research_cost.research_cost_type!
.value as number)), .value as number)),
0, 0
); );
const totalCredits = line.reduce( const totalCredits = line.reduce(
(credits, id) => credits + tankDefinitions.tanks[id].price.value, (credits, id) => credits + tankDefinitions.tanks[id].price.value,
0, 0
); );
useEffect(() => { useEffect(() => {
@@ -88,7 +87,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
direction="column" direction="column"
align="center" align="center"
gap="0" gap="0"
style={{ backgroundColor: 'var(--color-surface)' }} style={{ backgroundColor: "var(--color-surface)" }}
py="6" py="6"
> >
<Flex direction="column" align="center"> <Flex direction="column" align="center">
@@ -102,12 +101,12 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
<Flex gap="1" align="center"> <Flex gap="1" align="center">
<img <img
alt="XP" alt="XP"
src={asset('icons/currencies/xp.webp')} src={asset("icons/currencies/xp.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
objectFit: 'contain', objectFit: "contain",
objectPosition: 'center', objectPosition: "center",
}} }}
/> />
{Math.round(totalXp / xpMultiplier).toLocaleString(locale)} {Math.round(totalXp / xpMultiplier).toLocaleString(locale)}
@@ -118,12 +117,12 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
<Flex gap="1" align="center"> <Flex gap="1" align="center">
<img <img
alt="Silver" alt="Silver"
src={asset('icons/currencies/silver.webp')} src={asset("icons/currencies/silver.webp")}
style={{ style={{
width: '1em', width: "1em",
height: '1em', height: "1em",
objectFit: 'contain', objectFit: "contain",
objectPosition: 'center', objectPosition: "center",
}} }}
/> />
{totalCredits.toLocaleString(locale)} {totalCredits.toLocaleString(locale)}
@@ -140,15 +139,15 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
<Flex <Flex
key={multiplier} key={multiplier}
style={{ style={{
background: 'url(/assets/images/icons/xp-multiplier.png)', background: "url(/assets/images/icons/xp-multiplier.png)",
backgroundSize: 'contain', backgroundSize: "contain",
backgroundRepeat: 'no-repeat', backgroundRepeat: "no-repeat",
backgroundPosition: 'center', backgroundPosition: "center",
opacity: selected ? 1 : 0.5, opacity: selected ? 1 : 0.5,
cursor: 'pointer', cursor: "pointer",
}} }}
onClick={() => { onClick={() => {
mutateTankopediaEphemeral((draft) => { Tankopedia.mutate((draft) => {
draft.xpMultiplier = multiplier; draft.xpMultiplier = multiplier;
}); });
}} }}
@@ -1,11 +1,10 @@
import { ReloadIcon } from '@radix-ui/react-icons'; import { ReloadIcon } from '@radix-ui/react-icons';
import { Button, Flex } from '@radix-ui/themes'; import { Button, Flex } from '@radix-ui/themes';
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from '../../../hooks/useLocale';
import { TierList, tierListInitialState } from '../../../stores/tierList'; import { TierList } from '../../../stores/tierList';
import { Share } from './components/Share'; import { Share } from './components/Share';
export function TierListControls() { export function TierListControls() {
const mutateTierList = TierList.useMutation();
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -13,8 +12,8 @@ export function TierListControls() {
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
mutateTierList((draft) => { TierList.mutate((draft) => {
Object.assign(draft, tierListInitialState); Object.assign(draft, TierList.initial);
}); });
}} }}
> >
@@ -26,7 +26,6 @@ export function Header({ index }: HeaderProps) {
const rowStyle = tierListRows[index]; const rowStyle = tierListRows[index];
const name = TierList.use((state) => state.rows[index].name); const name = TierList.use((state) => state.rows[index].name);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const mutateTierList = TierList.useMutation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -67,9 +66,8 @@ export function Header({ index }: HeaderProps) {
onBlur={() => { onBlur={() => {
if (!input.current) return; if (!input.current) return;
mutateTierList((draft) => { TierList.mutate((draft) => {
if (!input.current) return; if (!input.current) return;
draft.rows[index].name = input.current.value; draft.rows[index].name = input.current.value;
}); });
@@ -25,7 +25,6 @@ type TierListTileProps = {
export function TierListTile(props: TierListTileProps) { export function TierListTile(props: TierListTileProps) {
const card = useRef<HTMLDivElement>(null); const card = useRef<HTMLDivElement>(null);
const mutateTierList = TierList.useMutation();
const lastPosition = useRef({ x: 0, y: 0 }); const lastPosition = useRef({ x: 0, y: 0 });
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => { (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -48,7 +47,7 @@ export function TierListTile(props: TierListTileProps) {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
mutateTierList((draft) => { TierList.mutate((draft) => {
draft.dragging = true; draft.dragging = true;
}); });
}, },
@@ -104,7 +103,7 @@ export function TierListTile(props: TierListTileProps) {
const tileIndex = Number(tile.dataset.tileIndex); const tileIndex = Number(tile.dataset.tileIndex);
mutateTierList((draft) => { TierList.mutate((draft) => {
draft.dragging = false; draft.dragging = false;
draft.rows.forEach((row) => { draft.rows.forEach((row) => {
row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id); row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id);
@@ -118,7 +117,7 @@ export function TierListTile(props: TierListTileProps) {
break rowLoop; break rowLoop;
} }
mutateTierList((draft) => { TierList.mutate((draft) => {
draft.dragging = false; draft.dragging = false;
draft.rows.forEach((row) => { draft.rows.forEach((row) => {
row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id); row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id);
@@ -133,7 +132,7 @@ export function TierListTile(props: TierListTileProps) {
} }
if (!wasPlaced) { if (!wasPlaced) {
mutateTierList((draft) => { TierList.mutate((draft) => {
draft.dragging = false; draft.dragging = false;
draft.rows.forEach((row) => { draft.rows.forEach((row) => {
row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id); row.tanks = row.tanks.filter((tankId) => tankId !== props.tank.id);
@@ -1,15 +1,14 @@
import { metaSortTank } from '@blitzkit/core'; import { metaSortTank } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { useMemo, useState } from "react";
import { useMemo, useState } from 'react'; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions";
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TierList } from "../../stores/tierList";
import { TierList } from '../../stores/tierList'; import { SkeletonTankCard } from "../TankSearch/components/SkeletonTankCard";
import { SkeletonTankCard } from '../TankSearch/components/SkeletonTankCard'; import { TankCardWrapper } from "../TankSearch/components/TankCardWrapper";
import { TankCardWrapper } from '../TankSearch/components/TankCardWrapper'; import { TierListTile } from "./Tile";
import { TierListTile } from './Tile';
const [tankDefinitions, gameDefinitions] = await Promise.all([ const [tankDefinitions, gameDefinitions] = await Promise.all([
awaitableTankDefinitions, awaitableTankDefinitions,
@@ -22,17 +21,17 @@ const PREVIEW_COUNT = 32;
const DEFAULT_LOADED_CARDS = 75; const DEFAULT_LOADED_CARDS = 75;
export function TierListTiles() { export function TierListTiles() {
const filters = useStore($tankFilters); const filters = TankFilters.use();
const placedTanks = TierList.use((state) => state.placedTanks); const placedTanks = TierList.use((state) => state.placedTanks);
const sorted = useMemo( const sorted = useMemo(
() => () =>
metaSortTank( metaSortTank(
tanks.filter( tanks.filter(
(tank) => filterTank(filters, tank) && !placedTanks.has(tank.id), (tank) => filterTank(filters, tank) && !placedTanks.has(tank.id)
), ),
gameDefinitions, gameDefinitions
).reverse(), ).reverse(),
[filters, placedTanks], [filters, placedTanks]
); );
const [loadedTiles, setLoadedTiles] = useState(DEFAULT_LOADED_CARDS); const [loadedTiles, setLoadedTiles] = useState(DEFAULT_LOADED_CARDS);
@@ -7,7 +7,6 @@ const tankDefinitions = await awaitableTankDefinitions;
export function URLManager() { export function URLManager() {
const rows = TierList.use((state) => state.rows); const rows = TierList.use((state) => state.rows);
const mutateTierList = TierList.useMutation();
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -20,7 +19,7 @@ export function URLManager() {
.map(Number) .map(Number)
.filter((id) => id in tankDefinitions.tanks); .filter((id) => id in tankDefinitions.tanks);
mutateTierList((draft) => { TierList.mutate((draft) => {
draft.rows[index].tanks = values; draft.rows[index].tanks = values;
}); });
} }
+8 -1
View File
@@ -127,11 +127,18 @@ export const guessTool: Tool = {
branches: ['main', 'dev', 'opentest', 'preview'], branches: ['main', 'dev', 'opentest', 'preview'],
}; };
export const mixerTool: Tool = {
id: 'mixer',
button: { color: 'red' },
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const TOOLS: Tool[] = [ export const TOOLS: Tool[] = [
tankopediaTool, tankopediaTool,
compareTool, compareTool,
performanceTool, performanceTool,
// guessTool, guessTool,
// mixerTool,
// chartsTool, // chartsTool,
galleryTool, galleryTool,
sessionTool, sessionTool,
@@ -0,0 +1,4 @@
export function isHalloween() {
const data = new Date();
return data.getMonth() === 9 && data.getDate() === 31;
}
@@ -1,19 +1,14 @@
import type { import type { ProvisionDefinitions, TankDefinition } from '@blitzkit/core';
ModelDefinition,
ProvisionDefinitions,
TankDefinition,
} from '@blitzkit/core';
import { uniqueId } from 'lodash-es'; import { uniqueId } from 'lodash-es';
import type { CompareMember } from '../../stores/compareEphemeral'; import type { CompareMember } from '../../stores/compareEphemeral';
import { tankToDuelMember } from './tankToDuelMember'; import { tankToDuelMember } from './tankToDuelMember';
export function tankToCompareMember( export function tankToCompareMember(
tank: TankDefinition, tank: TankDefinition,
model: ModelDefinition,
provisionDefinitions: ProvisionDefinitions, provisionDefinitions: ProvisionDefinitions,
) { ) {
return { return {
...tankToDuelMember(tank, model, provisionDefinitions), ...tankToDuelMember(tank, provisionDefinitions),
key: uniqueId(), key: uniqueId(),
} satisfies CompareMember; } satisfies CompareMember;
} }
@@ -1,6 +1,5 @@
import { import {
createDefaultProvisions, createDefaultProvisions,
ModelDefinition,
type ProvisionDefinitions, type ProvisionDefinitions,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from '@blitzkit/core';
@@ -9,7 +8,6 @@ import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
export function tankToDuelMember( export function tankToDuelMember(
tank: TankDefinition, tank: TankDefinition,
model: ModelDefinition,
provisionDefinitions: ProvisionDefinitions, provisionDefinitions: ProvisionDefinitions,
) { ) {
const turret = tank.turrets.at(-1)!; const turret = tank.turrets.at(-1)!;
@@ -1,5 +1,5 @@
import { TankopediaEphemeral } from '../stores/tankopediaEphemeral'; import { Tankopedia } from "../stores/tankopedia";
export function useTankModelDefinition() { export function useTankModelDefinition() {
return TankopediaEphemeral.use((state) => state.model); return Tankopedia.use((state) => state.model);
} }
@@ -15,7 +15,6 @@ import {
modelTransformEvent, modelTransformEvent,
type ModelTransformEventData, type ModelTransformEventData,
} from '../core/blitzkit/modelTransform'; } from '../core/blitzkit/modelTransform';
import { Duel } from '../stores/duel';
import { useTankModelDefinition } from './useTankModelDefinition'; import { useTankModelDefinition } from './useTankModelDefinition';
export function useTankTransform( export function useTankTransform(
@@ -25,7 +24,6 @@ export function useTankTransform(
gunContainer: RefObject<Group>, gunContainer: RefObject<Group>,
) { ) {
const tankModelDefinition = useTankModelDefinition(); const tankModelDefinition = useTankModelDefinition();
const duelStore = Duel.useStore();
useEffect(() => { useEffect(() => {
const trackModelDefinition = tankModelDefinition.tracks[track.id]; const trackModelDefinition = tankModelDefinition.tracks[track.id];
@@ -1,9 +1,5 @@
--- ---
import { DiscordLogoIcon, PaperPlaneIcon } from '@radix-ui/react-icons';
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { PageWrapper } from '../components/PageWrapper';
import { getStrings } from '../core/i18n/getStrings'; import { getStrings } from '../core/i18n/getStrings';
import BaseLayout from '../layouts/BaseLayout.astro';
export interface Props { export interface Props {
path: string; path: string;
@@ -16,30 +12,29 @@ const strings = getStrings(locale);
const resolvedPath = `/${locale === undefined ? '' : `${locale}/`}${path}`; const resolvedPath = `/${locale === undefined ? '' : `${locale}/`}${path}`;
--- ---
<BaseLayout> <html>
<link <head>
slot="head" <meta charset="utf-8" />
rel="canonical" <link rel="canonical" href={`${Astro.url.origin}${resolvedPath}`} />
href={`${Astro.url.origin}${resolvedPath}`} <meta http-equiv="refresh" content={`0; url=${resolvedPath}`} />
/> </head>
<meta slot="head" http-equiv="refresh" content={`0; url=${resolvedPath}`} />
<PageWrapper align="center" justify="center" gap="0"> <body>
<Heading>{strings.website.redirect.title}</Heading> <div
<Text color="gray">{strings.website.redirect.body}</Text> style={{
display: 'flex',
<Flex mt="4" gap="2"> flexDirection: 'column',
<Link href={resolvedPath}> alignItems: 'center',
<Button> justifyContent: 'center',
<PaperPlaneIcon /> Redirect width: '100vw',
</Button> height: '100vh',
</Link> backgroundColor: 'black',
color: 'white',
<Link href="https://discord.gg/nDt7AjGJQH"> fontFamily: 'sans-serif',
<Button variant="outline"> }}
<DiscordLogoIcon /> Discord >
</Button> <h2>{strings.website.redirect.title}</h2>
</Link> <span>{strings.website.redirect.body}</span>
</Flex> </div>
</PageWrapper> </body>
</BaseLayout> </html>
@@ -1,26 +1,25 @@
import { createDefaultSkills } from '@blitzkit/core'; import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Heading, Text } from '@radix-ui/themes'; import { Flex, Heading, Text } from "@radix-ui/themes";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { CompareTable } from '../../../components/Compare/CompareTable'; import { CompareTable } from "../../../components/Compare/CompareTable";
import { Controls } from '../../../components/Compare/Controls'; import { Controls } from "../../../components/Compare/Controls";
import { DamageWarning } from '../../../components/DamageWarning'; import { DamageWarning } from "../../../components/DamageWarning";
import { PageWrapper } from '../../../components/PageWrapper'; import { PageWrapper } from "../../../components/PageWrapper";
import { awaitableEquipmentDefinitions } from '../../../core/awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from "../../../core/awaitables/equipmentDefinitions";
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from '../../../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from "../../../core/awaitables/skillDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { tankCharacteristics } from '../../../core/blitzkit/tankCharacteristics'; import { tankCharacteristics } from "../../../core/blitzkit/tankCharacteristics";
import { tankToCompareMember } from '../../../core/blitzkit/tankToCompareMember'; import { tankToCompareMember } from "../../../core/blitzkit/tankToCompareMember";
import { import {
type LocaleAcceptorProps, type LocaleAcceptorProps,
LocaleProvider, LocaleProvider,
useLocale, useLocale,
} from '../../../hooks/useLocale'; } from "../../../hooks/useLocale";
import { App } from '../../../stores/app'; import { App } from "../../../stores/app";
import { CompareEphemeral } from '../../../stores/compareEphemeral'; import { CompareEphemeral } from "../../../stores/compareEphemeral";
import { ComparePersistent } from '../../../stores/comparePersistent'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
const [ const [
modelDefinitions, modelDefinitions,
@@ -37,17 +36,13 @@ const [
]); ]);
export function Page({ locale }: LocaleAcceptorProps) { export function Page({ locale }: LocaleAcceptorProps) {
CompareEphemeral.useInitialization(createDefaultSkills(skillDefinitions));
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<App.Provider> <App.Provider>
<TankopediaPersistent.Provider> <TankopediaPersistent.Provider>
<ComparePersistent.Provider> <Content />
<CompareEphemeral.Provider
data={createDefaultSkills(skillDefinitions)}
>
<Content />
</CompareEphemeral.Provider>
</ComparePersistent.Provider>
</TankopediaPersistent.Provider> </TankopediaPersistent.Provider>
</App.Provider> </App.Provider>
</LocaleProvider> </LocaleProvider>
@@ -72,18 +67,15 @@ function Content() {
stockTurret: thisMember.tank.turrets[0], stockTurret: thisMember.tank.turrets[0],
applyReactiveArmor: members.some( applyReactiveArmor: members.some(
(member) => (member) =>
member.key !== thisMember.key && member.key !== thisMember.key && member.consumables.includes(33)
member.consumables.includes(33),
), ),
applyDynamicArmor: members.some( applyDynamicArmor: members.some(
(member) => (member) =>
member.key !== thisMember.key && member.key !== thisMember.key && member.consumables.includes(73)
member.consumables.includes(73),
), ),
applySpallLiner: members.some( applySpallLiner: members.some(
(member) => (member) =>
member.key !== thisMember.key && member.key !== thisMember.key && member.provisions.includes(101)
member.provisions.includes(101),
), ),
assaultDistance: thisMember.assaultDistance, assaultDistance: thisMember.assaultDistance,
}, },
@@ -91,22 +83,21 @@ function Content() {
equipmentDefinitions: equipmentDefinitions, equipmentDefinitions: equipmentDefinitions,
provisionDefinitions: provisionDefinitions, provisionDefinitions: provisionDefinitions,
tankModelDefinition: modelDefinitions.models[thisMember.tank.id], tankModelDefinition: modelDefinitions.models[thisMember.tank.id],
}, }
), )
), ),
[members, crewSkills], [members, crewSkills]
); );
const mutateCompareEphemeral = CompareEphemeral.useMutation();
useEffect(() => { useEffect(() => {
const search = new URLSearchParams(window.location.search); const search = new URLSearchParams(window.location.search);
const tanksQuery = search.get('tanks'); const tanksQuery = search.get("tanks");
if (tanksQuery === null) return; if (tanksQuery === null) return;
const tanks = tanksQuery.split(',').map(Number); const tanks = tanksQuery.split(",").map(Number);
mutateCompareEphemeral((draft) => { CompareEphemeral.mutate((draft) => {
draft.members = []; draft.members = [];
tanks.forEach((id) => { tanks.forEach((id) => {
@@ -114,11 +105,7 @@ function Content() {
if (tank === undefined) return; if (tank === undefined) return;
const model = modelDefinitions.models[tank.id]; draft.members.push(tankToCompareMember(tank, provisionDefinitions));
draft.members.push(
tankToCompareMember(tank, model, provisionDefinitions),
);
}); });
}); });
}, []); }, []);
@@ -126,12 +113,12 @@ function Content() {
useEffect(() => { useEffect(() => {
const search = new URLSearchParams(window.location.search); const search = new URLSearchParams(window.location.search);
search.set('tanks', members.map((member) => member.tank.id).join(',')); search.set("tanks", members.map((member) => member.tank.id).join(","));
window.history.replaceState( window.history.replaceState(
{}, {},
'', "",
`${window.location.pathname}?${search.toString()}`, `${window.location.pathname}?${search.toString()}`
); );
}, [members]); }, [members]);
@@ -1,12 +1,11 @@
import { GalleryList } from '../../../components/Gallery/List'; import { GalleryList } from "../../../components/Gallery/List";
import { GallerySearch } from '../../../components/Gallery/Search'; import { GallerySearch } from "../../../components/Gallery/Search";
import { PageWrapper } from '../../../components/PageWrapper'; import { PageWrapper } from "../../../components/PageWrapper";
import { import {
LocaleProvider, LocaleProvider,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../../hooks/useLocale'; } from "../../../hooks/useLocale";
import { GalleryEphemeral } from '../../../stores/galleryEphemeral'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
export function Page({ export function Page({
locale, locale,
@@ -22,10 +21,8 @@ export function Page({
function Content({ skeleton }: MaybeSkeletonComponentProps) { function Content({ skeleton }: MaybeSkeletonComponentProps) {
return ( return (
<PageWrapper maxWidth="80rem" color="gold"> <PageWrapper maxWidth="80rem" color="gold">
<GalleryEphemeral.Provider> <GallerySearch skeleton={skeleton} />
<GallerySearch skeleton={skeleton} /> <GalleryList skeleton={skeleton} />
<GalleryList skeleton={skeleton} />
</GalleryEphemeral.Provider>
</PageWrapper> </PageWrapper>
); );
} }
@@ -1,21 +1,20 @@
import { Box, Flex, Heading } from '@radix-ui/themes'; import { Box, Flex, Heading } from "@radix-ui/themes";
import { Suspense } from 'react'; import { Suspense } from "react";
import { GuessBackground } from '../../../components/GuessBackground'; import { GuessBackground } from "../../../components/GuessBackground";
import { Guesser } from '../../../components/Guesser'; import { Guesser } from "../../../components/Guesser";
import { GuessRenderer } from '../../../components/GuessRenderer'; import { GuessRenderer } from "../../../components/GuessRenderer";
import { GuessRendererLoader } from '../../../components/GuessRendererLoader'; import { GuessRendererLoader } from "../../../components/GuessRendererLoader";
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../../core/awaitables/provisionDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { Var } from '../../../core/radix/var';
import { import {
type LocaleAcceptorProps, type LocaleAcceptorProps,
LocaleProvider, LocaleProvider,
useLocale, useLocale,
} from '../../../hooks/useLocale'; } from "../../../hooks/useLocale";
import { Duel } from '../../../stores/duel'; import { Duel } from "../../../stores/duel";
import { GuessEphemeral } from '../../../stores/guessEphemeral'; import { Guess, GuessState } from "../../../stores/guess";
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../stores/tankopedia";
const [tankDefinitions, modelDefinitions, provisionDefinitions] = const [tankDefinitions, modelDefinitions, provisionDefinitions] =
await Promise.all([ await Promise.all([
@@ -27,95 +26,92 @@ const [tankDefinitions, modelDefinitions, provisionDefinitions] =
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
export function Page({ locale }: LocaleAcceptorProps) { export function Page({ locale }: LocaleAcceptorProps) {
const initialId = Number(ids[Math.floor(Math.random() * ids.length)]); const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const initialTank = tankDefinitions.tanks[initialId]; const tank = tankDefinitions.tanks[id];
Guess.useInitialization(tank);
return ( return (
<GuessEphemeral.Provider data={initialTank}> <LocaleProvider locale={locale}>
<LocaleProvider locale={locale}> <Container />
<Page2 /> </LocaleProvider>
</LocaleProvider>
</GuessEphemeral.Provider>
); );
} }
function Page2() { function Container() {
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const model = modelDefinitions.models[tank.id]; const model = modelDefinitions.models[tank.id];
Tankopedia.useInitialization(model);
return ( return (
<TankopediaEphemeral.Provider data={model}> <Duel.Provider data={{ tank, model, provisionDefinitions }}>
<Duel.Provider data={{ tank, model, provisionDefinitions }}> <Content />
<Content /> </Duel.Provider>
</Duel.Provider>
</TankopediaEphemeral.Provider>
); );
} }
function Content() { function Content() {
const { unwrap } = useLocale(); const { unwrap } = useLocale();
const tank = GuessEphemeral.use((state) => state.tank); const tank = Guess.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState); const guessState = Guess.use((state) => state.guessState);
const isRevealed = guessState !== null; const isRevealed = guessState !== GuessState.NotGuessed;
const name = unwrap(tank.name);
const fontSize = `min(48vh, ${55 / name.length}vw)`;
const transitionDuration = isRevealed ? "2s" : undefined;
return ( return (
<Flex flexGrow="1" position="relative" overflow="hidden"> <Flex flexGrow="1" position="relative" overflow="hidden">
<GuessBackground /> <GuessBackground />
<Flex <Heading
position="absolute"
left="50%"
top="50%"
ml="9"
style={{ style={{
transitionDuration,
opacity: isRevealed ? 1 : 0, opacity: isRevealed ? 1 : 0,
transitionDuration: isRevealed ? '2s' : undefined, fontWeight: 900,
transform: 'translateY(-50%)', position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}} }}
> >
<Heading weight="medium" size="9"> {name}
{unwrap(tank.name)} </Heading>
</Heading>
</Flex>
<Box <Box
position="absolute" position="absolute"
top="0"
left="0"
width="100%" width="100%"
height="100%" height="100%"
top="0" style={{ transitionDuration }}
right={isRevealed ? '16rem' : '0'}
style={{
transitionDuration: isRevealed ? '2s' : undefined,
}}
> >
<Suspense fallback={<GuessRendererLoader />}> <Suspense fallback={<GuessRendererLoader />}>
<GuessRenderer /> <GuessRenderer />
</Suspense> </Suspense>
</Box> </Box>
<Flex <Heading
position="absolute"
left="50%"
top="50%"
ml="9"
style={{ style={{
opacity: isRevealed ? 1 : 0, transitionDuration,
transitionDuration: isRevealed ? '2s' : undefined, opacity: isRevealed ? 0.5 : 0,
transform: 'translateY(-50%)', fontWeight: 900,
color: 'transparent', position: "absolute",
WebkitTextStroke: `1px ${Var('gray-12')}`, left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}} }}
> >
<Heading {name}
style={{ </Heading>
WebkitTextStroke: `1px inherit`,
}}
weight="medium"
size="9"
>
{unwrap(tank.name)}
</Heading>
</Flex>
<Guesser /> <Guesser />
</Flex> </Flex>
@@ -7,7 +7,6 @@ import { Box, Flex, Heading, Link } from '@radix-ui/themes';
import type { GetStaticPaths } from 'astro'; import type { GetStaticPaths } from 'astro';
import { AesonPlug } from '../../components/AesonPlug'; import { AesonPlug } from '../../components/AesonPlug';
import HomeHero from '../../components/HomeHero.astro'; import HomeHero from '../../components/HomeHero.astro';
import { HomePageHotTanks } from '../../components/HomePageHotTanks';
import { Plugs } from '../../components/Plugs'; import { Plugs } from '../../components/Plugs';
import ToolCard from '../../components/ToolCard.astro'; import ToolCard from '../../components/ToolCard.astro';
import { import {
@@ -15,6 +14,7 @@ import {
discordTool, discordTool,
embedTool, embedTool,
galleryTool, galleryTool,
guessTool,
moreTool, moreTool,
performanceTool, performanceTool,
sessionTool, sessionTool,
@@ -25,7 +25,6 @@ import { getStrings } from '../../core/i18n/getStrings';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_BRANCH); const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_BRANCH);
const MODE = assertSecret(import.meta.env.MODE);
const locale = Astro.params.locale ?? DEFAULT_LOCALE; const locale = Astro.params.locale ?? DEFAULT_LOCALE;
const strings = getStrings(Astro.params.locale); const strings = getStrings(Astro.params.locale);
@@ -110,22 +109,22 @@ export const getStaticPaths = (() => {
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}> <Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<!-- <ToolCard tool={chartsTool} /> --> <!-- <ToolCard tool={chartsTool} /> -->
<!-- <ToolCard tool={guessTool} /> --> <ToolCard tool={guessTool} />
<ToolCard tool={galleryTool} /> <ToolCard tool={galleryTool} />
</Flex>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={sessionTool} /> <ToolCard tool={sessionTool} />
</Flex>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={discordTool} /> <ToolCard tool={discordTool} />
</Flex>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={tierListTool} /> <ToolCard tool={tierListTool} />
</Flex>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={embedTool} /> <ToolCard tool={embedTool} />
<ToolCard tool={moreTool} />
</Flex> </Flex>
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}> <Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<ToolCard tool={moreTool} />
<Box flexGrow="1" /> <Box flexGrow="1" />
</Flex> </Flex>
@@ -0,0 +1,62 @@
import { Box } from "@radix-ui/themes";
import { useRef } from "react";
import { MixerScene } from "../../../components/MixerScene";
import { PageWrapper } from "../../../components/PageWrapper";
import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
import { Mixer } from "../../../stores/mixer";
const tankDefinitions = await awaitableTankDefinitions;
const tanks = Object.values(tankDefinitions.tanks);
export function Page({ locale }: LocaleAcceptorProps) {
const hull = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
const turretTank = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
const turret = useRef(
turretTank.current.turrets[
Math.floor(Math.random() * turretTank.current.turrets.length)
]
);
const gunTank = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
const gunTurret = useRef(
gunTank.current.turrets[
Math.floor(Math.random() * gunTank.current.turrets.length)
]
);
const gun = useRef(
gunTurret.current.guns[
Math.floor(Math.random() * gunTurret.current.guns.length)
]
);
Mixer.useInitialization({
hull: hull.current,
turret: { tank: turretTank.current, turret: turret.current },
gun: {
tank: gunTank.current,
turret: gunTurret.current,
gun: gun.current,
},
});
return (
<LocaleProvider locale={locale}>
<Content />
</LocaleProvider>
);
}
function Content() {
return (
<PageWrapper p="0">
<Box flexGrow="1" position="relative">
<Box position="absolute" width="100%" height="100%" top="0" left="0">
<MixerScene />
</Box>
</Box>
</PageWrapper>
);
}
@@ -0,0 +1,15 @@
---
import { PageWrapper } from '../../../components/PageWrapper';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../index.astro';
const { locale } = Astro.params;
---
<BaseLayout>
<PageWrapper p="0" maxWidth="unset">
<Page {locale} client:load />
</PageWrapper>
</BaseLayout>
@@ -7,7 +7,6 @@ import {
LocaleProvider, LocaleProvider,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../../hooks/useLocale'; } from '../../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../../stores/tankPerformanceEphemeral';
import { TankPerformanceSort } from '../../../stores/tankPerformanceSort'; import { TankPerformanceSort } from '../../../stores/tankPerformanceSort';
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
@@ -17,9 +16,7 @@ export function Page({ skeleton, locale }: PageProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<TankPerformanceSort.Provider> <TankPerformanceSort.Provider>
<TankPerformanceEphemeral.Provider> <Content skeleton={skeleton} />
<Content skeleton={skeleton} />
</TankPerformanceEphemeral.Provider>
</TankPerformanceSort.Provider> </TankPerformanceSort.Provider>
</LocaleProvider> </LocaleProvider>
); );
@@ -12,13 +12,13 @@ import {
idToRegion, idToRegion,
searchPlayersAcrossRegions, searchPlayersAcrossRegions,
sumCompositeStats, sumCompositeStats,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n'; import { literals } from "@blitzkit/i18n";
import { import {
ArrowDownIcon, ArrowDownIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PlusIcon, PlusIcon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
import { import {
AlertDialog, AlertDialog,
Button, Button,
@@ -30,21 +30,21 @@ import {
Table, Table,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { debounce } from 'lodash-es'; import { debounce } from "lodash-es";
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from "react";
import { PageWrapper } from '../../../components/PageWrapper'; import { PageWrapper } from "../../../components/PageWrapper";
import { StickyRowHeaderCell } from '../../../components/StickyRowHeaderCell'; import { StickyRowHeaderCell } from "../../../components/StickyRowHeaderCell";
import { StickyTableRoot } from '../../../components/StickyTableRoot'; import { StickyTableRoot } from "../../../components/StickyTableRoot";
import { TankRowHeaderCell } from '../../../components/TankRowHeaderCell'; import { TankRowHeaderCell } from "../../../components/TankRowHeaderCell";
import { awaitableAverageDefinitions } from '../../../core/awaitables/averageDefinitions'; import { awaitableAverageDefinitions } from "../../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { import {
type LocaleAcceptorProps, type LocaleAcceptorProps,
LocaleProvider, LocaleProvider,
useLocale, useLocale,
} from '../../../hooks/useLocale'; } from "../../../hooks/useLocale";
import { Session, type SessionTracking } from '../../../stores/session'; import { Session, type SessionTracking } from "../../../stores/session";
const [tankDefinitions, averageDefinitions] = await Promise.all([ const [tankDefinitions, averageDefinitions] = await Promise.all([
awaitableTankDefinitions, awaitableTankDefinitions,
@@ -54,9 +54,7 @@ const [tankDefinitions, averageDefinitions] = await Promise.all([
export function Page({ locale }: LocaleAcceptorProps) { export function Page({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<Session.Provider> <Content />
<Content />
</Session.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -70,9 +68,8 @@ function Content() {
useState(false); useState(false);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const session = Session.use(); const session = Session.use();
const mutateSession = Session.useMutation();
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[] | null>( const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[] | null>(
null, null
); );
const [accountInfo, setAccountInfo] = useState< const [accountInfo, setAccountInfo] = useState<
IndividualAccountInfo | null | undefined IndividualAccountInfo | null | undefined
@@ -84,7 +81,7 @@ function Content() {
if (!id) return setAccountInfo(null); if (!id) return setAccountInfo(null);
setAccountInfo( setAccountInfo(
session.tracking ? await getAccountInfo(idToRegion(id), id) : null, session.tracking ? await getAccountInfo(idToRegion(id), id) : null
); );
})(); })();
}, [session.tracking && session.player.id]); }, [session.tracking && session.player.id]);
@@ -98,20 +95,20 @@ function Content() {
const average = averageDefinitions.averages[tank.id]; const average = averageDefinitions.averages[tank.id];
const composite = compositeStats( const composite = compositeStats(
{ ...entry.all, battle_life_time: entry.battle_life_time }, { ...entry.all, battle_life_time: entry.battle_life_time },
average?.mu, average?.mu
); );
return { tank, composite }; return { tank, composite };
}) })
: undefined, : undefined,
[session.tracking && session.player, tankStatsB], [session.tracking && session.player, tankStatsB]
); );
const total = useMemo( const total = useMemo(
() => () =>
delta delta
? sumCompositeStats(delta.map(({ composite }) => composite)) ? sumCompositeStats(delta.map(({ composite }) => composite))
: undefined, : undefined,
[delta], [delta]
); );
const search = debounce(async () => { const search = debounce(async () => {
if (!input.current) return; if (!input.current) return;
@@ -131,7 +128,8 @@ function Content() {
} }
setShowSearch(false); setShowSearch(false);
mutateSession((draft) => {
Session.mutate((draft) => {
if (!input.current) return; if (!input.current) return;
draft.tracking = true; draft.tracking = true;
@@ -152,7 +150,7 @@ function Content() {
const tankStats = await getTankStats( const tankStats = await getTankStats(
idToRegion(session.player.id), idToRegion(session.player.id),
session.player.id, session.player.id
); );
setTankStatsB(tankStats); setTankStatsB(tankStats);
} }
@@ -206,8 +204,8 @@ function Content() {
variant="classic" variant="classic"
size="3" size="3"
style={{ style={{
position: 'relative', position: "relative",
width: '75vw', width: "75vw",
maxWidth: 480, maxWidth: 480,
}} }}
ref={input} ref={input}
@@ -272,7 +270,7 @@ function Content() {
<Flex <Flex
mt="2" mt="2"
mb="2" mb="2"
direction={{ initial: 'column', sm: 'row' }} direction={{ initial: "column", sm: "row" }}
align="center" align="center"
justify="between" justify="between"
gap="2" gap="2"
@@ -296,7 +294,7 @@ function Content() {
onClick={async () => { onClick={async () => {
const stats = await getTankStats( const stats = await getTankStats(
session.player.region, session.player.region,
session.player.id, session.player.id
); );
if (stats === null) { if (stats === null) {
@@ -304,7 +302,7 @@ function Content() {
return; return;
} }
mutateSession((draft) => { Session.mutate((draft) => {
(draft as SessionTracking).player.stats = stats; (draft as SessionTracking).player.stats = stats;
}); });
}} }}
@@ -313,7 +311,7 @@ function Content() {
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
mutateSession((draft) => { Session.mutate((draft) => {
draft.tracking = false; draft.tracking = false;
}); });
}} }}
@@ -338,7 +336,7 @@ function Content() {
align="center" align="center"
width="0" width="0"
key={column} key={column}
style={{ position: 'relative' }} style={{ position: "relative" }}
> >
{index === 0 && session.columns.length < 4 && ( {index === 0 && session.columns.length < 4 && (
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -347,10 +345,10 @@ function Content() {
size="1" size="1"
variant="surface" variant="surface"
style={{ style={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
top: '50%', top: "50%",
transform: 'translate(-100%, -50%)', transform: "translate(-100%, -50%)",
zIndex: 1, zIndex: 1,
}} }}
> >
@@ -365,9 +363,9 @@ function Content() {
<DropdownMenu.Item <DropdownMenu.Item
key={key} key={key}
onClick={() => { onClick={() => {
mutateSession((draft) => { Session.mutate((draft) => {
(draft as SessionTracking).columns.unshift( (draft as SessionTracking).columns.unshift(
key, key
); );
}); });
}} }}
@@ -382,8 +380,8 @@ function Content() {
<Select.Root <Select.Root
value={column} value={column}
onValueChange={(value) => { onValueChange={(value) => {
mutateSession((draft) => { Session.mutate((draft) => {
if (value === 'remove') { if (value === "remove") {
(draft as SessionTracking).columns.splice(index, 1); (draft as SessionTracking).columns.splice(index, 1);
} else { } else {
(draft as SessionTracking).columns[index] = (draft as SessionTracking).columns[index] =
@@ -404,8 +402,7 @@ function Content() {
{compositeStatsKeys {compositeStatsKeys
.filter( .filter(
(item) => (item) =>
!session.columns.includes(item) || !session.columns.includes(item) || item === column
item === column,
) )
.map((key) => ( .map((key) => (
<Select.Item key={key} value={key}> <Select.Item key={key} value={key}>
@@ -422,14 +419,14 @@ function Content() {
<Table.Body> <Table.Body>
<Table.Row <Table.Row
style={{ style={{
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<StickyRowHeaderCell <StickyRowHeaderCell
style={{ style={{
paddingLeft: 32, paddingLeft: 32,
position: 'relative', position: "relative",
overflowY: 'hidden', overflowY: "hidden",
}} }}
> >
{strings.website.tools.session.table.total} {strings.website.tools.session.table.total}
@@ -446,7 +443,7 @@ function Content() {
<Table.Row <Table.Row
key={tank.id} key={tank.id}
style={{ style={{
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<TankRowHeaderCell tank={tank} /> <TankRowHeaderCell tank={tank} />
@@ -455,7 +452,7 @@ function Content() {
{formatCompositeStat( {formatCompositeStat(
composite[column], composite[column],
column, column,
composite, composite
)} )}
</Table.Cell> </Table.Cell>
))} ))}
@@ -1,26 +1,26 @@
import { TankType } from '@blitzkit/core'; import { TankType } from "@blitzkit/core";
import { PageWrapper } from '../../../../components/PageWrapper'; import { PageWrapper } from "../../../../components/PageWrapper";
import { CalloutsSection } from '../../../../components/Tankopedia/CalloutsSection'; import { CalloutsSection } from "../../../../components/Tankopedia/CalloutsSection";
import { CharacteristicsSection } from '../../../../components/Tankopedia/CharacteristicsSection'; import { CharacteristicsSection } from "../../../../components/Tankopedia/CharacteristicsSection";
import { GameModeSection } from '../../../../components/Tankopedia/GameModeSection'; import { GameModeSection } from "../../../../components/Tankopedia/GameModeSection";
import { GuideSection } from '../../../../components/Tankopedia/GuideSection'; import { GuideSection } from "../../../../components/Tankopedia/GuideSection";
import { HeroSection } from '../../../../components/Tankopedia/HeroSection'; import { HeroSection } from "../../../../components/Tankopedia/HeroSection";
import { MetaSection } from '../../../../components/Tankopedia/MetaSection'; import { MetaSection } from "../../../../components/Tankopedia/MetaSection";
import { TechTreeSection } from '../../../../components/Tankopedia/TechTreeSection'; import { TechTreeSection } from "../../../../components/Tankopedia/TechTreeSection";
import { VideoSection } from '../../../../components/Tankopedia/VideoSection'; import { VideoSection } from "../../../../components/Tankopedia/VideoSection";
import { awaitableModelDefinitions } from '../../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from "../../../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from "../../../../core/awaitables/provisionDefinitions";
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions";
import { import {
LocaleProvider, LocaleProvider,
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../../../hooks/useLocale'; } from "../../../../hooks/useLocale";
import { App } from '../../../../stores/app'; import { App } from "../../../../stores/app";
import { Duel } from '../../../../stores/duel'; import { Duel } from "../../../../stores/duel";
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { Tankopedia } from "../../../../stores/tankopedia";
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from "../../../../stores/tankopediaPersistent";
import type { MaybeSkeletonComponentProps } from '../../../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
import type { TankGuide } from './index.astro'; import type { TankGuide } from "./index.astro";
type PageProps = MaybeSkeletonComponentProps & type PageProps = MaybeSkeletonComponentProps &
LocaleAcceptorProps & { LocaleAcceptorProps & {
@@ -39,30 +39,30 @@ export function Page({ id, skeleton, locale, guide }: PageProps) {
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
const model = modelDefinitions.models[id]; const model = modelDefinitions.models[id];
Tankopedia.useInitialization(model);
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<TankopediaEphemeral.Provider data={model}> <App.Provider>
<App.Provider> <TankopediaPersistent.Provider>
<TankopediaPersistent.Provider> <Duel.Provider
<Duel.Provider data={{ tank, model, provisionDefinitions: provisionDefinitions }}
data={{ tank, model, provisionDefinitions: provisionDefinitions }} >
> <PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9">
<PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9"> <HeroSection skeleton={skeleton} />
<HeroSection skeleton={skeleton} /> <MetaSection />
<MetaSection /> <CalloutsSection />
<CalloutsSection /> {tank.type === TankType.RESEARCHABLE && !tank.deprecated && (
{tank.type === TankType.RESEARCHABLE && !tank.deprecated && ( <TechTreeSection skeleton={skeleton} />
<TechTreeSection skeleton={skeleton} /> )}
)} <CharacteristicsSection skeleton={skeleton} />
<CharacteristicsSection skeleton={skeleton} /> <GameModeSection />
<GameModeSection /> {guide && <GuideSection guide={guide} />}
{guide && <GuideSection guide={guide} />} <VideoSection skeleton={skeleton} />
<VideoSection skeleton={skeleton} /> </PageWrapper>
</PageWrapper> </Duel.Provider>
</Duel.Provider> </TankopediaPersistent.Provider>
</TankopediaPersistent.Provider> </App.Provider>
</App.Provider>
</TankopediaEphemeral.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
@@ -9,16 +9,13 @@ import {
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from '../../../hooks/useLocale'; } from '../../../hooks/useLocale';
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
import { TierList } from '../../../stores/tierList';
export function Page({ locale }: LocaleAcceptorProps) { export function Page({ locale }: LocaleAcceptorProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<TierList.Provider> <TankopediaPersistent.Provider>
<TankopediaPersistent.Provider> <Content />
<Content /> </TankopediaPersistent.Provider>
</TankopediaPersistent.Provider>
</TierList.Provider>
</LocaleProvider> </LocaleProvider>
); );
} }
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more