Compare commits

...
83 Commits
Author SHA1 Message Date
TrèsAbhi 97fc386bc7 New Crowdin updates (#168)
* New translations en.json (Russian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Russian)

* New translations en.json (Russian)
2025-08-31 09:54:49 -05:00
TrèsAbhi abfb3f8ddb fix: typo 2025-08-29 23:58:47 -05:00
TrèsAbhi 9cb5113f6e feat: haha 2025-08-29 23:58:14 -05:00
TrèsAbhi 7dd1c0c32a feat: logs 2025-08-29 23:57:33 -05:00
TrèsAbhi 03233f3fdc fix: align antagonist selector filters correctly 2025-08-29 23:46:57 -05:00
TrèsAbhi 377159c298 fix: consider tank models 2025-08-29 23:41:35 -05:00
TrèsAbhi d8431fb620 fix: gap alignemnt 2025-08-29 23:28:49 -05:00
TrèsAbhi 0546ddefbd fix: better shot card alignment 2025-08-29 23:24:12 -05:00
TrèsAbhi 26f6889012 feat: revert subtler advanced highlighting coloring 2025-08-29 23:10:20 -05:00
TrèsAbhi 3ad6088565 feat: skip button for reload visualizer 2025-08-29 23:08:44 -05:00
TrèsAbhi b6fd568cc6 fix: screenshot props 2025-08-29 23:00:02 -05:00
TrèsAbhi 3861b07aef fix: remove "show grid" 2025-08-29 22:53:04 -05:00
TrèsAbhi 2bf57e2c72 feat: better framing 2025-08-29 22:51:24 -05:00
TrèsAbhi 744c6654cf feat: cooler initial position 2025-08-29 22:39:34 -05:00
TrèsAbhi b82e905757 fix: dead code 2025-08-29 13:42:44 -05:00
TrèsAbhi c42e5b7dc3 feat: logs 2025-08-29 13:32:46 -05:00
TrèsAbhi b716218ecd fix: loading bar 2025-08-29 13:31:48 -05:00
TrèsAbhi 511346e4bb fix: select 2025-08-29 13:26:34 -05:00
TrèsAbhi 5c2e49453e feat: more lighting 2025-08-29 13:21:41 -05:00
TrèsAbhi 5d397de766 feat: more progress 2025-08-28 18:23:02 -05:00
TrèsAbhi aa763f65d1 feat: more progress 2025-08-28 17:59:31 -05:00
TrèsAbhi 94cbd95509 feat: start of new lighting 2025-08-28 16:52:23 -05:00
TrèsAbhi f3762e356f feat: start of mixer 2025-08-28 16:12:40 -05:00
TrèsAbhi 5747d7549f feat: tweak a few things and enable guess game 2025-08-27 17:43:42 -05:00
TrèsAbhi aea4905d20 fix: vars 2025-08-27 17:22:06 -05:00
TrèsAbhi 1558b69931 Merge branch 'main' of https://github.com/blitzkit/blitzkit 2025-08-27 17:19:45 -05:00
TrèsAbhi a560a3d3ae feat: logs 2025-08-27 17:19:40 -05:00
TrèsAbhi b620110d0f New Crowdin updates (#167)
* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)
2025-08-27 11:36:57 -05:00
TrèsAbhi 23ea72bf40 Merge branch 'dev' 2025-08-26 11:29:09 -05:00
TrèsAbhi aaee0b7965 feat: servicing 2025-08-26 11:28:57 -05:00
TrèsAbhi 347523f9e7 feat: burst dpm 2025-08-22 13:38:02 -05:00
TrèsAbhi 3205630a0f feat: recoil 2025-08-22 12:44:34 -05:00
TrèsAbhi 8446e3fbbd feat: flashes 2025-08-22 12:40:26 -05:00
TrèsAbhi 6d25a97fc8 fix: dynamic sizing 2025-08-22 12:23:57 -05:00
TrèsAbhi bdbfce0e18 feat: auto reloader compatibility 2025-08-22 12:21:04 -05:00
TrèsAbhi 8033e8f7cc feat: burst compatiblity 2025-08-21 20:22:12 -05:00
TrèsAbhi f98c801d6c New translations en.json (Russian) (#163) 2025-08-21 13:45:37 -05:00
TrèsAbhi 339663adaf Merge branch 'main' into dev 2025-08-20 11:07:50 -05:00
TrèsAbhi 2b15357dee New Crowdin updates (#162)
* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Russian)
2025-08-19 19:02:36 -05:00
TrèsAbhi 1e05717daf fix: remove logs 2025-08-18 12:07:02 -05:00
TrèsAbhi c20f8c5040 feat: burst data 2025-08-18 12:06:14 -05:00
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
118 changed files with 3280 additions and 3179 deletions

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
PUBLIC_ASSET_FAST_USER: ${{ secrets.PUBLIC_ASSET_FAST_USER }}
PUBLIC_ASSET_FAST_REPO: ${{ secrets.PUBLIC_ASSET_FAST_REPO }}
PUBLIC_PROMOTE_OPENTEST: ${{ secrets.PUBLIC_PROMOTE_OPENTEST }}
ALLOW_ROBOTS: ${{ secrets.ALLOW_ROBOTS }}
WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }}
PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }}
PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ${{ secrets.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }}
WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }}
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs:
host:
+2
View File
@@ -29,6 +29,8 @@ env:
PUBLIC_DISCORD_SERVER_ID: ${{ secrets.PUBLIC_DISCORD_SERVER_ID }}
PUBLIC_ASSET_BASE: ${{ secrets.PUBLIC_ASSET_BASE }}
PUBLIC_BRANCH: ${{ secrets.PUBLIC_BRANCH }}
PUBLIC_GUIDE_WEBSITE: ${{ secrets.PUBLIC_GUIDE_WEBSITE }}
SERVICING: ${{ secrets.SERVICING }}
jobs:
build:
+1
View File
@@ -222,6 +222,7 @@
"yolo",
"youtuber",
"youtubers",
"zoomable",
"zustand"
],
"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
+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.27.17
This update was released early for the website. Today, it'll be officially announced and released for the bot
## Features
- Added the shell reload visualizer to aid people in getting a feel for how long shell reloads really are
- Added lines of stats for the burst shells
## Changes
- Removed the redundant word "Shell" from "Shell reload" and "Shell reloads"
- Removed the penetration over distance slider
## Fixes
- Fixed the DPM of tanks with burst shells like the ATAC
- The DPM will still be different from the game due to a bug in World of Tanks Blitz; I am trying to get Wargaming to fix it; trust BlitzKit!
+11
View File
@@ -0,0 +1,11 @@
# BlitzKit 1.27.18
## Features
- Added a mini-game to BlitzKit similar to "Guess Who's That Pokemon" but with tanks: http://blitzkit.vercel.app/guess
- Switched to a new renderer for better lighting for the Tankopedia
## Fixes
- The loading bar for the Tankopedia sandbox now shows up again
- Removed a lot of dead code, improving performance
+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.27.19
Two updates in a day? I just got a little excited by the new Tankopedia lighting.
## Features
- The camera is now lower down in the Tankopedia, giving the tank a more heroic feel
- Tanks are better framed across all devices, minimizing whitespace
- Added a skip button for the reload visualizer to skip to the next event
## Fixes
- Removed the useless "show grid" setting
- Taking screenshots now no longer includes the scene props like the floor
- The subtler color contrast for the advanced highlighting has now been reverted back to the higher contrast
- The shot display card is now properly aligned as a proper table again
- Tank modules are once again considered in the penetration tester
- Tank filters in the antagonist selector in the Tankopedia sandbox are now properly aligned
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "blitzkit",
"private": true,
"version": "1.27.15",
"version": "1.27.19",
"description": "🎉 Tools for everything World of Tanks Blitz",
"type": "module",
"types": "./types.d.ts",
@@ -41,6 +41,7 @@
},
"license": "MIT",
"dependencies": {
"cheerio": "^1.1.2",
"googleapis": "^144.0.0",
"lodash-es": "^4.17.21",
"protobufjs": "^7.5.2",
+3 -8
View File
@@ -197,15 +197,10 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
turret.guns.forEach((gun) => {
if (
gun.gun_type!.value.base.research_cost !== undefined &&
typeof gun.gun_type!.value.base.research_cost
.research_cost_type!.value === 'number'
gun.research_cost !== undefined &&
typeof gun.research_cost.research_cost_type!.value === 'number'
) {
gunXps.set(
gun.gun_type!.value.base.id,
gun.gun_type!.value.base.research_cost.research_cost_type!
.value,
);
gunXps.set(gun.id, gun.research_cost.research_cost_type!.value);
}
});
});
+39 -27
View File
@@ -1,6 +1,6 @@
import {
GunDefinition,
GunDefinitionAutoReloaderProperties,
GunDefinitionAutoReloader,
ShellDefinition,
} from '../protos';
@@ -15,34 +15,46 @@ export function resolveDpm(
let dps: number;
if (gun.gun_type!.$case === 'regular') {
dps = alpha / (reloadCoefficient * gun.gun_type!.value.extension.reload);
dps = alpha / (reloadCoefficient * gun.gun_type!.value.reload);
} else if (gun.gun_type!.$case === 'auto_loader') {
dps =
(alpha * gun.gun_type!.value.extension.shell_count) /
(gun.gun_type!.value.extension.clip_reload * reloadCoefficient +
(gun.gun_type!.value.extension.shell_count - 1) *
gun.gun_type!.value.extension.intra_clip *
intraClipCoefficient);
} else {
const mostOptimalShell =
gun.gun_type!.value.extension.shell_reloads.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0
? (
gun.gun_type!.value
.extension as GunDefinitionAutoReloaderProperties
).intra_clip * intraClipCoefficient
: 0);
const damage = alpha * gun.gun_type!.value.shell_count;
let time = gun.gun_type!.value.clip_reload * reloadCoefficient;
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
if (gun.burst) {
time +=
(gun.gun_type!.value.shell_count / gun.burst.count - 1) *
gun.gun_type!.value.intra_clip *
intraClipCoefficient;
time +=
(gun.gun_type!.value.shell_count / gun.burst.count) *
(gun.burst.count - 1) *
gun.burst.interval;
} else {
time +=
(gun.gun_type!.value.shell_count - 1) *
gun.gun_type!.value.intra_clip *
intraClipCoefficient;
}
dps = damage / time;
} else {
const mostOptimalShell = gun.gun_type!.value.shell_reloads.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0
? (gun.gun_type!.value as GunDefinitionAutoReloader).intra_clip *
intraClipCoefficient
: 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
dps = alpha / mostOptimalShell?.reload;
}
+2 -2
View File
@@ -7,8 +7,8 @@ export function uniqueGuns(turrets: TurretDefinition[]) {
forEachRight(turrets, (turret) => {
forEachRight(turret.guns, (gun) => {
if (!gunIds.has(gun.gun_type!.value.base.id)) {
gunIds.add(gun.gun_type!.value.base.id);
if (!gunIds.has(gun.id)) {
gunIds.add(gun.id);
guns.push({ turret, gun });
}
});
+22 -33
View File
@@ -108,58 +108,47 @@ message GunDefinition {
GunDefinitionAutoLoader auto_loader = 2;
GunDefinitionAutoReloader auto_reloader = 3;
}
required uint32 id = 4;
required float rotation_speed = 5;
optional ResearchCost research_cost = 6;
required uint32 weight = 7;
required I18nString name = 8;
required uint32 tier = 9;
repeated ShellDefinition shells = 10;
required float camouflage_loss = 11;
required float aim_time = 12;
required float dispersion_base = 13;
required float dispersion_traverse = 14;
required float dispersion_shot = 15;
required float dispersion_damaged = 16;
repeated Unlock unlocks = 17;
required uint32 shell_capacity = 18;
optional AssaultRanges assault_ranges = 19;
optional Burst burst = 20;
}
message GunDefinitionRegular {
required GunDefinitionBase base = 1;
required GunDefinitionRegularProperties extension = 2;
}
message GunDefinitionRegularProperties {
required float reload = 1;
}
message GunDefinitionAutoLoader {
required GunDefinitionBase base = 1;
required GunDefinitionAutoLoaderProperties extension = 2;
}
message GunDefinitionAutoLoaderProperties {
required float clip_reload = 1;
required float intra_clip = 2;
required float shell_count = 3;
}
message GunDefinitionAutoReloader {
required GunDefinitionBase base = 1;
required GunDefinitionAutoReloaderProperties extension = 2;
message Burst {
required uint32 count = 1;
required float interval = 2;
}
message GunDefinitionAutoReloaderProperties {
message GunDefinitionAutoReloader {
repeated float shell_reloads = 1;
required float intra_clip = 2;
required float shell_count = 3;
}
message GunDefinitionBase {
required uint32 id = 1;
required float rotation_speed = 2;
optional ResearchCost research_cost = 3;
required uint32 weight = 4;
required I18nString name = 5;
required uint32 tier = 6;
repeated ShellDefinition shells = 7;
required float camouflage_loss = 8;
required float aim_time = 9;
required float dispersion_base = 10;
required float dispersion_traverse = 11;
required float dispersion_shot = 12;
required float dispersion_damaged = 13;
repeated Unlock unlocks = 14;
required uint32 shell_capacity = 15;
optional AssaultRanges assault_ranges = 16;
}
message AssaultRanges {
repeated AssaultRange ranges = 1;
repeated ShellType types = 2;
File diff suppressed because it is too large. Load diff
+24 -7
View File
@@ -861,6 +861,13 @@
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -887,6 +894,10 @@
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -913,6 +924,13 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
@@ -928,9 +946,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
@@ -1026,7 +1042,6 @@
"review": {
"title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program"
},
@@ -1090,8 +1105,8 @@
"dpmEffective": "Effective at 60s",
"shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads",
"shellReload": "Shell reload",
"shellReloads": "Reloads",
"shellReload": "Reload",
"intraClip": "Intra-clip",
"caliber": "Caliber",
"penetration": "Penetration",
@@ -1149,7 +1164,9 @@
"shellRange": "Shell range",
"shellCapacity": "Shell capacity",
"gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m"
"penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nivel %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindaje, estadisticas, equipamiento, proviciones y mas.\n%s2 DPM, %3hp alfa, %s4mm penetracion, %s5kph velocidad maxima, %s6 hp salud",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Penetración verde",
"hide_model_under_armor": "Ocultar modelo bajo la armadura",
"opaque": "Opaco",
"dev_wireframe": "DEV: wireframe",
"environment": "Entorno",
"show_grid": "Mostrar cuadrícula"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Descargar",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Vídeos de reseña",
"disclaimer": "BlitzKit no respalda ningún vídeo de reseñas.",
"join": "Aplica para el programa de YouTube de %s1"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s",
"shells": "Proyectiles",
"mostOptimalShellIndex": "Proyectil más óptimo",
"shellReloads": "Tiempos de recarga",
"shellReload": "Tiempo de recarga",
"shellReloads": "Reloads",
"shellReload": "Recarga",
"intraClip": "Intra-clip",
"caliber": "Calibre",
"penetration": "Penetración",
@@ -1032,7 +1044,9 @@
"shellRange": "Rango del proyectil",
"shellCapacity": "Capacidad de proyectiles",
"gunType": "Tipo de cañón",
"penetrationAt250m": "Penetración a 250 m"
"penetrationAt250m": "Penetración a 250 m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s",
"shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads",
"shellReload": "Shell reload",
"shellReloads": "Reloads",
"shellReload": "Reload",
"intraClip": "Intra-clip",
"caliber": "Caliber",
"penetration": "Penetration",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range",
"shellCapacity": "Shell capacity",
"gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m"
"penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+20 -6
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "貫通可能域を緑色表示",
"hide_model_under_armor": "車輌モデルを非表示",
"opaque": "貫通アシストを不透明にする",
"dev_wireframe": "DEV: wireframe",
"environment": "環境",
"show_grid": "グリッドを表示"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "保存する",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Review videos",
"disclaimer": "BlitzKitはいかなるレビュー動画も後援していません。",
"join": "%s1 YouTuberプログラムに応募する"
},
"configuration": {
@@ -973,7 +985,7 @@
"dpmEffective": "60秒での有効値",
"shells": "砲弾",
"mostOptimalShellIndex": "最適な弾数",
"shellReloads": "自動装填時間",
"shellReloads": "Reloads",
"shellReload": "装填時間",
"intraClip": "砲弾再装填時間",
"caliber": "口径",
@@ -1032,7 +1044,9 @@
"shellRange": "射程",
"shellCapacity": "総弾数",
"gunType": "主砲タイプ",
"penetrationAt250m": "250m での貫通力"
"penetrationAt250m": "250m での貫通力",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Tier %s2 %s3 %s4 | %s5 Tankopedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 armor, statistics, equipment, provisions, and more. %s2 DPM, %s3hp alpha, %s4mm penetration, %s5kph top speed, %s6hp health.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Green penetration",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s",
"shells": "Shells",
"mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads",
"shellReload": "Shell reload",
"shellReloads": "Reloads",
"shellReload": "Reload",
"intraClip": "Intra-clip",
"caliber": "Caliber",
"penetration": "Penetration",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range",
"shellCapacity": "Shell capacity",
"gunType": "Gun type",
"penetrationAt250m": "Penetration at 250m"
"penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Nível %s2 %s3 %s4 | %s5 Tanquepedia | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 blindagem, estatísticas, equipamentos, provisões e mais. %s2 DPM, %s3hp alfa, %s4mm de penetração, %s5km/h velocidade máxima, %s6hp vida.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Penetração verde",
"hide_model_under_armor": "Ocultar modelo sob blindagem",
"opaque": "Opaco",
"dev_wireframe": "DEV: wireframe",
"environment": "Ambiente",
"show_grid": "Exibir grade"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Download",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Vídeos de análise",
"disclaimer": "BlitzKit não aprova nenhum vídeo de análise.",
"join": "Candidate-se para %s1 um programa YouTuber"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Efetivo aos 60s",
"shells": "Projéteis",
"mostOptimalShellIndex": "Projétil mais ideal",
"shellReloads": "Recarga de projétil",
"shellReload": "Recarregamento de projétil",
"shellReloads": "Reloads",
"shellReload": "Recarga",
"intraClip": "Intra-clipe",
"caliber": "Calibre",
"penetration": "Penetração",
@@ -1032,7 +1044,9 @@
"shellRange": "Alcance de projétil",
"shellCapacity": "Capacidade de projéteis",
"gunType": "Tipo de arma",
"penetrationAt250m": "Penetração à 250m"
"penetrationAt250m": "Penetração à 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+67 -53
View File
@@ -1,8 +1,8 @@
{
"test": {
"default": "тест",
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт - %s1, потому что его цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3."
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт — %s1, потому что это цвет %s2. Он мне так нравится, что я произнесу его имя 3 раза! %s1! %s1! %s1! И ещё раз, это %s2, а не %s3.",
"fox": "Съешь же ещё этих мягких французских булок, да выпей чаю."
},
"common": {
"units": {
@@ -283,15 +283,15 @@
"name": "уровень",
"description": "Уровень",
"choices": {
"tier": "Уровень %s - %s"
"tier": "Уровень %s1 - %s2"
}
}
},
"periods": {
"career": "Карьера",
"today": "Cегодня",
"days": "%s дней",
"custom": "%s - %s дней"
"days": "%s1 дней",
"custom": "%s1 - %s2 дней"
},
"filters": {
"none": "Без фильтров",
@@ -323,9 +323,9 @@
"description": "Танки доступные на аукционе сейчас",
"body": {
"title": "Blitz аукцион",
"subtitle": "%s доступные • следующие цены %s\nПокупка сейчас: %s",
"next": "Следующая цена: %s",
"available": "Доступно: %s / %s"
"subtitle": "%s1 доступные • следующие цены %s2\nПокупка сейчас: %s3",
"next": "Следующая цена: %s1",
"available": "Доступно: %s1 / %s2"
},
"errors": {
"no_auction": "# Аукцион сейчас не проходит\n\nНа данный момент нет танков для покупки на аукционе. Если считаете, что это ошибка, пожалуйста о несоответствии на [официальном сервере Discord](https://discord.gg/nDt7AjGJQH)."
@@ -354,19 +354,19 @@
"body": {
"subtitle": "Исследование",
"total": "Всего",
"research": "Исследование: %s",
"upgrades": "Улучшения: %s",
"purchase": "Покупка: %s",
"equipment": "Снаряжение: %s",
"research": "Исследование: %s1",
"upgrades": "Улучшения: %s1",
"purchase": "Покупка: %s1",
"equipment": "Снаряжение: %s1",
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете."
},
"errors": {
"non_tech_tree": "# %s отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s и %s находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s более высокий уровень чем у %s\n\nОбязательно укажите %s в качестве «starting-tank», так как это танк более низкого уровня."
"non_tech_tree": "# %s1 отсутствует в дереве танков\n\nВы не можете исследовать премиум / коллекционный танк.",
"tier_1": "# %s1 - это уровень I\n\nУровень I всегда открыт бесплатно для всех игроков.",
"already_researched": "# %s1 уже исследован\n\nПопробуйте предоставить «starting-tank» при использовании команды для просмотра итогов уже исследованной ветки.",
"start_end_equal": "# %s1 был предоставлен дважды\n\nУбедитесь, что «starting-tank» и «target-tank» различаются.",
"tanks_not_on_line": "# %s1 и %s2 находятся не в одной ветке\n\nУбедитесь, что «starting-tank» и «target-tank» находятся на той же ветке дерева танков.",
"unordered": "# У %s1 более высокий уровень чем у %s2\n\nОбязательно укажите %s3 в качестве «starting-tank», так как это танк более низкого уровня."
}
},
"about": {
@@ -409,7 +409,7 @@
"wn8": "WN8",
"winrate": "Коэффициент побед",
"damage": "Урон",
"unknown_tank": "Неизвестный танк %s"
"unknown_tank": "Неизвестный танк %s1"
}
},
"debug": {
@@ -445,12 +445,12 @@
"options": {
"threshold": {
"name": "порог",
"description": "Количество дней не активности (по умолчанию: %s дней)"
"description": "Количество дней не активности (по умолчанию: %s1 дней)"
}
},
"body": {
"subtitle": "Неактивен %s+ дней",
"listing": "%s дней"
"subtitle": "Неактивен %s1+ дней",
"listing": "%s1 дней"
}
},
"full_stats": {
@@ -508,11 +508,11 @@
"options": {
"limit": {
"name": "лимит",
"description": "Максимальное количество результатов клана (по умолчанию: %s)"
"description": "Максимальное количество результатов клана (по умолчанию: %s1)"
}
},
"body": {
"title": "Поиск клана %s в регионе %s",
"title": "Поиск клана %s1 в регионе %s2",
"no_results": "Кланов не найдено"
}
},
@@ -522,11 +522,11 @@
"options": {
"limit": {
"name": "лимит",
"description": "Максимальное число результатов игрока (по умолчанию: %s)"
"description": "Максимальное число результатов игрока (по умолчанию: %s1)"
}
},
"body": {
"title": "Поиск игрока %s в регионе %s",
"title": "Поиск игрока %s1 в регионе %s2",
"no_results": "Игроков не найдено"
}
},
@@ -546,13 +546,13 @@
"options": {
"limit": {
"name": "лимит",
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s)"
"description": "Количество отображаемых ближайших игроков (по умолчанию: %s1)"
},
"season": {
"name": "сезон",
"description": "Номер сезона (по умолчанию: текущий)",
"choices": {
"current": "%s (текущая)"
"current": "%s1 (текущая)"
}
},
"league": {
@@ -561,12 +561,12 @@
}
},
"body": {
"top_players": "Топ %s игроков",
"no_participation": "# %s не играл в рейтинг в сезоне %s\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"top_players": "Топ %s1 игроков",
"no_participation": "# %s1 не играл в рейтинг в сезоне %s2\n\nЭтот игрок не участвовал в этом сезоне или не получил прошлую калибровку.",
"no_ongoing_season": "# Нет текущего сезона\n\nВ данный момент нет рейтингового сезона.",
"deleted_player": "Удалено %s",
"deleted_player": "Удалено %s1",
"neighbors": "Рейтинг ближайших игроков",
"more": "+ ещё %s"
"more": "+ ещё %s1"
}
},
"today": {
@@ -581,14 +581,14 @@
"name": "cобственные-танки",
"description": "Показывает все принадлежащие танки на уровне",
"body": {
"title": "# %s принадлежал уровень %s танков\n\n"
"title": "# %s1 принадлежал уровень %s2 танков\n\n"
}
},
"permissions": {
"name": "разрешения",
"description": "Проверяет, есть ли у бота необходимые разрешения для работы",
"body": {
"title": "# Разрешения\n\n%s",
"title": "# Разрешения\n\n%s1",
"view_channels": "Просмотр каналов: необходимо найти сообщение, на котором была использована кнопка обновления",
"read_message_history": "Просмотр истории сообщений: необходимо найти сообщение для обновления",
"attach_files": "Прикрепить файлы: необходимо для добавления изображений в команды на основе изображения",
@@ -600,7 +600,7 @@
"description": "Проверьте, что бот жив.",
"body": {
"pong": "Понг 🏓",
"pong_with_time": "Понг 🏓 - %sмс"
"pong_with_time": "Понг 🏓 - %s1мс"
},
"subcommands": {
"blitzkit": {
@@ -617,7 +617,7 @@
"name": "информация-об-игроке",
"description": "Основная информация о игроке",
"body": {
"title": "Информация о %s",
"title": "Информация о %s1",
"nickname": "Никнейм",
"battles": "Боёв",
"winrate": "Коэффициент побед",
@@ -646,7 +646,7 @@
}
},
"errors": {
"no_participation": "# %s не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"no_participation": "# %s1 не получил рейтинга\n\nЭтот игрок в этом сезоне не участвовал в рейтинговом режиме.",
"unrecorded_stats": "# Статистика недоступна в настоящее время\n\nВ настоящее время Wargaming, возможно, сталкивается с простоями сервера. Если сезон продолжается, статистика должна быть доступна в течение 24 часов."
}
}
@@ -676,7 +676,7 @@
"recent": "Недавние",
"select_all": "Выбрать всё",
"count_singular": "1 танк",
"count_plural": "%s1 танк",
"count_plural": "Танков: %s1",
"sorting_by": "Сортировать по %s1",
"no_tanks_found": {
"body": "Танков не найдено.",
@@ -693,7 +693,7 @@
"survivability.volume": "объём",
"survivability.length": "длина",
"fire.dpm": "базовый ДПМ",
"fire.dpmPremium": "улучшенный ДПМ",
"fire.dpmPremium": "премиумный ДПМ",
"fire.reload": "перезарядка",
"fire.caliber": "калибр",
"fire.standardPenetration": "базовое пробитие",
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Смешиватель",
"button": "Смешать и собрать",
"description": "Модули смешанного танка",
"seo_description": "Смешивание и сбор корпусов, башен и пушек танков из World of Tanks Blitz"
},
"guess": {
"name": "Угадайте танк",
"button": "Угадать",
@@ -784,11 +790,14 @@
"button": "Поиск танков",
"description": "Танковая энциклопедия Blitz",
"seo_description": "Статистика, броня и многое другое по всем танкам World of Tanks Blitz",
"tank_seo_title": "%s1 - %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)",
"tank_seo_title": "%s1 — %s4 %s3 %s2 уровня | Танкопедия %s5 | World of Tanks Blitz (WoTB)",
"tank_seo_description": "%s1 броня, статистика, оборудование, снабжение и многое другое. %s2 УВМ, %s3hp альфа, %s4mm пробитие, %s5км/ч лучшая скорость, %s6hp здоровье.",
"visualizers": {
"reload": {
"skip": "Пропуск"
},
"assault": {
"title": "Assault shells"
"title": "Штурмовые снаряды"
},
"rotator": {
"hull": "Корпус",
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Промзона",
"dead_rail": "Эшелон"
},
"flexibility": {
"yaw": "Поворот",
"pitch": "Наклон",
"min": "Вниз",
"max": "Вверх"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Зелёное пробитие",
"hide_model_under_armor": "Скрыть модель под броней",
"opaque": "Прозрачность",
"dev_wireframe": "DEV: wireframe",
"environment": "Окружение",
"show_grid": "Показать сетку"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Загрузить",
@@ -896,7 +909,7 @@
"tier": "Уровень",
"type": "Тип",
"dev_id": "DEV: ID",
"purchase": "Стоимость покупки",
"purchase": "Цена покупки",
"restoration": "Цена восстановления",
"sale": "Цена продажи",
"research": "Необходимо опыта"
@@ -912,7 +925,6 @@
},
"review": {
"title": "Видеообзоры",
"disclaimer": "BlitzKit не несёт ответственности за видеообзоры.",
"join": "Предложить ютубера для программы %s1"
},
"configuration": {
@@ -973,13 +985,13 @@
"dpmEffective": "Эффективность за 60с",
"shells": "Снарядов в барабане",
"mostOptimalShellIndex": "Самый эффективный снаряд",
"shellReloads": "Перезарядка снарядов",
"shellReloads": "Перезарядка",
"shellReload": "Перезарядка",
"intraClip": "Между снарядами",
"caliber": "Калибр",
"penetration": "Пробитие",
"damage": "Урон",
"damageWithoutAssault": "Damage without assault shells",
"damageWithoutAssault": "Урон без штурмовых снарядов",
"clipDamage": "Урон с барабана",
"moduleDamage": "Урон по модулям",
"explosionRadius": "Разлет осколков",
@@ -1032,7 +1044,9 @@
"shellRange": "Дальность снаряда",
"shellCapacity": "Боезапас",
"gunType": "Тип орудия",
"penetrationAt250m": "Пробитие на 250м"
"penetrationAt250m": "Пробитие на 250м",
"burstShells": "Кол-во разрывных снарядов",
"burstInterShell": "Взрыв между снарядами"
}
},
"firepower": {
@@ -1129,7 +1143,7 @@
"hits": "Уничтожен",
"damage_ratio": "Коэф. урона",
"damage_taken": "Получено урона",
"capture_points": "Ограничительные точки"
"capture_points": "Точки ограничения"
}
}
},
@@ -1141,7 +1155,7 @@
"last_updated": "Последнее обновление %s1",
"search": {
"hint": "Поиск аватаров...",
"results": "%s1 аватар"
"results": "Аватаров: %s1"
}
},
"session": {
@@ -1150,7 +1164,7 @@
"description": "Статистика сеанса игры",
"seo_description": "Отслеживайте свою статистику в реальном времени во время игры World of Tanks Blitz",
"search": {
"lookup": "Искать игрока, чтобы начать",
"lookup": "Введите ник игрока",
"hint": "Поиск игроков...",
"searching": "Поиск...",
"no_results": "Игроков не найдено"
@@ -1191,7 +1205,7 @@
"button": "Создать",
"description": "Встраиваемые элементы для стримов",
"seo_description": "Создать вставки для стримов по World of Tanks Blitz с невероятной кастомизации",
"coming_soon": "Скоро появятся новые встраиваемые элементы.",
"coming_soon": "В скором времени появятся новые встраиваемые элементы.",
"suggest": "Предложить идеи.",
"preview": {
"title": "Предпросмотр",
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "Guess the tank",
"button": "Guess away",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - Рівень %s2 %s3 %s4 | %s5 Танкопедія | WoT Blitz",
"tank_seo_description": "%s1 броня, характеристики, обладнання, амуніція та більше. %s2 шкода на хвилину, %s3 разова шкода, %s4 мм пробиття, %s5 км/год максимальна швидкість, %s6 міцність.",
"visualizers": {
"reload": {
"skip": "Skip"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "Ghost Factory",
"dead_rail": "Dead Rail"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "Зелене пробиття",
"hide_model_under_armor": "Hide model under armor",
"opaque": "Opaque",
"dev_wireframe": "DEV: wireframe",
"environment": "Environment",
"show_grid": "Show grid"
"dev_wireframe": "DEV: wireframe"
},
"screenshot": {
"download": "Завантажити",
@@ -912,7 +925,6 @@
},
"review": {
"title": "Review videos",
"disclaimer": "BlitzKit does not endorse any review videos.",
"join": "Apply to %s1 YouTuber program"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "Effective at 60s",
"shells": "Снаряди",
"mostOptimalShellIndex": "Most optimal shell",
"shellReloads": "Shell reloads",
"shellReload": "Shell reload",
"shellReloads": "Reloads",
"shellReload": "Час перезаряджання",
"intraClip": "Intra-clip",
"caliber": "Калібр",
"penetration": "Бронепробиття",
@@ -1032,7 +1044,9 @@
"shellRange": "Shell range",
"shellCapacity": "Shell capacity",
"gunType": "Тип гармати",
"penetrationAt250m": "Penetration at 250m"
"penetrationAt250m": "Penetration at 250m",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+21 -7
View File
@@ -764,6 +764,12 @@
}
},
"tools": {
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz"
},
"guess": {
"name": "猜坦克",
"button": "猜猜看",
@@ -787,6 +793,9 @@
"tank_seo_title": "%s1 - %s2 阶 %s3 %s4 | %s5 战车百科 | 战车世界闪击战 (WoTB)",
"tank_seo_description": "%s1 装甲,统计设备,装备,供给等。 %s2 每分钟伤害, %s3生命值, %s4毫米穿透, %s5kph 最高速度, %s6hp 生命值。",
"visualizers": {
"reload": {
"skip": "跳过"
},
"assault": {
"title": "Assault shells"
},
@@ -810,6 +819,12 @@
"percentage": "%s1%",
"ghost_factory": "幽灵工厂",
"dead_rail": "死亡铁道"
},
"flexibility": {
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
}
},
"sandbox": {
@@ -823,9 +838,7 @@
"green_penetration": "可击穿区域",
"hide_model_under_armor": "隐藏装甲下的模型",
"opaque": "不透明",
"dev_wireframe": "开发选项:网格",
"environment": "环境",
"show_grid": "显示网格"
"dev_wireframe": "开发选项:网格"
},
"screenshot": {
"download": "下载",
@@ -912,7 +925,6 @@
},
"review": {
"title": "回顾影片",
"disclaimer": "BlitzKit 不支援任何影片回顾。",
"join": "应用到 %s1 YouTuber程序"
},
"configuration": {
@@ -973,8 +985,8 @@
"dpmEffective": "60秒内生效",
"shells": "炮弹 ",
"mostOptimalShellIndex": "最优炮弹",
"shellReloads": "弹匣装填",
"shellReload": "炮弹装填",
"shellReloads": "Reloads",
"shellReload": "装填时间",
"intraClip": "炮弹装填",
"caliber": "口径",
"penetration": "穿深",
@@ -1032,7 +1044,9 @@
"shellRange": "射程",
"shellCapacity": "携弹量",
"gunType": "炮管类型",
"penetrationAt250m": "两百五十米处的穿透力"
"penetrationAt250m": "两百五十米处的穿透力",
"burstShells": "Burst shell count",
"burstInterShell": "Burst inter-shell"
}
},
"firepower": {
+20 -25
View File
@@ -12,7 +12,6 @@ import {
EquipmentSlot,
GameDefinitions,
GunDefinition,
GunDefinitionBase,
I18nString,
MapDefinitions,
ModelDefinitions,
@@ -274,6 +273,7 @@ interface VehicleDefinitions {
pumpGunMode?: boolean;
pumpGunReloadTimes?: string;
clip?: { count: number; rate: number };
burst?: { count: number; rate: number };
models: { undamaged: string };
extras?: {
trayShell?: {
@@ -1304,7 +1304,7 @@ export async function definitions() {
}
});
const base = {
tankDefinitions.tanks[tankId].turrets[turretIndex].guns.push({
id: gunId,
weight: gunListEntry.weight,
rotation_speed: gunListEntry.rotationSpeed,
@@ -1324,41 +1324,38 @@ export async function definitions() {
unlocks: resolveUnlocks(gun.unlocks),
shell_capacity: gun.maxAmmo ?? gunListEntry.maxAmmo,
assault_ranges,
} satisfies GunDefinitionBase;
tankDefinitions.tanks[tankId].turrets[turretIndex].guns.push({
burst:
gun.burst && gun.burst.count > 1
? {
count: gun.burst.count,
interval: 60 / gun.burst.rate,
}
: undefined,
gun_type:
gunType === 'regular'
? {
$case: 'regular',
value: {
base,
extension: { reload: gun.reloadTime },
reload: gun.reloadTime,
},
}
: gunType === 'autoReloader'
? {
$case: 'auto_reloader',
value: {
base,
extension: {
intra_clip: 60 / gun.clip!.rate,
shell_count: gunClipCount,
shell_reloads: gun
.pumpGunReloadTimes!.split(' ')
.map(Number),
},
intra_clip: 60 / gun.clip!.rate,
shell_count: gunClipCount,
shell_reloads: gun
.pumpGunReloadTimes!.split(' ')
.map(Number),
},
}
: {
$case: 'auto_loader',
value: {
base,
extension: {
intra_clip: 60 / gun.clip!.rate,
clip_reload: gun.reloadTime,
shell_count: gunClipCount,
},
intra_clip: 60 / gun.clip!.rate,
clip_reload: gun.reloadTime,
shell_count: gunClipCount,
},
},
} satisfies GunDefinition);
@@ -1407,7 +1404,7 @@ export async function definitions() {
tankDefinitions.tanks[tankId].turrets[turretIndex].guns[
gunIndex
].gun_type!.value.base!.shells.push({
].shells.push({
id: shellId,
name: shellName,
velocity: gunShellEntry.speed,
@@ -1495,9 +1492,7 @@ export async function definitions() {
turret.research_cost = turretXps.get(turret.id);
Object.values(turret.guns).forEach((gunRaw) => {
gunRaw.gun_type!.value.base!.research_cost = gunXps.get(
gunRaw.gun_type!.value.base!.id,
);
gunRaw.research_cost = gunXps.get(gunRaw.id);
});
});
@@ -62,6 +62,7 @@ export async function tankIcons() {
const nationVehicleId = tank.id;
const id = (nationVehicleId << 8) + (NATION_IDS[nation] << 4) + 1;
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
);
@@ -1,7 +1,7 @@
import { toUniqueId } from '@blitzkit/core';
import { NodeIO } from '@gltf-transform/core';
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 { readXMLDVPL } from '../core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../core/blitz/readYAMLDVPL';
@@ -32,6 +32,7 @@ export async function tankModels() {
if (tankKey.includes('tutorial_bot')) continue;
const id = toUniqueId(nation, tank.id);
const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

@@ -100,8 +100,7 @@ export function ArmorPlateDisplay() {
const { protagonist } = duelStore.getState();
const tank = draft.model;
const turret = tank.turrets[protagonist.turret.id];
const gun =
turret.guns[protagonist.gun.gun_type!.value.base.id];
const gun = turret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
if (highlightArmor.name.startsWith('hull_')) {
@@ -154,12 +153,8 @@ export function ArmorPlateDisplay() {
const turret = tank.turrets[protagonist.turret.id];
const initialTurret =
initialTank.turrets[protagonist.turret.id];
const gun =
turret.guns[protagonist.gun.gun_type!.value.base.id];
const initialGun =
initialTurret.guns[
protagonist.gun.gun_type!.value.base.id
];
const gun = turret.guns[protagonist.gun.id];
const initialGun = initialTurret.guns[protagonist.gun.id];
const track = tank.tracks[protagonist.track.id];
const initialTrack =
initialTank.tracks[protagonist.track.id];
@@ -14,7 +14,6 @@ export const PrimaryArmorScene = memo(() => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
// const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
@@ -24,8 +23,7 @@ export const PrimaryArmorScene = memo(() => {
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
@@ -7,8 +7,10 @@ import { invalidate, useFrame } from '@react-three/fiber';
import { useEffect } from 'react';
import {
MeshBasicMaterial,
NormalBlending,
Object3D,
ShaderMaterial,
SubtractiveBlending,
UniformsLib,
UniformsUtils,
Vector2,
@@ -32,6 +34,9 @@ const excludeMaterial = new MeshBasicMaterial({
colorWrite: false,
});
const date = new Date();
const isHalloween = date.getMonth() === 9 && date.getDate() === 31;
export function PrimaryArmorSceneComponent({
node,
thickness,
@@ -46,6 +51,8 @@ export function PrimaryArmorSceneComponent({
fog: true,
transparent: true,
blending: isHalloween ? SubtractiveBlending : NormalBlending,
uniforms: UniformsUtils.merge([
UniformsLib.common,
UniformsLib.fog,
@@ -73,7 +80,7 @@ export function PrimaryArmorSceneComponent({
});
useEffect(() => {
async function handleShellChange() {
function handleShellChange() {
const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
@@ -90,14 +97,8 @@ export function PrimaryArmorSceneComponent({
material.uniforms.damage.value = shell.armor_damage;
material.uniforms.explosionRadius.value = shell.explosion_radius;
await handleProtagonistEquipmentChange(
duel.protagonist.equipmentMatrix,
true,
);
await handleAntagonistEquipmentChange(
duel.antagonist.equipmentMatrix,
true,
);
handleProtagonistEquipmentChange(duel.protagonist.equipmentMatrix, true);
handleAntagonistEquipmentChange(duel.antagonist.equipmentMatrix, true);
invalidate();
}
@@ -114,12 +115,12 @@ export function PrimaryArmorSceneComponent({
function handleWireframeChange(wireframe: boolean) {
material.wireframe = wireframe;
}
async function handleProtagonistEquipmentChange(
function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix,
noInvalidate = false,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
const hasEnhancedArmor = hasEquipment(
110,
duel.protagonist.tank.equipment_preset,
equipment,
@@ -130,7 +131,7 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate();
}
async function handleAntagonistEquipmentChange(
function handleAntagonistEquipmentChange(
equipment: EquipmentMatrix,
noInvalidate = false,
) {
@@ -138,7 +139,7 @@ export function PrimaryArmorSceneComponent({
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment(
const hasCalibratedShells = hasEquipment(
103,
duel.antagonist.tank.equipment_preset,
equipment,
@@ -25,7 +25,6 @@ uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth;
const float HALF = 0.5;
const float EXPLOSION_DAMAGE_FACTOR = 1.1;
const float RANDOMIZATION_FACTOR = 0.05;
const float GREEN_VALUE = 0.392;
@@ -81,12 +80,12 @@ void main() {
float distFromArmor = primaryDist - spacedDist;
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);
penetrationChance = 0.0;
} else {
remainingPen -= HALF * remainingPen * distFromArmor;
remainingPen -= 0.5 * remainingPen * distFromArmor;
}
}
}
@@ -98,13 +97,13 @@ void main() {
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = HALF * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness;
float splashDamage = 0.5 * damage - EXPLOSION_DAMAGE_FACTOR * finalThickness;
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);
if (advancedHighlighting && didRicochet) {
@@ -16,6 +16,7 @@ import {
Path,
Quaternion,
} from 'three';
import { isHalloween } from '../../../core/blitzkit/isHalloween';
import { LocaleProvider, useLocale } from '../../../hooks/useLocale';
import {
TankopediaEphemeral,
@@ -72,11 +73,8 @@ export function ShotDisplay() {
});
useEffect(() => {
const data = new Date();
const isHalloween = data.getMonth() === 9 && data.getDate() === 31;
if (
isHalloween &&
isHalloween() &&
shot?.splashRadius !== undefined &&
shot.in.status === 'penetration'
) {
@@ -37,44 +37,38 @@ function LayerEntry({
const { locale, strings } = useLocale();
return (
<Flex align="center" gap="2">
<Text size="1" color="gray" style={{ width: 12 }}>
{layerIndex}
</Text>
<Text size="2" color={shotStatusColor} style={{ width: 48 }}>
{layerName}
</Text>
<tr>
<td>
<Text size="1" color="gray">
{layerIndex}
</Text>
</td>
{layer.type === null && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
<td>
<Text size="2" color={shotStatusColor}>
{layerName}
</Text>
</td>
<td>
{layer.type === null && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, [
Math.round(layer.distance * 1000).toLocaleString(locale),
])}
</Text>
)}
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)],
)}
{layer.type === ArmorType.External && (
<Text size="2" color={shotStatusColor}>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text size="2" color={shotStatusColor} style={{ width: 64 }}>
{literals(strings.common.units.mm, [
Math.round(layer.thickness).toLocaleString(locale),
])}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<>
<Text size="2" color={shotStatusColor} style={{ width: 80 }}>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color={shotStatusColor}>
{literals(
angle === undefined
? strings.common.units.mm
@@ -86,7 +80,22 @@ function LayerEntry({
],
)}
</Text>
)}
</td>
<td>
{layer.type === null && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
.ricochet_loss,
[Math.max(-100, -50 * layer.distance).toFixed(0)],
)}
</Text>
)}
{(layer.type === ArmorType.Primary ||
layer.type === ArmorType.Spaced) && (
<Text size="2" color="gray">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
@@ -94,9 +103,9 @@ function LayerEntry({
[Math.round(layer.thickness).toLocaleString(locale)],
)}
</Text>
</>
)}
</Flex>
)}
</td>
</tr>
);
}
@@ -115,7 +124,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
const inTitleColor = shotStatusColors[shot.in.status];
return (
<Card variant="classic" style={{ width: 300 }} {...props}>
<Card variant="classic" {...props}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
@@ -127,7 +136,12 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
)}
</Text>
<Flex direction="column">
<table
style={{
whiteSpace: 'nowrap',
borderSpacing: 'var(--space-1) 0',
}}
>
{shot.in.layers.map((layer, index) => {
const layerName =
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
@@ -161,7 +175,7 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
/>
);
})}
</Flex>
</table>
</Flex>
{shot.out && shot.out.layers.length > 0 && (
@@ -36,8 +36,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
@@ -54,11 +54,9 @@ export function SpacedArmorSubExternal({
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near;
}
async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix,
) {
function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
const hasEnhancedArmor = hasEquipment(
110,
duel.protagonist.tank.equipment_preset,
equipment,
@@ -67,12 +65,12 @@ export function SpacedArmorSubExternal({
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment(
const hasCalibratedShells = hasEquipment(
103,
duel.antagonist.tank.equipment_preset,
equipment,
@@ -125,6 +123,7 @@ export function SpacedArmorSubExternal({
return (
<mesh
{...props}
onClick={() => {}}
key={`${key}-spaced-sub-external-exclude`}
renderOrder={3}
material={
@@ -64,11 +64,9 @@ export function SpacedArmorSubSpaced({
shell.normalization ?? 0,
);
}
async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix,
) {
function handleProtagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
const hasEnhancedArmor = hasEquipment(
110,
duel.protagonist.tank.equipment_preset,
equipment,
@@ -77,12 +75,12 @@ export function SpacedArmorSubSpaced({
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment(
const hasCalibratedShells = hasEquipment(
103,
duel.antagonist.tank.equipment_preset,
equipment,
@@ -81,7 +81,7 @@ export function SpacedArmorSceneComponent({
const camera = useThree((state) => state.camera);
const shoot = useCallback(
async (
(
point: Vector3,
intersections: Intersection[],
allowRicochet: boolean,
@@ -103,12 +103,12 @@ export function SpacedArmorSceneComponent({
},
};
const hasCalibratedShells = await hasEquipment(
const hasCalibratedShells = hasEquipment(
103,
antagonist.tank.equipment_preset,
antagonist.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
const hasEnhancedArmor = hasEquipment(
110,
protagonist.tank.equipment_preset,
protagonist.equipmentMatrix,
@@ -340,7 +340,7 @@ export function SpacedArmorSceneComponent({
props.scene.children,
true,
);
const outShot = await shoot(
const outShot = shoot(
lastLayer.point,
ricochetIntersections,
false,
@@ -386,13 +386,11 @@ export function SpacedArmorSceneComponent({
thickness,
} satisfies ArmorUserData
}
onClick={async (event) => {
onClick={(event) => {
event.stopPropagation();
const shot = (await shoot(
event.point,
event.intersections,
true,
))!;
const shot = shoot(event.point, event.intersections, true)!;
tankopediaEphemeralStore.setState({ shot });
}}
/>
@@ -1,4 +1,3 @@
import { normalizeAngleRad } from '@blitzkit/core';
import { useThree, type ThreeEvent } from '@react-three/fiber';
import { memo, useRef } from 'react';
import { Group, Plane, Vector2, Vector3 } from 'three';
@@ -31,7 +30,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const protagonist = Duel.use((draft) => draft.protagonist);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
@@ -43,12 +41,10 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
const mutateDuel = Duel.useMutation();
const canvas = useThree((state) => state.gl.domElement);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const duelStore = Duel.useStore();
@@ -153,15 +149,11 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
@@ -173,14 +165,14 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -190,21 +182,18 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + delta.x * (Math.PI / boundingRect.width),
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
@@ -218,7 +207,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
<StaticArmorSceneComponent
name={node.name}
thicknessRange={thicknessRange}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
@@ -253,8 +241,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
@@ -268,20 +254,18 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -290,24 +274,22 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch -
delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
@@ -341,8 +323,6 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
const position = new Vector2();
const delta = new Vector2();
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
@@ -356,20 +336,17 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -378,24 +355,23 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch -
delta.y * (Math.PI / boundingRect.height),
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
@@ -1,6 +1,7 @@
import { I_HAT, J_HAT } from '@blitzkit/core';
import { type MeshProps, useThree } from '@react-three/fiber';
import { clamp } from 'lodash-es';
import type { QuicklimeEvent } from 'quicklime';
import { useEffect, useMemo } from 'react';
import {
Box3,
@@ -9,7 +10,7 @@ import {
FrontSide,
LineBasicMaterial,
Mesh,
MeshStandardMaterial,
MeshBasicMaterial,
Object3D,
Plane,
Quaternion,
@@ -107,7 +108,7 @@ export function StaticArmorSceneComponent({
const surfaceMaterial = useMemo(
() =>
new MeshStandardMaterial({
new MeshBasicMaterial({
color,
opacity,
transparent: opacity < 1,
@@ -186,16 +187,18 @@ export function StaticArmorSceneComponent({
const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3();
function handleModelTransform(data: ModelTransformEventData) {
function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>,
) {
clip.normal
.set(0, 0, -1)
.applyAxisAngle(I_HAT, data.pitch)
.applyAxisAngle(J_HAT, data.yaw ?? 0);
.applyAxisAngle(I_HAT, event.data.pitch)
.applyAxisAngle(J_HAT, event.data.yaw);
clip.constant = 0;
point
.copy(gunOrigin)
.applyAxisAngle(J_HAT, data.yaw ?? 0)
.applyAxisAngle(J_HAT, event.data.yaw)
.add(clip.normal.multiplyScalar(-distanceToBarrel))
.add(neckOrigin);
clip.normal.normalize();
@@ -33,43 +33,35 @@ export function Body({ stats }: BodyProps) {
<StickyColumnHeaderCell key={key} top={137}>
<Flex justify="center">
<Flex>
{gun.gun_type!.value.base.shells.map(
(thisShell, shellIndex) => (
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius:
shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex ===
gun.gun_type!.value.base.shells.length - 1
? undefined
: 0,
borderBottomRightRadius:
shellIndex ===
gun.gun_type!.value.base.shells.length - 1
? undefined
: 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].shell = thisShell;
});
}}
>
<img
alt={unwrap(thisShell.name)}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
),
)}
{gun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius:
shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateCompareEphemeral((draft) => {
draft.members[index].shell = thisShell;
});
}}
>
<img
alt={unwrap(thisShell.name)}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
))}
</Flex>
</Flex>
</StickyColumnHeaderCell>
@@ -250,8 +250,7 @@ export function CompareTable({ stats }: CompareTableProps) {
member.turret = member.tank.turrets[0];
member.gun = member.turret.guns[0];
member.shell =
member.gun.gun_type!.value.base.shells[0];
member.shell = member.gun.shells[0];
member.engine = member.tank.engines[0];
member.track = member.tank.tracks[0];
});
@@ -267,8 +266,7 @@ export function CompareTable({ stats }: CompareTableProps) {
member.turret = member.tank.turrets.at(-1)!;
member.gun = member.turret.guns.at(-1)!;
member.shell =
member.gun.gun_type!.value.base.shells[0];
member.shell = member.gun.shells[0];
member.engine = member.tank.engines.at(-1)!;
member.track = member.tank.tracks.at(-1)!;
});
@@ -1,6 +1,5 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons';
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes';
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember';
import { useLocale } from '../../hooks/useLocale';
@@ -16,10 +15,7 @@ interface ControlsProps {
onAddTankDialogOpenChange: (open: boolean) => void;
}
const [provisionDefinitions, modelDefinitions] = await Promise.all([
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
const provisionDefinitions = await awaitableProvisionDefinitions;
export function Controls({
addTankDialogOpen,
@@ -60,11 +56,9 @@ export function Controls({
<TankSearch
compact
onSelect={(tank) => {
const model = modelDefinitions.models[tank.id];
mutateCompareEphemeral((draft) => {
draft.members.push(
tankToCompareMember(tank, model, provisionDefinitions),
tankToCompareMember(tank, provisionDefinitions),
);
draft.sorting = undefined;
});
@@ -74,13 +68,7 @@ export function Controls({
mutateCompareEphemeral((draft) => {
draft.members.push(
...tanks.map((tank) => {
const model = modelDefinitions.models[tank.id];
return tankToCompareMember(
tank,
model,
provisionDefinitions,
);
return tankToCompareMember(tank, provisionDefinitions);
}),
);
draft.sorting = undefined;
@@ -1,7 +1,6 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons';
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
import { useState } from 'react';
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../core/awaitables/provisionDefinitions';
import { tankToCompareMember } from '../../core/blitzkit/tankToCompareMember';
import { useLocale } from '../../hooks/useLocale';
@@ -14,10 +13,7 @@ interface TankControlProps {
slug: string;
}
const [provisionDefinitions, modelDefinitions] = await Promise.all([
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
const provisionDefinitions = await awaitableProvisionDefinitions;
export function TankControl({ index, slug }: TankControlProps) {
const { locale } = useLocale();
@@ -61,12 +57,9 @@ export function TankControl({ index, slug }: TankControlProps) {
<TankSearch
compact
onSelect={(tank) => {
const model = modelDefinitions.models[tank.id];
mutateCompareEphemeral((draft) => {
draft.members[index] = tankToCompareMember(
tank,
model,
provisionDefinitions,
);
draft.sorting = undefined;
@@ -22,8 +22,7 @@ export function GuessBackground() {
useEffect(() => {
mutateDuel((state) => {
const model = modelDefinitions.models[tank.id];
state.protagonist = tankToDuelMember(tank, model, provisionDefinitions);
state.protagonist = tankToDuelMember(tank, provisionDefinitions);
});
mutateTankopediaEphemeral((state) => {
state.model = modelDefinitions.models[tank.id];
@@ -1,11 +1,10 @@
import { Canvas } from '@react-three/fiber';
import { GuessEphemeral } from '../stores/guessEphemeral';
import { TankopediaDisplay } from '../stores/tankopediaPersistent/constants';
import { SmartCanvas } from './SmartCanvas';
import { Controls } from './Tankopedia/HeroSection/components/TankSandbox/components/Control';
import { Lighting } from './Tankopedia/HeroSection/components/TankSandbox/components/Lighting';
import { TankModel } from './Tankopedia/HeroSection/components/TankSandbox/components/TankModel';
const BRIGHTNESS = 2 ** -5;
const BRIGHTNESS = 2 ** -5.5;
const CONTRAST = 2 ** 2;
export function GuessRenderer() {
@@ -13,18 +12,17 @@ export function GuessRenderer() {
const isRevealed = guessState !== null;
return (
<Canvas
<SmartCanvas
style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
// filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`,
transitionDuration: isRevealed ? '2s' : undefined,
transitionProperty: 'filter',
}}
camera={{ position: [-5, 5, -5] }}
>
<Controls distanceScale={2} zoomable={false} />
<Lighting display={TankopediaDisplay.Model} />
<Controls zoomable={false} />
<Lighting />
<TankModel />
</Canvas>
</SmartCanvas>
);
}
+1 -1
View File
@@ -138,7 +138,7 @@ export function Guesser() {
</Card>
)}
<Flex justify="center">
<Flex justify="center" style={{ userSelect: 'none' }}>
<Text>
{literals(strings.website.tools.guess.stats, [
`${correctGuesses}`,
@@ -0,0 +1,21 @@
import type { SpotLightProps } from '@react-three/fiber';
import { memo } from 'react';
interface Props extends SpotLightProps {
debug?: boolean;
}
export const HelpingSpotLight = memo<Props>(({ debug, ...props }) => {
return (
<>
{debug && (
<mesh position={props.position}>
<icosahedronGeometry args={[0.1, 2]} />
<meshBasicMaterial color={props.color} />
</mesh>
)}
<spotLight {...props} />
</>
);
});
@@ -1,10 +1,15 @@
import { Link, Table } from '@radix-ui/themes';
import MarkdownToJSX from 'markdown-to-jsx';
import type { ComponentProps } from 'react';
interface MarkdownProps {
children: string;
}
function Image(props: ComponentProps<'img'>) {
return <img style={{ maxWidth: '100%' }} {...props} />;
}
export function Markdown({ children }: MarkdownProps) {
return (
<MarkdownToJSX
@@ -17,6 +22,7 @@ export function Markdown({ children }: MarkdownProps) {
tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell },
img: { component: Image },
},
}}
>
@@ -0,0 +1,125 @@
import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions';
import { jsxTree } from '../../../core/blitzkit/jsxTree';
import { useModel } from '../../../hooks/useModel';
import { MixerEphemeral } from '../../../stores/mixerEphemeral';
const modelDefinitions = await awaitableModelDefinitions;
export function Model() {
const hull = MixerEphemeral.use((state) => state.hull);
const turret = MixerEphemeral.use((state) => state.turret);
const gun = MixerEphemeral.use((state) => state.gun);
const hullGltf = useModel(hull.id);
const turretGltf = useModel(turret.tank.id);
const gunGltf = useModel(gun.tank.id);
const hullNodes = Object.values(hullGltf.gltf.nodes);
const turretNodes = Object.values(turretGltf.gltf.nodes);
const gunNodes = Object.values(gunGltf.gltf.nodes);
const tankModel = modelDefinitions.models[hull.id];
const trackModel = tankModel.tracks[hull.tracks.at(-1)!.id];
const turretTankModel = modelDefinitions.models[turret.tank.id];
const turretTrackModel =
turretTankModel.tracks[turret.tank.tracks.at(-1)!.id];
const turretModel = turretTankModel.turrets[turret.turret.id];
const gunTankModel = modelDefinitions.models[gun.tank.id];
const gunTrackModel = gunTankModel.tracks[gun.tank.tracks.at(-1)!.id];
const gunTurretModel = gunTankModel.turrets[gun.turret.id];
const gunModel = gunTurretModel.guns[gun.gun.id];
return (
<group rotation={[-Math.PI / 2, 0, 0]}>
{hullNodes.map((node) => {
const isHull = node.name === 'hull';
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isHull || isWheel || isTrack;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<group
position={[
tankModel.turret_origin.x +
trackModel.origin.x -
turretTankModel.turret_origin.x -
turretTrackModel.origin.x,
tankModel.turret_origin.z +
trackModel.origin.z -
turretTankModel.turret_origin.z -
turretTrackModel.origin.z,
tankModel.turret_origin.y +
trackModel.origin.y -
turretTankModel.turret_origin.y -
turretTrackModel.origin.y,
]}
>
{turretNodes.map((node) => {
const isCurrentTurret =
node.name ===
`turret_${turretModel.model_id.toString().padStart(2, '0')}`;
const isVisible = isCurrentTurret;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
<group
position={[
turretTankModel.turret_origin.x +
turretTrackModel.origin.x +
turretModel.gun_origin.x -
gunTankModel.turret_origin.x -
gunTrackModel.origin.x -
gunTurretModel.gun_origin.x,
turretTankModel.turret_origin.z +
turretTrackModel.origin.z +
turretModel.gun_origin.z -
gunTankModel.turret_origin.z -
gunTrackModel.origin.z -
gunTurretModel.gun_origin.z,
turretTankModel.turret_origin.y +
turretTrackModel.origin.y +
turretModel.gun_origin.y -
gunTankModel.turret_origin.y -
gunTrackModel.origin.y -
gunTurretModel.gun_origin.y,
]}
>
{gunNodes.map((node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, '0')}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModel.model_id.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun || isCurrentMantlet;
if (!isVisible) return null;
return jsxTree(node, {
mesh(_, props, key) {
return <mesh {...props} key={key} castShadow receiveShadow />;
},
});
})}
</group>
</group>
</group>
);
}
@@ -0,0 +1,25 @@
import { OrbitControls } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { Suspense } from 'react';
import { Model } from './components/Model';
export function MixerScene() {
return (
<Canvas
shadows
camera={{
fov: 45,
position: [5, 5, -5],
}}
>
<OrbitControls />
<directionalLight position={[10, 10, 10]} intensity={5} castShadow />
<directionalLight position={[-10, -10, -10]} intensity={3} castShadow />
<Suspense fallback={null}>
<Model />
</Suspense>
</Canvas>
);
}
@@ -62,7 +62,7 @@ export function ModuleManager({
const draft = { ...modules };
draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.gun_type!.value.base.shells[0];
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setTurretMenuOpen(false);
@@ -83,9 +83,7 @@ export function ModuleManager({
<ModuleButton
module="gun"
discriminator={
TIER_ROMAN_NUMERALS[modules.gun.gun_type!.value.base.tier]
}
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
onClick={() => setGunMenuOpen(true)}
/>
@@ -95,18 +93,16 @@ export function ModuleManager({
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={`${modules.gun.gun_type!.value.base.id}`}
>
<DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
{[...modules.turret.guns].reverse().map((gun) => {
return (
<DropdownMenu.RadioItem
key={gun.gun_type!.value.base.id}
value={`${gun.gun_type!.value.base.id}`}
key={gun.id}
value={`${gun.id}`}
onClick={() => {
const draft = { ...modules };
draft.gun = gun;
draft.shell = draft.gun.gun_type!.value.base.shells[0];
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setGunMenuOpen(false);
@@ -115,11 +111,9 @@ export function ModuleManager({
<ModuleButton
variant="ghost"
module="gun"
discriminator={
TIER_ROMAN_NUMERALS[gun.gun_type!.value.base.tier]
}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>{' '}
{unwrap(gun.gun_type!.value.base.name)}
{unwrap(gun.name)}
</DropdownMenu.RadioItem>
);
})}
@@ -0,0 +1,14 @@
---
import { Heading, Link, Text } from '@radix-ui/themes';
import { PageWrapper } from './PageWrapper';
---
<PageWrapper align="center" justify="center" gap="0">
<Heading>BlitzKit down for servicing</Heading>
<Text color="gray">
<Link color="blue" href="https://discord.gg/nDt7AjGJQH">
Join the official Discord server
</Link>
for updates.
</Text>
</PageWrapper>
@@ -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}
/>
);
},
);
@@ -25,19 +25,18 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell0 = gun.gun_type!.value.base.shells[0];
const shell1 = gun.gun_type!.value.base.shells[1];
const shell2 = gun.gun_type!.value.base.shells[2];
const shell0 = gun.shells[0];
const shell1 = gun.shells[1];
const shell2 = gun.shells[2];
const tracks = tank.tracks.at(-1)!;
const engine = tank.engines.at(-1)!;
const tankModelDefinition = modelDefinitions.models[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
switch (tankopediaSort.by) {
case 'fire.aimTime':
return gun.gun_type!.value.base.aim_time.toFixed(2);
return gun.aim_time.toFixed(2);
case 'fire.caliber':
return shell0.caliber.toFixed(0);
case 'fire.damage':
@@ -47,7 +46,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
case 'fire.dispersionMoving':
return tracks.dispersion_move.toFixed(3);
case 'fire.dispersionStill':
return gun.gun_type!.value.base.dispersion_base.toFixed(3);
return gun.dispersion_base.toFixed(3);
case 'fire.dpm':
return resolveDpm(gun, shell0).toFixed(0);
case 'fire.dpmPremium':
@@ -63,7 +62,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
case 'fire.shellVelocity':
return shell0.velocity.toFixed(0);
case 'fire.shellCapacity':
return gun.gun_type!.value.base.shell_capacity.toFixed(0);
return gun.shell_capacity.toFixed(0);
case 'fire.shellRange':
return shell0.range.toFixed(0);
case 'fire.gunDepression':
@@ -89,7 +88,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight)
gun.weight)
).toFixed(1);
case 'maneuverability.weight':
return (
@@ -97,7 +96,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight) /
gun.weight) /
1000
).toFixed(1);
case 'maneuverability.traverseSpeed':
@@ -111,11 +110,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
case 'survivability.camouflageMoving':
return (tank.camouflage_moving * 100).toFixed(0);
case 'survivability.camouflageShooting':
return (
tank.camouflage_still *
gun.gun_type!.value.base.camouflage_loss *
100
).toFixed(0);
return (tank.camouflage_still * gun.camouflage_loss * 100).toFixed(0);
case 'survivability.volume': {
const dimensions = normalizeBoundingBox(
unionBoundingBox(
@@ -307,7 +307,7 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex>
<Flex
direction={{ initial: 'row', md: 'column' }}
direction={compact ? 'row' : { initial: 'row', md: 'column' }}
align="start"
gap={{ initial: '2', md: '1' }}
>
@@ -104,11 +104,9 @@ export const TankSearch = memo<TankSearchProps>(
sorted = filtered.sort(
(a, b) =>
a.camouflage_still *
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.camouflage_loss -
a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss -
b.camouflage_still *
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.camouflage_loss,
b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss,
);
break;
@@ -171,11 +169,11 @@ export const TankSearch = memo<TankSearchProps>(
(a, b) =>
resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0],
a.turrets.at(-1)!.guns.at(-1)!.shells[0],
) -
resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0],
b.turrets.at(-1)!.guns.at(-1)!.shells[0],
),
);
break;
@@ -183,18 +181,16 @@ export const TankSearch = memo<TankSearchProps>(
case 'fire.dpmPremium':
sorted = filtered.sort(
(a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[1]
(a.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.shells[1],
a.turrets.at(-1)!.guns.at(-1)!.shells[1],
)
: 0) -
(b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[1]
(b.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.shells[1],
b.turrets.at(-1)!.guns.at(-1)!.shells[1],
)
: 0),
);
@@ -211,108 +207,92 @@ export const TankSearch = memo<TankSearchProps>(
case 'fire.caliber':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.caliber -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.caliber,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber,
);
break;
case 'fire.standardPenetration':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.penetration.near -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.penetration.near,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near,
);
break;
case 'fire.premiumPenetration':
sorted = filtered.sort(
(a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[1]
?.penetration.near ?? 0) -
(b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[1]
?.penetration.near ?? 0),
(a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0),
);
break;
case 'fire.tertiaryPenetration':
sorted = filtered.sort(
(a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[2]
?.penetration.near ?? 0) -
(b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[2]
?.penetration.near ?? 0),
(a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0),
);
break;
case 'fire.damage':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.armor_damage -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.armor_damage,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage,
);
break;
case 'fire.moduleDamage':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.module_damage -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.module_damage,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage,
);
break;
case 'fire.shellVelocity':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.velocity -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.velocity,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity,
);
break;
case 'fire.shellRange':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.range -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.range,
a.turrets.at(-1)!.guns.at(-1)!.shells[0].range -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].range,
);
break;
case 'fire.shellCapacity':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.shell_capacity -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.shell_capacity,
a.turrets.at(-1)!.guns.at(-1)!.shell_capacity -
b.turrets.at(-1)!.guns.at(-1)!.shell_capacity,
);
break;
case 'fire.aimTime':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.aim_time -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.aim_time,
a.turrets.at(-1)!.guns.at(-1)!.aim_time -
b.turrets.at(-1)!.guns.at(-1)!.aim_time,
);
break;
case 'fire.dispersionStill':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.dispersion_base -
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.dispersion_base,
a.turrets.at(-1)!.guns.at(-1)!.dispersion_base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion_base,
);
break;
@@ -328,13 +308,11 @@ export const TankSearch = memo<TankSearchProps>(
sorted = filtered.sort(
(a, b) =>
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.id]
.pitch.max +
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.max +
(modelDefinitions.models[a.id].initial_turret_rotation?.pitch ??
0) -
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.id]
.pitch.max -
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max -
(modelDefinitions.models[b.id].initial_turret_rotation?.pitch ??
0),
);
@@ -344,13 +322,11 @@ export const TankSearch = memo<TankSearchProps>(
sorted = filtered.sort(
(a, b) =>
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.id]
.pitch.min +
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.min +
(modelDefinitions.models[b.id].initial_turret_rotation?.pitch ??
0) -
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.id]
.pitch.min -
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min -
(modelDefinitions.models[a.id].initial_turret_rotation?.pitch ??
0),
);
@@ -382,14 +358,13 @@ export const TankSearch = memo<TankSearchProps>(
a.engines.at(-1)!.weight +
a.tracks.at(-1)!.weight +
a.turrets.at(-1)!.weight +
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base
.weight) -
a.turrets.at(-1)!.guns.at(-1)!.weight) -
b.engines.at(-1)!.power /
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.weight),
b.turrets.at(-1)!.guns.at(-1)!.weight),
);
break;
@@ -400,12 +375,12 @@ export const TankSearch = memo<TankSearchProps>(
a.engines.at(-1)!.weight +
a.tracks.at(-1)!.weight +
a.turrets.at(-1)!.weight +
a.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.weight -
a.turrets.at(-1)!.guns.at(-1)!.weight -
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.weight),
b.turrets.at(-1)!.guns.at(-1)!.weight),
);
break;
@@ -1,6 +1,6 @@
import type { AssaultRanges } from '@blitzkit/core';
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 {
fakeLogSquishyHigh,
@@ -9,6 +9,7 @@ import {
import type { TankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics';
import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import { VisualizerCard } from '../VisualizerCard';
import { AssaultCursor } from './components/Cursor';
interface Props {
@@ -29,7 +30,7 @@ export function AssaultRangesVisualizer({ ranges: _ranges, stats }: Props) {
const { strings } = useLocale();
return (
<Card mb="6" variant="classic" style={{ aspectRatio: '1 / 1' }}>
<VisualizerCard>
<Flex
position="absolute"
top="0"
@@ -152,6 +153,6 @@ export function AssaultRangesVisualizer({ ranges: _ranges, stats }: Props) {
</Flex>
</Flex>
</Flex>
</Card>
</VisualizerCard>
);
}
@@ -1,62 +1,31 @@
import {
asset,
coefficient,
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { asset, isExplosive } from '@blitzkit/core';
import { literals } from '@blitzkit/i18n';
import {
Flex,
Heading,
IconButton,
Slider,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { debounce } from 'lodash-es';
import { useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils.js';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { Flex, Heading, IconButton, Tooltip } from '@radix-ui/themes';
import { useLocale } from '../../../../../../hooks/useLocale';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import { Duel } from '../../../../../../stores/duel';
import type { MaybeSkeletonComponentProps } from '../../../../../../types/maybeSkeletonComponentProps';
import { AssaultRangesVisualizer } from './AssaultRangesVisualizer';
import { GunFlexibilityVisualizer } from './GunFlexibilityVisualizer';
import { Info } from './Info';
import { InfoWithDelta } from './InfoWithDelta';
import { ReloadVisualizer } from './ReloadVisualizer';
import { RicochetVisualizer } from './RicochetVisualizer';
import { StatsTableWrapper } from './StatsTableWrapper';
import type { StatsAcceptorProps } from './TraverseVisualizer';
export function Firepower({ stats }: StatsAcceptorProps) {
export function Firepower({
stats,
skeleton,
}: StatsAcceptorProps & MaybeSkeletonComponentProps) {
const mutateDuel = Duel.useMutation();
const { strings, unwrap } = useLocale();
const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const { gun, shell, turret } = Duel.use((state) => state.protagonist);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const hasSupercharger = useEquipment(107);
const hasCalibratedShells = useEquipment(103);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.6,
]);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const shell = Duel.use((state) => state.protagonist.shell);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
useEffect(() => {
if (penetrationDistanceInput.current) {
penetrationDistanceInput.current.value = `${penetrationDistance}`;
}
}, [penetrationDistance]);
const gunModelDefinition = turretModelDefinition.guns[gun.id];
return (
<StatsTableWrapper>
@@ -66,7 +35,7 @@ export function Firepower({ stats }: StatsAcceptorProps) {
</Heading>
<Flex>
{gun.gun_type!.value.base.shells.map((thisShell, shellIndex) => {
{gun.shells.map((thisShell, shellIndex) => {
const selected = thisShell.id === shell.id;
return (
@@ -79,13 +48,9 @@ export function Firepower({ stats }: StatsAcceptorProps) {
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.gun_type!.value.base.shells.length - 1
? undefined
: 0,
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.gun_type!.value.base.shells.length - 1
? undefined
: 0,
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
@@ -120,11 +85,8 @@ export function Firepower({ stats }: StatsAcceptorProps) {
<InfoWithDelta stats={stats} decimals={0} value="moduleDamage" />
<InfoWithDelta stats={stats} decimals={0} value="penetration" />
{gun.gun_type!.value.base.assault_ranges?.types.includes(shell.type) && (
<AssaultRangesVisualizer
stats={stats}
ranges={gun.gun_type!.value.base.assault_ranges}
/>
{gun.assault_ranges?.types.includes(shell.type) && (
<AssaultRangesVisualizer stats={stats} ranges={gun.assault_ranges} />
)}
{gun.gun_type!.$case === 'auto_reloader' && (
@@ -185,75 +147,8 @@ export function Firepower({ stats }: StatsAcceptorProps) {
value="intraClip"
/>
)}
{/* <ReloadVisualizer stats={stats} /> */}
<ReloadVisualizer stats={stats} />
{typeof shell.penetration !== 'number' && (
<>
<InfoWithDelta
stats={stats}
indent
decimals={0}
name={literals(
strings.website.tools.tankopedia.characteristics.values
.at_distance,
[`${penetrationDistance}`],
)}
noRanking
value={() =>
lerp(
shell.penetration.near,
shell.penetration.far,
(penetrationDistance * penetrationLossOverDistanceCoefficient) /
500,
) * penetrationCoefficient
}
/>
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text color="gray">
{
strings.website.tools.tankopedia.characteristics.values
.at_distance_slider_label
}
</Text>
<Slider
variant="classic"
key={penetrationDistance}
min={0}
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={([value]) => {
setPenetrationDistanceDebounced(value);
if (!penetrationDistanceInput.current) return;
penetrationDistanceInput.current.value = value.toString();
}}
/>
<TextField.Root
variant="classic"
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
)}
{isExplosive(shell.type) && (
<InfoWithDelta
stats={stats}
@@ -342,7 +237,7 @@ export function Firepower({ stats }: StatsAcceptorProps) {
<InfoWithDelta
stats={stats}
decimals={1}
prefix="x "
prefix="x"
indent
deltaType="lowerIsBetter"
value="dispersionGunDamaged"
@@ -403,6 +298,7 @@ export function Firepower({ stats }: StatsAcceptorProps) {
/>
</>
)}
<GunFlexibilityVisualizer skeleton={skeleton} />
</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.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.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 { Flex, Progress, Text } from '@radix-ui/themes';
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 { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../core/awaitables/provisionDefinitions';
@@ -48,148 +48,142 @@ const [
awaitableSkillDefinitions,
]);
export function InfoWithDelta({
stats,
indent,
noRanking,
deltaType,
...props
}: InfoWithDeltaProps) {
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 =
export const InfoWithDelta = memo<InfoWithDeltaProps>(
({ stats, indent, noRanking, deltaType, ...props }) => {
const { strings } = useLocale();
const relativeAgainst = TankopediaEphemeral.use(
(state) => state.relativeAgainst,
);
const uhWhatDoICallThisVariable =
typeof props.value === 'function'
? props.value(tank)!
: (tank[props.value] as number);
? 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.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 deltaType === 'lowerIsBetter'
? othersValue < uhWhatDoICallThisVariable
: othersValue > uhWhatDoICallThisVariable;
});
const goodness = (others.length - betterTanks.length) / others.length;
let color: ComponentProps<typeof Progress>['color'];
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 member = tankToDuelMember(tank, provisionDefinitions);
if (goodness <= 0.25) color = 'red';
else if (goodness <= 0.5) color = 'orange';
else if (goodness <= 0.75) color = 'yellow';
else color = 'green';
if (member.gun.shells[shellIndex] === undefined) {
return undefined;
}
return (
<Flex direction="column">
<Info
deltaType={deltaType}
indent={indent}
{...props}
name={
'name' in props
? props.name
: strings.website.tools.tankopedia.characteristics.values[
props.value
]
}
delta={delta}
>
{uhWhatDoICallThisVariable}
</Info>
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.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;
{!noRanking && (
<Flex pl={indent ? '2' : '0'} align="center" gap="2" mb="2">
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{ height: '0.125rem', opacity: 0.5 }}
/>
const othersValue =
typeof props.value === 'function'
? props.value(tank)!
: (tank[props.value] as number);
<Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} / {others.length}
</Text>
</Flex>
)}
</Flex>
);
}
return othersValue !== undefined;
}) as TankCharacteristics[];
}, [relativeAgainst, shellIndex, equipmentMatrix]);
const betterTanks = others.filter((tank) => {
const othersValue =
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]),
);
@@ -40,7 +40,7 @@ export function Shell({ index, stats }: ShellProps & StatsAcceptorProps) {
<Box
left="50%"
position="absolute"
ml="calc(25% + 2.5rem)"
ml="40%"
width="1.25rem"
height="1.25rem"
style={{
@@ -70,11 +70,9 @@ export function Shell({ index, stats }: ShellProps & StatsAcceptorProps) {
overflow: 'hidden',
maskImage:
'url(/assets/images/tankopedia/visualizers/reload/shell.png)',
// maskComposite: 'exclude',
maskSize: 'contain',
maskRepeat: 'no-repeat',
maskPosition: 'center',
// background: 'red',
}}
>
<Box
@@ -13,8 +13,8 @@ export function ShellBorder(props: ComponentProps<'svg'>) {
<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"
stroke="currentColor"
stroke-width="15"
stroke-linejoin="round"
strokeWidth="15"
strokeLinejoin="round"
/>
</svg>
);
@@ -0,0 +1,21 @@
#reload-visualizer-flash {
background: transparent;
transition-duration: 200ms;
}
#reload-visualizer-flash.go {
background: var(--white-a3);
transition: none;
}
.reload {
width: 65%;
height: 65%;
transition-duration: 200ms;
}
.reload.go {
width: 70%;
height: 70%;
transition: none;
}
@@ -1,12 +1,17 @@
import { type Datum, type Serie } from '@nivo/line';
import { Box, Card, Code } from '@radix-ui/themes';
import { DoubleArrowRightIcon } from '@radix-ui/react-icons';
import { Box, Button, Code } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { Quicklime, type QuicklimeCallback } from 'quicklime';
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import type { StatsAcceptorProps } from '../TraverseVisualizer';
import { VisualizerCard } from '../VisualizerCard';
import { Shell } from './components/Shell';
import { Target } from './components/Target';
import './index.css';
const PRECISION = 2;
export interface ReloadUpdateData {
reload: number;
@@ -15,30 +20,31 @@ export interface ReloadUpdateData {
export const reloadUpdate = new Quicklime<ReloadUpdateData>();
const T = 60;
export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
const { strings } = useLocale();
const reloadCircle = useRef<HTMLDivElement>(null);
const reloadCore = useRef<HTMLDivElement>(null);
const reloadGlow = useRef<HTMLDivElement>(null);
const dpmDisplay = useRef<HTMLSpanElement>(null);
const totalTime = useRef<HTMLSpanElement>(null);
const progressTime = useRef<HTMLSpanElement>(null);
const flash = useRef<HTMLDivElement>(null);
/**
* 3-shell gun example:
*
* 0-1: clip reload
* ~~~: shell shot
* 1-2: intra-clip
* ~~~: shell shot
* 2-3: intra-clip
* ~~~: shell shot & reset to 0
*
* intra-clip time = intraClip * (n - 1)
*/
const state = useRef(0);
const dpm = useRef(0);
const shotPool = useRef(new Set<number>());
const data = useRef<Serie[]>([{ id: 'value', data: [] }]);
const shoot = useCallback(() => {
flash.current?.classList.add('go');
reloadCircle.current?.classList.add('go');
reloadGlow.current?.classList.add('go');
void flash.current?.offsetWidth;
void reloadCircle.current?.offsetWidth;
void reloadGlow.current?.offsetWidth;
flash.current?.classList.remove('go');
reloadCircle.current?.classList.remove('go');
reloadGlow.current?.classList.remove('go');
}, []);
useEffect(() => {
let cancel = false;
@@ -46,40 +52,76 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
let lastState = state.current;
const shellArray = new Array(stats.shells);
function shoot() {
shotPool.current.add(Date.now() / 1000);
dpm.current += stats.damage;
}
function frame() {
if (!dpmDisplay.current) return;
if (!totalTime.current || !progressTime.current) return;
const reloadThreshold = stats.shellReloads
? stats.shellReloads.length
: 1;
const now = Date.now() / 1000;
const dt = now - lastT;
let reload;
let shells;
shotPool.current.forEach((t) => {
if (now - t > 60) shotPool.current.delete(t);
});
if (state.current < reloadThreshold) {
const shellReload =
reloadThreshold === 1
? stats.shellReload!
: stats.shellReloads![Math.floor(state.current)];
dpm.current = shotPool.current.size * stats.damage;
dpmDisplay.current.innerText = `${Math.round(dpm.current)}`;
if (state.current < 1) {
state.current = Math.min(1, state.current + dt / stats.shellReload!);
reload = state.current;
state.current = Math.min(
reloadThreshold,
state.current + dt / shellReload,
);
reload = state.current % 1;
shells = shellArray.fill(reload);
if (state.current >= 1) shoot();
} else if (state.current < stats.shells) {
if (reloadThreshold > 1) {
shells = shells.map((_, index) =>
Math.max(0, state.current - shellArray.length + index + 1),
);
}
totalTime.current.innerHTML = shellReload.toFixed(PRECISION);
progressTime.current.innerHTML = ((1 - reload) * shellReload).toFixed(
PRECISION,
);
if (state.current >= reloadThreshold) shoot();
} else if (state.current < stats.shells + reloadThreshold - 1) {
let interval;
if (
stats.burstShells === 1 ||
Math.floor(state.current) % stats.burstShells === 0
) {
interval = stats.intraClip!;
} else {
interval = stats.burstInterShell!;
}
state.current = Math.min(
stats.shells,
state.current + dt / stats.intraClip!,
stats.shells + reloadThreshold,
state.current + dt / interval,
);
shells = times(stats.shells, (index) => index > state.current - 1);
reload = state.current % 1;
if (reloadThreshold > 1) {
shells = shellArray.map((_, index) =>
Math.max(
0,
Math.floor(index + 1 + reloadThreshold - state.current),
),
);
} else {
shells = times(stats.shells, (index) => index > state.current - 1);
}
totalTime.current.innerHTML = interval.toFixed(PRECISION);
progressTime.current.innerHTML = ((1 - reload) * interval).toFixed(
PRECISION,
);
if (lastState % 1 > state.current % 1) shoot();
} else {
state.current = 0;
@@ -93,15 +135,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
lastT = now;
lastState = state.current;
(data.current[0].data as Datum[]).push({
x: now,
y: dpm.current / (stats.dpm + stats.damage),
});
(data.current[0].data as Datum[]).splice(
0,
data.current[0].data.findIndex((d) => now - (d.x as number) < T),
);
}
frame();
@@ -133,7 +166,7 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
}, []);
return (
<Card variant="classic" mb="6" style={{ aspectRatio: '1 / 1' }}>
<VisualizerCard>
<Box position="absolute" top="0" left="0" width="100%" height="100%">
<Target />
@@ -154,8 +187,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
position="absolute"
top="50%"
left="50%"
width="12rem"
height="12rem"
className="reload"
ref={reloadGlow}
style={{
@@ -170,8 +201,6 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
position="absolute"
top="50%"
left="50%"
width="12rem"
height="12rem"
className="reload"
ref={reloadCircle}
style={{
@@ -219,14 +248,71 @@ export function ReloadVisualizer({ stats }: StatsAcceptorProps) {
}}
/>
<Code
ref={progressTime}
size="2"
variant="ghost"
weight="bold"
color="gray"
highContrast
style={{
position: 'absolute',
top: '50%',
right: 'calc(50% + var(--space-8))',
transform: 'translate(50%, -50%)',
}}
>
0.00
</Code>
<Code
ref={totalTime}
size="2"
variant="ghost"
weight="bold"
color="gray"
style={{
position: 'absolute',
top: '50%',
left: 'calc(50% + var(--space-8))',
transform: 'translate(-50%, -50%)',
}}
>
0.00
</Code>
{stats.shells > 1 &&
times(stats.shells, (index) => (
<Shell stats={stats} index={index} key={index} />
))}
<Box position="absolute" right="3" bottom="3">
<Code ref={dpmDisplay}>0 dpm</Code>
</Box>
</Card>
<Box
id="reload-visualizer-flash"
ref={flash}
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
/>
<Button
size="1"
variant="outline"
color="gray"
highContrast
style={{
position: 'absolute',
top: '68%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
onClick={() => {
state.current = Math.floor(state.current + 1);
}}
>
{strings.website.tools.tankopedia.visualizers.reload.skip}
<DoubleArrowRightIcon />
</Button>
</VisualizerCard>
);
}
@@ -11,7 +11,7 @@ export function ShellSwitcher() {
return (
<Flex>
{gun.gun_type!.value.base.shells.map((thisShell, shellIndex) => {
{gun.shells.map((thisShell, shellIndex) => {
const selected = thisShell.id === shell.id;
return (
@@ -26,9 +26,7 @@ export function ShellSwitcher() {
borderBottomLeftRadius: shellIndex === 0 ? '50%' : 0,
borderTopRightRadius: 0,
borderBottomRightRadius:
shellIndex === gun.gun_type!.value.base.shells.length - 1
? '50%'
: 0,
shellIndex === gun.shells.length - 1 ? '50%' : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
@@ -1,12 +1,14 @@
import { asset, isExplosive } from '@blitzkit/core';
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 { clamp, degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { Var } from '../../../../../../../core/radix/var';
import { useLocale } from '../../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../../stores/duel';
import type { StatsAcceptorProps } from '../TraverseVisualizer';
import { VisualizerCard } from '../VisualizerCard';
import { VisualizerCornerStat } from '../VisualizerCornerStat';
import { ShellSwitcher } from './components/ShellSwitcher';
export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
@@ -26,13 +28,9 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
return (
<Flex direction="column" mb="6" align="center">
<Card
variant="classic"
style={{
width: '100%',
aspectRatio: '1 / 1',
touchAction: 'none',
}}
<VisualizerCard
mb="0"
style={{ width: '100%' }}
ref={container}
onPointerMove={(event) => {
if (!container.current || explodes) return;
@@ -73,23 +71,23 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
position="absolute"
style={{
background: `conic-gradient(
at 0 50%,
at 0 50%,
${Var('ruby-a2')},
${Var('ruby-a1')} ${Math.PI / 2 - ricochet}rad,
${Var('ruby-a2')},
${Var('ruby-a1')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a2')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a4')} ${Math.PI / 2 - normalization}rad,
${Var('jade-a2')} ${Math.PI / 2 - ricochet}rad,
${Var('jade-a4')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a5')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a3')} ${Math.PI / 2 + normalization}rad,
${Var('cyan-a5')} ${Math.PI / 2 - normalization}rad,
${Var('cyan-a3')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a2')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a4')} ${Math.PI / 2 + ricochet}rad,
${Var('jade-a2')} ${Math.PI / 2 + normalization}rad,
${Var('jade-a4')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a1')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a2')} 180deg
`,
${Var('ruby-a1')} ${Math.PI / 2 + ricochet}rad,
${Var('ruby-a2')} 180deg
`,
}}
/>
@@ -160,85 +158,51 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
/>
</Box>
<Flex
style={{ userSelect: 'none' }}
gap="4"
position="absolute"
top="0"
left="0"
p="3"
width="100%"
justify="between"
>
<Flex gap="2">
<Text color="gray" size="1">
{strings.website.tools.tankopedia.visualizers.ricochet.angle}
</Text>
<Code size="1" variant="ghost">
{literals(strings.common.units.deg, [
radToDeg(Math.abs(angle)).toFixed(0),
])}
</Code>
</Flex>
<Flex gap="2">
<Text color="gray" size="1">
{
strings.website.tools.tankopedia.visualizers.ricochet
.normalization
}
</Text>
<Code size="1" variant="ghost">
{doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.min(normalization, Math.abs(angle))).toFixed(
0,
),
])}
</Code>
</Flex>
</Flex>
<Flex
style={{ userSelect: 'none' }}
gap="4"
position="absolute"
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>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.ricochet.angle}
value={literals(strings.common.units.deg, [
radToDeg(Math.abs(angle)).toFixed(0),
])}
side="top-left"
/>
<VisualizerCornerStat
label={
strings.website.tools.tankopedia.visualizers.ricochet.normalization
}
value={
doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.min(normalization, Math.abs(angle))).toFixed(0),
])
}
side="top-right"
/>
<VisualizerCornerStat
label={
strings.website.tools.tankopedia.visualizers.ricochet
.effective_angle
}
value={
doesRicochet
? '-'
: literals(strings.common.units.deg, [
radToDeg(Math.abs(effectiveAngle)).toFixed(0),
])
}
side="bottom-left"
/>
<VisualizerCornerStat
label={strings.website.tools.tankopedia.visualizers.ricochet.armor}
value={
doesRicochet
? '-'
: literals(strings.common.units.percentage, [
armorPercentage.toFixed(0),
])
}
side="bottom-right"
/>
{explodes && (
<Flex
@@ -262,7 +226,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) {
</Text>
</Flex>
)}
</Card>
</VisualizerCard>
<ShellSwitcher />
</Flex>
@@ -1,8 +1,9 @@
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 type { TankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics';
import { useLocale } from '../../../../../../../hooks/useLocale';
import { VisualizerCard } from '../VisualizerCard';
export interface StatsAcceptorProps {
stats: TankCharacteristics;
@@ -58,7 +59,7 @@ export function TraverseVisualizer({ stats }: StatsAcceptorProps) {
}, [terrain, turretSpeedDeg, hullSpeedDeg]);
return (
<Card variant="classic" style={{ aspectRatio: '1 / 1' }}>
<VisualizerCard>
<div
ref={hull}
style={{
@@ -188,6 +189,6 @@ export function TraverseVisualizer({ stats }: StatsAcceptorProps) {
</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 { awaitableProvisionDefinitions } from '../../../../../core/awaitables/provisionDefinitions';
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../core/blitzkit/modelTransform';
import { tankCharacteristics } from '../../../../../core/blitzkit/tankCharacteristics';
import { useEquipment } from '../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../hooks/useTankModelDefinition';
import { Duel } from '../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps';
import { Crew } from './components/Crew';
import { Firepower } from './components/Firepower';
import { Maneuverability } from './components/Maneuverability';
@@ -18,8 +20,7 @@ const [equipmentDefinitions, provisionDefinitions] = await Promise.all([
awaitableProvisionDefinitions,
]);
export function Characteristics() {
const mutateDuel = Duel.useMutation();
export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const provisions = Duel.use((state) => state.protagonist.provisions);
const consumables = Duel.use((state) => state.protagonist.consumables);
@@ -39,8 +40,7 @@ export function Characteristics() {
const stockGun = stockTurret.guns[0];
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
const stats = tankCharacteristics(
{
tank,
@@ -74,16 +74,16 @@ export function Characteristics() {
const hasDownImprovedVerticalStabilizer = useEquipment(124);
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
});
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, [hasImprovedVerticalStabilizer, hasDownImprovedVerticalStabilizer]);
return (
@@ -93,7 +93,7 @@ export function Characteristics() {
align={{ initial: 'center', sm: 'start' }}
width={{ initial: '100%', sm: 'auto' }}
>
<Firepower stats={stats} />
<Firepower skeleton={skeleton} stats={stats} />
<Flex
gap="8"
@@ -33,7 +33,7 @@ type ModuleDefinition =
function extractModule(raw: ModuleDefinition | TankDefinition) {
if ('gun_type' in raw) {
return raw.gun_type!.value.base;
return raw;
} else
return raw as Exclude<ModuleDefinition | TankDefinition, GunDefinition>;
}
@@ -179,37 +179,31 @@ export function Modules() {
if (
!draft.protagonist.turret.guns.some(
(gun) =>
gun.gun_type!.value.base.id ===
draft.protagonist.gun.gun_type!.value.base.id,
(gun) => gun.id === draft.protagonist.gun.id,
)
) {
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell =
draft.protagonist.gun.gun_type!.value.base.shells[0];
draft.protagonist.shell = draft.protagonist.gun.shells[0];
}
} else if (unlock.type === ModuleType.GUN) {
const gunInTurret = draft.protagonist.turret.guns.find(
(gun) => gun.gun_type!.value.base.id === unlock.id,
(gun) => gun.id === unlock.id,
);
if (gunInTurret) {
draft.protagonist.gun = gunInTurret;
draft.protagonist.shell = gunInTurret.gun_type!.value.base.shells[0];
draft.protagonist.shell = gunInTurret.shells[0];
} else {
// TODO: warn somehow?
const suitableTurret = draft.protagonist.tank.turrets.find((turret) =>
turret.guns.some(
(gun) => gun.gun_type!.value.base.id === unlock.id,
),
turret.guns.some((gun) => gun.id === unlock.id),
)!;
const gunInSuitableTurret = suitableTurret.guns.find(
(gun) => gun.gun_type!.value.base.id === unlock.id,
(gun) => gun.id === unlock.id,
)!;
draft.protagonist.turret = suitableTurret;
draft.protagonist.gun = gunInSuitableTurret;
draft.protagonist.shell =
gunInSuitableTurret.gun_type!.value.base.shells[0];
draft.protagonist.shell = gunInSuitableTurret.shells[0];
}
} else if (unlock.type === ModuleType.ENGINE) {
draft.protagonist.engine = draft.protagonist.tank.engines.find(
@@ -241,11 +235,9 @@ export function Modules() {
} else if (unlock.type === ModuleType.GUN) {
module = tank.turrets
.find((turret) =>
turret.guns.some(
(gun) => gun.gun_type!.value.base.id === unlock.id,
),
turret.guns.some((gun) => gun.id === unlock.id),
)!
.guns.find((gun) => gun.gun_type!.value.base.id === unlock.id)!;
.guns.find((gun) => gun.id === unlock.id)!;
} else if (unlock.type === ModuleType.VEHICLE) {
module = tankDefinitions.tanks[unlock.id];
}
@@ -308,7 +300,7 @@ export function Modules() {
(unlock.type === ModuleType.TURRET
? turret.id
: unlock.type === ModuleType.GUN
? gun.gun_type!.value.base.id
? gun.id
: unlock.type === ModuleType.ENGINE
? engine.id
: unlock.type === ModuleType.TRACKS
@@ -319,8 +311,7 @@ export function Modules() {
unlock.type === ModuleType.TURRET
? extractModule(module).id == topTurret.id
: unlock.type === ModuleType.GUN
? extractModule(module).id ===
topGun.gun_type!.value.base.id
? extractModule(module).id === topGun.id
: unlock.type === ModuleType.ENGINE
? extractModule(module).id === topEngine.id
: unlock.type === ModuleType.TRACKS
@@ -371,8 +362,7 @@ export function Modules() {
mutateDuel((draft) => {
draft.protagonist.turret = draft.protagonist.tank.turrets[0];
draft.protagonist.gun = draft.protagonist.turret.guns[0];
draft.protagonist.shell =
draft.protagonist.gun.gun_type!.value.base.shells[0];
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine = draft.protagonist.tank.engines[0];
draft.protagonist.track = draft.protagonist.tank.tracks[0];
});
@@ -387,8 +377,7 @@ export function Modules() {
draft.protagonist.turret =
draft.protagonist.tank.turrets.at(-1)!;
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
draft.protagonist.shell =
draft.protagonist.gun.gun_type!.value.base.shells[0];
draft.protagonist.shell = draft.protagonist.gun.shells[0];
draft.protagonist.engine =
draft.protagonist.tank.engines.at(-1)!;
draft.protagonist.track =
@@ -427,7 +416,7 @@ export function Modules() {
{tree(ModuleType.GUN, [
{
cost: { type: 'xp', value: 0 },
id: gun0.gun_type!.value.base.id,
id: gun0.id,
type: ModuleType.GUN,
},
])}
@@ -1,4 +1,5 @@
import { Flex } from '@radix-ui/themes';
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps';
import { Characteristics } from './components/Characteristics';
import { CompareOptions } from './components/Characteristics/components/CompareOptions';
import { StatSearch } from './components/Characteristics/components/StatSearch';
@@ -9,7 +10,9 @@ import { Modules } from './components/Modules';
import { Provisions } from './components/Provisions';
import { Skills } from './components/Skills';
export function CharacteristicsSection() {
export function CharacteristicsSection({
skeleton,
}: MaybeSkeletonComponentProps) {
return (
<Flex direction="column" gap="3">
<Flex
@@ -64,7 +67,7 @@ export function CharacteristicsSection() {
<CompareOptions />
</Flex>
<Characteristics />
<Characteristics skeleton={skeleton} />
</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>
);
}
@@ -90,7 +90,7 @@ export function CustomShellButton() {
modified.normalization = undefined;
modified.ricochet = undefined;
modified.explosion_radius =
gun.gun_type!.value.base.shells.find(
gun.shells.find(
(shell) => shell.type === ShellType.HE,
)?.explosion_radius ?? 1;
break;
@@ -1,5 +1,6 @@
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes';
import type { QuicklimeEvent } from 'quicklime';
import { useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
@@ -13,46 +14,32 @@ import { Duel } from '../../../../../../../stores/duel';
import './index.css';
export function QuickInputs() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition =
turretModelDefinition.guns[protagonist.gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch =
tankModelDefinition.initial_turret_rotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
useEffect(() => {
if (!yawInput.current) return;
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) {
function handleTransformEvent(
event: QuicklimeEvent<ModelTransformEventData>,
) {
if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = (
-radToDeg(pitch) +
-radToDeg(event.data.pitch) +
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).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);
@@ -85,24 +72,27 @@ export function QuickInputs() {
defaultValue="0.0"
onBlur={() => {
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
yawInput.current!.value = radToDeg(
modelTransformEvent.last!.yaw,
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
modelTransformEvent.last!.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
modelTransformEvent.dispatch({ pitch, yaw });
yawInput.current!.value = radToDeg(
modelTransformEvent.last!.yaw,
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -128,25 +118,21 @@ export function QuickInputs() {
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
-radToDeg(modelTransformEvent.last!.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
modelTransformEvent.dispatch({ pitch, yaw });
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
-radToDeg(modelTransformEvent.last!.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
@@ -12,10 +12,8 @@ import {
EnterFullScreenIcon,
ExitFullScreenIcon,
EyeOpenIcon,
GearIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Checkbox,
Dialog,
@@ -34,12 +32,8 @@ import { useEquipment } from '../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../hooks/useFullScreen';
import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAvailability';
import { useLocale } from '../../../../../hooks/useLocale';
import { App } from '../../../../../stores/app';
import { Duel } from '../../../../../stores/duel';
import {
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../stores/tankopediaEphemeral';
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../../../stores/tankopediaPersistent';
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps';
@@ -47,6 +41,7 @@ import type { ThicknessRange } from '../../../../Armor/components/StaticArmor';
import { ModuleButton } from '../../../../ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../SmallTankIcon';
import { TankSearch } from '../../../../TankSearch';
import { screenshotReadyEvent } from '../TankSandbox/components/SceneProps';
import { CustomShellButton } from './components/CustomShellButton';
import { DynamicArmorSwitcher } from './components/DynamicArmorSwitcher';
import { QuickInputs } from './components/QuickInputs';
@@ -63,20 +58,10 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
);
const display = TankopediaEphemeral.use((state) => state.display);
const isFullScreen = useFullScreen();
const showGrid = TankopediaPersistent.use((state) => state.showGrid);
const greenPenetration = TankopediaPersistent.use(
(state) => state.greenPenetration,
);
const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor,
);
const advancedHighlighting = TankopediaPersistent.use(
(state) => state.advancedHighlighting,
);
const wireframe = TankopediaPersistent.use((state) => state.wireframe);
const opaque = TankopediaPersistent.use((state) => state.opaque);
const fullScreenAvailable = useFullscreenAvailability(true);
const developerMode = App.use((state) => state.developerMode);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const antagonistGun = Duel.use((state) => state.antagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell);
@@ -97,23 +82,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<>
{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} />
<Flex
@@ -162,7 +130,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
mr="2"
mb="1"
>
{TIER_ROMAN_NUMERALS[antagonistGun.gun_type!.value.base.tier]}
{TIER_ROMAN_NUMERALS[antagonistGun.tier]}
</Text>
<img
@@ -182,26 +150,20 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
{[...antagonistUniqueGuns].reverse().map(({ gun, turret }) => (
<ModuleButton
module="gun"
key={gun.gun_type!.value.base.id}
key={gun.id}
onClick={() =>
mutateDuel((draft) => {
draft.antagonist.turret = turret;
draft.antagonist.gun = gun;
draft.antagonist.shell =
gun.gun_type!.value.base.shells[0];
draft.antagonist.shell = gun.shells[0];
})
}
selected={
gun.gun_type!.value.base.id ===
antagonistGun.gun_type!.value.base.id
}
discriminator={
TIER_ROMAN_NUMERALS[gun.gun_type!.value.base.tier]
}
selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
secondaryDiscriminator={
<Text style={{ fontSize: '0.75em' }}>
{literals(strings.common.units.mm, [
gun.gun_type!.value.base.shells[0].caliber.toFixed(0),
gun.shells[0].caliber.toFixed(0),
])}
</Text>
}
@@ -219,7 +181,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
}}
overflow="hidden"
>
{antagonistGun.gun_type!.value.base.shells.map((thisShell) => (
{antagonistGun.shells.map((thisShell) => (
<IconButton
color={
thisShell.id === antagonistShell.id && !hasCustomShell
@@ -357,6 +319,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Text color="gray" size="2">
{strings.website.tools.tankopedia.sandbox.dynamic.shooter}
</Text>
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorOpen}
@@ -388,8 +351,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell =
draft.antagonist.gun.gun_type!.value.base.shells[0];
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
setAntagonistSelectorOpen(false);
}}
@@ -408,7 +370,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
mutateTankopediaEphemeral((draft) => {
draft.disturbed = true;
draft.display = Number(value);
draft.shootingRangeZoom = ShootingRangeZoom.Arcade;
});
}}
>
@@ -448,13 +409,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</Flex>
</Tooltip>
</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>
{disturbed && (
@@ -487,106 +441,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{display === TankopediaDisplay.DynamicArmor && (
<>
<DropdownMenu.Label>
{
strings.website.tools.tankopedia.sandbox.settings
.armor
}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.greenPenetration = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.green_penetration
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={hideTankModelUnderArmor}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.hideTankModelUnderArmor = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.hide_model_under_armor
}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.opaque = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.opaque
}
</DropdownMenu.CheckboxItem>
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.wireframe = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.dev_wireframe
}
</DropdownMenu.CheckboxItem>
)}
</>
)}
<DropdownMenu.Label>
{
strings.website.tools.tankopedia.sandbox.settings
.environment
}
</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.showGrid = checked;
});
}}
>
{
strings.website.tools.tankopedia.sandbox.settings
.show_grid
}
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Popover.Root>
<Popover.Trigger>
<IconButton color="gray" variant="ghost">
@@ -599,19 +453,25 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Popover.Close>
<Button
onClick={() => {
if (!canvas.current) return;
screenshotReadyEvent.dispatch(true);
const anchor = document.createElement('a');
requestAnimationFrame(() => {
if (!canvas.current) return;
anchor.setAttribute(
'download',
`${protagonistTank.name}.png`,
);
anchor.setAttribute(
'href',
canvas.current.toDataURL('image/png'),
);
anchor.click();
const anchor = document.createElement('a');
anchor.setAttribute(
'download',
`${protagonistTank.name}.png`,
);
anchor.setAttribute(
'href',
canvas.current.toDataURL('image/png'),
);
anchor.click();
screenshotReadyEvent.dispatch(false);
});
}}
>
<DownloadIcon />
@@ -625,14 +485,20 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Button
variant="outline"
onClick={() => {
if (!canvas.current) return;
screenshotReadyEvent.dispatch(true);
canvas.current.toBlob((blob) => {
if (!blob) return;
requestAnimationFrame(() => {
if (!canvas.current) return;
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
canvas.current.toBlob((blob) => {
if (!blob) return;
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
});
screenshotReadyEvent.dispatch(false);
});
}}
>
@@ -13,10 +13,10 @@ export function Tracker({ fontSize }: Props) {
<Box
position="absolute"
left="50%"
bottom="0"
top="50%"
width="min(100%, 16rem)"
style={{
transform: `translate(-50%, calc(${fontSize} / 2))`,
transform: `translate(-50%, calc(${fontSize} / 2 + var(--space-4)))`,
}}
>
<Progress color="gray" value={progress} />
@@ -1,99 +1,40 @@
import { TankType } from '@blitzkit/core';
import { Box, Heading } from '@radix-ui/themes';
import { Var } from '../../../../../../core/radix/var';
import { Flex, Heading } from '@radix-ui/themes';
import { useLocale } from '../../../../../../hooks/useLocale';
import { Duel } from '../../../../../../stores/duel';
import type { RadixColor } from '../../../../../../stores/embedState';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { Tracker } from './components/Tracker';
interface TitleProps {
foreground?: boolean;
}
export const NATION_COLORS: Record<
string,
{
text: RadixColor;
background: RadixColor[];
tint: RadixColor;
}
> = {
france: {
text: 'mint',
tint: 'purple',
background: ['mint', 'cyan', 'jade'],
},
germany: {
text: 'sage',
tint: 'blue',
background: ['slate', 'sage', 'mauve'],
},
usa: {
text: 'gold',
tint: 'red',
background: ['brown', 'gold', 'gray'],
},
european: {
text: 'sky',
tint: 'violet',
background: ['cyan', 'blue', 'indigo'],
},
ussr: {
text: 'bronze',
tint: 'red',
background: ['bronze', 'tomato', 'bronze'],
},
uk: {
text: 'gold',
tint: 'red',
background: ['bronze', 'gold', 'brown'],
},
japan: {
text: 'mint',
tint: 'violet',
background: ['green', 'teal', 'jade'],
},
china: {
text: 'mint',
tint: 'pink',
background: ['green', 'teal', 'jade'],
},
other: {
text: 'amber',
tint: 'sky',
background: ['amber', 'yellow', 'gray'],
},
};
export function Title({ foreground }: TitleProps) {
export function Title() {
const { unwrap } = useLocale();
const protagonist = Duel.use((state) => state.protagonist.tank);
const disturbed = TankopediaEphemeral.use((state) => state.disturbed);
const revealed = TankopediaEphemeral.use((state) => state.revealed);
const nationColors = NATION_COLORS[protagonist.nation];
const color = Var(
disturbed
? protagonist.type === TankType.COLLECTOR
? 'blue-11'
: protagonist.type === TankType.PREMIUM
? 'amber-11'
: 'gray-12'
: `${nationColors.text}-12`,
);
const name = unwrap(protagonist.name);
const fontSize = revealed
? disturbed
? revealed
? '2rem'
: `min(65vh, ${125 / name.length}vw)`
: `min(48vh, ${75 / name.length}vw)`;
: `min(16vh, ${125 / name.length}vw)`
: `min(12vh, ${75 / name.length}vw)`;
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
return (
<Box
<Flex
onPointerDown={(event) => {
event.preventDefault();
mutateTankopediaEphemeral((draft) => {
draft.revealed = true;
});
}}
direction="column"
position="absolute"
top={disturbed ? '6rem' : '50%'}
align="center"
justify="center"
top={revealed ? '6rem' : '50%'}
left="50%"
width="100%"
height={revealed ? fontSize : '100%'}
style={{
pointerEvents: revealed ? 'none' : undefined,
transitionDuration: '1s',
transform: 'translate(-50%, -50%)',
}}
@@ -105,11 +46,10 @@ export function Title({ foreground }: TitleProps) {
pointerEvents: 'none',
fontSize,
whiteSpace: 'nowrap',
color: foreground ? Var('gray-a10') : color,
opacity: foreground && disturbed ? 0 : 1,
letterSpacing: disturbed || !revealed ? 0 : '-0.03em',
opacity: revealed ? 1 : 0.5,
letterSpacing: revealed || !revealed ? 0 : '-0.03em',
transition: `
letter-spacing 1.5s ${disturbed ? '' : 'cubic-bezier(.81,-2,.68,1)'},
letter-spacing 1.5s ${revealed ? '' : 'cubic-bezier(0.81, -2, 0.68, 1)'},
font-size 1s,
-webkit-text-stroke 2s,
opacity 1s
@@ -121,6 +61,6 @@ export function Title({ foreground }: TitleProps) {
</Heading>
{!revealed && <Tracker fontSize={fontSize} />}
</Box>
</Flex>
);
}
@@ -1,23 +1,15 @@
import { I_HAT, J_HAT } from '@blitzkit/core';
import { OrbitControls } from '@react-three/drei';
import { invalidate, useThree } from '@react-three/fiber';
import { clamp } from 'lodash-es';
import { invalidate, useFrame, useThree } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import { PerspectiveCamera, Vector3 } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { degToRad } from 'three/src/math/MathUtils.js';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { Duel } from '../../../../../../stores/duel';
import {
SHOOTING_RANGE_ZOOM_COEFFICIENTS,
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { aimTarget } from './SceneProps/constants';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
const poseDistances: Record<Pose, number> = {
[Pose.HullDown]: 15,
@@ -27,17 +19,11 @@ const poseDistances: Record<Pose, number> = {
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 {
naked?: boolean;
autoRotate?: boolean;
zoomable?: boolean;
enableRotate?: boolean;
distanceScale?: number;
}
export function Controls({
@@ -45,10 +31,8 @@ export function Controls({
autoRotate = true,
zoomable = true,
enableRotate = true,
distanceScale = 1,
}: ControlsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const display = TankopediaEphemeral.use((state) => state.display);
const duelStore = Duel.useStore();
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const camera = useThree((state) => state.camera as PerspectiveCamera);
@@ -70,9 +54,7 @@ export function Controls({
const antagonistTurretModelDefinition =
antagonistModelDefinition.turrets[antagonistTurret.id];
const protagonistGunModelDefinition =
protagonistTurretModelDefinition.guns[
protagonistGun.gun_type!.value.base.id
];
protagonistTurretModelDefinition.guns[protagonistGun.id];
const protagonistHullOrigin = new Vector3(
protagonistTrackModelDefinition.origin.x,
protagonistTrackModelDefinition.origin.y,
@@ -100,45 +82,24 @@ export function Controls({
protagonistGunOrigin.y;
const inspectModeInitialPosition = new Vector3(
-8,
gunHeight + (naked ? 10 : 2),
naked ? gunHeight + 10 : 2,
-13,
).multiplyScalar(distanceScale);
const protagonistGunOriginOnlyY = new Vector3(
0,
protagonistTurretModelDefinition.gun_origin.y,
0,
);
const shellOrigin = protagonistHullOrigin
.clone()
.add(protagonistTurretOrigin)
.add(protagonistGunOriginOnlyY);
useEffect(() => {
if (!orbitControls.current || !doAutoRotate) return;
const t0 = useRef(Date.now() / 1000);
useFrame(() => {
if (!doAutoRotate) return;
const t0 = Date.now() / 1000;
let cancel = false;
const now = Date.now() / 1000;
const t = now - t0.current;
function frame() {
if (!cancel) requestAnimationFrame(frame);
camera.position
.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;
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]);
invalidate();
});
useEffect(() => {
const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe(
@@ -148,14 +109,14 @@ export function Controls({
},
);
async function handlePoseEvent(event: Pose) {
function handlePoseEvent(event: Pose) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -252,38 +213,11 @@ export function Controls({
}, [camera, protagonistTank.id, antagonistTank.id]);
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) {
if (display !== TankopediaDisplay.ShootingRange) return;
event.preventDefault();
mutateTankopediaEphemeral((draft) => {
draft.shootingRangeZoom = clamp(
draft.shootingRangeZoom - Math.sign(event.deltaY),
ShootingRangeZoom.Arcade,
ShootingRangeZoom.Zoom2,
);
});
}
function handleScroll(event: Event) {
if (display !== TankopediaDisplay.ShootingRange) return;
event.preventDefault();
}
@@ -295,7 +229,6 @@ export function Controls({
poseEvent.on(disturb);
canvas.addEventListener('pointerdown', disturb);
window.addEventListener('keydown', handleKeyDown);
canvas.addEventListener('wheel', handleWheel);
document.body.addEventListener('scroll', handleScroll);
@@ -304,80 +237,28 @@ export function Controls({
orbitControls.current.rotateSpeed = 0.25;
if (display === TankopediaDisplay.ShootingRange) {
const { shootingRangeZoom } = tankopediaEphemeralStore.getState();
orbitControls.current.enablePan = false;
orbitControls.current.enableZoom = false;
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.position.copy(inspectModeInitialPosition);
orbitControls.current.target.set(
0,
gunHeight * (naked ? 1 / 4 : 3 / 4),
0,
);
orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true;
camera.fov = naked ? 20 : 25;
camera.updateProjectionMatrix();
}
updateCamera();
const unsubscribeDisplay = tankopediaEphemeralStore.subscribe(
(state) => state.display === TankopediaDisplay.ShootingRange,
() => {
disturb();
updateCamera();
},
);
const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
(state) => state.shootingRangeZoom,
updateCamera,
);
return () => {
canvas.removeEventListener('pointerdown', disturb);
poseEvent.off(disturb);
unsubscribeDisplay();
unsubscribeShootingRangeZoom();
window.removeEventListener('keydown', handleKeyDown);
canvas.removeEventListener('wheel', handleWheel);
document.body.removeEventListener('scroll', handleScroll);
};
}, [display === TankopediaDisplay.ShootingRange]);
}, []);
return (
<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.id];
useEffect(() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(8),
degToRad(25),
gunModel.pitch,
turretModel.yaw,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, []);
return null;
}
@@ -1,53 +0,0 @@
import { Environment } from '@react-three/drei';
import { times } from 'lodash-es';
import { Euler } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import { useModel } from '../../../../../../hooks/useModel';
import { Duel } from '../../../../../../stores/duel';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
const LIGHTS_COUNT = 5;
interface LightingProps {
display: TankopediaDisplay;
}
export function Lighting({ display }: LightingProps) {
const protagonist = Duel.use((state) => state.protagonist);
const { hasPbr } = useModel(protagonist.tank.id);
const isBrighterLighting =
!hasPbr && display !== TankopediaDisplay.StaticArmor;
return (
<>
{display !== TankopediaDisplay.StaticArmor && (
<Environment
files="https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/winter_river_1k.hdr"
environmentIntensity={isBrighterLighting ? 1.5 : 1.25}
environmentRotation={new Euler(0, degToRad(180), 0)}
/>
)}
{display === TankopediaDisplay.StaticArmor && (
<Environment preset="lobby" />
)}
{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)),
]}
/>
))}
</>
)}
</>
);
}
@@ -0,0 +1,49 @@
import { useHelper } from '@react-three/drei';
import { times } from 'lodash-es';
import { useRef } from 'react';
import { Color, SpotLight, SpotLightHelper } from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import { isHalloween } from '../../../../../../../core/blitzkit/isHalloween';
import { HelpingSpotLight } from '../../../../../../HelpingSpotLight';
const LIGHTS_COUNT = 5;
const LIGHT_DISTANCE = 13;
const LIGHT_HEIGHT_0 = 4;
const LIGHT_HEIGHT_1 = 2;
const INTENSITY = 30;
export function Lighting() {
const topLight = useRef<SpotLight>(null!);
const frontLight = useRef<SpotLight>(null!);
useHelper(topLight, SpotLightHelper, 'red');
useHelper(frontLight, SpotLightHelper, 'green');
return (
<>
{times(LIGHTS_COUNT, (index) => {
const x = index / (LIGHTS_COUNT - 1);
const theta = 2 * Math.PI * (index / LIGHTS_COUNT) - degToRad(152);
const position = [
LIGHT_DISTANCE * Math.sin(theta),
LIGHT_HEIGHT_0 * (1 - x) + LIGHT_HEIGHT_1 * x,
LIGHT_DISTANCE * Math.cos(theta),
] as const;
const color = new Color();
if (isHalloween()) color.set(1, 0.25, 0.25);
return (
<HelpingSpotLight
position={position}
intensity={INTENSITY}
penumbra={1}
castShadow
decay={1}
color={color}
/>
);
})}
</>
);
}
@@ -1,285 +1,50 @@
import { asset, I_HAT, J_HAT } from '@blitzkit/core';
import { Html } from '@react-three/drei';
import { useFrame, useLoader } from '@react-three/fiber';
import { invalidate, useFrame } from '@react-three/fiber';
import { clamp } from 'lodash-es';
import { useRef } from 'react';
import {
Group,
MeshStandardMaterial,
PerspectiveCamera,
TextureLoader,
Vector2,
Vector3,
} from 'three';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';
import { degToRad, radToDeg } from 'three/src/math/MathUtils.js';
import { awaitableEquipmentDefinitions } from '../../../../../../../core/awaitables/equipmentDefinitions';
import { awaitableModelDefinitions } from '../../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../../core/awaitables/provisionDefinitions';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTransform';
import { tankCharacteristics } from '../../../../../../../core/blitzkit/tankCharacteristics';
import { Duel } from '../../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../../stores/tankopediaPersistent/constants';
import { TargetCircle } from './components/TargetCircle';
import { aimTarget } from './constants';
import { Quicklime, type QuicklimeEvent } from 'quicklime';
import { useEffect, useRef } from 'react';
import { Mesh, MeshStandardMaterial } from 'three';
const [modelDefinitions, equipmentDefinitions, provisionDefinitions] =
await Promise.all([
awaitableModelDefinitions,
awaitableEquipmentDefinitions,
awaitableProvisionDefinitions,
]);
const SIZE = 2 ** 5;
const emptyVector = new Vector2();
export const screenshotReadyEvent = new Quicklime(false);
export function SceneProps() {
const disturbed = TankopediaEphemeral.use((state) => state.disturbed);
const targetCircleWrapper = useRef<Group>(null);
const clientTargetCircle = useRef<HTMLDivElement>(null);
const serverTargetCircle = useRef<HTMLDivElement>(null);
const mesh = useRef<Mesh>(null!);
const material = useRef<MeshStandardMaterial>(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 track = Duel.use((state) => state.protagonist.track);
const tank = Duel.use((state) => state.protagonist.tank);
const gun = Duel.use((state) => state.protagonist.gun);
const shell = Duel.use((state) => state.protagonist.shell);
const engine = Duel.use((state) => state.protagonist.engine);
const tankModelDefinition = modelDefinitions.models[tank.id];
const trackModelDefinition =
modelDefinitions.models[tank.id].tracks[track.id];
const gunModelDefinition =
tankModelDefinition.turrets[turret.id].guns[gun.gun_type!.value.base.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const hullOrigin = new Vector3(
trackModelDefinition.origin.x,
trackModelDefinition.origin.y,
-trackModelDefinition.origin.z,
);
const turretOrigin = new Vector3(
tankModelDefinition.turret_origin.x,
tankModelDefinition.turret_origin.y,
-tankModelDefinition.turret_origin.z,
);
const gunOriginOnlyY = new Vector3(0, turretModelDefinition.gun_origin.y, 0);
const shellOrigin = hullOrigin.clone().add(turretOrigin).add(gunOriginOnlyY);
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const consumables = Duel.use((state) => state.protagonist.consumables);
const provisions = Duel.use((state) => state.protagonist.provisions);
const assaultDistance = Duel.use(
(state) => state.protagonist.assaultDistance,
);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const characteristics = tankCharacteristics(
{
tank,
turret,
gun,
shell,
consumables,
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage,
crewSkills,
engine,
equipmentMatrix,
provisions,
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTurret: tank.turrets[0],
stockTrack: tank.tracks[0],
track,
assaultDistance,
},
{ equipmentDefinitions, provisionDefinitions, tankModelDefinition },
);
let { dispersion } = characteristics;
const gunRotationSpeed = degToRad(characteristics.gunTraverseSpeed);
const mockTank = useLoader(GLTFLoader, asset(`3d/tanks/models/6929.glb`));
const texture = useLoader(TextureLoader, '/assets/images/3d/grid.png');
const path = new Vector3();
const direction = new Vector3();
texture.anisotropy = 2;
useFrame(({ camera }) => {
if (!material.current) return;
material.current.opacity = clamp(0.5 * camera.position.y, 0, 1);
});
let lastTime = 0;
let DEBUG_disable = false;
useFrame(({ raycaster, camera, clock, gl }) => {
if (
display !== TankopediaDisplay.ShootingRange ||
!playground.current ||
!targetCircleWrapper.current ||
DEBUG_disable ||
!clientTargetCircle.current ||
!serverTargetCircle.current
) {
return;
useEffect(() => {
function handleScreenshotReady(event: QuicklimeEvent<boolean>) {
mesh.current.visible = !event.data;
invalidate();
}
raycaster.setFromCamera(emptyVector, camera);
screenshotReadyEvent.on(handleScreenshotReady);
const cameraIntersections = raycaster.intersectObjects(
playground.current.children,
true,
);
let length: number;
if (cameraIntersections.length === 0) {
length = characteristics.shellRange;
direction.set(0, 0, -1).applyQuaternion(camera.quaternion);
aimTarget.copy(shellOrigin).addScaledVector(direction, length);
} else {
aimTarget.copy(cameraIntersections[0].point);
path.copy(aimTarget).sub(shellOrigin);
length = path.length();
direction.copy(path).normalize();
}
const deltaT = clock.elapsedTime - lastTime;
lastTime = clock.elapsedTime;
const desiredPitch = Math.asin(direction.y);
const desiredYaw = Math.atan2(-direction.x, -direction.z);
const deltaPitch = desiredPitch - lastPitch;
const deltaYaw = desiredYaw - lastYaw;
const deltaAngle = Math.sqrt(deltaPitch ** 2 + deltaYaw ** 2);
const maxPossibleRotationInTime = gunRotationSpeed * deltaT;
const clampedDeltaAngle = Math.min(maxPossibleRotationInTime, deltaAngle);
const clampedDeltaPitch =
deltaAngle === 0 ? 0 : deltaPitch * (clampedDeltaAngle / deltaAngle);
const clampedDeltaYaw =
deltaAngle === 0 ? 0 : deltaYaw * (clampedDeltaAngle / deltaAngle);
const [pitch, yaw] = applyPitchYawLimits(
lastPitch + clampedDeltaPitch,
lastYaw + clampedDeltaYaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
);
const actualDeltaPitch = pitch - lastPitch;
const actualDeltaYaw = yaw - lastYaw;
const deltaRotation = Math.abs(actualDeltaPitch) + Math.abs(actualDeltaYaw);
const rotationSpeed = deltaRotation / deltaT;
const turretTraversePenalty =
characteristics.dispersionTurretTraversing * radToDeg(rotationSpeed);
const penalty = Math.sqrt(1 + turretTraversePenalty ** 2);
if (penalty === 1) {
const dispersionMod = dispersion - characteristics.dispersion;
const dispersionModDischarged =
dispersionMod * Math.exp(-deltaT / characteristics.aimTime) -
dispersionMod;
dispersion += dispersionModDischarged;
} else {
dispersion = Math.max(characteristics.dispersion * penalty, dispersion);
}
modelTransformEvent.emit({ pitch, yaw });
lastPitch = pitch;
lastYaw = yaw;
const gunDirection = new Vector3(0, 0, -1)
.applyAxisAngle(I_HAT, pitch)
.applyAxisAngle(J_HAT, yaw);
raycaster.set(shellOrigin, gunDirection);
const gunIntersections = raycaster.intersectObjects(
playground.current.children,
true,
);
let gunTarget: Vector3;
if (gunIntersections.length === 0) {
gunTarget = shellOrigin
.clone()
.add(gunDirection.multiplyScalar(characteristics.shellRange));
} else {
gunTarget = gunIntersections[0].point;
}
targetCircleWrapper.current.position.copy(gunTarget);
const distanceToTarget = gunTarget.distanceTo(shellOrigin);
const dispersionRadius = dispersion * (distanceToTarget / 100);
const dispersionSize = 2 * dispersionRadius;
const targetDistanceFromCamera = gunTarget.distanceTo(camera.position);
const angularSize =
2 * Math.atan(dispersionSize / (2 * targetDistanceFromCamera));
const verticalScreenSize =
2 * Math.tan(degToRad((camera as PerspectiveCamera).fov) / 2);
const screenSpaceSize =
gl.domElement.clientHeight * (angularSize / verticalScreenSize);
const targetCircleDotsSize = `${screenSpaceSize}px`;
clientTargetCircle.current.style.width = targetCircleDotsSize;
clientTargetCircle.current.style.height = targetCircleDotsSize;
serverTargetCircle.current.style.width = targetCircleDotsSize;
serverTargetCircle.current.style.height = targetCircleDotsSize;
});
if (!disturbed) return null;
return () => {
screenshotReadyEvent.off(handleScreenshotReady);
};
}, []);
return (
<>
{display !== TankopediaDisplay.ShootingRange && (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10]} />
<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>
</>
)}
</>
<mesh
rotation={[-Math.PI / 2, 0, 0]}
receiveShadow
visible={!screenshotReadyEvent.last!}
ref={mesh}
>
<planeGeometry args={[SIZE, SIZE]} />
<meshStandardMaterial
transparent
ref={material}
color="black"
roughness={1}
metalness={1}
/>
</mesh>
);
}
@@ -1,6 +1,5 @@
import { normalizeAngleRad } from '@blitzkit/core';
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 { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
@@ -10,15 +9,11 @@ import { useModel } from '../../../../../../hooks/useModel';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../../../../hooks/useTankTransform';
import { Duel } from '../../../../../../stores/duel';
import {
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { ModelTankWrapper } from '../../../../../Armor/components/ModelTankWrapper';
export function TankModel() {
const mutateDuel = Duel.useMutation();
const duelStore = Duel.useStore();
const protagonist = Duel.use((draft) => draft.protagonist);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
@@ -32,23 +27,10 @@ export function TankModel() {
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition =
turretModelDefinition.guns[protagonist.gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
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);
return (
@@ -122,26 +104,10 @@ export function TankModel() {
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
// 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 +123,12 @@ export function TankModel() {
if (!isVisible) return null;
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
if (
!isTurret ||
tankopediaEphemeralStore.getState().display ===
TankopediaDisplay.ShootingRange
)
return;
if (!isTurret) return;
position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
@@ -184,14 +140,14 @@ export function TankModel() {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -201,21 +157,18 @@ export function TankModel() {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + delta.x * (Math.PI / boundingRect.width),
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
@@ -231,27 +184,10 @@ export function TankModel() {
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
// 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>) {
event.stopPropagation();
if (
tankopediaEphemeralStore.getState().display ===
TankopediaDisplay.ShootingRange
)
return;
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
});
@@ -292,20 +222,20 @@ export function TankModel() {
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
pitch = modelTransformEvent.last!.pitch;
yaw = modelTransformEvent.last!.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
async function handlePointerMove(event: PointerEvent) {
function handlePointerMove(event: PointerEvent) {
const duel = duelStore.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
const hasImprovedVerticalStabilizer = hasEquipment(
122,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = await hasEquipment(
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
duel.protagonist.tank.equipment_preset,
duel.protagonist.equipmentMatrix,
@@ -322,16 +252,12 @@ export function TankModel() {
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = true;
});
mutateDuel((draft) => {
draft.protagonist.pitch = normalizeAngleRad(pitch);
draft.protagonist.yaw = normalizeAngleRad(yaw);
});
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
@@ -344,27 +270,10 @@ export function TankModel() {
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
// 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>
@@ -1,14 +1,11 @@
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { Group, ShaderMaterial, UniformsLib, UniformsUtils } from 'three';
import { jsxTree } from '../../../../../../../core/blitzkit/jsxTree';
import { useModel } from '../../../../../../../hooks/useModel';
import { useTankModelDefinition } from '../../../../../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../../../../../hooks/useTankTransform';
import { Duel } from '../../../../../../../stores/duel';
import {
ShootingRangeZoom,
TankopediaEphemeral,
} from '../../../../../../../stores/tankopediaEphemeral';
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
import { ModelTankWrapper } from '../../../../../../Armor/components/ModelTankWrapper';
import fragmentShader from './shaders/fragment.glsl?raw';
import vertexShader from './shaders/vertex.glsl?raw';
@@ -34,24 +31,11 @@ export function TransitionSkeleton() {
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition =
turretModelDefinition.guns[protagonist.gun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
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);
return (
@@ -1,5 +1,5 @@
import { BLITZKIT_TANK_ICON_SIZE } from '@blitzkit/core';
import { Canvas, invalidate } from '@react-three/fiber';
import { invalidate } from '@react-three/fiber';
import {
forwardRef,
Suspense,
@@ -13,7 +13,6 @@ import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimi
import { modelTransformEvent } from '../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../hooks/useEquipment';
import { useOnScreen } from '../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../hooks/useTankModelDefinition';
import { Duel } from '../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
@@ -26,8 +25,10 @@ import {
StaticArmor,
type ThicknessRange,
} from '../../../../Armor/components/StaticArmor';
import { SmartCanvas } from '../../../../SmartCanvas';
import { AutoClear } from './components/AutoClear';
import { Controls } from './components/Control';
import { InitialAligner } from './components/InitialAligner';
import { InitialFogReveal } from './components/InitialFogReveal';
import { Lighting } from './components/Lighting';
import { SceneProps } from './components/SceneProps';
@@ -54,18 +55,15 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const protagonistGun = Duel.use((state) => state.protagonist.gun);
const protagonistTurret = Duel.use((state) => state.protagonist.turret);
const mutateDuel = Duel.useMutation();
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonistTurret.id];
const gunModelDefinition =
turretModelDefinition.guns[protagonistGun.gun_type!.value.base.id];
const gunModelDefinition = turretModelDefinition.guns[protagonistGun.id];
const rawDisplay = TankopediaEphemeral.use((state) => state.display);
const [display, setDisplay] = useState(rawDisplay);
const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor,
);
const onScreen = useOnScreen(canvas);
useEffect(() => {
if (rawDisplay === display) return;
@@ -115,56 +113,44 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
@@ -178,15 +164,15 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
});
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, [protagonistGun, protagonistTurret]);
useEffect(() => {
@@ -204,22 +190,15 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
}, [display]);
return (
<Canvas
<SmartCanvas
ref={canvas}
scene={{ fog: naked ? undefined : fog.current }}
frameloop={
onScreen
? display === TankopediaDisplay.ShootingRange
? 'always'
: 'demand'
: 'never'
}
gl={{
clippingPlanes: [],
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows
shadows="soft"
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => {
@@ -252,9 +231,11 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} />
)}
<Lighting display={display} />
<Lighting />
</Suspense>
</Canvas>
<InitialAligner />
</SmartCanvas>
);
},
);
@@ -2,7 +2,6 @@ import { Box, Flex } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { Suspense, useEffect, useMemo, useRef } from 'react';
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions';
import { Var } from '../../../core/radix/var';
import { useFullScreen } from '../../../hooks/useFullScreen';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
@@ -10,7 +9,7 @@ import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonCo
import type { ThicknessRange } from '../../Armor/components/StaticArmor';
import { Options } from './components/Options';
import { TankSandbox } from './components/TankSandbox';
import { NATION_COLORS, Title } from './components/TankSandbox/Title';
import { Title } from './components/TankSandbox/Title';
const tankDefinitions = await awaitableTankDefinitions;
@@ -29,8 +28,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
(filtered.reduce((accumulator, thisTank) => {
return (
accumulator +
thisTank.turrets.at(-1)!.guns.at(-1)!.gun_type!.value.base.shells[0]
.penetration.near
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near
);
}, 0) /
filtered.length) *
@@ -41,7 +39,6 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const duelStore = Duel.useStore();
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const nationColors = NATION_COLORS[protagonist.nation];
useEffect(() => {
if (disturbed) {
@@ -51,8 +48,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const { shells } =
duelStore.getState().antagonist.gun.gun_type!.value.base;
const { shells } = duelStore.getState().antagonist.gun;
times(3, (index) => {
if (event.key === `${index + 1}` && shells.length > index) {
@@ -87,9 +83,7 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
left="0"
style={{
opacity: disturbed ? 0 : 1,
background: `linear-gradient(${nationColors.background
.map((color) => Var(`${color}-2`))
.join(',')})`,
background: 'black',
transitionDuration: '1s',
}}
/>
@@ -103,8 +97,16 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
transitionDuration: '1s',
background: isFullScreen ? 'black' : undefined,
}}
height={!isFullScreen ? 'calc(100svh - 6rem)' : '100vh'}
maxHeight={isFullScreen ? undefined : '60rem'}
height={
isFullScreen
? '100vh'
: disturbed
? 'calc(100svh - 6rem)'
: {
initial: '28rem',
md: 'calc(100svh - 12rem)',
}
}
maxWidth={isFullScreen ? undefined : '120rem'}
flexGrow="1"
width={isFullScreen ? '100vw' : undefined}
@@ -112,8 +114,6 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
top={isFullScreen ? '0' : undefined}
left={isFullScreen ? '0' : undefined}
>
<Title />
<Box
className="tank-sandbox-container"
flexGrow="1"
@@ -136,7 +136,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
height: '100%',
transitionDuration: '2s',
opacity: revealed ? 1 : 0,
filter: disturbed ? undefined : `drop-shadow(0 0 1rem black)`,
filter: disturbed
? undefined
: `drop-shadow(0 1rem 1rem black)`,
}}
>
<Suspense>
@@ -145,6 +147,8 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
</Box>
</Box>
<Title />
<Options
skeleton={skeleton}
canvas={canvas}
@@ -152,8 +156,6 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
/>
</Box>
</Box>
<Title foreground />
</Flex>
</Flex>
);
@@ -24,10 +24,6 @@ export function VideoSection({ skeleton }: MaybeSkeletonComponentProps) {
<Heading size="6">
{strings.website.tools.tankopedia.review.title}
</Heading>
<Text color="gray">
{strings.website.tools.tankopedia.review.disclaimer}
</Text>
</Flex>
<Flex gap="3" wrap="wrap" justify="center">
+8 -1
View File
@@ -127,11 +127,18 @@ export const guessTool: Tool = {
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const mixerTool: Tool = {
id: 'mixer',
button: { color: 'red' },
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const TOOLS: Tool[] = [
tankopediaTool,
compareTool,
performanceTool,
// guessTool,
guessTool,
// mixerTool,
// chartsTool,
galleryTool,
sessionTool,
@@ -6,6 +6,8 @@ import {
import { clamp } from 'lodash-es';
import { degToRad } from 'three/src/math/MathUtils.js';
export const DEFAULT_PITCH_TRANSITION = 15;
export function applyPitchYawLimits(
pitch: number, // rad
yaw: number, // rad
@@ -28,20 +30,23 @@ export function applyPitchYawLimits(
const elevationBuff = verticalStabilizer ? degToRad(4) : 0;
let lowerPitch = -degToRad(pitchLimits.max) - depressionBuff;
let upperPitch = -degToRad(pitchLimits.min) + elevationBuff;
const transition = degToRad(pitchLimits.transition ?? 15);
const transition = degToRad(
pitchLimits.transition ?? DEFAULT_PITCH_TRANSITION,
);
if (pitchLimits.back) {
const range = degToRad(pitchLimits.back.range);
const yawRotatedAbs = Math.abs(normalizeAngleRad(newYaw - Math.PI));
if (yawRotatedAbs <= range + transition) {
if (yawRotatedAbs <= range) {
if (yawRotatedAbs <= range / 2 + transition) {
if (yawRotatedAbs <= range / 2) {
// inside range
lowerPitch = -degToRad(pitchLimits.back.max);
upperPitch = -degToRad(pitchLimits.back.min);
} else {
// inside transition
const transitionProgress = (yawRotatedAbs - range) / transition;
const transitionProgress = (yawRotatedAbs - range / 2) / transition;
// console.log(transitionProgress);
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.back.max +
transitionProgress * pitchLimits.max;
@@ -59,14 +64,14 @@ export function applyPitchYawLimits(
const range = degToRad(pitchLimits.front.range);
const yawAbs = Math.abs(normalizeAngleRad(newYaw));
if (yawAbs <= range + transition) {
if (yawAbs <= range) {
if (yawAbs <= range / 2 + transition) {
if (yawAbs <= range / 2) {
// inside range
lowerPitch = -degToRad(pitchLimits.front.max);
upperPitch = -degToRad(pitchLimits.front.min);
} else {
// inside transition
const transitionProgress = (yawAbs - range) / transition;
const transitionProgress = (yawAbs - range / 2) / transition;
const maxInterpolatedPitch =
(1 - transitionProgress) * pitchLimits.front.max +
transitionProgress * pitchLimits.max;
@@ -82,5 +87,5 @@ export function applyPitchYawLimits(
newPitch = clamp(newPitch, lowerPitch, upperPitch);
return [newPitch, newYaw];
return [newPitch, newYaw] as const;
}
@@ -31,9 +31,8 @@ export function filterTank(filters: TankFilters, tank: TankDefinition) {
SHELLS.every(
(index) =>
filters.shells[index] === null ||
gun.gun_type!.value.base.shells[index] === undefined ||
gun.gun_type!.value.base.shells[index].type ===
filters.shells[index],
gun.shells[index] === undefined ||
gun.shells[index].type === filters.shells[index],
),
),
)
@@ -1,12 +1,13 @@
import type { EquipmentMatrix } from '../../stores/duel';
import { awaitableEquipmentDefinitions } from '../awaitables/equipmentDefinitions';
export async function hasEquipment(
const equipmentDefinitions = await awaitableEquipmentDefinitions;
export function hasEquipment(
id: number,
equipmentPreset: string,
equipmentMatrix: EquipmentMatrix,
) {
const equipmentDefinitions = await awaitableEquipmentDefinitions;
const preset = equipmentDefinitions.presets[equipmentPreset];
return preset.slots.some((slot, index) => {
const row = Math.floor(index / 3);
@@ -0,0 +1,4 @@
export function isHalloween() {
const data = new Date();
return data.getMonth() === 9 && data.getDate() === 31;
}
@@ -1,8 +1,11 @@
import { EventManager } from '@blitzkit/core';
import { Quicklime } from 'quicklime';
export interface ModelTransformEventData {
yaw?: number;
yaw: number;
pitch: number;
}
export const modelTransformEvent = new EventManager<ModelTransformEventData>();
export const modelTransformEvent = new Quicklime<ModelTransformEventData>({
pitch: 0,
yaw: 0,
});
@@ -2,18 +2,16 @@ import type { GunDefinition } from '@blitzkit/core';
export function resolveReload(gun: GunDefinition) {
if (gun.gun_type!.$case === 'regular') {
return gun.gun_type!.value.extension.reload;
return gun.gun_type!.value.reload;
} else if (gun.gun_type!.$case === 'auto_loader') {
return (
gun.gun_type!.value.extension.clip_reload +
(gun.gun_type!.value.extension.shell_count - 1) *
gun.gun_type!.value.extension.intra_clip
gun.gun_type!.value.clip_reload +
(gun.gun_type!.value.shell_count - 1) * gun.gun_type!.value.intra_clip
);
} else {
return (
gun.gun_type!.value.extension.shell_reloads.reduce((a, b) => a + b, 0) +
(gun.gun_type!.value.extension.shell_count - 1) *
gun.gun_type!.value.extension.intra_clip
gun.gun_type!.value.shell_reloads.reduce((a, b) => a + b, 0) +
(gun.gun_type!.value.shell_count - 1) * gun.gun_type!.value.intra_clip
);
}
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more