Compare commits

...
42 Commits
Author SHA1 Message Date
TrèsAbhi f22f4eeccd Update website.yml 2025-08-18 11:56:41 -05:00
TrèsAbhi 79c450798c feat: logs 2025-08-18 11:28:16 -05:00
TrèsAbhi d247e67690 fix: link the whole frame 2025-08-18 11:21:16 -05:00
TrèsAbhi 69b2035cfc fix: module damage no longer scales with assault shells 2025-08-18 11:14:46 -05:00
TrèsAbhi 62fed63310 fix: max width docs images 2025-08-18 11:11:35 -05:00
TrèsAbhi 7c29046390 fix: doubled range 2025-08-18 11:05:10 -05:00
TrèsAbhi b65c3c1d93 feat: some recoloring 2025-08-18 10:54:15 -05:00
TrèsAbhi 87f5d8fe8f Merge branch 'main' into dev 2025-08-18 10:51:38 -05:00
TrèsAbhi 525b9377cc feat: optimize info lines 2025-08-12 13:48:10 -05:00
TrèsAbhi 94cba0c9ba feat: remove reactive pitch and yaw 2025-08-12 13:43:11 -05:00
TrèsAbhi 4ddf77fc7f fix: invalidate 2025-08-12 13:20:29 -05:00
TrèsAbhi ae6dc259dc feat: rename things 2025-08-12 13:17:07 -05:00
TrèsAbhi c2e95f6ef6 feat: hide ReloadVisualizer 2025-08-12 13:11:59 -05:00
TrèsAbhi 868812e578 feat adjust coloring 2025-08-12 13:11:34 -05:00
TrèsAbhi b5064f9e8b fix: touch action 2025-08-12 12:59:46 -05:00
TrèsAbhi a0eb0d54f0 feat: syncronize tank model 2025-08-12 12:52:57 -05:00
TrèsAbhi e47b1315a9 feat: hull rotate 2025-08-12 12:45:23 -05:00
TrèsAbhi cde6904513 feat: yaw support 2025-08-11 11:34:51 -05:00
TrèsAbhi 997d1df8f9 fix: auto rotate disabling 2025-08-11 11:26:53 -05:00
TrèsAbhi 9f15ded0c9 feat: optimize rendering 2025-08-11 11:25:25 -05:00
TrèsAbhi b2ee97b3d4 feat: active camera positioner 2025-08-11 11:17:26 -05:00
TrèsAbhi 03d819a6c1 feat: dog 2025-08-11 11:08:47 -05:00
TrèsAbhi 8ff26d57f6 feat: some model 2025-08-10 20:36:48 -05:00
TrèsAbhi c243ebd19a feat: embedded canvas 2025-08-10 14:03:13 -05:00
TrèsAbhi 40f5042896 feat: cursor 2025-08-09 13:30:53 -05:00
TrèsAbhi 21130b29f8 fix: 90 / 2 2025-08-09 12:39:20 -05:00
TrèsAbhi d50b054243 fix: unintended space before cross 2025-08-09 12:32:37 -05:00
TrèsAbhi 6b7f8049fe feat: elevation 2025-08-09 12:31:36 -05:00
TrèsAbhi e6029157aa feat: allow scrolling on hero section 2025-08-09 12:07:26 -05:00
TrèsAbhi ba656eb603 fix: remove dead code 2025-08-09 11:59:47 -05:00
TrèsAbhi e8dd26b2ba feat: moved shadow down 2025-08-09 11:52:10 -05:00
TrèsAbhi 897da9479d feat: transition and continous 2025-08-08 12:29:21 -05:00
TrèsAbhi 291395718f feat: yaw 2025-08-08 12:09:16 -05:00
TrèsAbhi 42e860b171 feat: start of gun depression visualzier 2025-08-08 12:00:33 -05:00
TrèsAbhi 3b6d339310 feat: compact info lines 2025-08-08 11:03:20 -05:00
TrèsAbhi 728fd5b824 feat: remove dpm counter 2025-08-08 11:01:26 -05:00
TrèsAbhi 34217b2754 feat: more progress 2025-08-08 10:57:39 -05:00
TrèsAbhi 0b5c88531f feat: guidesblitz 2025-08-07 20:24:19 -05:00
TrèsAbhi e145d7c6e8 Merge branch 'main' into dev 2025-08-07 14:11:24 -05:00
TrèsAbhi 86f4712b87 feat: subtler advanced highlighting 2025-08-06 12:33:50 -05:00
TrèsAbhi ea90ca21b1 feat: remove shooting range 2025-08-06 12:23:57 -05:00
TrèsAbhi b3046d8655 feat: enable reload visualizer 2025-08-06 12:16:14 -05:00
57 changed files with 1284 additions and 870 deletions

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ env:
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }} PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }} PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }} PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
jobs: jobs:
build: build:
+1
View File
@@ -222,6 +222,7 @@
"yolo", "yolo",
"youtuber", "youtuber",
"youtubers", "youtubers",
"zoomable",
"zustand" "zustand"
], ],
"deno.enable": false, "deno.enable": false,
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
# BlitzKit 1.27.16
## Features
- Added links to GuidesBlitz to Tankopedia to aid users understand tanks beyond just numbers and stats
- Added a gun depression and yaw visualizer
## Changes
- Made the colors of the advanced highlighting mode more subtle
- Made the Tankopedia stat lines more condensed
- You can now scroll the page even with your cursor on the Tankopedia sandbox
## Fixes
- Fixed a bug where the range of increased gun depression was incorrectly doubled
- Removed an unintended space before the "x" in the "Gun damaged" stat
- Fixed an issue causing the 3D renderer to render frames even when off screen
- Fixed a differential issue causing excessive rerendering of the stat lines
- Images no longer exceed the width of the device in the docs
- Assault shells no longer scale the module damage with distance
## Technical Changes
- Removed all code relating to the canceled "Shooting Range" mode
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"private": true, "private": true,
"version": "1.27.15", "version": "1.27.16",
"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",
@@ -41,6 +41,7 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cheerio": "^1.1.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"protobufjs": "^7.5.2", "protobufjs": "^7.5.2",
+7 -1
View File
@@ -913,6 +913,13 @@
"percentage": "%s1%", "percentage": "%s1%",
"ghost_factory": "Ghost Factory", "ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail" "dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
} }
}, },
@@ -1026,7 +1033,6 @@
"review": { "review": {
"title": "Review videos", "title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program" "join": "Apply to %s1 YouTuber program"
}, },
@@ -62,6 +62,7 @@ export async function tankIcons() {
const nationVehicleId = tank.id; const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1; const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
); );
@@ -1,7 +1,7 @@
import { toUniqueId } from '@blitzkit/core'; import { toUniqueId } from '@blitzkit/core';
import { NodeIO } from '@gltf-transform/core'; import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { readdir, writeFile } from 'fs/promises'; import { readdir } from 'fs/promises';
import { extractModel } from '../core/blitz/extractModel'; import { extractModel } from '../core/blitz/extractModel';
import { readXMLDVPL } from '../core/blitz/readXMLDVPL'; import { readXMLDVPL } from '../core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL';
@@ -32,6 +32,7 @@ export async function tankModels() {
if (tankKey.includes('tutorial_bot')) continue; if (tankKey.includes('tutorial_bot')) continue;
const id = toUniqueId(nation, tank.id); const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
); );
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

@@ -7,8 +7,10 @@ import { invalidate, useFrame } from '@react-three/fiber';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import {
MeshBasicMaterial, MeshBasicMaterial,
NormalBlending,
Object3D, Object3D,
ShaderMaterial, ShaderMaterial,
SubtractiveBlending,
UniformsLib, UniformsLib,
UniformsUtils, UniformsUtils,
Vector2, Vector2,
@@ -32,6 +34,9 @@ const excludeMaterial = new MeshBasicMaterial({
colorWrite: false, colorWrite: false,
}); });
const date = new Date();
const isHalloween = date.getMonth() === 9 && date.getDate() === 31;
export function PrimaryArmorSceneComponent({ export function PrimaryArmorSceneComponent({
node, node,
thickness, thickness,
@@ -46,6 +51,8 @@ export function PrimaryArmorSceneComponent({
fog: true, fog: true,
transparent: true, transparent: true,
blending: isHalloween ? SubtractiveBlending : NormalBlending,
uniforms: UniformsUtils.merge([ uniforms: UniformsUtils.merge([
UniformsLib.common, UniformsLib.common,
UniformsLib.fog, UniformsLib.fog,
@@ -25,7 +25,6 @@ uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer; uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth; uniform highp sampler2D spacedArmorDepth;
const float HALF = 0.5;
const float EXPLOSION_DAMAGE_FACTOR = 1.1; const float EXPLOSION_DAMAGE_FACTOR = 1.1;
const float RANDOMIZATION_FACTOR = 0.05; const float RANDOMIZATION_FACTOR = 0.05;
const float GREEN_VALUE = 0.392; const float GREEN_VALUE = 0.392;
@@ -38,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 ? 1.0 : 0.0); return vec3(0.0, 1.0, isThreeCalibersRule ? 0.5 : 0.0);
} }
return vec3(0.0, 1.0, 0.0); return vec3(0.0, 1.0, 0.0);
@@ -81,12 +80,12 @@ void main() {
float distFromArmor = primaryDist - spacedDist; float distFromArmor = primaryDist - spacedDist;
if (canSplash) { if (canSplash) {
float finalDamage = HALF * damage * (1.0 - distFromArmor / explosionRadius) - EXPLOSION_DAMAGE_FACTOR * (finalThickness + spacedArmorThickness); float finalDamage = 0.5 * damage * (1.0 - distFromArmor / explosionRadius) - EXPLOSION_DAMAGE_FACTOR * (finalThickness + spacedArmorThickness);
splashChance = step(0.0, finalDamage); splashChance = step(0.0, finalDamage);
penetrationChance = 0.0; penetrationChance = 0.0;
} else { } else {
remainingPen -= HALF * remainingPen * distFromArmor; remainingPen -= 0.5 * remainingPen * distFromArmor;
} }
} }
} }
@@ -98,17 +97,17 @@ void main() {
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0); penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) { if (canSplash) {
float splashDamage = HALF * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness; float splashDamage = 0.5 * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness;
splashChance = step(0.0, splashDamage); splashChance = step(0.0, splashDamage);
} }
} }
} }
float alpha = opaque ? 1.0 : HALF; float alpha = opaque ? 1.0 : 0.5;
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, 1.0); baseColor = vec3(1.0, baseColor.g, 0.5);
} }
if (greenPenetration || advancedHighlighting) { if (greenPenetration || advancedHighlighting) {
@@ -1,4 +1,3 @@
import { normalizeAngleRad } from '@blitzkit/core';
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';
@@ -153,15 +152,11 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
@@ -190,21 +185,18 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch, modelTransformEvent.last!.pitch,
yaw + delta.x * (Math.PI / boundingRect.width), modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
@@ -253,8 +245,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
@@ -268,8 +258,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
@@ -290,24 +278,22 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height), modelTransformEvent.last!.pitch -
yaw + delta.x * (Math.PI / boundingRect.width), delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
} }
@@ -341,8 +327,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2(); const position = new Vector2();
const delta = new Vector2(); const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
@@ -356,9 +340,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
} }
@@ -378,24 +359,23 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height), modelTransformEvent.last!.pitch -
yaw + delta.x * (Math.PI / boundingRect.width), delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
} }
@@ -1,6 +1,7 @@
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 { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { import {
Box3, Box3,
@@ -186,16 +187,18 @@ export function StaticArmorSceneComponent({
const distanceToBarrel = clip.distanceToPoint(barrelOrigin); const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3(); const point = new Vector3();
function handleModelTransform(data: ModelTransformEventData) { function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>,
) {
clip.normal clip.normal
.set(0, 0, -1) .set(0, 0, -1)
.applyAxisAngle(I_HAT, data.pitch) .applyAxisAngle(I_HAT, event.data.pitch)
.applyAxisAngle(J_HAT, data.yaw ?? 0); .applyAxisAngle(J_HAT, event.data.yaw);
clip.constant = 0; clip.constant = 0;
point point
.copy(gunOrigin) .copy(gunOrigin)
.applyAxisAngle(J_HAT, data.yaw ?? 0) .applyAxisAngle(J_HAT, event.data.yaw)
.add(clip.normal.multiplyScalar(-distanceToBarrel)) .add(clip.normal.multiplyScalar(-distanceToBarrel))
.add(neckOrigin); .add(neckOrigin);
clip.normal.normalize(); clip.normal.normalize();
@@ -1,6 +1,6 @@
import { Canvas } from '@react-three/fiber';
import { GuessEphemeral } from '../stores/guessEphemeral'; import { GuessEphemeral } from '../stores/guessEphemeral';
import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants';
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';
@@ -13,7 +13,7 @@ export function GuessRenderer() {
const isRevealed = guessState !== null; const isRevealed = guessState !== null;
return ( return (
<Canvas <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')})`, // filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`,
@@ -25,6 +25,6 @@ export function GuessRenderer() {
<Controls distanceScale={2} zoomable={false} /> <Controls distanceScale={2} zoomable={false} />
<Lighting display={TankopediaDisplay.Model} /> <Lighting display={TankopediaDisplay.Model} />
<TankModel /> <TankModel />
</Canvas> </SmartCanvas>
); );
} }
@@ -1,10 +1,15 @@
import { Link, Table } from '@radix-ui/themes'; import { Link, Table } from '@radix-ui/themes';
import MarkdownToJSX from 'markdown-to-jsx'; import MarkdownToJSX from 'markdown-to-jsx';
import type { ComponentProps } from 'react';
interface MarkdownProps { interface MarkdownProps {
children: string; children: string;
} }
function Image(props: ComponentProps<'img'>) {
return <img style={{ maxWidth: '100%' }} {...props} />;
}
export function Markdown({ children }: MarkdownProps) { export function Markdown({ children }: MarkdownProps) {
return ( return (
<MarkdownToJSX <MarkdownToJSX
@@ -17,6 +22,7 @@ export function Markdown({ children }: MarkdownProps) {
tr: { component: Table.Row }, tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell }, th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell }, td: { component: Table.Cell },
img: { component: Image },
}, },
}} }}
> >
@@ -0,0 +1,20 @@
import { Canvas, type CanvasProps } from '@react-three/fiber';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import { useOnScreen } from '../hooks/useOnScreen';
export const SmartCanvas = forwardRef<HTMLCanvasElement, CanvasProps>(
(props, ref) => {
const canvas = useRef<HTMLCanvasElement>(null);
const onScreen = useOnScreen(canvas);
useImperativeHandle(ref, () => canvas.current!, []);
return (
<Canvas
frameloop={onScreen ? 'demand' : 'never'}
ref={canvas}
{...props}
/>
);
},
);
@@ -1,6 +1,6 @@
import type { AssaultRanges } from '@blitzkit/core'; import type { AssaultRanges } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n'; import { literals } from '@blitzkit/i18n';
import { Box, Card, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from '@radix-ui/themes';
import { clamp } from 'three/src/math/MathUtils.js'; import { clamp } from 'three/src/math/MathUtils.js';
import { import {
fakeLogSquishyHigh, fakeLogSquishyHigh,
@@ -9,6 +9,7 @@ import {
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 { VisualizerCard } from '../VisualizerCard';
import { AssaultCursor } from './components/Cursor'; import { AssaultCursor } from './components/Cursor';
interface Props { interface Props {
@@ -29,7 +30,7 @@ export function AssaultRangesVisualizer({ ranges: _ranges, stats }: Props) {
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
<Card mb="6" variant="classic" style={{ aspectRatio: '1 / 1' }}> <VisualizerCard>
<Flex <Flex
position="absolute" position="absolute"
top="0" top="0"
@@ -152,6 +153,6 @@ export function AssaultRangesVisualizer({ ranges: _ranges, stats }: Props) {
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
</Card> </VisualizerCard>
); );
} }
@@ -21,14 +21,19 @@ 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';
import type { MaybeSkeletonComponentProps } from '../../../../../../types/maybeSkeletonComponentProps';
import { AssaultRangesVisualizer } from './AssaultRangesVisualizer'; import { AssaultRangesVisualizer } from './AssaultRangesVisualizer';
import { GunFlexibilityVisualizer } from './GunFlexibilityVisualizer';
import { Info } from './Info'; import { Info } from './Info';
import { InfoWithDelta } from './InfoWithDelta'; import { InfoWithDelta } from './InfoWithDelta';
import { RicochetVisualizer } from './RicochetVisualizer'; import { RicochetVisualizer } from './RicochetVisualizer';
import { StatsTableWrapper } from './StatsTableWrapper'; import { StatsTableWrapper } from './StatsTableWrapper';
import type { StatsAcceptorProps } from './TraverseVisualizer'; import type { StatsAcceptorProps } from './TraverseVisualizer';
export function Firepower({ stats }: StatsAcceptorProps) { export function Firepower({
stats,
skeleton,
}: StatsAcceptorProps & MaybeSkeletonComponentProps) {
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const { strings, unwrap } = useLocale(); const { strings, unwrap } = useLocale();
const [penetrationDistance, setPenetrationDistance] = useState(250); const [penetrationDistance, setPenetrationDistance] = useState(250);
@@ -342,7 +347,7 @@ export function Firepower({ stats }: StatsAcceptorProps) {
<InfoWithDelta <InfoWithDelta
stats={stats} stats={stats}
decimals={1} decimals={1}
prefix="x " prefix="x"
indent indent
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
value="dispersionGunDamaged" value="dispersionGunDamaged"
@@ -403,6 +408,7 @@ export function Firepower({ stats }: StatsAcceptorProps) {
/> />
</> </>
)} )}
<GunFlexibilityVisualizer skeleton={skeleton} />
</StatsTableWrapper> </StatsTableWrapper>
); );
} }
@@ -0,0 +1,14 @@
import { useFrame } from '@react-three/fiber';
export const zoom = 60;
export function CameraPositioner() {
useFrame(({ camera, gl: { domElement } }) => {
const { width } = domElement.getBoundingClientRect();
const physicalWidth = width / zoom;
camera.position.set(10, 0, -physicalWidth / 6);
});
return null;
}
@@ -0,0 +1,55 @@
import type { QuicklimeEvent } from 'quicklime';
import { useEffect, useRef } from 'react';
import type { Group } from 'three';
import { awaitableModelDefinitions } from '../../../../../../../../core/awaitables/modelDefinitions';
import {
modelTransformEvent,
type ModelTransformEventData,
} from '../../../../../../../../core/blitzkit/modelTransform';
import { Duel } from '../../../../../../../../stores/duel';
import { ModelChunk } from './ModelChunk';
const modelDefinitions = await awaitableModelDefinitions;
export function CanvasContent() {
const hullWrapper = useRef<Group>(null);
const gunWrapper = useRef<Group>(null);
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
useEffect(() => {
function handleModelTransformEvent(
event: QuicklimeEvent<ModelTransformEventData>,
) {
if (!gunWrapper.current || !hullWrapper.current) return;
gunWrapper.current.rotation.x = event.data.pitch;
if (event.data.yaw) hullWrapper.current.rotation.y = event.data.yaw;
}
modelTransformEvent.on(handleModelTransformEvent);
return () => {
modelTransformEvent.off(handleModelTransformEvent);
};
}, []);
return (
<>
<group ref={hullWrapper} position={[0, 0, turretModel.gun_origin.z]}>
<group position={[0, 0, -turretModel.gun_origin.z]}>
<ModelChunk only="body" />
</group>
</group>
<ModelChunk only="turret" />
<group ref={gunWrapper}>
<ModelChunk only="gun" />
</group>
</>
);
}
@@ -0,0 +1,12 @@
import { SmartCanvas } from '../../../../../../../SmartCanvas';
import { CameraPositioner, zoom } from './CameraPositioner';
import { CanvasContent } from './CanvasContent';
export function FlexibilityCanvas() {
return (
<SmartCanvas orthographic camera={{ zoom, rotation: [0, Math.PI / 2, 0] }}>
<CanvasContent />
<CameraPositioner />
</SmartCanvas>
);
}
@@ -0,0 +1,105 @@
import { mauveDark } from '@radix-ui/colors';
import { LineBasicMaterial, MeshBasicMaterial } from 'three';
import { awaitableModelDefinitions } from '../../../../../../../../core/awaitables/modelDefinitions';
import { jsxTree } from '../../../../../../../../core/blitzkit/jsxTree';
import { useModel } from '../../../../../../../../hooks/useModel';
import { Duel } from '../../../../../../../../stores/duel';
const modelDefinitions = await awaitableModelDefinitions;
const surfaceMaterial: Record<Props['only'], MeshBasicMaterial> = {
gun: new MeshBasicMaterial({
color: mauveDark.mauve8,
toneMapped: false,
}),
turret: new MeshBasicMaterial({
color: mauveDark.mauve6,
toneMapped: false,
}),
body: new MeshBasicMaterial({
color: mauveDark.mauve6,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
const outlineMaterial: Record<Props['only'], LineBasicMaterial> = {
gun: new LineBasicMaterial({
color: mauveDark.mauve11,
toneMapped: false,
}),
turret: new LineBasicMaterial({
color: mauveDark.mauve8,
toneMapped: false,
}),
body: new LineBasicMaterial({
color: mauveDark.mauve8,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
interface Props {
only: 'gun' | 'turret' | 'body';
}
export function ModelChunk({ only }: Props) {
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.gun_type!.value.base.id];
const originZ = tankModel.turret_origin.z + turretModel.gun_origin.z;
const originY = tankModel.turret_origin.y + turretModel.gun_origin.y;
const { gltf } = useModel(tank.id);
const nodes = Object.values(gltf.nodes);
return (
<group position={[0, -originY, originZ]} rotation={[-Math.PI / 2, 0, 0]}>
{nodes.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 isGun = isCurrentGun || isCurrentMantlet;
const isCurrentTurret =
node.name ===
`turret_${turretModel.model_id.toString().padStart(2, '0')}`;
const isTurret = isCurrentTurret;
const isHull = node.name === 'hull';
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isBody = isHull || isWheel || isTrack;
if (
(only === 'gun' && !isGun) ||
(only === 'turret' && !isTurret) ||
(only === 'body' && !isBody)
) {
return null;
}
return jsxTree(node, {
mesh(mesh, props, key) {
return (
<>
<mesh key={key} {...props} material={surfaceMaterial[only]} />
<lineSegments material={outlineMaterial[only]}>
<edgesGeometry args={[mesh.geometry, 29]} />
</lineSegments>
</>
);
},
});
})}
</group>
);
}
@@ -0,0 +1,397 @@
import { literals } from '@blitzkit/i18n';
import { Box, Card, Flex } from '@radix-ui/themes';
import { clamp } from 'lodash-es';
import { useRef, useState } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { awaitableModelDefinitions } from '../../../../../../../core/awaitables/modelDefinitions';
import {
DEFAULT_PITCH_TRANSITION,
applyPitchYawLimits,
} from '../../../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../../../core/blitzkit/hasEquipment';
import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTransform';
import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../../stores/duel';
import type { MaybeSkeletonComponentProps } from '../../../../../../../types/maybeSkeletonComponentProps';
import { VisualizerCard } from '../VisualizerCard';
import { VisualizerCornerStat } from '../VisualizerCornerStat';
import { FlexibilityCanvas } from './components/FlexibilityCanvas';
const ANGLE_COEFFICIENT = 1 / 10;
const modelDefinition = await awaitableModelDefinitions;
function mag(x: number) {
return (1 / Math.PI) * Math.atan(ANGLE_COEFFICIENT * x) + 1 / 2;
}
function magInverse(y: number): number {
return (1 / ANGLE_COEFFICIENT) * Math.tan(Math.PI * y - Math.PI / 2);
}
function c(thetaDeg: number, m = 1) {
const theta = thetaDeg * (Math.PI / 180);
return `${m * Math.cos(theta)} ${-m * Math.sin(theta)}`;
}
export function GunFlexibilityVisualizer({
skeleton,
}: MaybeSkeletonComponentProps) {
const { strings } = useLocale();
const duelStore = Duel.useStore();
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModel = modelDefinition.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.gun_type!.value.base.id];
const [yaw, setYaw] = useState(0);
const [pitch, setPitch] = useState(0);
const [minPitch, setMinPitch] = useState(
-degToRad(gunModel.pitch.front?.max ?? gunModel.pitch.max),
);
const [maxPitch, setMaxPitch] = useState(
-degToRad(gunModel.pitch.front?.min ?? gunModel.pitch.min),
);
const container = useRef<HTMLDivElement>(null);
const highlighter = useRef<HTMLDivElement>(null);
const pointer = useRef<HTMLDivElement>(null);
const p = gunModel.pitch;
const y = turretModel.yaw;
const f = p.front;
const b = p.back;
const t = gunModel.pitch.transition ?? DEFAULT_PITCH_TRANSITION;
let d = '';
// right
const mo = mag(p.max);
if (y) {
d += `M 0 0`;
d += `L ${c(90 - y.max, mo)}`;
} else if (b) {
d += `M ${c(-90 + b.range / 2 + t, mo)}`;
} else {
d += `M ${c(-90, mo)}`;
}
if (f) {
d += `A ${mo} ${mo} 0 0 0 ${c(90 - f.range / 2 - t, mo)}`;
} else {
d += `A ${mo} ${mo} 0 0 0 ${c(90, mo)}`;
}
// front
if (f) {
const m = mag(f.max);
d += `L ${c(90 - f.range / 2, m)}`;
d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`;
}
// left
if (f) {
d += `L ${c(90 + f.range / 2 + t, mo)}`;
} else {
d += `L ${c(90, mo)}`;
}
if (y) {
d += `A ${mo} ${mo} 0 0 0 ${c(90 - y.min, mo)}`;
} else if (b) {
d += `A ${mo} ${mo} 0 0 0 ${c(-90 - b.range / 2 - t, mo)}`;
} else {
d += `A ${mo} ${mo} 0 0 0 ${c(-90, mo)}`;
}
// back
if (!y && b) {
const m = mag(b.max);
d += `L ${c(-90 - b.range / 2, m)}`;
d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`;
}
d += `Z`;
// right
const mi = mag(p.min);
if (y) {
d += `M 0 0`;
d += `L ${c(90 - y.max, mi)}`;
} else if (b) {
d += `M ${c(-90 + b.range / 2 + t, mi)}`;
} else {
d += `M ${c(-90, mi)}`;
}
if (f) {
d += `A ${mi} ${mi} 0 0 0 ${c(90 - f.range / 2 - t, mi)}`;
} else {
d += `A ${mi} ${mi} 0 0 0 ${c(90, mi)}`;
}
// front
if (f) {
const m = mag(f.min);
d += `L ${c(90 - f.range / 2, m)}`;
d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`;
}
// left
if (f) {
d += `L ${c(90 + f.range / 2 + t, mi)}`;
} else {
d += `L ${c(90, mi)}`;
}
if (y) {
d += `A ${mi} ${mi} 0 0 0 ${c(90 - y.min, mi)}`;
} else if (b) {
d += `A ${mi} ${mi} 0 0 0 ${c(-90 - b.range / 2 - t, mi)}`;
} else {
d += `A ${mi} ${mi} 0 0 0 ${c(-90, mi)}`;
}
// back
if (!y && b) {
const m = mag(b.min);
d += `L ${c(-90 - b.range / 2, m)}`;
d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`;
}
d += `Z`;
const r = mag(radToDeg(-pitch));
const dotX = r * Math.sin(yaw);
const dotY = r * Math.cos(yaw);
return (
<Flex direction="column-reverse" gap="0">
<Box mt="-3" px="2">
<Card variant="classic" style={{ height: '10rem' }}>
<Box
style={{
background: 'linear-gradient(var(--gray-1), var(--gray-3))',
}}
position="absolute"
width="100%"
height="100%"
top="0"
left="0"
>
{!skeleton && <FlexibilityCanvas />}
<Box
position="absolute"
width="100%"
height="100%"
top="0"
left="33%"
style={{
background: `conic-gradient(
at 0 50%,
transparent,
transparent ${Math.PI / 2 - maxPitch}rad,
${Var('cyan-a4')} ${Math.PI / 2 - maxPitch}rad,
${Var('jade-a4')} ${Math.PI / 2 - minPitch}rad,
transparent ${Math.PI / 2 - minPitch}rad,
transparent 180deg
)`,
}}
/>
</Box>
</Card>
</Box>
<VisualizerCard
ref={container}
style={{ touchAction: 'none' }}
onPointerMove={(event) => {
event.preventDefault();
if (!container.current || !highlighter.current || !pointer.current)
return;
const rect = container.current.getBoundingClientRect();
const u = event.clientX - rect.left - rect.width / 2;
const v = event.clientY - rect.top - rect.height / 2;
let yaw = Math.atan2(u, -v);
const {
equipmentMatrix,
tank: { equipment_preset },
} = duelStore.getState().protagonist;
const hasImprovedVerticalStabilizer = hasEquipment(
122,
equipment_preset,
equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
equipment_preset,
equipmentMatrix,
);
const min = applyPitchYawLimits(
-Math.PI,
yaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
const max = applyPitchYawLimits(
Math.PI,
yaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
yaw = min[1];
let t = 2 * Math.sqrt((u / rect.width) ** 2 + (v / rect.height) ** 2);
t = clamp(t, 0, 1);
let pitch = degToRad(magInverse(-t));
pitch = clamp(pitch, min[0], max[0]);
setPitch(pitch);
setYaw(yaw);
setMinPitch(min[0]);
setMaxPitch(max[0]);
modelTransformEvent.dispatch({ pitch, yaw });
}}
mb="0"
>
<Box
ref={highlighter}
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{
borderRadius: Var('radius-2'),
overflow: 'hidden',
}}
>
<Box
width="100%"
height="100%"
style={{
opacity: 2 ** -3,
backgroundImage: `url(/assets/images/tankopedia/visualizers/ricochet/armor-hash.png)`,
boxShadow: 'inset 0 0 4rem 1rem black',
}}
/>
</Box>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: 'calc(100%)',
height: 'calc(100%)',
}}
viewBox="-1 -1 2 2"
>
<path
fillRule="evenodd"
fill="var(--gray-5)"
stroke="var(--gray-8)"
strokeWidth="1px"
vectorEffect="non-scaling-stroke"
d={d}
/>
<path
stroke="var(--gray-10)"
fill="transparent"
strokeWidth="1px"
strokeDasharray="0.5rem 0.5rem"
vectorEffect="non-scaling-stroke"
d={`
M 0.5 0
A 0.5 0.5 0 1 1 -0.5 0
A 0.5 0.5 0 1 1 0.5 0
Z
`}
/>
</svg>
<Box
ref={pointer}
position="absolute"
top="50%"
left="50%"
width="0"
height="0"
style={{
transform: `translate(-50%, -50%) rotate(${yaw + Math.PI}rad)`,
}}
>
<Box
width="1pt"
height="14rem"
style={{
borderRadius: Var('radius-1'),
background: `linear-gradient(${Var('gray-a11')}, ${Var('gray-a6')})`,
boxShadow:
'var(--white-a12) -0.25rem 0 1rem, var(--black-a12) 0.25rem 0 1rem',
transform: 'translateX(-50%)',
}}
/>
</Box>
<Box
position="absolute"
left={`${50 * (dotX + 1)}%`}
bottom={`${50 * (dotY + 1)}%`}
width="0.25rem"
height="0.25rem"
style={{
borderRadius: '50%',
backgroundColor: Var('gray-12'),
transform: 'translate(-50%, 50%)',
}}
/>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.flexibility.max}
value={literals(strings.common.units.deg, [
radToDeg(maxPitch).toFixed(0),
])}
side="bottom-left"
/>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.flexibility.min}
value={literals(strings.common.units.deg, [
radToDeg(minPitch).toFixed(0),
])}
side="bottom-right"
/>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.flexibility.pitch}
value={literals(strings.common.units.deg, [
radToDeg(pitch).toFixed(0),
])}
side="top-left"
/>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.flexibility.yaw}
value={literals(strings.common.units.deg, [radToDeg(yaw).toFixed(0)])}
side="top-right"
/>
</VisualizerCard>
</Flex>
);
}
@@ -1,7 +1,7 @@
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 { 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';
@@ -48,148 +48,151 @@ const [
awaitableSkillDefinitions, awaitableSkillDefinitions,
]); ]);
export function InfoWithDelta({ export const InfoWithDelta = memo<InfoWithDeltaProps>(
stats, ({ stats, indent, noRanking, deltaType, ...props }) => {
indent, const { strings } = useLocale();
noRanking, const relativeAgainst = TankopediaEphemeral.use(
deltaType, (state) => state.relativeAgainst,
...props );
}: InfoWithDeltaProps) { const uhWhatDoICallThisVariable =
const { strings } = useLocale();
const relativeAgainst = TankopediaEphemeral.use(
(state) => state.relativeAgainst,
);
const uhWhatDoICallThisVariable =
typeof props.value === 'function'
? props.value(stats)!
: (stats[props.value] as number);
const delta = useDelta(uhWhatDoICallThisVariable);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const shellIndex = Duel.use((state) =>
state.protagonist.gun.gun_type!.value.base.shells.indexOf(
state.protagonist.shell,
),
);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const others = useMemo(() => {
const defaultSkills = createDefaultSkills(skillDefinitions);
return Object.values(tankDefinitions.tanks)
.filter(
(tank) =>
(relativeAgainst === TankopediaRelativeAgainst.Class &&
tank.tier === protagonistTank.tier &&
tank.class === protagonistTank.class) ||
(relativeAgainst === TankopediaRelativeAgainst.Tier &&
tank.tier === protagonistTank.tier) ||
relativeAgainst === TankopediaRelativeAgainst.All,
)
.map((tank) => {
const thisTankModel = modelDefinitions.models[tank.id];
const member = tankToDuelMember(
tank,
thisTankModel,
provisionDefinitions,
);
if (member.gun.gun_type!.value.base.shells[shellIndex] === undefined) {
return undefined;
}
return tankCharacteristics(
{
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage: member.camouflage,
consumables: member.consumables,
crewSkills: defaultSkills,
provisions: member.provisions,
engine: member.engine,
gun: member.gun,
equipmentMatrix: equipmentMatrix,
shell: member.gun.gun_type!.value.base.shells[shellIndex],
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTrack: tank.tracks[0],
stockTurret: tank.turrets[0],
tank,
track: member.track,
turret: member.turret,
assaultDistance: member.assaultDistance,
},
{
equipmentDefinitions,
provisionDefinitions,
tankModelDefinition: modelDefinitions.models[tank.id],
},
);
})
.filter((tank) => {
if (tank === undefined) return false;
const othersValue =
typeof props.value === 'function'
? props.value(tank)!
: (tank[props.value] as number);
return othersValue !== undefined;
}) as TankCharacteristics[];
}, [relativeAgainst, shellIndex, equipmentMatrix]);
const betterTanks = others.filter((tank) => {
const othersValue =
typeof props.value === 'function' typeof props.value === 'function'
? props.value(tank)! ? props.value(stats)!
: (tank[props.value] as number); : (stats[props.value] as number);
const delta = useDelta(uhWhatDoICallThisVariable);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const shellIndex = Duel.use((state) =>
state.protagonist.gun.gun_type!.value.base.shells.indexOf(
state.protagonist.shell,
),
);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const others = useMemo(() => {
const defaultSkills = createDefaultSkills(skillDefinitions);
if (othersValue === undefined) return false; return Object.values(tankDefinitions.tanks)
return deltaType === 'lowerIsBetter' .filter(
? othersValue < uhWhatDoICallThisVariable (tank) =>
: othersValue > uhWhatDoICallThisVariable; (relativeAgainst === TankopediaRelativeAgainst.Class &&
}); tank.tier === protagonistTank.tier &&
const goodness = (others.length - betterTanks.length) / others.length; tank.class === protagonistTank.class) ||
let color: ComponentProps<typeof Progress>['color']; (relativeAgainst === TankopediaRelativeAgainst.Tier &&
tank.tier === protagonistTank.tier) ||
relativeAgainst === TankopediaRelativeAgainst.All,
)
.map((tank) => {
const thisTankModel = modelDefinitions.models[tank.id];
const member = tankToDuelMember(
tank,
thisTankModel,
provisionDefinitions,
);
if (goodness <= 0.25) color = 'red'; if (
else if (goodness <= 0.5) color = 'orange'; member.gun.gun_type!.value.base.shells[shellIndex] === undefined
else if (goodness <= 0.75) color = 'yellow'; ) {
else color = 'green'; return undefined;
}
return ( return tankCharacteristics(
<Flex direction="column"> {
<Info applyDynamicArmor: false,
deltaType={deltaType} applyReactiveArmor: false,
indent={indent} applySpallLiner: false,
{...props} camouflage: member.camouflage,
name={ consumables: member.consumables,
'name' in props crewSkills: defaultSkills,
? props.name provisions: member.provisions,
: strings.website.tools.tankopedia.characteristics.values[ engine: member.engine,
props.value gun: member.gun,
] equipmentMatrix: equipmentMatrix,
} shell: member.gun.gun_type!.value.base.shells[shellIndex],
delta={delta} stockEngine: tank.engines[0],
> stockGun: tank.turrets[0].guns[0],
{uhWhatDoICallThisVariable} stockTrack: tank.tracks[0],
</Info> stockTurret: tank.turrets[0],
tank,
track: member.track,
turret: member.turret,
assaultDistance: member.assaultDistance,
},
{
equipmentDefinitions,
provisionDefinitions,
tankModelDefinition: modelDefinitions.models[tank.id],
},
);
})
.filter((tank) => {
if (tank === undefined) return false;
{!noRanking && ( const othersValue =
<Flex pl={indent ? '2' : '0'} align="center" gap="2" mb="2"> typeof props.value === 'function'
<Progress ? props.value(tank)!
variant="soft" : (tank[props.value] as number);
size="1"
value={goodness * 100}
color={color}
style={{ height: '0.125rem', opacity: 0.5 }}
/>
<Text color="gray" size="1"> return othersValue !== undefined;
{clamp(betterTanks.length + 1, 1, others.length)} / {others.length} }) as TankCharacteristics[];
</Text> }, [relativeAgainst, shellIndex, equipmentMatrix]);
</Flex> const betterTanks = others.filter((tank) => {
)} const othersValue =
</Flex> typeof props.value === 'function'
); ? props.value(tank)!
} : (tank[props.value] as number);
if (othersValue === undefined) return false;
return deltaType === 'lowerIsBetter'
? othersValue < uhWhatDoICallThisVariable
: othersValue > uhWhatDoICallThisVariable;
});
const goodness = (others.length - betterTanks.length) / others.length;
let color: ComponentProps<typeof Progress>['color'];
if (goodness <= 0.25) color = 'red';
else if (goodness <= 0.5) color = 'orange';
else if (goodness <= 0.75) color = 'yellow';
else color = 'green';
return (
<Flex direction="column">
<Info
deltaType={deltaType}
indent={indent}
name={
'name' in props
? props.name
: strings.website.tools.tankopedia.characteristics.values[
props.value
]
}
delta={delta}
{...props}
>
{uhWhatDoICallThisVariable}
</Info>
{!noRanking && (
<Flex pl={indent ? '2' : '0'} align="center" gap="2">
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{ height: '0.125rem', opacity: 0.5 }}
/>
<Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{' '}
{others.length}
</Text>
</Flex>
)}
</Flex>
);
},
(a, b) =>
(typeof a.value === 'function' ? a.value(a.stats) : a.stats[a.value]) ===
(typeof b.value === 'function' ? b.value(b.stats) : b.stats[b.value]),
);
@@ -13,8 +13,8 @@ export function ShellBorder(props: ComponentProps<'svg'>) {
<path <path
d="M149.917 7.5L151.5 9.91406V50.085L149.917 52.5H134.674C133.504 51.6657 132.116 51.1813 130.673 51.1133L130.319 51.1045H93.085C91.8919 51.1046 90.7173 51.3895 89.6582 51.9336L89.4482 52.0459L88.6494 52.4883C48.806 52.0806 22.9344 39.046 11.1416 30C22.9344 20.954 48.806 7.91846 88.6494 7.51074L89.4482 7.9541C90.5611 8.57122 91.8125 8.89543 93.085 8.89551H130.319C131.888 8.89551 133.409 8.40216 134.674 7.5H149.917Z" d="M149.917 7.5L151.5 9.91406V50.085L149.917 52.5H134.674C133.504 51.6657 132.116 51.1813 130.673 51.1133L130.319 51.1045H93.085C91.8919 51.1046 90.7173 51.3895 89.6582 51.9336L89.4482 52.0459L88.6494 52.4883C48.806 52.0806 22.9344 39.046 11.1416 30C22.9344 20.954 48.806 7.91846 88.6494 7.51074L89.4482 7.9541C90.5611 8.57122 91.8125 8.89543 93.085 8.89551H130.319C131.888 8.89551 133.409 8.40216 134.674 7.5H149.917Z"
stroke="currentColor" stroke="currentColor"
stroke-width="15" strokeWidth="15"
stroke-linejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
); );
@@ -1,10 +1,11 @@
import { type Datum, type Serie } from '@nivo/line'; import { type Datum, type Serie } from '@nivo/line';
import { Box, Card, Code } from '@radix-ui/themes'; import { Box } 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 { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Var } from '../../../../../../../core/radix/var'; import { Var } from '../../../../../../../core/radix/var';
import type { StatsAcceptorProps } from '../TraverseVisualizer'; import type { StatsAcceptorProps } from '../TraverseVisualizer';
import { VisualizerCard } from '../VisualizerCard';
import { Shell } from './components/Shell'; import { Shell } from './components/Shell';
import { Target } from './components/Target'; import { Target } from './components/Target';
@@ -21,7 +22,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
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);
const dpmDisplay = useRef<HTMLSpanElement>(null);
/** /**
* 3-shell gun example: * 3-shell gun example:
@@ -52,8 +52,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
} }
function frame() { function frame() {
if (!dpmDisplay.current) return;
const now = Date.now() / 1000; const now = Date.now() / 1000;
const dt = now - lastT; const dt = now - lastT;
let reload; let reload;
@@ -64,7 +62,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
}); });
dpm.current = shotPool.current.size * stats.damage; dpm.current = shotPool.current.size * stats.damage;
dpmDisplay.current.innerText = `${Math.round(dpm.current)}`;
if (state.current < 1) { if (state.current < 1) {
state.current = Math.min(1, state.current + dt / stats.shellReload!); state.current = Math.min(1, state.current + dt / stats.shellReload!);
@@ -133,7 +130,7 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
}, []); }, []);
return ( return (
<Card variant="classic" mb="6" style={{ aspectRatio: '1 / 1' }}> <VisualizerCard>
<Box position="absolute" top="0" left="0" width="100%" height="100%"> <Box position="absolute" top="0" left="0" width="100%" height="100%">
<Target /> <Target />
@@ -223,10 +220,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
times(stats.shells, (index) => ( times(stats.shells, (index) => (
<Shell stats={stats} index={index} key={index} /> <Shell stats={stats} index={index} key={index} />
))} ))}
</VisualizerCard>
<Box position="absolute" right="3" bottom="3">
<Code ref={dpmDisplay}>0 dpm</Code>
</Box>
</Card>
); );
} }
@@ -1,12 +1,14 @@
import { asset, isExplosive } from '@blitzkit/core'; import { asset, isExplosive } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n'; import { literals } from '@blitzkit/i18n';
import { Box, Card, Code, Flex, Text } from '@radix-ui/themes'; import { Box, Flex, Text } from '@radix-ui/themes';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { clamp, degToRad, radToDeg } from 'three/src/math/MathUtils.js'; import { clamp, degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { Var } from '../../../../../../../core/radix/var'; import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale'; import { useLocale } from '../../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../../stores/duel'; import { Duel } from '../../../../../../../stores/duel';
import type { StatsAcceptorProps } from '../TraverseVisualizer'; import type { StatsAcceptorProps } from '../TraverseVisualizer';
import { VisualizerCard } from '../VisualizerCard';
import { VisualizerCornerStat } from '../VisualizerCornerStat';
import { ShellSwitcher } from './components/ShellSwitcher'; import { ShellSwitcher } from './components/ShellSwitcher';
export function RicochetVisualizer({ stats }: StatsAcceptorProps) { export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
@@ -26,13 +28,9 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
return ( return (
<Flex direction="column" mb="6" align="center"> <Flex direction="column" mb="6" align="center">
<Card <VisualizerCard
variant="classic" mb="0"
style={{ style={{ width: '100%' }}
width: '100%',
aspectRatio: '1 / 1',
touchAction: 'none',
}}
ref={container} ref={container}
onPointerMove={(event) => { onPointerMove={(event) => {
if (!container.current || explodes) return; if (!container.current || explodes) return;
@@ -73,23 +71,23 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
position="absolute" position="absolute"
style={{ style={{
background: `conic-gradient( background: `conic-gradient(
at 0 50%, at 0 50%,
${Var('ruby-a2')}, ${Var('ruby-a2')},
${Var('ruby-a1')} ${Math.PI / 2 - ricochet}rad, ${Var('ruby-a1')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a2')} ${Math.PI / 2 - ricochet}rad, ${Var('jade-a2')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a4')} ${Math.PI / 2 - normalization}rad, ${Var('jade-a4')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a5')} ${Math.PI / 2 - normalization}rad, ${Var('cyan-a5')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a3')} ${Math.PI / 2 + normalization}rad, ${Var('cyan-a3')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a2')} ${Math.PI / 2 + normalization}rad, ${Var('jade-a2')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a4')} ${Math.PI / 2 + ricochet}rad, ${Var('jade-a4')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a1')} ${Math.PI / 2 + ricochet}rad, ${Var('ruby-a1')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a2')} 180deg ${Var('ruby-a2')} 180deg
`, `,
}} }}
/> />
@@ -160,85 +158,51 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
/> />
</Box> </Box>
<Flex <VisualizerCornerStat
style={{ userSelect: 'none' }} label={strings.website.tools.tankopedia.visualizers.ricochet.angle}
gap="4" value={literals(strings.common.units.deg, [
position="absolute" radToDeg(Math.abs(angle)).toFixed(0),
top="0" ])}
left="0" side="top-left"
p="3" />
width="100%" <VisualizerCornerStat
justify="between" label={
> strings.website.tools.tankopedia.visualizers.ricochet.normalization
<Flex gap="2"> }
<Text color="gray" size="1"> value={
{strings.website.tools.tankopedia.visualizers.ricochet.angle} doesRicochet
</Text> ? '-'
<Code size="1" variant="ghost"> : literals(strings.common.units.deg, [
{literals(strings.common.units.deg, [ radToDeg(Math.min(normalization, Math.abs(angle))).toFixed(0),
radToDeg(Math.abs(angle)).toFixed(0), ])
])} }
</Code> side="top-right"
</Flex> />
<VisualizerCornerStat
<Flex gap="2"> label={
<Text color="gray" size="1"> strings.website.tools.tankopedia.visualizers.ricochet
{ .effective_angle
strings.website.tools.tankopedia.visualizers.ricochet }
.normalization value={
} doesRicochet
</Text> ? '-'
<Code size="1" variant="ghost"> : literals(strings.common.units.deg, [
{doesRicochet radToDeg(Math.abs(effectiveAngle)).toFixed(0),
? '-' ])
: literals(strings.common.units.deg, [ }
radToDeg(Math.min(normalization, Math.abs(angle))).toFixed( side="bottom-left"
0, />
), <VisualizerCornerStat
])} label={strings.website.tools.tankopedia.visualizers.ricochet.armor}
</Code> value={
</Flex> doesRicochet
</Flex> ? '-'
: literals(strings.common.units.percentage, [
<Flex armorPercentage.toFixed(0),
style={{ userSelect: 'none' }} ])
gap="4" }
position="absolute" side="bottom-right"
bottom="0" />
left="0"
p="3"
width="100%"
justify="between"
>
<Flex gap="2">
<Text color="gray" size="1">
{
strings.website.tools.tankopedia.visualizers.ricochet
.effective_angle
}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.abs(effectiveAngle)).toFixed(0),
])}
</Code>
</Flex>
<Flex gap="2">
<Text color="gray" size="1">
{strings.website.tools.tankopedia.visualizers.ricochet.armor}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.percentage, [
armorPercentage.toFixed(0),
])}
</Code>
</Flex>
</Flex>
{explodes && ( {explodes && (
<Flex <Flex
@@ -262,7 +226,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
</Text> </Text>
</Flex> </Flex>
)} )}
</Card> </VisualizerCard>
<ShellSwitcher /> <ShellSwitcher />
</Flex> </Flex>
@@ -1,8 +1,9 @@
import { literals } from '@blitzkit/i18n'; import { literals } from '@blitzkit/i18n';
import { Card, Checkbox, Code, Flex, Tabs, Text } from '@radix-ui/themes'; import { Checkbox, Code, Flex, Tabs, Text } from '@radix-ui/themes';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
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 { VisualizerCard } from '../VisualizerCard';
export interface StatsAcceptorProps { export interface StatsAcceptorProps {
stats: TankCharacteristics; stats: TankCharacteristics;
@@ -58,7 +59,7 @@ export function TraverseVisualizer({ stats }: StatsAcceptorProps) {
}, [terrain, turretSpeedDeg, hullSpeedDeg]); }, [terrain, turretSpeedDeg, hullSpeedDeg]);
return ( return (
<Card variant="classic" style={{ aspectRatio: '1 / 1' }}> <VisualizerCard>
<div <div
ref={hull} ref={hull}
style={{ style={{
@@ -188,6 +189,6 @@ export function TraverseVisualizer({ stats }: StatsAcceptorProps) {
</Flex> </Flex>
</Flex> </Flex>
)} )}
</Card> </VisualizerCard>
); );
} }
@@ -0,0 +1,17 @@
import { Card, type CardProps } from '@radix-ui/themes';
import { forwardRef } from 'react';
export const VisualizerCard = forwardRef<HTMLDivElement, CardProps>(
({ style, ...props }, ref) => {
return (
<Card
ref={ref}
variant="classic"
mt="4"
mb="6"
style={{ aspectRatio: '1 / 1', ...style }}
{...props}
/>
);
},
);
@@ -0,0 +1,29 @@
import { Code, Flex, Text } from '@radix-ui/themes';
interface Props {
label: string;
value: string;
side: `${'top' | 'bottom'}-${'left' | 'right'}`;
}
export function VisualizerCornerStat({ label, value, side }: Props) {
return (
<Flex
gap="2"
position="absolute"
top={side === 'top-left' || side === 'top-right' ? '3' : undefined}
bottom={
side === 'bottom-left' || side === 'bottom-right' ? '3' : undefined
}
left={side === 'top-left' || side === 'bottom-left' ? '3' : undefined}
right={side === 'top-right' || side === 'bottom-right' ? '3' : undefined}
>
<Text color="gray" size="1">
{label}
</Text>
<Code size="1" variant="ghost">
{value}
</Code>
</Flex>
);
}
@@ -3,11 +3,13 @@ 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 { 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 { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
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';
@@ -18,7 +20,7 @@ const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
awaitableProvisionDefinitions, awaitableProvisionDefinitions,
]); ]);
export function Characteristics() { export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
const mutateDuel = Duel.useMutation(); const mutateDuel = Duel.useMutation();
const crewSkills = TankopediaEphemeral.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);
@@ -74,16 +76,16 @@ export function Characteristics() {
const hasDownImprovedVerticalStabilizer = useEquipment(124); const hasDownImprovedVerticalStabilizer = useEquipment(124);
useEffect(() => { useEffect(() => {
mutateDuel((draft) => { const [pitch, yaw] = applyPitchYawLimits(
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits( modelTransformEvent.last!.pitch,
draft.protagonist.pitch, modelTransformEvent.last!.yaw,
draft.protagonist.yaw, gunModelDefinition.pitch,
gunModelDefinition.pitch, turretModelDefinition.yaw,
turretModelDefinition.yaw, hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, );
);
}); modelTransformEvent.dispatch({ pitch, yaw });
}, [hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer]); }, [hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer]);
return ( return (
@@ -93,7 +95,7 @@ export function Characteristics() {
align={{ initial: 'center', sm: 'start' }} align={{ initial: 'center', sm: 'start' }}
width={{ initial: '100%', sm: 'auto' }} width={{ initial: '100%', sm: 'auto' }}
> >
<Firepower stats={stats} /> <Firepower skeleton={skeleton} stats={stats} />
<Flex <Flex
gap="8" gap="8"
@@ -1,4 +1,5 @@
import { Flex } from '@radix-ui/themes'; import { Flex } from '@radix-ui/themes';
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
import { Characteristics } from './components/Characteristics'; import { Characteristics } from './components/Characteristics';
import { CompareOptions } from './components/Characteristics/components/CompareOptions'; import { CompareOptions } from './components/Characteristics/components/CompareOptions';
import { StatSearch } from './components/Characteristics/components/StatSearch'; import { StatSearch } from './components/Characteristics/components/StatSearch';
@@ -9,7 +10,9 @@ import { Modules } from './components/Modules';
import { Provisions } from './components/Provisions'; import { Provisions } from './components/Provisions';
import { Skills } from './components/Skills'; import { Skills } from './components/Skills';
export function CharacteristicsSection() { export function CharacteristicsSection({
skeleton,
}: MaybeSkeletonComponentProps) {
return ( return (
<Flex direction="column" gap="3"> <Flex direction="column" gap="3">
<Flex <Flex
@@ -64,7 +67,7 @@ export function CharacteristicsSection() {
<CompareOptions /> <CompareOptions />
</Flex> </Flex>
<Characteristics /> <Characteristics skeleton={skeleton} />
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
@@ -0,0 +1,66 @@
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Box, Flex, Link, Text } from '@radix-ui/themes';
import { Var } from '../../../core/radix/var';
import type { TankGuide } from '../../../pages/[...locale]/tanks/[slug]/index.astro';
interface Props {
guide: TankGuide;
}
export function GuideSection({ guide }: Props) {
return (
<Flex justify="center">
<Link
color="gray"
href={guide.url}
underline="none"
highContrast
style={{
display: 'flex',
maxWidth: '35rem',
flexDirection: 'column',
gap: Var('space-4'),
}}
>
<Flex maxWidth="35rem" direction="column" gap="4">
<Flex direction="column" gap="4" position="relative">
<Text size="4" weight="medium">
<Flex width="100%" justify="start" align="center" gap="2">
<img
style={{
width: '1.5em',
height: '1.5em',
objectFit: 'contain',
}}
src="https://guidesblitz.com/wp-content/uploads/2023/11/cropped-logo-1.png"
/>
GuidesBlitz
</Flex>
</Text>
<Text size="2">{guide.description}</Text>
<img style={{ width: '100%' }} src={guide.image} />
<Box
position="absolute"
bottom="0"
width="100%"
height="100%"
left="0"
style={{
background: `linear-gradient(${Var('gray-a1')}, ${Var('gray-1')})`,
}}
/>
</Flex>
<Text color="purple">
<Flex width="100%" justify="center" align="center" gap="1">
Read the full guide
<CaretRightIcon width="1em" height="1em" />
</Flex>
</Text>
</Flex>
</Link>
</Flex>
);
}
@@ -1,5 +1,6 @@
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons'; import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes'; import { Flex, TextField } from '@radix-ui/themes';
import type { QuicklimeEvent } from 'quicklime';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils.js'; import { degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
@@ -28,31 +29,19 @@ export function QuickInputs() {
const hasDownImprovedVerticalStabilizer = useEquipment(124); const hasDownImprovedVerticalStabilizer = useEquipment(124);
useEffect(() => { useEffect(() => {
if (!yawInput.current) return; function handleTransformEvent(
event: QuicklimeEvent<ModelTransformEventData>,
yawInput.current.value = radToDeg(protagonist.yaw).toFixed(1); ) {
}, [protagonist.yaw]);
useEffect(() => {
if (!pitchInput.current) return;
pitchInput.current.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}, [protagonist.pitch]);
useEffect(() => {
function handleTransformEvent({ pitch, yaw }: ModelTransformEventData) {
if (!pitchInput.current || !yawInput.current) return; if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = ( pitchInput.current.value = (
-radToDeg(pitch) + -radToDeg(event.data.pitch) +
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0) (tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).toFixed(1); ).toFixed(1);
if (yaw === undefined) return; if (event.data.yaw === undefined) return;
yawInput.current.value = radToDeg(yaw).toFixed(1); yawInput.current.value = radToDeg(event.data.yaw).toFixed(1);
} }
modelTransformEvent.on(handleTransformEvent); modelTransformEvent.on(handleTransformEvent);
@@ -85,24 +74,27 @@ export function QuickInputs() {
defaultValue="0.0" defaultValue="0.0"
onBlur={() => { onBlur={() => {
const value = Number(yawInput.current!.value); const value = Number(yawInput.current!.value);
if (isNaN(value)) { if (isNaN(value)) {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1); yawInput.current!.value = radToDeg(
modelTransformEvent.last!.yaw,
).toFixed(1);
return; return;
} }
const [pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch, modelTransformEvent.last!.pitch,
degToRad(value), degToRad(value),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => { modelTransformEvent.dispatch({ pitch, yaw });
state.protagonist.pitch = pitch; yawInput.current!.value = radToDeg(
state.protagonist.yaw = yaw; modelTransformEvent.last!.yaw,
}); ).toFixed(1);
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@@ -128,25 +120,21 @@ export function QuickInputs() {
const value = Number(pitchInput.current!.value); const value = Number(pitchInput.current!.value);
if (isNaN(value)) { if (isNaN(value)) {
pitchInput.current!.value = ( pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch -radToDeg(modelTransformEvent.last!.pitch) + initialGunPitch
).toFixed(1); ).toFixed(1);
return; return;
} }
const [pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch), degToRad(-value + initialGunPitch),
protagonist.yaw, modelTransformEvent.last!.yaw,
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
pitchInput.current!.value = ( pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch -radToDeg(modelTransformEvent.last!.pitch) + initialGunPitch
).toFixed(1); ).toFixed(1);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -15,7 +15,6 @@ import {
GearIcon, GearIcon,
} from '@radix-ui/react-icons'; } from '@radix-ui/react-icons';
import { import {
Box,
Button, Button,
Checkbox, Checkbox,
Dialog, Dialog,
@@ -36,10 +35,7 @@ import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAva
import { useLocale } from '../../../../../hooks/useLocale'; import { 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 { import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
ShootingRangeZoom,
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';
@@ -97,23 +93,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<> <>
{disturbed && <QuickInputs />} {disturbed && <QuickInputs />}
{display === TankopediaDisplay.ShootingRange && (
<Box
width="1rem"
height="1rem"
position="absolute"
top="50%"
left="50%"
style={{
zIndex: 2,
backgroundImage: 'url(/assets/images/icons/aim-caret.png)',
backgroundSize: 'contain',
transform: 'translateX(-50%)',
borderRadius: '50%',
}}
/>
)}
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} /> <Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
<Flex <Flex
@@ -408,7 +387,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.disturbed = true; draft.disturbed = true;
draft.display = Number(value); draft.display = Number(value);
draft.shootingRangeZoom = ShootingRangeZoom.Arcade;
}); });
}} }}
> >
@@ -448,13 +426,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</Flex> </Flex>
</Tooltip> </Tooltip>
</SegmentedControl.Item> </SegmentedControl.Item>
{/* <SegmentedControl.Item value={`${TankopediaDisplay.ShootingRange}`}>
<Tooltip content="Shooting range">
<Flex height="100%" align="center">
<img src="/assets/images/icons/tankopedia-shooting-range.png" style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item> */}
</SegmentedControl.Root> </SegmentedControl.Root>
{disturbed && ( {disturbed && (
@@ -1,5 +1,5 @@
import { TankType } from '@blitzkit/core'; import { TankType } from '@blitzkit/core';
import { Box, Heading } from '@radix-ui/themes'; import { Flex, Heading } from '@radix-ui/themes';
import { Var } from '../../../../../../core/radix/var'; import { Var } from '../../../../../../core/radix/var';
import { useLocale } from '../../../../../../hooks/useLocale'; import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel'; import { Duel } from '../../../../../../stores/duel';
@@ -87,13 +87,27 @@ export function Title({ foreground }: TitleProps) {
? '2rem' ? '2rem'
: `min(65vh, ${125 / name.length}vw)` : `min(65vh, ${125 / name.length}vw)`
: `min(48vh, ${75 / name.length}vw)`; : `min(48vh, ${75 / name.length}vw)`;
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
return ( return (
<Box <Flex
onPointerDown={(event) => {
event.preventDefault();
mutateTankopediaEphemeral((draft) => {
draft.disturbed = true;
});
}}
direction="column"
position="absolute" position="absolute"
align="center"
justify="center"
top={disturbed ? '6rem' : '50%'} top={disturbed ? '6rem' : '50%'}
left="50%" left="50%"
width="100%"
height={disturbed ? fontSize : '100%'}
style={{ style={{
pointerEvents: disturbed ? 'none' : undefined,
transitionDuration: '1s', transitionDuration: '1s',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}} }}
@@ -109,7 +123,7 @@ export function Title({ foreground }: TitleProps) {
opacity: foreground && disturbed ? 0 : 1, opacity: foreground && disturbed ? 0 : 1,
letterSpacing: disturbed || !revealed ? 0 : '-0.03em', letterSpacing: disturbed || !revealed ? 0 : '-0.03em',
transition: ` transition: `
letter-spacing 1.5s ${disturbed ? '' : 'cubic-bezier(.81,-2,.68,1)'}, letter-spacing 1.5s ${disturbed ? '' : '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
@@ -121,6 +135,6 @@ export function Title({ foreground }: TitleProps) {
</Heading> </Heading>
{!revealed && <Tracker fontSize={fontSize} />} {!revealed && <Tracker fontSize={fontSize} />}
</Box> </Flex>
); );
} }
@@ -1,23 +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, useThree } from '@react-three/fiber'; import { invalidate, useFrame, useThree } from '@react-three/fiber';
import { clamp } from 'lodash-es';
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 { degToRad } from 'three/src/math/MathUtils.js';
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 { import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
SHOOTING_RANGE_ZOOM_COEFFICIENTS,
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { aimTarget } from './SceneProps/constants';
const poseDistances: Record<Pose, number> = { const poseDistances: Record<Pose, number> = {
[Pose.HullDown]: 15, [Pose.HullDown]: 15,
@@ -27,11 +19,6 @@ const poseDistances: Record<Pose, number> = {
const modelDefinitions = await awaitableModelDefinitions; const modelDefinitions = await awaitableModelDefinitions;
const ARCADE_MODE_DISTANCE = 19;
// const ARCADE_MODE_ANGLE = Math.PI / 8;
const ARCADE_MODE_ANGLE = degToRad(10);
const ARCADE_MODE_FOV = 54;
interface ControlsProps { interface ControlsProps {
naked?: boolean; naked?: boolean;
autoRotate?: boolean; autoRotate?: boolean;
@@ -48,7 +35,6 @@ export function Controls({
distanceScale = 1, distanceScale = 1,
}: ControlsProps) { }: ControlsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const display = TankopediaEphemeral.use((state) => state.display);
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera as PerspectiveCamera); const camera = useThree((state) => state.camera as PerspectiveCamera);
@@ -103,42 +89,21 @@ export function Controls({
gunHeight + (naked ? 10 : 2), gunHeight + (naked ? 10 : 2),
-13, -13,
).multiplyScalar(distanceScale); ).multiplyScalar(distanceScale);
const protagonistGunOriginOnlyY = new Vector3(
0,
protagonistTurretModelDefinition.gun_origin.y,
0,
);
const shellOrigin = protagonistHullOrigin
.clone()
.add(protagonistTurretOrigin)
.add(protagonistGunOriginOnlyY);
useEffect(() => { const t0 = useRef(Date.now() / 1000);
if (!orbitControls.current || !doAutoRotate) return; useFrame(() => {
if (!doAutoRotate) return;
const t0 = Date.now() / 1000; const now = Date.now() / 1000;
let cancel = false; const t = now - t0.current;
function frame() { camera.position
if (!cancel) requestAnimationFrame(frame); .copy(inspectModeInitialPosition)
.applyAxisAngle(I_HAT, (Math.PI / 32) * Math.sin(t / 9))
.applyAxisAngle(J_HAT, (-Math.PI / 16) * Math.sin(t / 7));
const now = Date.now() / 1000; invalidate();
const t = now - t0; });
camera.position
.copy(inspectModeInitialPosition)
.applyAxisAngle(I_HAT, (Math.PI / 32) * Math.sin(t / 9))
.applyAxisAngle(J_HAT, (-Math.PI / 16) * Math.sin(t / 7));
invalidate();
}
frame();
return () => {
cancel = true;
};
}, [doAutoRotate, camera]);
useEffect(() => { useEffect(() => {
const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe( const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe(
@@ -252,38 +217,11 @@ export function Controls({
}, [camera, protagonistTank.id, antagonistTank.id]); }, [camera, protagonistTank.id, antagonistTank.id]);
useEffect(() => { useEffect(() => {
let isInitialIteration = true;
function handleKeyDown(event: KeyboardEvent) {
if (display !== TankopediaDisplay.ShootingRange || event.repeat) return;
if (event.key === 'Shift') {
mutateTankopediaEphemeral((draft) => {
draft.shootingRangeZoom =
draft.shootingRangeZoom === ShootingRangeZoom.Arcade
? ShootingRangeZoom.Zoom0
: ShootingRangeZoom.Arcade;
});
}
}
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
if (display !== TankopediaDisplay.ShootingRange) return;
event.preventDefault(); event.preventDefault();
mutateTankopediaEphemeral((draft) => {
draft.shootingRangeZoom = clamp(
draft.shootingRangeZoom - Math.sign(event.deltaY),
ShootingRangeZoom.Arcade,
ShootingRangeZoom.Zoom2,
);
});
} }
function handleScroll(event: Event) { function handleScroll(event: Event) {
if (display !== TankopediaDisplay.ShootingRange) return;
event.preventDefault(); event.preventDefault();
} }
@@ -295,7 +233,6 @@ export function Controls({
poseEvent.on(disturb); poseEvent.on(disturb);
canvas.addEventListener('pointerdown', disturb); canvas.addEventListener('pointerdown', disturb);
window.addEventListener('keydown', handleKeyDown);
canvas.addEventListener('wheel', handleWheel); canvas.addEventListener('wheel', handleWheel);
document.body.addEventListener('scroll', handleScroll); document.body.addEventListener('scroll', handleScroll);
@@ -304,80 +241,24 @@ export function Controls({
orbitControls.current.rotateSpeed = 0.25; orbitControls.current.rotateSpeed = 0.25;
if (display === TankopediaDisplay.ShootingRange) { camera.position.copy(inspectModeInitialPosition);
const { shootingRangeZoom } = tankopediaEphemeralStore.getState(); orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
orbitControls.current.enablePan = true;
orbitControls.current.enablePan = false; orbitControls.current.enableZoom = true;
orbitControls.current.enableZoom = false; camera.fov = naked ? 20 : 25;
orbitControls.current.rotateSpeed *=
SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
camera.fov =
ARCADE_MODE_FOV * SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
if (shootingRangeZoom === ShootingRangeZoom.Arcade) {
orbitControls.current.target.set(0, gunHeight + 3, 0);
if (isInitialIteration) {
isInitialIteration = false;
camera.position.set(
0,
gunHeight + ARCADE_MODE_DISTANCE * Math.sin(ARCADE_MODE_ANGLE),
ARCADE_MODE_DISTANCE * Math.cos(ARCADE_MODE_ANGLE),
);
} else {
camera.position
.copy(aimTarget)
.sub(orbitControls.current.target)
.normalize()
.multiplyScalar(-ARCADE_MODE_DISTANCE)
.add(orbitControls.current.target);
}
} else {
orbitControls.current.target.copy(shellOrigin);
camera.position
.copy(aimTarget)
.sub(shellOrigin)
.normalize()
.multiplyScalar(-Number.EPSILON)
.add(shellOrigin);
}
} else {
camera.position.copy(inspectModeInitialPosition);
orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true;
camera.fov = naked ? 20 : 25;
}
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
updateCamera(); updateCamera();
const unsubscribeDisplay = tankopediaEphemeralStore.subscribe(
(state) => state.display === TankopediaDisplay.ShootingRange,
() => {
disturb();
updateCamera();
},
);
const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
(state) => state.shootingRangeZoom,
updateCamera,
);
return () => { return () => {
canvas.removeEventListener('pointerdown', disturb); canvas.removeEventListener('pointerdown', disturb);
poseEvent.off(disturb); poseEvent.off(disturb);
unsubscribeDisplay();
unsubscribeShootingRangeZoom();
window.removeEventListener('keydown', handleKeyDown);
canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('wheel', handleWheel);
document.body.removeEventListener('scroll', handleScroll); document.body.removeEventListener('scroll', handleScroll);
}; };
}, [display === TankopediaDisplay.ShootingRange]); }, []);
return ( return (
<OrbitControls <OrbitControls
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { degToRad } from 'three/src/math/MathUtils.js';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Duel } from '../../../../../../stores/duel';
const modelDefinitions = await awaitableModelDefinitions;
export function InitialAligner() {
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.gun_type!.value.base.id];
useEffect(() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(8),
degToRad(25),
gunModel.pitch,
turretModel.yaw,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, []);
return null;
}
@@ -1,5 +1,4 @@
import { Environment } from '@react-three/drei'; import { Environment } from '@react-three/drei';
import { times } from 'lodash-es';
import { Euler } from 'three'; import { Euler } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from 'three/src/math/MathUtils.js';
import { useModel } from '../../../../../../hooks/useModel'; import { useModel } from '../../../../../../hooks/useModel';
@@ -30,24 +29,6 @@ export function Lighting({ display }: LightingProps) {
{display === TankopediaDisplay.StaticArmor && ( {display === TankopediaDisplay.StaticArmor && (
<Environment preset="lobby" /> <Environment preset="lobby" />
)} )}
{display === TankopediaDisplay.ShootingRange && (
<>
<directionalLight position={[0, -1, 0]} />
{times(LIGHTS_COUNT, (index) => (
<directionalLight
key={index}
castShadow
intensity={3 / LIGHTS_COUNT}
position={[
Math.sin(2 * Math.PI * (index / LIGHTS_COUNT)),
1,
Math.cos(2 * Math.PI * (index / LIGHTS_COUNT)),
]}
/>
))}
</>
)}
</> </>
); );
} }
@@ -1,5 +1,4 @@
import { asset, I_HAT, J_HAT } from '@blitzkit/core'; import { asset, I_HAT, J_HAT } from '@blitzkit/core';
import { Html } from '@react-three/drei';
import { useFrame, useLoader } 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 { useRef } from 'react';
@@ -21,8 +20,6 @@ import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTra
import { tankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics'; import { tankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics';
import { Duel } from '../../../../../../../stores/duel'; import { Duel } from '../../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../../stores/tankopediaPersistent/constants';
import { TargetCircle } from './components/TargetCircle';
import { aimTarget } from './constants'; import { aimTarget } from './constants';
const [modelDefinitions, equipmentDefinitions, provisionDefinitions] = const [modelDefinitions, equipmentDefinitions, provisionDefinitions] =
@@ -42,9 +39,6 @@ export function SceneProps() {
const material = useRef<MeshStandardMaterial>(null); const material = useRef<MeshStandardMaterial>(null);
const playground = useRef<Group>(null); const playground = useRef<Group>(null);
let lastPitch = Duel.use((state) => state.protagonist.pitch);
let lastYaw = Duel.use((state) => state.protagonist.yaw);
const display = TankopediaEphemeral.use((state) => state.display);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const track = Duel.use((state) => state.protagonist.track); const track = Duel.use((state) => state.protagonist.track);
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
@@ -121,7 +115,6 @@ export function SceneProps() {
useFrame(({ raycaster, camera, clock, gl }) => { useFrame(({ raycaster, camera, clock, gl }) => {
if ( if (
display !== TankopediaDisplay.ShootingRange ||
!playground.current || !playground.current ||
!targetCircleWrapper.current || !targetCircleWrapper.current ||
DEBUG_disable || DEBUG_disable ||
@@ -156,8 +149,8 @@ export function SceneProps() {
const desiredPitch = Math.asin(direction.y); const desiredPitch = Math.asin(direction.y);
const desiredYaw = Math.atan2(-direction.x, -direction.z); const desiredYaw = Math.atan2(-direction.x, -direction.z);
const deltaPitch = desiredPitch - lastPitch; const deltaPitch = desiredPitch - modelTransformEvent.last!.pitch;
const deltaYaw = desiredYaw - lastYaw; const deltaYaw = desiredYaw - modelTransformEvent.last!.yaw;
const deltaAngle = Math.sqrt(deltaPitch ** 2 + deltaYaw ** 2); const deltaAngle = Math.sqrt(deltaPitch ** 2 + deltaYaw ** 2);
const maxPossibleRotationInTime = gunRotationSpeed * deltaT; const maxPossibleRotationInTime = gunRotationSpeed * deltaT;
const clampedDeltaAngle = Math.min(maxPossibleRotationInTime, deltaAngle); const clampedDeltaAngle = Math.min(maxPossibleRotationInTime, deltaAngle);
@@ -167,14 +160,14 @@ export function SceneProps() {
deltaAngle === 0 ? 0 : deltaYaw * (clampedDeltaAngle / deltaAngle); deltaAngle === 0 ? 0 : deltaYaw * (clampedDeltaAngle / deltaAngle);
const [pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
lastPitch + clampedDeltaPitch, modelTransformEvent.last!.pitch + clampedDeltaPitch,
lastYaw + clampedDeltaYaw, modelTransformEvent.last!.yaw + clampedDeltaYaw,
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
); );
const actualDeltaPitch = pitch - lastPitch; const actualDeltaPitch = pitch - modelTransformEvent.last!.pitch;
const actualDeltaYaw = yaw - lastYaw; const actualDeltaYaw = yaw - modelTransformEvent.last!.yaw;
const deltaRotation = Math.abs(actualDeltaPitch) + Math.abs(actualDeltaYaw); const deltaRotation = Math.abs(actualDeltaPitch) + Math.abs(actualDeltaYaw);
const rotationSpeed = deltaRotation / deltaT; const rotationSpeed = deltaRotation / deltaT;
const turretTraversePenalty = const turretTraversePenalty =
@@ -193,9 +186,7 @@ export function SceneProps() {
dispersion = Math.max(characteristics.dispersion * penalty, dispersion); dispersion = Math.max(characteristics.dispersion * penalty, dispersion);
} }
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
lastPitch = pitch;
lastYaw = yaw;
const gunDirection = new Vector3(0, 0, -1) const gunDirection = new Vector3(0, 0, -1)
.applyAxisAngle(I_HAT, pitch) .applyAxisAngle(I_HAT, pitch)
@@ -241,45 +232,9 @@ export function SceneProps() {
if (!disturbed) return null; if (!disturbed) return null;
return ( return (
<> <mesh rotation={[-Math.PI / 2, 0, 0]}>
{display !== TankopediaDisplay.ShootingRange && ( <planeGeometry args={[10, 10]} />
<mesh rotation={[-Math.PI / 2, 0, 0]}> <meshStandardMaterial ref={material} map={texture} transparent />
<planeGeometry args={[10, 10]} /> </mesh>
<meshStandardMaterial ref={material} map={texture} transparent />
</mesh>
)}
{display === TankopediaDisplay.ShootingRange && (
<>
<group ref={targetCircleWrapper}>
<Html center occlude={false} zIndexRange={[0, 0]}>
<TargetCircle variant="client" ref={clientTargetCircle} />
<TargetCircle variant="server" ref={serverTargetCircle} />
</Html>
</group>
<group ref={playground}>
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[100, 100]} />
<meshStandardMaterial color={0x404040} />
</mesh>
<mesh
receiveShadow
castShadow
position={[10, 1.5, -10]}
rotation={[0, Math.PI / 4, 0]}
>
<boxGeometry args={[1, 3, 1]} />
<meshStandardMaterial color={0xff8040} />
</mesh>
<group position={[0, 0, -25]} rotation={[-Math.PI / 2, 0, Math.PI]}>
<primitive object={mockTank.scene} />
</group>
</group>
</>
)}
</>
); );
} }
@@ -1,6 +1,5 @@
import { normalizeAngleRad } from '@blitzkit/core';
import { invalidate, useThree, type ThreeEvent } from '@react-three/fiber'; import { invalidate, useThree, type ThreeEvent } from '@react-three/fiber';
import { useEffect, 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';
@@ -10,10 +9,7 @@ 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 { import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { ModelTankWrapper } from '../../../../../Armor/components/ModelTankWrapper'; import { ModelTankWrapper } from '../../../../../Armor/components/ModelTankWrapper';
@@ -37,18 +33,6 @@ export function TankModel() {
const { gltf } = useModel(protagonist.tank.id); const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes); const nodes = Object.values(gltf.nodes);
useEffect(() => {
const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
(state) => state.shootingRangeZoom,
(zoom) => {
if (!hullContainer.current) return;
hullContainer.current.visible = zoom === ShootingRangeZoom.Arcade;
},
);
return unsubscribeShootingRangeZoom;
}, []);
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
return ( return (
@@ -125,23 +109,6 @@ export function TankModel() {
/> />
); );
}, },
// group(group) {
// return {
// visible:
// group.name !== 'ST_base' &&
// group.name !== 'MR_Base2' &&
// (!group.name.includes('state_entity') ||
// (isDynamicArmorActive &&
// group.name.includes('state_entity_01')) ||
// (!isDynamicArmorActive &&
// group.name.includes('state_entity_00'))) &&
// (!group.name.includes('shields') ||
// (isDynamicArmorActive &&
// group.name.includes('shields_rev')) ||
// (!isDynamicArmorActive && group.name.endsWith('shields'))),
// };
// },
}); });
})} })}
@@ -157,22 +124,12 @@ export function TankModel() {
if (!isVisible) return null; if (!isVisible) return null;
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
if ( if (!isTurret) return;
!isTurret ||
tankopediaEphemeralStore.getState().display ===
TankopediaDisplay.ShootingRange
)
return;
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
@@ -201,21 +158,18 @@ export function TankModel() {
delta.set(event.clientX, event.clientY).sub(position); delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits( const [pitch, yaw] = applyPitchYawLimits(
pitch, modelTransformEvent.last!.pitch,
yaw + delta.x * (Math.PI / boundingRect.width), modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch, gunModelDefinition.pitch,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
@@ -234,24 +188,6 @@ export function TankModel() {
/> />
); );
}, },
// group(group) {
// return {
// visible:
// group.name !== 'ST_base' &&
// group.name !== 'MR_Base2' &&
// (!group.name.includes('state_entity') ||
// (isDynamicArmorActive &&
// group.name.includes('state_entity_01')) ||
// (!isDynamicArmorActive &&
// group.name.includes('state_entity_00'))) &&
// (!group.name.includes('shields') ||
// (isDynamicArmorActive &&
// group.name.includes('shields_rev')) ||
// (!isDynamicArmorActive &&
// group.name.endsWith('shields'))),
// };
// },
}); });
})} })}
@@ -277,12 +213,6 @@ export function TankModel() {
function onPointerDown(event: ThreeEvent<PointerEvent>) { function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation(); event.stopPropagation();
if (
tankopediaEphemeralStore.getState().display ===
TankopediaDisplay.ShootingRange
)
return;
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
@@ -292,8 +222,8 @@ export function TankModel() {
}); });
position.set(event.clientX, event.clientY); position.set(event.clientX, event.clientY);
pitch = protagonist.pitch; pitch = modelTransformEvent.last!.pitch;
yaw = protagonist.yaw; yaw = modelTransformEvent.last!.yaw;
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
@@ -322,16 +252,12 @@ export function TankModel() {
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
} }
function handlePointerUp() { function handlePointerUp() {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true; draft.controlsEnabled = true;
}); });
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
} }
@@ -347,24 +273,6 @@ export function TankModel() {
/> />
); );
}, },
// group(group) {
// return {
// visible:
// group.name !== 'ST_base' &&
// group.name !== 'MR_Base2' &&
// (!group.name.includes('state_entity') ||
// (isDynamicArmorActive &&
// group.name.includes('state_entity_01')) ||
// (!isDynamicArmorActive &&
// group.name.includes('state_entity_00'))) &&
// (!group.name.includes('shields') ||
// (isDynamicArmorActive &&
// group.name.includes('shields_rev')) ||
// (!isDynamicArmorActive &&
// group.name.endsWith('shields'))),
// };
// },
}); });
})} })}
</group> </group>
@@ -1,14 +1,11 @@
import { useEffect, 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 { import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../../stores/tankopediaEphemeral';
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';
@@ -40,18 +37,6 @@ export function TransitionSkeleton() {
const nodes = Object.values(gltf.nodes); const nodes = Object.values(gltf.nodes);
const revealed = tankopediaEphemeralStore((state) => state.revealed); const revealed = tankopediaEphemeralStore((state) => state.revealed);
useEffect(() => {
const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
(state) => state.shootingRangeZoom,
(zoom) => {
if (!hullContainer.current) return;
hullContainer.current.visible = zoom === ShootingRangeZoom.Arcade;
},
);
return unsubscribeShootingRangeZoom;
}, []);
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
return ( return (
@@ -1,5 +1,5 @@
import { BLITZKIT_TANK_ICON_SIZE } from '@blitzkit/core'; import { BLITZKIT_TANK_ICON_SIZE } from '@blitzkit/core';
import { Canvas, invalidate } from '@react-three/fiber'; import { invalidate } from '@react-three/fiber';
import { import {
forwardRef, forwardRef,
Suspense, Suspense,
@@ -13,7 +13,6 @@ import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimi
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 { useOnScreen } from '../../../../../hooks/useOnScreen';
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 { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
@@ -26,8 +25,10 @@ import {
StaticArmor, StaticArmor,
type ThicknessRange, type ThicknessRange,
} from '../../../../Armor/components/StaticArmor'; } from '../../../../Armor/components/StaticArmor';
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 { 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';
@@ -65,7 +66,6 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
const hideTankModelUnderArmor = TankopediaPersistent.use( const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor, (state) => state.hideTankModelUnderArmor,
); );
const onScreen = useOnScreen(canvas);
useEffect(() => { useEffect(() => {
if (rawDisplay === display) return; if (rawDisplay === display) return;
@@ -115,56 +115,44 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
function handlePoseEvent(pose: Pose) { function handlePoseEvent(pose: Pose) {
switch (pose) { switch (pose) {
case Pose.HullDown: { case Pose.HullDown: {
mutateDuel((draft) => { const [pitch, yaw] = applyPitchYawLimits(
const [pitch, yaw] = applyPitchYawLimits( -Infinity,
-Infinity, 0,
0, gunModelDefinition.pitch,
gunModelDefinition.pitch, turretModelDefinition.yaw,
turretModelDefinition.yaw, hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer, );
);
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break; break;
} }
case Pose.FaceHug: { case Pose.FaceHug: {
mutateDuel((draft) => { const [pitch, yaw] = applyPitchYawLimits(
const [pitch, yaw] = applyPitchYawLimits( Infinity,
Infinity, 0,
0, gunModelDefinition.pitch,
gunModelDefinition.pitch, turretModelDefinition.yaw,
turretModelDefinition.yaw, hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer, );
);
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break; break;
} }
case Pose.Default: case Pose.Default:
mutateDuel((draft) => { const [pitch, yaw] = applyPitchYawLimits(
const [pitch, yaw] = applyPitchYawLimits( 0,
0, 0,
0, gunModelDefinition.pitch,
gunModelDefinition.pitch, turretModelDefinition.yaw,
turretModelDefinition.yaw, hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer, );
);
modelTransformEvent.emit({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break; break;
} }
@@ -178,15 +166,15 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
}); });
useEffect(() => { useEffect(() => {
mutateDuel((draft) => { const [pitch, yaw] = applyPitchYawLimits(
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits( modelTransformEvent.last!.pitch,
draft.protagonist.pitch, modelTransformEvent.last!.yaw,
draft.protagonist.yaw, gunModelDefinition.pitch,
gunModelDefinition.pitch, turretModelDefinition.yaw,
turretModelDefinition.yaw, hasImprovedVerticalStabilizer,
hasImprovedVerticalStabilizer, );
);
}); modelTransformEvent.dispatch({ pitch, yaw });
}, [protagonistGun, protagonistTurret]); }, [protagonistGun, protagonistTurret]);
useEffect(() => { useEffect(() => {
@@ -204,16 +192,9 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
}, [display]); }, [display]);
return ( return (
<Canvas <SmartCanvas
ref={canvas} ref={canvas}
scene={{ fog: naked ? undefined : fog.current }} scene={{ fog: naked ? undefined : fog.current }}
frameloop={
onScreen
? display === TankopediaDisplay.ShootingRange
? 'always'
: 'demand'
: 'never'
}
gl={{ gl={{
clippingPlanes: [], clippingPlanes: [],
localClippingEnabled: true, localClippingEnabled: true,
@@ -254,7 +235,9 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
)} )}
<Lighting display={display} /> <Lighting display={display} />
</Suspense> </Suspense>
</Canvas>
<InitialAligner />
</SmartCanvas>
); );
}, },
); );
@@ -112,8 +112,6 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
top={isFullScreen ? '0' : undefined} top={isFullScreen ? '0' : undefined}
left={isFullScreen ? '0' : undefined} left={isFullScreen ? '0' : undefined}
> >
<Title />
<Box <Box
className="tank-sandbox-container" className="tank-sandbox-container"
flexGrow="1" flexGrow="1"
@@ -122,6 +120,8 @@ 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%"
@@ -136,7 +136,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
height: '100%', height: '100%',
transitionDuration: '2s', transitionDuration: '2s',
opacity: revealed ? 1 : 0, opacity: revealed ? 1 : 0,
filter: disturbed ? undefined : `drop-shadow(0 0 1rem black)`, filter: disturbed
? undefined
: `drop-shadow(0 1rem 1rem black)`,
}} }}
> >
<Suspense> <Suspense>
@@ -145,6 +147,8 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
</Box> </Box>
</Box> </Box>
<Title foreground />
<Options <Options
skeleton={skeleton} skeleton={skeleton}
canvas={canvas} canvas={canvas}
@@ -152,8 +156,6 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
/> />
</Box> </Box>
</Box> </Box>
<Title foreground />
</Flex> </Flex>
</Flex> </Flex>
); );
@@ -24,10 +24,6 @@ export function VideoSection({ skeleton }: MaybeSkeletonComponentProps) {
<Heading size="6"> <Heading size="6">
{strings.website.tools.tankopedia.review.title} {strings.website.tools.tankopedia.review.title}
</Heading> </Heading>
<Text color="gray">
{strings.website.tools.tankopedia.review.disclaimer}
</Text>
</Flex> </Flex>
<Flex gap="3" wrap="wrap" justify="center"> <Flex gap="3" wrap="wrap" justify="center">
@@ -6,6 +6,8 @@ import {
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from 'three/src/math/MathUtils.js';
export const DEFAULT_PITCH_TRANSITION = 15;
export function applyPitchYawLimits( export function applyPitchYawLimits(
pitch: number, // rad pitch: number, // rad
yaw: number, // rad yaw: number, // rad
@@ -28,20 +30,23 @@ export function applyPitchYawLimits(
const elevationBuff = verticalStabilizer ? degToRad(4) : 0; const elevationBuff = verticalStabilizer ? degToRad(4) : 0;
let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff; let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff;
let upperPitch = -degToRad(pitchLimits.min) + elevationBuff; let upperPitch = -degToRad(pitchLimits.min) + elevationBuff;
const transition = degToRad(pitchLimits.transition ?? 15); const transition = degToRad(
pitchLimits.transition ?? DEFAULT_PITCH_TRANSITION,
);
if (pitchLimits.back) { if (pitchLimits.back) {
const range = degToRad(pitchLimits.back.range); const range = degToRad(pitchLimits.back.range);
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI)); const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
if (yawRotatedAbs <= range + transition) { if (yawRotatedAbs <= range / 2 + transition) {
if (yawRotatedAbs <= range) { if (yawRotatedAbs <= range / 2) {
// inside range // inside range
lowerPitch = -degToRad(pitchLimits.back.max); lowerPitch = -degToRad(pitchLimits.back.max);
upperPitch = -degToRad(pitchLimits.back.min); upperPitch = -degToRad(pitchLimits.back.min);
} else { } else {
// inside transition // inside transition
const transitionProgress = (yawRotatedAbs - range) / transition; const transitionProgress = (yawRotatedAbs - range / 2) / transition;
// console.log(transitionProgress);
const maxInterpolatedPitch = const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.max + (1 - transitionProgress) * pitchLimits.back.max +
transitionProgress * pitchLimits.max; transitionProgress * pitchLimits.max;
@@ -59,14 +64,14 @@ export function applyPitchYawLimits(
const range = degToRad(pitchLimits.front.range); const range = degToRad(pitchLimits.front.range);
const yawAbs = Math.abs(normalizeAngleRad(newYaw)); const yawAbs = Math.abs(normalizeAngleRad(newYaw));
if (yawAbs <= range + transition) { if (yawAbs <= range / 2 + transition) {
if (yawAbs <= range) { if (yawAbs <= range / 2) {
// inside range // inside range
lowerPitch = -degToRad(pitchLimits.front.max); lowerPitch = -degToRad(pitchLimits.front.max);
upperPitch = -degToRad(pitchLimits.front.min); upperPitch = -degToRad(pitchLimits.front.min);
} else { } else {
// inside transition // inside transition
const transitionProgress = (yawAbs - range) / transition; const transitionProgress = (yawAbs - range / 2) / transition;
const maxInterpolatedPitch = const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.max + (1 - transitionProgress) * pitchLimits.front.max +
transitionProgress * pitchLimits.max; transitionProgress * pitchLimits.max;
@@ -82,5 +87,5 @@ export function applyPitchYawLimits(
newPitch = clamp(newPitch, lowerPitch, upperPitch); newPitch = clamp(newPitch, lowerPitch, upperPitch);
return [newPitch, newYaw]; return [newPitch, newYaw] as const;
} }
@@ -1,12 +1,13 @@
import type { EquipmentMatrix } from '../../stores/duel'; import type { EquipmentMatrix } from '../../stores/duel';
import { awaitableEquipmentDefinitions } from '../awaitables/equipmentDefinitions'; import { awaitableEquipmentDefinitions } from '../awaitables/equipmentDefinitions';
export async function hasEquipment( const equipmentDefinitions = await awaitableEquipmentDefinitions;
export function hasEquipment(
id: number, id: number,
equipmentPreset: string, equipmentPreset: string,
equipmentMatrix: EquipmentMatrix, equipmentMatrix: EquipmentMatrix,
) { ) {
const equipmentDefinitions = await awaitableEquipmentDefinitions;
const preset = equipmentDefinitions.presets[equipmentPreset]; const preset = equipmentDefinitions.presets[equipmentPreset];
return preset.slots.some((slot, index) => { return preset.slots.some((slot, index) => {
const row = Math.floor(index / 3); const row = Math.floor(index / 3);
@@ -1,8 +1,11 @@
import { EventManager } from '@blitzkit/core'; import { Quicklime } from 'quicklime';
export interface ModelTransformEventData { export interface ModelTransformEventData {
yaw?: number; yaw: number;
pitch: number; pitch: number;
} }
export const modelTransformEvent = new EventManager<ModelTransformEventData>(); export const modelTransformEvent = new Quicklime<ModelTransformEventData>({
pitch: 0,
yaw: 0,
});
@@ -166,8 +166,7 @@ export function tankCharacteristics(
({ distance }) => distance >= assaultDistance, ({ distance }) => distance >= assaultDistance,
)?.factor ?? 0) )?.factor ?? 0)
: 1; : 1;
const moduleDamageCoefficient = const moduleDamageCoefficient = coefficient([hasTungsten, 0.15]);
coefficient([hasTungsten, 0.15]) * assaultDamageCoefficient;
const reloadCoefficient = const reloadCoefficient =
(coefficient([hasGunRammer, -0.05]) * (coefficient([hasGunRammer, -0.05]) *
coefficient([true, degressiveStat(loaderMastery)])) / coefficient([true, degressiveStat(loaderMastery)])) /
@@ -4,10 +4,8 @@ import {
type ProvisionDefinitions, type ProvisionDefinitions,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from '@blitzkit/core';
import { degToRad } from 'three/src/math/MathUtils.js';
import type { DuelMember } from '../../stores/duel'; import type { DuelMember } from '../../stores/duel';
import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants'; import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
import { applyPitchYawLimits } from '../blitz/applyPitchYawLimits';
export function tankToDuelMember( export function tankToDuelMember(
tank: TankDefinition, tank: TankDefinition,
@@ -15,16 +13,7 @@ export function tankToDuelMember(
provisionDefinitions: ProvisionDefinitions, provisionDefinitions: ProvisionDefinitions,
) { ) {
const turret = tank.turrets.at(-1)!; const turret = tank.turrets.at(-1)!;
const turretModel = model.turrets[turret.id];
const gun = turret.guns.at(-1)!; const gun = turret.guns.at(-1)!;
const gunModel = turretModel.guns[gun.gun_type!.value.base.id];
const [pitch, yaw] = applyPitchYawLimits(
degToRad(8),
degToRad(25),
gunModel.pitch,
turretModel.yaw,
);
return { return {
camouflage: true, camouflage: true,
@@ -37,8 +26,6 @@ export function tankToDuelMember(
shell: tank.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0], shell: tank.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0],
tank, tank,
track: tank.tracks.at(-1)!, track: tank.tracks.at(-1)!,
yaw,
pitch,
turret, turret,
assaultDistance: assaultDistance:
(gun.gun_type!.value.base.assault_ranges?.ranges[0].distance ?? 0) / 2, (gun.gun_type!.value.base.assault_ranges?.ranges[0].distance ?? 0) / 2,
+1
View File
@@ -25,5 +25,6 @@ export function useModel(id: number) {
), ),
}; };
} }
return cache[id]; return cache[id];
} }
+12 -18
View File
@@ -6,6 +6,7 @@ import {
TurretDefinition, TurretDefinition,
} from '@blitzkit/core'; } from '@blitzkit/core';
import { invalidate } from '@react-three/fiber'; import { invalidate } from '@react-three/fiber';
import type { QuicklimeEvent } from 'quicklime';
import { type RefObject, useEffect } from 'react'; import { type RefObject, useEffect } from 'react';
import { Euler, Group, Vector3 } from 'three'; import { Euler, Group, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from 'three/src/math/MathUtils.js';
@@ -37,10 +38,15 @@ export function useTankTransform(
const gunPosition = new Vector3(); const gunPosition = new Vector3();
const gunRotation = new Euler(); const gunRotation = new Euler();
function handleModelTransform(modelTransform?: ModelTransformEventData) { function handleModelTransform(
const duel = duelStore.getState(); event: QuicklimeEvent<ModelTransformEventData>,
const yaw = modelTransform?.yaw ?? duel.protagonist.yaw; ) {
const pitch = modelTransform?.pitch ?? duel.protagonist.pitch; handleModelTransformInner(event.data);
}
function handleModelTransformInner(data?: ModelTransformEventData) {
const yaw = data?.yaw ?? modelTransformEvent.last!.yaw;
const pitch = data?.pitch ?? modelTransformEvent.last!.pitch;
gunPosition gunPosition
.set(0, 0, 0) .set(0, 0, 0)
@@ -91,24 +97,12 @@ export function useTankTransform(
invalidate(); invalidate();
} }
handleModelTransform(); handleModelTransformInner();
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
const unsubscribes = [
duelStore.subscribe(
(state) => state.protagonist.pitch,
() => handleModelTransform(),
),
duelStore.subscribe(
(state) => state.protagonist.yaw,
() => handleModelTransform(),
),
() => modelTransformEvent.off(handleModelTransform),
];
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); modelTransformEvent.off(handleModelTransform);
}; };
}, [track, turret]); }, [track, turret]);
} }
@@ -3,6 +3,7 @@ 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 { 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';
@@ -19,10 +20,12 @@ import { Duel } from '../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
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';
type PageProps = MaybeSkeletonComponentProps & type PageProps = MaybeSkeletonComponentProps &
LocaleAcceptorProps & { LocaleAcceptorProps & {
id: number; id: number;
guide?: TankGuide;
}; };
const [tankDefinitions, provisionDefinitions, modelDefinitions] = const [tankDefinitions, provisionDefinitions, modelDefinitions] =
@@ -32,7 +35,7 @@ const [tankDefinitions, provisionDefinitions, modelDefinitions] =
awaitableModelDefinitions, awaitableModelDefinitions,
]); ]);
export function Page({ id, skeleton, locale }: PageProps) { 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];
@@ -51,8 +54,9 @@ export function Page({ id, skeleton, locale }: PageProps) {
{tank.type === TankType.RESEARCHABLE && !tank.deprecated && ( {tank.type === TankType.RESEARCHABLE && !tank.deprecated && (
<TechTreeSection skeleton={skeleton} /> <TechTreeSection skeleton={skeleton} />
)} )}
<CharacteristicsSection /> <CharacteristicsSection skeleton={skeleton} />
<GameModeSection /> <GameModeSection />
{guide && <GuideSection guide={guide} />}
<VideoSection skeleton={skeleton} /> <VideoSection skeleton={skeleton} />
</PageWrapper> </PageWrapper>
</Duel.Provider> </Duel.Provider>
@@ -1,6 +1,7 @@
--- ---
import { AstroSeo } from '@astrolib/seo'; import { AstroSeo } from '@astrolib/seo';
import { import {
assertSecret,
asset, asset,
createDefaultProvisions, createDefaultProvisions,
createDefaultSkills, createDefaultSkills,
@@ -14,6 +15,7 @@ import {
import { DEFAULT_LOCALE, unwrapper } from '@blitzkit/i18n'; import { DEFAULT_LOCALE, unwrapper } from '@blitzkit/i18n';
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from '@blitzkit/i18n/src/literals';
import type { GetStaticPaths } from 'astro'; import type { GetStaticPaths } from 'astro';
import { load } from 'cheerio';
import { tankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics'; import { tankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { getStrings } from '../../../../core/i18n/getStrings'; import { getStrings } from '../../../../core/i18n/getStrings';
import BaseLayout from '../../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../../layouts/BaseLayout.astro';
@@ -43,6 +45,7 @@ const skillDefinitions = await fetchSkillDefinitions();
const provisionDefinitions = await fetchProvisionDefinitions(); const provisionDefinitions = await fetchProvisionDefinitions();
const equipmentDefinitions = await fetchEquipmentDefinitions(); const equipmentDefinitions = await fetchEquipmentDefinitions();
const tankModelDefinitions = await fetchModelDefinitions(); const tankModelDefinitions = await fetchModelDefinitions();
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
const tankModelDefinition = tankModelDefinitions.models[tank.id]; const tankModelDefinition = tankModelDefinitions.models[tank.id];
const unwrap = unwrapper(locale); const unwrap = unwrapper(locale);
@@ -57,6 +60,7 @@ const title = literals(strings.website.tools.tankopedia.tank_seo_title, [
strings.common.nations[tank.nation as keyof typeof strings.common.nations], strings.common.nations[tank.nation as keyof typeof strings.common.nations],
]); ]);
const url = import.meta.env.SITE + Astro.url.pathname; const url = import.meta.env.SITE + Astro.url.pathname;
const icon = asset(`icons/tanks/big/${id}.webp`); const icon = asset(`icons/tanks/big/${id}.webp`);
const turret = tank.turrets.at(-1)!; const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!; const gun = turret.guns.at(-1)!;
@@ -96,6 +100,30 @@ const description = literals(
Math.round(characteristics.health).toLocaleString(locale), Math.round(characteristics.health).toLocaleString(locale),
], ],
); );
export interface TankGuide {
url: string;
description?: string;
image?: string;
}
const guideUrl = `${assertSecret(import.meta.env.PUBLIC_GUIDE_WEBSITE)}/${tank.slug}`;
let guide: TankGuide | undefined = undefined;
const guideResponse = await fetch(guideUrl);
if (guideResponse.ok) {
const $ = await guideResponse.text().then(load);
const description = $('.entry-content p').first().text();
const image = $('.entry-content img').first().attr('src')?.toString();
if (description !== undefined || image !== undefined) {
guide = {
url: guideUrl,
description,
image,
};
}
}
--- ---
<AstroSeo <AstroSeo
@@ -110,7 +138,7 @@ const description = literals(
<meta name="twitter:image:src" content={icon} /> <meta name="twitter:image:src" content={icon} />
<BaseLayout integrated> <BaseLayout integrated>
<Page {locale} id={id} client:only="react"> <Page {locale} id={id} {guide} client:only="react">
<Page {locale} id={id} skeleton slot="fallback" /> <Page {locale} id={id} {guide} skeleton slot="fallback" />
</Page> </Page>
</BaseLayout> </BaseLayout>
@@ -33,8 +33,6 @@ export interface DuelMember {
shell: ShellDefinition; shell: ShellDefinition;
track: TrackDefinition; track: TrackDefinition;
equipmentMatrix: EquipmentMatrix; equipmentMatrix: EquipmentMatrix;
pitch: number;
yaw: number;
consumables: number[]; consumables: number[];
provisions: number[]; provisions: number[];
camouflage: boolean; camouflage: boolean;
@@ -78,28 +78,10 @@ export enum TankopediaRelativeAgainst {
All, All,
} }
export enum ShootingRangeZoom {
Arcade,
Zoom0,
Zoom1,
Zoom2,
}
export const SHOOTING_RANGE_ZOOM_COEFFICIENTS: Record<
ShootingRangeZoom,
number
> = {
[ShootingRangeZoom.Arcade]: 1,
[ShootingRangeZoom.Zoom0]: 1 / 8,
[ShootingRangeZoom.Zoom1]: 1 / 16,
[ShootingRangeZoom.Zoom2]: 1 / 25,
};
interface TankopediaEphemeral { interface TankopediaEphemeral {
disturbed: boolean; disturbed: boolean;
revealed: boolean; revealed: boolean;
shot?: Shot; shot?: Shot;
shootingRangeZoom: ShootingRangeZoom;
skills: Record<string, number>; skills: Record<string, number>;
relativeAgainst: TankopediaRelativeAgainst; relativeAgainst: TankopediaRelativeAgainst;
controlsEnabled: boolean; controlsEnabled: boolean;
@@ -133,7 +115,6 @@ export const TankopediaEphemeral = createContextualStore(
subscribeWithSelector<TankopediaEphemeral>(() => ({ subscribeWithSelector<TankopediaEphemeral>(() => ({
disturbed: false, disturbed: false,
revealed: false, revealed: false,
shootingRangeZoom: ShootingRangeZoom.Arcade,
relativeAgainst: TankopediaRelativeAgainst.Class, relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false, editStatic: false,
skills: createDefaultSkills(skillDefinitions), skills: createDefaultSkills(skillDefinitions),
@@ -38,5 +38,4 @@ export enum TankopediaDisplay {
Model, Model,
DynamicArmor, DynamicArmor,
StaticArmor, StaticArmor,
ShootingRange,
} }