Compare commits

...
55 Commits
Author SHA1 Message Date
tresabhi f2d3c59e97 lint 2026-11-07 20:52:38 -06:00
tresabhi 1cb760e227 heheheha 2026-11-07 20:51:53 -06:00
tresabhi ce27388c00 organize 2026-11-07 20:48:08 -06:00
tresabhi e8b2651595 move to global scripts 2026-11-07 20:44:23 -06:00
tresabhi 3695e2cd5e add scripts 2026-11-07 20:42:52 -06:00
tresabhi 01445cf845 move download-smart-dlc to closed 2026-11-07 20:20:36 -06:00
tresabhi b121147b43 undo optional dep 2026-11-07 20:17:10 -06:00
tresabhi b3b56462d1 move @blitzkit/closed to optional 2026-11-07 20:07:30 -06:00
tresabhi d42cccc3c2 partial submodules 2026-11-07 20:01:33 -06:00
tresabhi c592501f48 ignore submodules on pr's 2026-11-07 19:59:26 -06:00
TrèsAbhi 5f4cf15500 Merge pull request #405 from Hitjack007/animated-legendary-camos 2026-11-07 18:32:38 -06:00
Mark Greene 232b3e7770 Fix: Address karelpak1's review on animated legendary camouflages
- Sockets bound to a joint now sit right on the joint, as in game. Only the skeleton's own children are bound. This fixes the WZ-114 gun barrels collapsing.
- Clips keep their full duration when it runs past their last key.
- Part states are kept per entity and exported on each part's own switchers, so parts with matching names can't overwrite each other.
- A finished one-shot animation now stops on whatever it switched to, as in game. It only keeps looping if it changes nothing.
- Delayed actions run in delay order.
2026-11-07 15:35:12 +13:00
Mark Greene 99373872ed Merge branch 'main' into animated-legendary-camos 2026-11-07 15:02:21 +13:00
Mark Greene fddf407cc0 Fix: Address review on animated legendary camouflages
- Inline the camouflage preset lookup instead of a one-off `camouflagePreset` helper
- Move the `primitive` override into the `jsxTree` mergers, so `skinTree` takes plain mergers and falls back to `<primitive>` like the mesh and group defaults
2026-11-05 17:26:56 +13:00
tresabhi 88346308cb more hacks for this stupid ai slop wargaming is shoving down the client 2026-11-04 16:14:42 -06:00
tresabhi ebedac0642 some hacks to get some tier 11's compiling 2026-11-04 16:04:02 -06:00
TrèsAbhi a2b97380ac Update countdown text to reflect 3 tanks 2026-11-04 03:50:49 +00:00
tresabhi de4094554e countdown 2026-11-03 09:26:32 -06:00
tresabhi cdcec8548a logs 2026-11-02 22:17:18 -06:00
TrèsAbhi 5ffe5a0a9f Merge pull request #283 from Hitjack007/tankopedia-antagonist-selector 2026-11-01 13:30:18 -06:00
tresabhi 7fb73edb87 Merge branch 'main' into pr/Hitjack007/283 2026-11-01 13:29:12 -06:00
TrèsAbhi 1bc0561dbc Merge pull request #378 from Hitjack007/compare-switch-all-shells 2026-11-01 13:27:49 -06:00
tresabhi c82d2ccf1c Merge branch 'main' into pr/Hitjack007/378-2 2026-11-01 13:26:16 -06:00
TrèsAbhi 15a08b934b Merge pull request #285 from Hitjack007/tankopedia-scale 2026-11-01 13:23:50 -06:00
tresabhi f4894655b0 yeah this is fine 2026-11-01 13:23:35 -06:00
tresabhi e4b49e1600 use canvas 2026-11-01 13:20:02 -06:00
tresabhi 7bfd930cea Revert "remove granuality"
This reverts commit b65229dc80.
2026-11-01 13:15:06 -06:00
tresabhi 71e9e6fd5b Revert "use canvas"
This reverts commit ff507ed4e6.
2026-11-01 13:15:06 -06:00
tresabhi be0be90bdb Revert "no gray picking"
This reverts commit 62637418e5.
2026-11-01 13:15:06 -06:00
tresabhi 62637418e5 no gray picking 2026-11-01 13:07:22 -06:00
tresabhi ff507ed4e6 use canvas 2026-11-01 13:05:23 -06:00
tresabhi b65229dc80 remove granuality 2026-11-01 13:03:37 -06:00
tresabhi 4a6a5b3d34 Merge branch 'main' into pr/Hitjack007/285-1 2026-11-01 12:55:25 -06:00
tresabhi 2150fb3758 Merge branch 'main' into pr/Hitjack007/405 2026-11-01 12:44:23 -06:00
Mark Greene 682f039c6c Fix: Hide unused states of legendary camouflage parts (fixes maus) 2026-11-01 14:46:21 +13:00
tresabhi 0632899ecf disable halloween 2026-10-31 18:14:10 -05:00
TrèsAbhi 72758e9ff7 Merge pull request #423 from Hitjack007/legendary-camos-fix
Fix: Legendary camouflage parts, materials, names and attachments
2026-10-31 13:26:16 -05:00
Mark Greene 8566b2cc2f Feature: Looping garage animations for legendary camouflages
Legendary camo add-ons now loop their garage animation in the Tankopedia viewer. It's always on, with no settings.

- The garage replay now also works out which motions end up looping, and the export writes only those clips
- Customization parts export skeletons, skin weights and sockets
- Parts render as primitives so their bones can animate, with attachments layered on top
- Base tank models are unchanged
2026-10-31 22:15:57 +13:00
tresabhi 8028446788 fall back to github.token for pr runs 2026-10-30 16:59:31 -05:00
tresabhi e216b4cd6b fix 1 pixel lights 2026-10-30 16:49:43 -05:00
Mark Greene c535811c1d Fix: Fade glowing camouflage parts with the lights
Unlit `Textured` materials loaded as `MeshBasicMaterial`, so they stayed bright while the lights receded during a camouflage switch (E 100 legendary camouflage).

- `Textured` materials now export as standard materials with the base color texture as emissive and a black base color, so they look the same as before.
- The Tankopedia model fades their emissive intensity with the lighting transition.
- Additive blending applies to these parts again, since they are standard materials.
2026-10-30 17:26:50 +13:00
Mark Greene c772d47a07 Fix: Unify Tankopedia model, camouflage and attachment loaders
- Tankopedia state uses `loaders: { model, skin, attachment }` with `requested` and `loaded`
- useSkin swaps the camouflage halfway through the lighting transition for both cached and downloaded skins
- Remove the leftover "Unreleased" camouflage name handling
2026-10-30 07:11:31 +13:00
tresabhi 523fee5010 its only ever going to be standard material 2026-10-29 11:15:28 -05:00
tresabhi e5be69dd37 no unreleased! 2026-10-29 11:08:20 -05:00
tresabhi bddcf10e90 assert 2026-10-29 11:07:33 -05:00
tresabhi 484d4ae3da please dont make name undefinedable, just default to the string id 2026-10-29 11:07:11 -05:00
Mark Greene 0f299335d6 Fix: Legendary camouflage parts, materials, names and attachments
Fixes a batch of legendary camouflage problems in the Tankopedia viewer, adds three missing unique camouflages and lets attachments be shown together with a camouflage.

## Camouflage parts (state switchers)
- Camouflages now choose which tank pieces show the same way the game does. Each switcher's load-time scenario script runs at export time (checks for attached camouflage items, or compares the camouflage preset), along with its state-switch actions. This replaces the old guess from part names (new `core/blitzkit/switcherStates.ts`).
- Fixes missing roof MGs, fenders and fuel tanks (IS-3 Bear, Obj. 140 Terror, IS-7, IS-4, FV215b 183, AMX 50 B, KV-1S) and default parts mixed into camouflages (Pershing Speedfire, Forest Witch Fullmetal, Waffenträger Goldstreifen).
- Switchers inside camouflage parts now show one state instead of all of them stacked (Obj. Kust double net, Goldstreifen stowage, Tiger II cat).
- States are matched by original node names, because the GLTF loader renames duplicates.
- Camouflage part meshes with no LODs (`lodIndex -1`) are kept, which restores the Tiger II cat body and missing pieces on about 11 other camouflages.

## Materials
- `AlphaBlend` materials export as transparent. `AlphaBlendAdditive` materials export as additive and are applied in `jsxTree`, and unlit `Textured` materials export as unlit (IS-8 Raroh flames, dinosaur holograms, and glass and screens on a few base tanks).
- Camouflage part materials that share a name with the tank's material no longer replace the tank's camouflage material (Bisonte C45 Unbreakable).

## Names
- With a legendary camouflage selected, the title shows the tank's in-battle name ("GuP Type 95 Ha-Go", "KV-1 Warrior", "Shinobi").
- Camouflages without a name in the game files yet show "Unreleased" in orange instead of their raw string key, and no longer crash the tank names list.

## Attachments
- Attachments (`kind: customization`) are listed in their own group under the camouflages and can be turned on together with a camouflage. They load the same way as camouflages and fade with the same transition.

## Other
- Liberté, You Rock! and Ozzie (single-tank camouflages that only set a preset through `override`) are now available.
- Mastery gun mark meshes (`custom_gun_marks_*`) are no longer drawn (Helsing, HWT20 FalconFire and two camouflage guns).

## Not included
- Pixel Wave, Digital Trace and Forest Shadow are tiled pattern camouflages, which the viewer can't render yet.
- FV301 Corbie's drone position and the dinosaur heads' glow depend on camouflage animations and emissive materials, which are separate PRs.

Fixes #417
Fixes #419
Fixes #421
2026-10-29 15:28:11 +13:00
TrèsAbhi 091607b0a8 Add Beta option to bug report issue template 2026-10-25 21:53:59 -05:00
Mark Greene ffb5a1f9c8 Feature: Animated legendary camouflages
Legendary camo add-ons now play their garage animations in the Tankopedia viewer, and a new menu next to the camo column triggers their battle reactions.

**Export (`model.glb` / `skins/[skin].glb`)**
- Customization parts export skeletons, skin weights and one clip per `.anim` motion file (`core/blitzkit/skinAnimations`)
- Sockets bound to skeleton joints follow their joint
- Action lists and scenario graphs are carried in glTF extras
- Part batches with no LODs (`lodIndex` -1) are now exported, fixing meshes that were missing entirely (e.g. the Tiger II cat body, the BDT-5A engine rings)
- Base tank models are unchanged

**Viewer**
- A runtime plays the clips and drives state switchers, action lists and the game's scenario graphs (a small interpreter for the node types the camos use)
- Camo parts render as primitives so their bones can animate
- Garage animations play once rather than looping, so the camo settles

**Animation menu**
- Floats left of the camo column when the selected camo has triggerable animations
- Icon buttons use the game's own white icons, served from `/icons/skin-events/[event].webp`
- Idle replays the garage animation
- Triggers: Shoot, Kill, Take damage, Damage enemy, Ricochet / No penetration, Start moving, Consumable, Reload. Only events that actually animate get a button
- Buttons are disabled while a triggered animation plays; triggering cuts any garage animation short

All legendary skin files together grow from 1.32 GB to 1.35 GB (+2.4%), almost all of it in the 102 animated camos.
2026-10-20 18:48:35 +13:00
Mark Greene 85e03d3b42 Feature: Legendary camouflages in the Tankopedia viewer 2026-10-20 18:46:29 +13:00
Mark Greene a0c54b50fc Feature: tank distribution scale on Tankopedia stats 2026-10-20 07:18:01 +13:00
tresabhi 6e30da4978 add some padding to distinguish navbar end with the start of the dialog 2026-10-09 17:56:42 -05:00
TrèsAbhi b45f281cb9 Merge branch 'main' into compare-switch-all-shells 2026-10-09 17:39:15 -05:00
Mark Greene 2288c089e8 Fix: dialogs clipping under the navbar and uneven Compare spacing 2026-10-10 11:16:36 +13:00
Mark Greene d93b202ee8 Feature: Switch all shells in Compare 2026-10-08 18:36:17 +13:00
58 changed files with 1783 additions and 330 deletions

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ body:
description: What branch of BlitzKit were you using? Please see the "Branch" section of the footer at the very bottom of any page. description: What branch of BlitzKit were you using? Please see the "Branch" section of the footer at the very bottom of any page.
options: options:
- Production - Production
- Beta
- Preview - Preview
- OpenTest - OpenTest
default: 0 default: 0
+3 -3
View File
@@ -30,8 +30,8 @@ jobs:
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Set up script
run: bun install run: scripts/setup.sh
- name: Setup Git - name: Setup Git
run: | run: |
@@ -39,7 +39,7 @@ jobs:
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Discover IDs - name: Discover IDs
run: bun run discover-ids run: scripts/discover-ids.sh
env: env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }} PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
GH_TOKEN: ${{ secrets.IDS_TOKEN }} GH_TOKEN: ${{ secrets.IDS_TOKEN }}
+24 -10
View File
@@ -89,6 +89,9 @@ env:
# wargaming # wargaming
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }} PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
# workflow
SHOULD_USE_BLITZKIT_CLOSED: ${{ github.event_name != 'pull_request' }}
jobs: jobs:
build: build:
name: Build website name: Build website
@@ -107,10 +110,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
# the PR's ref, or the manual input, or production (selected when cron)
ref: ${{ github.event.pull_request.head.sha || inputs.branch || 'production' }} ref: ${{ github.event.pull_request.head.sha || inputs.branch || 'production' }}
submodules: recursive submodules: false
token: ${{ secrets.GH_TOKEN }} token: ${{ secrets.GH_TOKEN || github.token }}
# Future proofing for Reforged: we'll have both closed and open repos, so
# we need to handle them here instead of disabling submodules outright
- name: Checkout submodules
run: |
if [ "$SHOULD_USE_BLITZKIT_CLOSED" = "true" ]; then
git submodule update --init --recursive --depth=1
else
git submodule update --init --recursive --depth=1 -- . ':!packages/closed'
fi
- name: Setup bun - name: Setup bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -125,19 +137,21 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y aria2 libarchive-tools sudo apt-get install -y aria2 libarchive-tools
- name: Install dependencies - name: Set up script
run: bun install run: scripts/setup.sh
- name: Lint
run: scripts/lint.sh
- name: Download client - name: Download client
run: bun run download-client run: scripts/download-client.sh
- name: Download SmartDLC - name: Download SmartDLC
if: ${{ env.CLIENT_PROVIDER == 'steam' }} if: ${{ env.CLIENT_PROVIDER == 'steam' && env.SHOULD_USE_BLITZKIT_CLOSED == 'true' }}
run: scripts/download-smart-dlc.sh
run: bun run download-smart-dlc
- name: Build - name: Build
run: bun run --bun build run: scripts/build-website.sh
env: env:
CHUNK: ${{ matrix.chunk }} CHUNK: ${{ matrix.chunk }}
+6 -6
View File
@@ -44,16 +44,16 @@ cd blitzkit
> [!NOTE] > [!NOTE]
> There is a closed source submodule under `packages/closed` that you can safely ignore. This sole purpose of this submodule is to provide `dvp.ts`, a proprietary Wargaming game asset tree compression tool. You will not need this at any point. > There is a closed source submodule under `packages/closed` that you can safely ignore. This sole purpose of this submodule is to provide `dvp.ts`, a proprietary Wargaming game asset tree compression tool. You will not need this at any point.
Then, download the dependencies with Bun. Then, grand execution permission to the setup script.
```bash ```bash
bun install chmod +x scripts/setup.sh
``` ```
Finally, compile the proto files into TypeScript. Finally, run the setup script.
```bash ```bash
build build-protos scripts/setup.sh
``` ```
## Developing: Environment ## Developing: Environment
@@ -65,11 +65,11 @@ Go through the different packages in the `packages` directory and clone their re
Launching the website is as easy as running the `dev` script. Launching the website is as easy as running the `dev` script.
```bash ```bash
bun dev scripts/dev-website.sh
``` ```
If you ever make a change to any proto file, make sure to rebuild them. If you ever make a change to any proto file, make sure to rebuild them.
```bash ```bash
build build-protos scripts/build-protos.sh
``` ```
+1 -6
View File
@@ -55,7 +55,6 @@
"packages/scripts": { "packages/scripts": {
"name": "@blitzkit/scripts", "name": "@blitzkit/scripts",
"dependencies": { "dependencies": {
"@blitzkit/closed": "workspace:*",
"@blitzkit/core": "workspace:*", "@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*", "@blitzkit/i18n": "workspace:*",
"@gltf-transform/core": "^4.2.1", "@gltf-transform/core": "^4.2.1",
@@ -1643,8 +1642,6 @@
"jimp": ["[email protected]", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], "jimp": ["[email protected]", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ=="],
"jpeg-js": ["[email protected]", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "jpeg-js": ["[email protected]", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -1951,7 +1948,7 @@
"potpack": ["[email protected]", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "potpack": ["[email protected]", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@2.8.7", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw=="],
"pretty-bytes": ["[email protected]", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], "pretty-bytes": ["[email protected]", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="],
@@ -2733,8 +2730,6 @@
"wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"yaml-language-server/prettier": ["[email protected]", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw=="],
"yaml-language-server/request-light": ["[email protected]", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/request-light": ["[email protected]", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
"yaml-language-server/vscode-languageserver": ["[email protected]", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="], "yaml-language-server/vscode-languageserver": ["[email protected]", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="],
+21
View File
@@ -0,0 +1,21 @@
# BlitzKit 1.33.0
Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update.
## Features
- Added legendary camouflages to Tankopedia [HitJack]
- The rank bars below every statistic in Tankopedia now shows the distribution of every other tank [HitJack]
- Added a Credits page for everyone who has contributed to BlitzKit
- Added an option to synchronize shells across tanks in Compare [HitJack]
## Fixes
- The tank selector popups are now properly sized vertically, stopping them from clipping the top of the page [HitJack]
- When the lights recede, there is no longer a 1-pixel spot where the light still shines
## Technical Changes
- The BlitzKit API is now split across `legendary.models.blitzkit.app`, `tanks.models.blitzkit.app`, `maps.models.blitzkit.app`, and `api.models.blitzkit.app`
- Added some compatibility for the new tier XI tanks
- More work is needed in this area to implement the new mechanics they bring
+1 -12
View File
@@ -1,19 +1,8 @@
{ {
"name": "blitzkit", "name": "blitzkit",
"version": "1.33.0-beta.1", "version": "1.33.0",
"license": "BlitzKit License", "license": "BlitzKit License",
"private": true, "private": true,
"scripts": {
"dev": "cd packages/website && bun --bun run dev",
"build": "cd packages/website && bun --bun run build",
"discover-ids": "cd packages/scripts && bun run discover-ids",
"build-protos": "cd packages/scripts && bun run build-protos",
"record-performance": "cd packages/scripts && bun run record-performance",
"download-client": "cd packages/scripts && bun run download-client",
"download-smart-dlc": "cd packages/scripts && bun run download-smart-dlc",
"clean-astro": "rm -rf packages/website/.astro packages/website/node_modules/.astro packages/website/node_modules/.vite",
"vfs": "cd packages/scripts && bun run vfs"
},
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -87,14 +87,19 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
), ),
); );
const [nation, tankKey] = vehicle.split(":");
// some unique camos only set the preset for their one tank
const preset =
yamlEntry.preset ??
yamlEntry.override?.[nation]?.override?.[tankKey]?.preset;
let hasPreset = false; let hasPreset = false;
if (camo.group === "legendary" && yamlEntry.preset !== undefined) { if (preset !== undefined) {
if (!skinPresets.has(vehicle)) { if (!skinPresets.has(vehicle)) {
skinPresets.set(vehicle, this.skinPresets(vehicle)); skinPresets.set(vehicle, this.skinPresets(vehicle));
} }
hasPreset = (await skinPresets.get(vehicle)!).has(yamlEntry.preset); hasPreset = (await skinPresets.get(vehicle)!).has(preset);
} }
if (resolvedParts.some(Boolean) || hasPreset) { if (resolvedParts.some(Boolean) || hasPreset) {
@@ -110,6 +115,7 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
tank_name: resolvedTankName, tank_name: resolvedTankName,
tank_name_full: resolvedTankNameFull, tank_name_full: resolvedTankNameFull,
tank_id: tankId, tank_id: tankId,
attachment: camo.kind === "customization",
}; };
} }
@@ -79,14 +79,8 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
I_HAT, I_HAT,
-MathUtils.degToRad(turretRotation.pitch), -MathUtils.degToRad(turretRotation.pitch),
) )
.applyAxisAngle( .applyAxisAngle(J_HAT, -MathUtils.degToRad(turretRotation.roll))
J_HAT, .applyAxisAngle(K_HAT, -MathUtils.degToRad(turretRotation.yaw));
-MathUtils.degToRad(turretRotation.roll),
)
.applyAxisAngle(
K_HAT,
-MathUtils.degToRad(turretRotation.yaw),
);
} }
const coordinates = position.toArray(); const coordinates = position.toArray();
@@ -298,23 +292,29 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
turret.userString; turret.userString;
const turretCollisionName = parsePath( let turretCollisionName = parsePath(
turret.hitTester.collisionModel, turret.hitTester.collisionModel,
).name.toLowerCase(); ).name.toLowerCase();
const isFullYaw = -turretYaw[0] + turretYaw[1] === 360; const isFullYaw = -turretYaw[0] + turretYaw[1] === 360;
if (!collisionBoundingBoxes[turretCollisionName] && isFullYaw) { if (!collisionBoundingBoxes[turretCollisionName] && isFullYaw) {
throw new Error( turretCollisionName = "turret_01";
`Missing turret collision mesh for ${nation}-${tankKey} ${turretKey}`,
console.warn(
`HACK: Missing turret collision mesh for ${nation}/${tankKey}; turret key: ${
turretKey
}; collision model: ${turretCollisionName}; available options: ${Object.keys(
collisionBoundingBoxes,
).join(", ")}; using ${turretCollisionName} instead`,
); );
} }
const turretBoundingBox = collisionBoundingBoxes[ let turretBoundingBox = collisionBoundingBoxes[turretCollisionName];
turretCollisionName
] ?? { if (turretBoundingBox === undefined) {
min: [0, 0, 0] as Vector3Tuple, console.warn("HACK: missing collision; using 0's");
max: [0, 0, 0] as Vector3Tuple, turretBoundingBox = { min: [0, 0, 0], max: [0, 0, 0] };
}; }
modelDefinitions.models[tankId].turrets[turretId] = { modelDefinitions.models[tankId].turrets[turretId] = {
armor: turretArmor, armor: turretArmor,
@@ -342,9 +342,18 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
) )
.split(" ") .split(" ")
.map(Number) as [number, number]; .map(Number) as [number, number];
const gunModel = Number( let gunModel = Number(
parsePath(gun.models.undamaged).name.split("_")[1], parsePath(gun.models.undamaged).name.split("_")[1],
); );
if (tankId === 30497) {
console.warn(
"HACK: using gun_02 for hacker tank instead of gun_01... hehe, that's quite appropriate for this tank haha",
);
gunModel = 2;
}
const front = gun.extraPitchLimits?.front const front = gun.extraPitchLimits?.front
? gun.extraPitchLimits.front.split(" ").map(Number) ? gun.extraPitchLimits.front.split(" ").map(Number)
: undefined; : undefined;
@@ -14,4 +14,5 @@ message Camouflage {
optional I18nString tank_name = 3; optional I18nString tank_name = 3;
optional I18nString tank_name_full = 4; optional I18nString tank_name_full = 4;
optional uint32 tank_id = 5; optional uint32 tank_id = 5;
optional bool attachment = 6;
} }
@@ -25,6 +25,7 @@ export interface Camouflage {
tank_name?: I18nString | undefined; tank_name?: I18nString | undefined;
tank_name_full?: I18nString | undefined; tank_name_full?: I18nString | undefined;
tank_id?: number | undefined; tank_id?: number | undefined;
attachment?: boolean | undefined;
} }
function createBaseCamouflageDefinitions(): CamouflageDefinitions { function createBaseCamouflageDefinitions(): CamouflageDefinitions {
@@ -192,7 +193,7 @@ export const CamouflageDefinitions_CamouflagesEntry: MessageFns<CamouflageDefini
}; };
function createBaseCamouflage(): Camouflage { function createBaseCamouflage(): Camouflage {
return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0 }; return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0, attachment: false };
} }
export const Camouflage: MessageFns<Camouflage> = { export const Camouflage: MessageFns<Camouflage> = {
@@ -212,6 +213,9 @@ export const Camouflage: MessageFns<Camouflage> = {
if (message.tank_id !== undefined && message.tank_id !== 0) { if (message.tank_id !== undefined && message.tank_id !== 0) {
writer.uint32(40).uint32(message.tank_id); writer.uint32(40).uint32(message.tank_id);
} }
if (message.attachment !== undefined && message.attachment !== false) {
writer.uint32(48).bool(message.attachment);
}
return writer; return writer;
}, },
@@ -262,6 +266,14 @@ export const Camouflage: MessageFns<Camouflage> = {
message.tank_id = reader.uint32(); message.tank_id = reader.uint32();
continue; continue;
} }
case 6: {
if (tag !== 48) {
break;
}
message.attachment = reader.bool();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -278,6 +290,7 @@ export const Camouflage: MessageFns<Camouflage> = {
tank_name: isSet(object.tank_name) ? I18nString.fromJSON(object.tank_name) : undefined, tank_name: isSet(object.tank_name) ? I18nString.fromJSON(object.tank_name) : undefined,
tank_name_full: isSet(object.tank_name_full) ? I18nString.fromJSON(object.tank_name_full) : undefined, tank_name_full: isSet(object.tank_name_full) ? I18nString.fromJSON(object.tank_name_full) : undefined,
tank_id: isSet(object.tank_id) ? globalThis.Number(object.tank_id) : 0, tank_id: isSet(object.tank_id) ? globalThis.Number(object.tank_id) : 0,
attachment: isSet(object.attachment) ? globalThis.Boolean(object.attachment) : false,
}; };
}, },
@@ -298,6 +311,9 @@ export const Camouflage: MessageFns<Camouflage> = {
if (message.tank_id !== undefined && message.tank_id !== 0) { if (message.tank_id !== undefined && message.tank_id !== 0) {
obj.tank_id = Math.round(message.tank_id); obj.tank_id = Math.round(message.tank_id);
} }
if (message.attachment !== undefined && message.attachment !== false) {
obj.attachment = message.attachment;
}
return obj; return obj;
}, },
@@ -317,6 +333,7 @@ export const Camouflage: MessageFns<Camouflage> = {
? I18nString.fromPartial(object.tank_name_full) ? I18nString.fromPartial(object.tank_name_full)
: undefined; : undefined;
message.tank_id = object.tank_id ?? 0; message.tank_id = object.tank_id ?? 0;
message.attachment = object.attachment ?? false;
return message; return message;
}, },
}; };
+7 -1
View File
@@ -40,6 +40,8 @@ export interface ConfigArchive {
interface EnabledPresets { interface EnabledPresets {
AlphaTest: boolean; AlphaTest: boolean;
AlphaBlend?: boolean;
AlphaBlendAdditive?: boolean;
} }
interface Flags { interface Flags {
@@ -156,7 +158,11 @@ export type Component =
"ro.notShadowOnly": boolean; "ro.notShadowOnly": boolean;
"ro.sOclCull": boolean; "ro.sOclCull": boolean;
"ro.sOclIndex": number; "ro.sOclIndex": number;
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number>; } & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number> &
Record<
`skinnedObject.batch${number}.targetsData`,
ArrayBuffer | undefined
>;
} }
| { | {
"comp.typename": "SlotComponent"; "comp.typename": "SlotComponent";
@@ -5,5 +5,10 @@ export type CamouflagesYaml = Record<
shortUserString?: string; shortUserString?: string;
preset?: string; preset?: string;
customEntities?: Record<string, { item: string }>; customEntities?: Record<string, { item: string }>;
// nation -> tank
override?: Record<
string,
{ override?: Record<string, { preset?: string }> }
>;
} }
>; >;
+2 -1
View File
@@ -744,7 +744,8 @@
"none": "No deltas", "none": "No deltas",
"percentage": "Percentage", "percentage": "Percentage",
"absolute": "Absolute" "absolute": "Absolute"
} },
"sync_shells": "Sync shells"
}, },
"no_tanks": { "no_tanks": {
-9
View File
@@ -1,16 +1,7 @@
{ {
"name": "@blitzkit/scripts", "name": "@blitzkit/scripts",
"private": true, "private": true,
"scripts": {
"discover-ids": "bun src/discoverIds/index.ts",
"build-protos": "bun src/protos.ts",
"record-performance": "bun src/recordPerformance/index.ts",
"download-client": "bun src/downloadClient/index.ts",
"download-smart-dlc": "bun src/downloadSmartDLC/index.ts",
"vfs": "bun src/vfs/index.ts"
},
"dependencies": { "dependencies": {
"@blitzkit/closed": "workspace:*",
"@blitzkit/core": "workspace:*", "@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*", "@blitzkit/i18n": "workspace:*",
"@gltf-transform/core": "^4.2.1", "@gltf-transform/core": "^4.2.1",
@@ -1,90 +0,0 @@
import { dvp } from "@blitzkit/closed";
import { assertSecret } from "@blitzkit/core";
import { mkdir, writeFile } from "fs/promises";
import { parse as parsePath } from "path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { RegionsPublish } from "../types/regionsPublish";
import { vfs } from "../vfs/vfs";
const CLIENT_DIR = assertSecret(import.meta.env.CLIENT_DIR);
const regionsPublish = await vfs.yaml<RegionsPublish>(
"Data/regions_publish.yaml",
);
const [smartDlcUrl] = regionsPublish.Common.smartDlcUrls;
const versionTextFile = await vfs.text(`Data/version.txt`);
const currentVersion = versionTextFile
.split(" ")[0]
.split(".")
.slice(0, 3)
.join(".");
console.log(`Installing patches for ${currentVersion}...`);
let patchIndex = 1;
while (true) {
const response = await fetch(
`${smartDlcUrl}/s${currentVersion}_${patchIndex}.yaml`,
);
console.log(patchIndex, response.status);
if (!response.ok) break;
console.log(`Applying patch ${patchIndex}...`);
const data = parseYaml(await response.text());
const dvpm = await fetch(`${smartDlcUrl}/${data.dx11}`).then((response) =>
response.arrayBuffer(),
);
const dvpd = await fetch(
`${smartDlcUrl}/${data.dx11.replace(".dvpm", ".dvpd")}`,
).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd);
for (const { path, data } of files) {
console.log(`Patching "${path}"...`);
const { dir } = parsePath(path);
await mkdir(`${CLIENT_DIR}/Data/${dir}`, { recursive: true });
const buffer = Buffer.from(data);
await writeFile(`${CLIENT_DIR}/Data/${path}`, new Uint8Array(buffer));
}
console.log("patches done for patch");
if ("dynamicContentLocalizationsDir" in data) {
console.log(
`${smartDlcUrl}/${data.dynamicContentLocalizationsDir}/en.yaml`,
);
const localizationsResponse = await fetch(
`${smartDlcUrl}/${data.dynamicContentLocalizationsDir}/en.yaml`,
);
console.log("fetched");
const newStrings = parseYaml(await localizationsResponse.text());
console.log("got new strings");
const oldStrings =
await vfs.yaml<Record<string, string>>(`Data/Strings/en.yaml`);
console.log("got old strings");
const patchedStrings = { ...oldStrings, ...newStrings };
const patchedContent = stringifyYaml(patchedStrings);
console.log("stringified");
await mkdir(`${CLIENT_DIR}/Data/Strings`, { recursive: true });
console.log("mkdir");
await writeFile(
`${CLIENT_DIR}/Data/Strings/en.yaml`,
new Uint8Array(Buffer.from(patchedContent)),
);
console.log("file");
}
patchIndex++;
}
vfs.dispose();
process.exit(0);
-7
View File
@@ -1,13 +1,6 @@
{ {
"name": "@blitzkit/website", "name": "@blitzkit/website",
"type": "module", "type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"lint": "astro check",
"astro": "astro"
},
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@astrojs/node": "^9.4.4", "@astrojs/node": "^9.4.4",
@@ -2,7 +2,7 @@ import { alias } from "@blitzkit/core";
import { Flex, Heading, IconButton, Table } from "@radix-ui/themes"; import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics"; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { useLocale } from "../../hooks/useLocale"; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from "../../stores/compareEphemeral"; import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
import { CompareCellDirection } from "../CompareCell"; import { CompareCellDirection } from "../CompareCell";
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell"; import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { CompareRow } from "./CompareRow"; import { CompareRow } from "./CompareRow";
@@ -50,7 +50,11 @@ export function Body({ stats }: BodyProps) {
}} }}
onClick={() => { onClick={() => {
CompareEphemeral.mutate((draft) => { CompareEphemeral.mutate((draft) => {
draft.members[index].shell = thisShell; if (draft.syncShells) {
syncShellSlot(draft.members, shellIndex);
} else {
draft.members[index].shell = thisShell;
}
}); });
}} }}
> >
@@ -1,9 +1,17 @@
import { PlusIcon, TrashIcon } from "@radix-ui/react-icons"; import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes"; import {
Button,
Checkbox,
Dialog,
Flex,
SegmentedControl,
Text,
} from "@radix-ui/themes";
import { DIALOG_MAX_HEIGHT } from "../../constants/dialog";
import { api } from "../../core/blitzkit/api"; import { api } from "../../core/blitzkit/api";
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember"; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { useLocale } from "../../hooks/useLocale"; import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from "../../stores/compareEphemeral"; import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
import { import {
ComparePersistent, ComparePersistent,
type DeltaMode, type DeltaMode,
@@ -22,6 +30,7 @@ export function Controls({
onAddTankDialogOpenChange, onAddTankDialogOpenChange,
}: ControlsProps) { }: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode); const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const syncShells = CompareEphemeral.use((state) => state.syncShells);
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
@@ -36,11 +45,11 @@ export function Controls({
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
<Dialog.Title align="center"> <Dialog.Title align="center">
{strings.website.tools.compare.actions.add.title} {strings.website.tools.compare.actions.add.title}
</Dialog.Title> </Dialog.Title>
<Dialog.Description align="center"> <Dialog.Description align="center" mb="4">
{strings.website.tools.compare.actions.add.description} {strings.website.tools.compare.actions.add.description}
</Dialog.Description> </Dialog.Description>
@@ -111,6 +120,31 @@ export function Controls({
{strings.website.tools.compare.actions.deltas.absolute} {strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item> </SegmentedControl.Item>
</SegmentedControl.Root> </SegmentedControl.Root>
<Flex
align="center"
gap="2"
style={{ cursor: "pointer" }}
onClick={() => {
CompareEphemeral.mutate((draft) => {
draft.syncShells = !draft.syncShells;
if (!draft.syncShells || draft.members.length === 0) return;
const [first] = draft.members;
syncShellSlot(
draft.members,
first.gun.shells.findIndex(({ id }) => id === first.shell.id),
);
});
}}
>
<Checkbox variant="classic" checked={syncShells} />
<Text size="2">
{strings.website.tools.compare.actions.sync_shells}
</Text>
</Flex>
</Flex> </Flex>
); );
} }
@@ -1,6 +1,7 @@
import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons"; import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
import { Dialog, Flex, IconButton } from "@radix-ui/themes"; import { Dialog, Flex, IconButton } from "@radix-ui/themes";
import { useState } from "react"; import { useState } from "react";
import { DIALOG_MAX_HEIGHT } from "../../constants/dialog";
import { api } from "../../core/blitzkit/api"; import { api } from "../../core/blitzkit/api";
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember"; import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { useLocale } from "../../hooks/useLocale"; import { useLocale } from "../../hooks/useLocale";
@@ -43,7 +44,7 @@ export function TankControl({ index, slug }: TankControlProps) {
</IconButton> </IconButton>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
<Dialog.Title align="center"> <Dialog.Title align="center">
{strings.website.common.tank_search.swap_dialog_title} {strings.website.common.tank_search.swap_dialog_title}
</Dialog.Title> </Dialog.Title>
@@ -3,7 +3,8 @@ import { useEffect, useState, type ReactNode } from "react";
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps"; import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from "./InlineSkeleton"; import { InlineSkeleton } from "./InlineSkeleton";
const TIME = 1792598400 * 1000; const TIME = 1793898000 * 1000;
const HREF = "https://preview.blitzkit.app/tanks";
export function Countdown({ skeleton }: MaybeSkeletonComponentProps) { export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now()); const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now());
@@ -31,7 +32,7 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
string = ( string = (
<> <>
revealed in{" "} in{" "}
<Code highContrast> <Code highContrast>
{hours}h {minutes}m {seconds}s {hours}h {minutes}m {seconds}s
</Code> </Code>
@@ -47,7 +48,7 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
color="gray" color="gray"
highContrast highContrast
style={{ display: "content" }} style={{ display: "content" }}
href="https://preview.blitzkit.app/tanks" href={timeLeft <= 0 ? HREF : undefined}
underline="none" underline="none"
> >
<Flex <Flex
@@ -75,11 +76,11 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
<Flex direction="column"> <Flex direction="column">
<Heading size={{ initial: "4", sm: "6" }}> <Heading size={{ initial: "4", sm: "6" }}>
Tier XIs {!skeleton && string}{" "} More Tier XIs {!skeleton && string}{" "}
{skeleton && <InlineSkeleton width="10rem" />} {skeleton && <InlineSkeleton width="10rem" />}
</Heading> </Heading>
<Text size={{ initial: "2", sm: "3" }}> <Text size={{ initial: "2", sm: "3" }}>
Get ready to go to the Tankopedia! Catch the next 3 tanks on BlitzKit Preview
</Text> </Text>
</Flex> </Flex>
</Flex> </Flex>
@@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun); const gun = Duel.use((state) => state.protagonist.gun);
const modelRequested = Tankopedia.use((state) => state.modelRequested); const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const tankModel = modelDefinition.models[tank.id]; const tankModel = modelDefinition.models[tank.id];
const turretModel = tankModel.turrets[turret.id]; const turretModel = tankModel.turrets[turret.id];
@@ -1,5 +1,5 @@
import { createDefaultSkills } from "@blitzkit/core"; import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Progress, Text } from "@radix-ui/themes"; import { Box, Flex, Progress, Text } from "@radix-ui/themes";
import { clamp } from "lodash-es"; import { clamp } from "lodash-es";
import { memo, useMemo, type ComponentProps, type ReactNode } from "react"; import { memo, useMemo, type ComponentProps, type ReactNode } from "react";
import { api } from "../../../../../../core/blitzkit/api"; import { api } from "../../../../../../core/blitzkit/api";
@@ -16,6 +16,7 @@ import {
TankopediaRelativeAgainst, TankopediaRelativeAgainst,
} from "../../../../../../stores/tankopedia"; } from "../../../../../../stores/tankopedia";
import { Info, type InfoProps } from "./Info"; import { Info, type InfoProps } from "./Info";
import { StatScale } from "./StatScale";
type InfoWithDeltaProps = Omit<InfoProps, "name"> & { type InfoWithDeltaProps = Omit<InfoProps, "name"> & {
stats: TankCharacteristics; stats: TankCharacteristics;
@@ -122,6 +123,15 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
return othersValue !== undefined; return othersValue !== undefined;
}) as TankCharacteristics[]; }) as TankCharacteristics[];
}, [relativeAgainst, shellIndex, equipmentMatrix, equalize]); }, [relativeAgainst, shellIndex, equipmentMatrix, equalize]);
const values = useMemo(
() =>
others.map((tank) =>
typeof props.value === "function"
? props.value(tank)!
: (tank[props.value] as number),
),
[others],
);
const betterTanks = others.filter((tank) => { const betterTanks = others.filter((tank) => {
const othersValue = const othersValue =
typeof props.value === "function" typeof props.value === "function"
@@ -161,13 +171,25 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
{!noRanking && ( {!noRanking && (
<Flex pl={indent ? "2" : "0"} align="center" gap="2"> <Flex pl={indent ? "2" : "0"} align="center" gap="2">
<Progress <Box position="relative" flexGrow="1">
variant="soft" <StatScale
size="1" values={values}
value={goodness * 100} value={uhWhatDoICallThisVariable}
color={color} lowerIsBetter={deltaType === "lowerIsBetter"}
style={{ height: "0.125rem", opacity: 0.5 }} />
/>
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{
position: "relative",
height: "0.125rem",
opacity: 0.75,
}}
/>
</Box>
<Text color="gray" size="1"> <Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{" "} {clamp(betterTanks.length + 1, 1, others.length)} /{" "}
@@ -0,0 +1,91 @@
import { memo, useEffect, useMemo, useRef } from "react";
const RESOLUTION = 1000;
const HEIGHT = 8;
const DENSE_TICKS = 100;
interface StatScaleProps {
values: number[];
value: number;
lowerIsBetter?: boolean;
}
export const StatScale = memo<StatScaleProps>(
({ values, value, lowerIsBetter }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticks = useMemo(() => {
const min = Math.min(value, ...values);
const max = Math.max(value, ...values);
const range = max - min;
const positions = new Set<number>();
for (const other of values) {
const raw = range === 0 ? 0.5 : (other - min) / range;
const x = Math.round((lowerIsBetter ? 1 - raw : raw) * RESOLUTION);
if (x === 0 || x === RESOLUTION) continue;
positions.add(x);
}
return Array.from(positions, (x) => ({ x }));
}, [values, value, lowerIsBetter]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const draw = () => {
const { width } = canvas.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio || 1;
const canvasWidth = Math.ceil(width * pixelRatio);
const canvasHeight = Math.ceil(HEIGHT * pixelRatio);
if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, HEIGHT);
const styles = getComputedStyle(canvas);
for (const { x } of ticks) {
context.fillStyle = styles.getPropertyValue("--gray-a6").trim();
context.fillRect((x / RESOLUTION) * width, 0, 1, HEIGHT);
}
};
const observer = new ResizeObserver(draw);
observer.observe(canvas);
window.addEventListener("resize", draw);
draw();
return () => {
observer.disconnect();
window.removeEventListener("resize", draw);
};
}, [ticks]);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
width: "100%",
height: HEIGHT,
margin: "auto 0",
opacity: Math.min(1, Math.sqrt(DENSE_TICKS / ticks.length)),
pointerEvents: "none",
}}
/>
);
},
);
@@ -13,7 +13,10 @@ const camouflageDefinitions = await api.camouflageDefinitions();
export function SkinSwitcher() { export function SkinSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const skin = Tankopedia.use((state) => state.requestedSkin); const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const attachment = Tankopedia.use(
(state) => state.loaders.attachment.requested,
);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const { unwrap } = useLocale(); const { unwrap } = useLocale();
@@ -24,11 +27,18 @@ export function SkinSwitcher() {
(camouflage) => unwrap(camouflage.name!), (camouflage) => unwrap(camouflage.name!),
); );
// attachments aren't camouflages so they get their own group
const groups = [
skins.filter((camouflage) => !camouflage.attachment),
skins.filter((camouflage) => camouflage.attachment),
].filter((group) => group.length > 0);
if (skins.length === 0) return null; if (skins.length === 0) return null;
return ( return (
<Flex <Flex
direction="column" direction="column"
gap="3"
top="50%" top="50%"
right={ right={
disturbed && requestedDisplay === TankopediaDisplay.Model disturbed && requestedDisplay === TankopediaDisplay.Model
@@ -42,42 +52,55 @@ export function SkinSwitcher() {
pointerEvents: "auto", pointerEvents: "auto",
}} }}
> >
{skins.map((camouflage, index) => { {groups.map((group) => (
const name = unwrap(camouflage.name!).replace( <Flex key={group[0].id} direction="column">
"%(camo_num)", {group.map((camouflage, index) => {
SKIN_NUMBER, const name = unwrap(camouflage.name!).replace(
); "%(camo_num)",
const top = index === 0 ? "var(--radius-full)" : "0"; SKIN_NUMBER,
const bottom = index === skins.length - 1 ? "var(--radius-full)" : "0"; );
const top = index === 0 ? "var(--radius-full)" : "0";
const bottom =
index === group.length - 1 ? "var(--radius-full)" : "0";
return ( return (
<Tooltip key={camouflage.id} content={name} side="left"> <Tooltip key={camouflage.id} content={name} side="left">
<SidebarIconButton <SidebarIconButton
selected={skin === camouflage.id} selected={
alt={name} (camouflage.attachment ? attachment : skin) ===
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)} camouflage.id
size={{ initial: "3", sm: "4" }} }
radius="full" alt={name}
style={{ src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
borderRadius: `${top} ${top} ${bottom} ${bottom}`, size={{ initial: "3", sm: "4" }}
}} radius="full"
imageStyle={{ style={{
width: "70%", borderRadius: `${top} ${top} ${bottom} ${bottom}`,
height: "70%", }}
borderRadius: "50%", imageStyle={{
}} width: "70%",
onClick={() => { height: "70%",
Tankopedia.mutate((draft) => { borderRadius: "50%",
draft.requestedSkin = }}
draft.requestedSkin === camouflage.id onClick={() => {
? undefined Tankopedia.mutate((draft) => {
: camouflage.id; const loader =
}); draft.loaders[
}} camouflage.attachment ? "attachment" : "skin"
/> ];
</Tooltip>
); loader.requested =
})} loader.requested === camouflage.id
? undefined
: camouflage.id;
});
}}
/>
</Tooltip>
);
})}
</Flex>
))}
</Flex> </Flex>
); );
} }
@@ -27,6 +27,7 @@ import {
Tooltip, Tooltip,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { Suspense, useState, type RefObject } from "react"; import { Suspense, useState, type RefObject } from "react";
import { DIALOG_MAX_HEIGHT } from "../../../../../constants/dialog";
import { Pose, poseEvent } from "../../../../../core/blitzkit/pose"; import { Pose, poseEvent } from "../../../../../core/blitzkit/pose";
import { useEquipment } from "../../../../../hooks/useEquipment"; import { useEquipment } from "../../../../../hooks/useEquipment";
import { useFullScreen } from "../../../../../hooks/useFullScreen"; import { useFullScreen } from "../../../../../hooks/useFullScreen";
@@ -82,7 +83,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics); const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const modelRequested = Tankopedia.use((state) => state.modelRequested); const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const equalize = Duel.use((state) => state.equalize); const equalize = Duel.use((state) => state.equalize);
const equalizer = Duel.use((state) => state.antagonist.tank.equalizer); const equalizer = Duel.use((state) => state.antagonist.tank.equalizer);
@@ -472,7 +475,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
<Dialog.Title align="center"> <Dialog.Title align="center">
{strings.website.common.tank_search.select_dialog_title} {strings.website.common.tank_search.select_dialog_title}
</Dialog.Title> </Dialog.Title>
@@ -13,18 +13,25 @@ const camouflageDefinitions = await api.camouflageDefinitions();
export function Title({ skeleton }: MaybeSkeletonComponentProps) { export function Title({ skeleton }: MaybeSkeletonComponentProps) {
const { unwrap, strings } = useLocale(); const { unwrap, strings } = useLocale();
const modelRequested = Tankopedia.use((state) => state.modelRequested); const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const hasRevealed = useRef(false); const hasRevealed = useRef(false);
const skin = Tankopedia.use((state) => state.requestedSkin); const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const camouflage = const camouflage =
skin === undefined ? undefined : camouflageDefinitions.camouflages[skin]; skin === undefined ? undefined : camouflageDefinitions.camouflages[skin];
const tankName = unwrap(protagonist.name!); const tankName = unwrap(protagonist.name!);
const name = camouflage // legendary camos carry the tank's in-battle name
? `${tankName} ${unwrap(camouflage.name!).replace("%(camo_num)", SKIN_NUMBER)}` const name = (
: tankName; camouflage
? camouflage.tank_name
? unwrap(camouflage.tank_name)
: `${tankName} ${unwrap(camouflage.name!)}`
: tankName
).replace("%(camo_num)", SKIN_NUMBER);
const fontSize = revealed const fontSize = revealed
? disturbed ? disturbed
? "1.5rem" ? "1.5rem"
@@ -102,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onClick={() => { onClick={() => {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.modelRequested = true; draft.loaders.model.requested = true;
}); });
}} }}
> >
@@ -48,7 +48,9 @@ export function Lighting({ hasPbr }: Props) {
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use( const skinPending = Tankopedia.use(
(state) => state.loadedSkin !== state.skin, (state) =>
state.loaders.skin.requested !== state.loaders.skin.loaded ||
state.loaders.attachment.requested !== state.loaders.attachment.loaded,
); );
const animationTime = useRef(REVEAL_ANIMATION_TIME); const animationTime = useRef(REVEAL_ANIMATION_TIME);
@@ -63,6 +65,7 @@ export function Lighting({ hasPbr }: Props) {
useEffect(() => { useEffect(() => {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.revealed = true; draft.revealed = true;
draft.loaders.model.loaded = true;
}); });
}, []); }, []);
@@ -162,6 +165,7 @@ export function Lighting({ hasPbr }: Props) {
key={index} key={index}
position={position} position={position}
intensity={intensity} intensity={intensity}
userData={{ intensity }}
penumbra={1} penumbra={1}
castShadow={highGraphics} castShadow={highGraphics}
decay={1} decay={1}
@@ -212,6 +216,11 @@ function Animator({
for (const child of wrapper.current.children) { for (const child of wrapper.current.children) {
if (child instanceof SpotLight) { if (child instanceof SpotLight) {
child.angle = ANGLE * t * factor; child.angle = ANGLE * t * factor;
child.intensity = child.userData.intensity;
if (child.angle === 0) {
child.intensity = 0;
}
} else if (child instanceof HemisphereLight) { } else if (child instanceof HemisphereLight) {
child.intensity = HEMISPHERE_INTENSITY * t * factor; child.intensity = HEMISPHERE_INTENSITY * t * factor;
} }
@@ -244,12 +253,6 @@ function Animator({
draft.display = requestedDisplay; draft.display = requestedDisplay;
}); });
} }
if (x >= 1 && Tankopedia.state.skin !== Tankopedia.state.loadedSkin) {
Tankopedia.mutate((draft) => {
draft.skin = draft.loadedSkin;
});
}
}); });
return null; return null;
@@ -1,5 +1,6 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber"; import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from "react"; import type { QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three"; import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits"; import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment"; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
@@ -8,12 +9,14 @@ import { skinTree } from "../../../../../../core/blitzkit/skinTree";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled"; import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { useModel } from "../../../../../../hooks/useModel"; import { useModel } from "../../../../../../hooks/useModel";
import { useSkin } from "../../../../../../hooks/useSkin"; import { useSkin } from "../../../../../../hooks/useSkin";
import { useSkinAnimation } from "../../../../../../hooks/useSkinAnimation";
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition"; import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from "../../../../../../hooks/useTankTransform"; import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from "../../../../../../stores/duel"; import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia"; import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants"; import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper"; import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
import { transitionEvent } from "./Lighting";
export function TankModel() { export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist); const protagonist = Duel.use((draft) => draft.protagonist);
@@ -30,8 +33,49 @@ export function TankModel() {
const { gltf } = useModel(protagonist.tank.id); const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes); const nodes = Object.values(gltf.nodes);
const skinGltf = useSkin(protagonist.tank.id); const skinGltf = useSkin(protagonist.tank.id);
const attachmentGltf = useSkin(protagonist.tank.id, "attachment");
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
useSkinAnimation(skins);
useEffect(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>();
const roots = [
gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
];
for (const root of roots) {
root.traverse((object) => {
if (
object instanceof Mesh &&
object.material instanceof MeshStandardMaterial &&
object.material.emissiveMap
) {
emissiveMaterials.add(object.material);
}
});
}
// emissive parts fade with the lights instead of glowing in the dark
function applyIntensity(intensity: number) {
for (const material of emissiveMaterials) {
material.emissiveIntensity = intensity;
}
}
function handleTransitionEvent(event: QuicklimeEvent<number>) {
applyIntensity(event.data);
}
applyIntensity(transitionEvent.last!);
transitionEvent.on(handleTransitionEvent);
return () => {
transitionEvent.off(handleTransitionEvent);
};
}, [gltf, skinGltf, attachmentGltf]);
return ( return (
<ModelTankWrapper ref={hullContainer}> <ModelTankWrapper ref={hullContainer}>
@@ -94,7 +138,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return skinTree(node, skinGltf, { return skinTree(node, skins, {
mesh(_, props, key) { mesh(_, props, key) {
return ( return (
<mesh <mesh
@@ -106,6 +150,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
@@ -171,7 +224,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return skinTree(node, skinGltf, { return skinTree(node, skins, {
mesh(_, props, key) { mesh(_, props, key) {
return ( return (
<mesh <mesh
@@ -183,6 +236,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
@@ -252,7 +314,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
} }
return skinTree(node, skinGltf, { return skinTree(node, skins, {
mesh(_, props, key) { mesh(_, props, key) {
return ( return (
<mesh <mesh
@@ -264,6 +326,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
</group> </group>
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const canvas = useRef<HTMLCanvasElement>(null!); const canvas = useRef<HTMLCanvasElement>(null!);
const isFullScreen = useFullScreen(); const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const modelRequested = Tankopedia.use((state) => state.modelRequested); const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const thicknessRange = useMemo(() => { const thicknessRange = useMemo(() => {
const entries = Object.values(tankDefinitions.tanks); const entries = Object.values(tankDefinitions.tanks);
const filtered = entries.filter( const filtered = entries.filter(
+3
View File
@@ -0,0 +1,3 @@
import { NAVBAR_HEIGHT } from "./navbar";
export const DIALOG_MAX_HEIGHT = `calc(100svh - 2 * (${NAVBAR_HEIGHT}px + var(--space-4) + var(--space-2)))`;
@@ -1,4 +1,6 @@
export function isHalloween() { export function isHalloween() {
return false;
const data = new Date(); const data = new Date();
return data.getMonth() === 9 && data.getDate() === 31; return data.getMonth() === 9 && data.getDate() === 31;
} }
+19 -4
View File
@@ -1,5 +1,21 @@
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { Mesh, MeshStandardMaterial, Object3D } from "three"; import {
AdditiveBlending,
Mesh,
MeshStandardMaterial,
Object3D,
type Material,
} from "three";
export function prepareMaterial(material: Material | Material[]) {
if (!(material instanceof MeshStandardMaterial)) return;
material.depthWrite = true;
if (material.userData.blending === "additive") {
material.blending = AdditiveBlending;
}
}
export function jsxTree( export function jsxTree(
node: Object3D, node: Object3D,
@@ -14,6 +30,7 @@ export function jsxTree(
props: ComponentProps<"group">, props: ComponentProps<"group">,
key: string, key: string,
) => ReactNode; ) => ReactNode;
primitive?: (object3d: Object3D, key: string) => ReactNode;
}, },
): ReactNode { ): ReactNode {
if (node instanceof Mesh) { if (node instanceof Mesh) {
@@ -25,9 +42,7 @@ export function jsxTree(
scale: node.scale, scale: node.scale,
}; };
if (props.material instanceof MeshStandardMaterial) { prepareMaterial(props.material);
props.material.depthWrite = true;
}
return mergers?.mesh ? ( return mergers?.mesh ? (
mergers.mesh(node, props, node.uuid) mergers.mesh(node, props, node.uuid)
@@ -0,0 +1,64 @@
import { ReadStream } from "@blitzkit/core";
import { times } from "lodash-es";
const HEADER_SIZE = 16;
export enum MotionChannel {
Translation,
Rotation,
Scale,
}
export interface MotionKeys {
times: number[];
values: number[];
}
export interface Motion {
duration: number;
tracks: Map<string, Map<MotionChannel, MotionKeys>>;
}
export class MotionReadStream extends ReadStream {
motion(): Motion {
this.seek(HEADER_SIZE);
const duration = this.float32();
const tracks: Motion["tracks"] = new Map();
times(this.uint32(), () => {
const uid = this.paddedString();
const channels = new Map<MotionChannel, MotionKeys>();
this.paddedString();
this.seek(4);
times(this.uint32(), () => {
const type = this.uint32() & 0xff;
this.seek(4);
const dimension = this.uint32() & 0xff;
const keys: MotionKeys = { times: [], values: [] };
times(this.uint32(), () => {
keys.times.push(this.float32());
times(dimension, () => keys.values.push(this.float32()));
});
channels.set(type, keys);
});
tracks.set(uid, channels);
});
return { duration, tracks };
}
paddedString() {
const end = new Uint8Array(this.buffer).indexOf(0, this.index);
const value = this.utf8(end - this.index);
this.index = (end + 4) & ~3;
return value;
}
}
@@ -0,0 +1,287 @@
import { VertexAttribute, type Component } from "@blitzkit/core";
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import {
MathUtils,
type Buffer,
type Document,
type mat4,
type Node,
type Primitive,
type Skin,
type TypedArray,
type vec3,
type vec4,
} from "@gltf-transform/core";
import { isEqual, times } from "lodash-es";
import { dirname, normalize } from "path/posix";
import { Matrix4 } from "three";
import { MotionChannel, MotionReadStream, type Motion } from "./motion";
type SkeletonComponent = Extract<
Component,
{ "comp.typename": "SkeletonComponent" }
>;
type MotionComponent = Extract<
Component,
{ "comp.typename": "MotionComponent" }
>;
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
const JOINT_ROOT = 0xffffff;
const MOTION_PATHS = {
[MotionChannel.Translation]: "translation",
[MotionChannel.Rotation]: "rotation",
[MotionChannel.Scale]: "scale",
} as const;
function createAccessor(
document: Document,
buffer: Buffer,
type: AccessorType,
array: TypedArray,
) {
return document
.createAccessor()
.setType(type)
.setArray(array)
.setBuffer(buffer);
}
export async function loadMotion(
vfs: AbstractVFS,
path: string,
component: MotionComponent,
) {
const file = await vfs.file(
normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`),
);
return new MotionReadStream(
file.buffer.slice(
file.byteOffset,
file.byteOffset + file.byteLength,
) as ArrayBuffer,
).motion();
}
export function createSkeleton(
document: Document,
buffer: Buffer,
node: Node,
skeleton: SkeletonComponent,
motion: Motion | undefined,
) {
const joints = times(
skeleton.jointsCount,
(index) => skeleton.joints[index.toString().padStart(4, "0")],
);
const jointNodes = joints.map((joint) => {
const translation: vec3 = [0, 0, 0];
const rotation: vec4 = [0, 0, 0, 1];
const scale: vec3 = [1, 1, 1];
const track = motion?.tracks.get(joint["joint.uid"]);
const trackScale = track?.get(MotionChannel.Scale)?.values[0];
MathUtils.decompose(
joint["joint.bindPose"].flat() as mat4,
translation,
rotation,
scale,
);
// rest on the first frame so the static pose matches the animation
return document
.createNode(joint["joint.name"])
.setTranslation(
(track?.get(MotionChannel.Translation)?.values.slice(0, 3) as vec3) ??
translation,
)
.setRotation(
(track?.get(MotionChannel.Rotation)?.values.slice(0, 4) as vec4) ??
rotation,
)
.setScale(
trackScale === undefined ? scale : [trackScale, trackScale, trackScale],
);
});
joints.forEach((joint, index) => {
const parent = joint["joint.parentIndex"];
(parent === JOINT_ROOT ? node : jointNodes[parent]).addChild(
jointNodes[index],
);
});
const skin = document
.createSkin()
.setInverseBindMatrices(
createAccessor(
document,
buffer,
"MAT4",
new Float32Array(
joints.flatMap((joint) => joint["joint.invBindPose"].flat()),
),
),
);
jointNodes.forEach((jointNode) => skin.addJoint(jointNode));
return {
skin,
joints: new Map(
joints.map((joint, index) => [joint["joint.uid"], jointNodes[index]]),
),
};
}
export function createMotionClip(
document: Document,
buffer: Buffer,
clip: string,
joints: Map<string, Node>,
motion: Motion,
component: MotionComponent,
) {
const animation = document.createAnimation(clip);
const playbackRate = component["motion.playbackRate"];
const duration = motion.duration / playbackRate;
for (const [uid, track] of motion.tracks) {
const joint = joints.get(uid);
if (!joint) continue;
for (const [type, { times, values }] of track) {
const keyTimes = times.map((time) => time / playbackRate);
const keyValues =
type === MotionChannel.Scale
? values.flatMap((value) => [value, value, value])
: [...values];
// the game loops over the clip's duration, which can outlast its keys
if (keyTimes[keyTimes.length - 1] < duration) {
const size = keyValues.length / keyTimes.length;
keyTimes.push(duration);
keyValues.push(...keyValues.slice(-size));
}
const sampler = document
.createAnimationSampler()
.setInput(
createAccessor(
document,
buffer,
"SCALAR",
new Float32Array(keyTimes),
),
)
.setOutput(
createAccessor(
document,
buffer,
type === MotionChannel.Rotation ? "VEC4" : "VEC3",
new Float32Array(keyValues),
),
)
.setInterpolation("LINEAR");
animation
.addSampler(sampler)
.addChannel(
document
.createAnimationChannel()
.setTargetNode(joint)
.setTargetPath(MOTION_PATHS[type])
.setSampler(sampler),
);
}
}
}
/**
* Makes a socket follow a joint. The game replaces the socket's own transform
* with the joint's, so it sits right on the joint.
*/
export function bindSocket(socket: Node, joint: Node, skin: Skin) {
joint.addChild(socket);
socket.setMatrix(new Matrix4().toArray() as mat4);
socket.traverse((child) => {
const childSkin = child.getSkin();
// skinned meshes carrying a copy of the owner's skeleton deform with it
if (
childSkin &&
isEqual(
childSkin.listJoints().map((joint) => joint.getName()),
skin.listJoints().map((joint) => joint.getName()),
)
) {
child.setSkin(skin);
}
});
}
export function skinPrimitive(
document: Document,
buffer: Buffer,
primitive: Primitive,
attributes: Map<VertexAttribute, number[][]>,
targetsData: ArrayBuffer | undefined,
) {
const jointIndices =
attributes.get(VertexAttribute.JOINTINDEX) ??
attributes.get(VertexAttribute.HARD_JOINTINDEX);
if (!jointIndices || !targetsData) return false;
const targets = new Uint32Array(new Uint8Array(targetsData).slice().buffer);
const weights =
attributes.get(VertexAttribute.JOINTWEIGHT) ?? jointIndices.map(() => [1]);
primitive
.setAttribute(
"JOINTS_0",
createAccessor(
document,
buffer,
"VEC4",
new Uint16Array(
jointIndices.flatMap((indices) =>
times(4, (index) => {
if (index >= indices.length) return 0;
const target = targets[indices[index]];
if (target === undefined) {
throw new Error(`Missing joint target ${indices[index]}`);
}
return target;
}),
),
),
),
)
.setAttribute(
"WEIGHTS_0",
createAccessor(
document,
buffer,
"VEC4",
new Float32Array(
weights.flatMap((vertexWeights) => {
const padded = times(4, (index) => vertexWeights[index] ?? 0);
// some game weights sum below 1; glTF requires normalized weights
const sum = padded.reduce((total, weight) => total + weight, 0);
return padded.map((weight) => (sum > 0 ? weight / sum : weight));
}),
),
),
);
return true;
}
+46 -19
View File
@@ -4,39 +4,66 @@ import { jsxTree } from "./jsxTree";
type Mergers = Parameters<typeof jsxTree>[1]; type Mergers = Parameters<typeof jsxTree>[1];
// names get sanitized and made unique on load
export function nameOf(object3d: Object3D): string {
return object3d.userData.name ?? object3d.name;
}
/**
* skins are layered in order, so an attachment's states win over the
* camouflage's; `primitive` renders customization parts as they are so their
* bones can animate
*/
export function skinTree( export function skinTree(
node: Object3D, node: Object3D,
skin: Skin | undefined, skins: Skin[],
mergers: Mergers, mergers: NonNullable<Mergers>,
) { ) {
const skinNodes = new Map( const materials = new Map<string, Material>();
skin?.scene.children.map((node) => [node.name, node]), const states: Record<string, string | null> = {};
);
for (const skin of skins) {
for (const [name, material] of skin.materials) {
materials.set(name, material);
}
Object.assign(states, skin.scene.userData.states);
}
function switchChildren(object3d: Object3D) {
if (object3d.userData.state === undefined) return;
const name = nameOf(object3d);
const state = name in states ? states[name] : object3d.userData.state;
return object3d.children
.filter((child) => nameOf(child) === state)
.map((child) => jsxTree(child, skinMergers));
}
const skinMergers: Mergers = { const skinMergers: Mergers = {
mesh(mesh, props, key) { mesh(mesh, props, key) {
const material = skin?.materials.get((mesh.material as Material).name); const material = materials.get((mesh.material as Material).name);
return mergers!.mesh!( return mergers.mesh!(
mesh, mesh,
material ? { ...props, material } : props, material ? { ...props, material } : props,
key, key,
); );
}, },
group(object3d, props, key) { group(object3d, props, key) {
const skinNode = skinNodes.get(object3d.name);
const isSwitch = object3d.userData.state !== undefined;
const state = skin
? skin.scene.userData.states[object3d.name]
: object3d.userData.state;
return ( return (
<group key={key} {...props}> <group key={key} {...props}>
{isSwitch {switchChildren(object3d) ?? props.children}
? object3d.children {skins.flatMap((skin) =>
.filter((child) => child.name === state) (skin.parts.get(object3d.name) ?? []).map((part) =>
.map((child) => jsxTree(child, skinMergers)) mergers.primitive ? (
: props.children} mergers.primitive(part, part.uuid)
{skinNode?.children.map((child) => jsxTree(child, mergers))} ) : (
<primitive key={part.uuid} object={part} />
),
),
)}
</group> </group>
); );
}, },
@@ -0,0 +1,540 @@
import {
parseHierarchies,
type Hierarchy,
type Sc2ReadStream,
} from "@blitzkit/core";
import { sortBy, times } from "lodash-es";
interface Value {
"any.type": string;
"any.value": unknown;
}
interface ScriptNode {
type: string;
node: Record<string, unknown> & { defaults?: Record<string, Value> };
connections?: Record<
string,
{ pinIn: string; pinOut: string; target: string }
>;
}
interface Script {
nodes: Record<string, ScriptNode>;
variables?: Record<string, Value>;
}
interface Action {
"act.delay": number;
"act.entityName": string;
"act.event": number;
"act.eventToTrigger": string;
"act.stopAfterNRepeats": number;
"act.switchIndex": number;
"act.type": number;
"act.userEventId": string;
}
interface Entity {
name: string;
parent?: Entity;
children: Entity[];
hierarchy?: Hierarchy;
repeats?: number;
}
const SELF_ENTITY = "*** Self ***";
const SCENE_LOADED_EVENT = 1;
const USER_EVENT = 2;
const ANIMATION_END_EVENTS = [3, 8];
const START_ANIMATION_ACTION = 6;
const SWITCH_ACTIONS = [8, 9];
const TRIGGER_EVENT_ACTION = 10;
// what the game fires as a tank is shown in the garage, in order
const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"];
const MAX_DEPTH = 64;
function findChild(entity: Entity, name: string): Entity | undefined {
for (const child of entity.children) {
if (child.name === name) return child;
const found = findChild(child, name);
if (found) return found;
}
}
function resolveTarget(owner: Entity, entityName: string) {
if (entityName === SELF_ENTITY) return owner;
const [first, ...rest] = entityName.split("->");
let target: Entity | undefined;
for (
let ancestor: Entity | undefined = owner;
ancestor && !target;
ancestor = ancestor.parent
) {
target = ancestor.name === first ? ancestor : findChild(ancestor, first);
}
for (const name of rest) target = target && findChild(target, name);
return target;
}
/**
* replays what the game does as a tank is shown in the garage, with the camo's
* preset applied and its items attached; `animate` follows customization parts
* through their motions, delays and alarms until they settle
*/
function replay(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
animate: boolean,
) {
const scripts: Record<string, Script> = {};
const root: Entity = { name: "", children: [] };
const stateNames = new Map<Entity, string[]>();
const scenarios: {
owner: Entity;
script: Script;
variables: Map<string, unknown>;
}[] = [];
const actions: { owner: Entity; action: Action }[] = [];
// names can repeat, so states are kept per entity
const states = new Map<Entity, string | null>();
const looping = new Set<Entity>();
const finished = new Set<Entity>();
const pending: (() => void)[] = [];
const fired = new Set<unknown>();
function activateState(entity: Entity | undefined, state: number) {
const names = entity && stateNames.get(entity);
if (!names) return;
const previous = states.get(entity);
states.set(entity, names[state] ?? null);
if (!animate || previous === states.get(entity)) return;
for (const scenario of scenarios) {
if (scenario.owner === entity) {
runScenario(scenario, "State Activated", state);
}
}
}
function isShown(entity: Entity) {
for (let child = entity; child.parent; child = child.parent) {
if (
stateNames.get(child.parent)?.includes(child.name) &&
states.get(child.parent) !== child.name
) {
return false;
}
}
return true;
}
function startAnimation(entity: Entity, repeats?: number) {
if (entity.repeats === undefined) {
for (const child of entity.children) startAnimation(child, repeats);
return;
}
if (looping.has(entity)) return;
looping.add(entity);
// some clips restart themselves when they end, so each finishes once
if ((repeats ?? entity.repeats) > 0 && !finished.has(entity)) {
pending.push(() => finishAnimation(entity));
}
}
function finishAnimation(entity: Entity) {
const statesBefore = new Map(states);
const shownBefore = [...looping].filter(isShown);
looping.delete(entity);
finished.add(entity);
for (const { owner, action } of actions) {
if (
owner === entity &&
ANIMATION_END_EVENTS.includes(action["act.event"])
) {
runAction(owner, action);
}
}
for (
let ancestor: Entity | undefined = entity;
ancestor;
ancestor = ancestor.parent
) {
const owned = scenarios.filter(({ owner }) => owner === ancestor);
for (const scenario of owned) {
runScenario(scenario, "Motion Fragment Stop");
runScenario(scenario, "Motion Sequence Stop");
}
if (owned.length > 0) break;
}
// as in game, a clip stops on whatever it switched to or revealed; one that
// changes nothing is the only animation there is, so it keeps looping
if (
[...states].every(
([other, state]) => statesBefore.get(other) === state,
) &&
[...looping].every(
(other) => !isShown(other) || shownBefore.includes(other),
)
) {
looping.add(entity);
}
}
function runAction(owner: Entity, action: Action) {
const target = resolveTarget(owner, action["act.entityName"]);
if (SWITCH_ACTIONS.includes(action["act.type"])) {
// what shows for a while in game stays, as its animation loops here
if (action["act.delay"] > 0 && action["act.switchIndex"] < 0) return;
activateState(target, action["act.switchIndex"]);
} else if (animate && action["act.type"] === START_ANIMATION_ACTION) {
if (target) {
const repeats = action["act.stopAfterNRepeats"];
startAnimation(target, repeats > 0 ? repeats : undefined);
}
} else if (animate && action["act.type"] === TRIGGER_EVENT_ACTION) {
fireEvent(action["act.eventToTrigger"]);
}
}
for (const component of Object.values(
sc2["#sceneComponents"] as unknown as Record<string, unknown>,
) as { "comp.typename"?: string; "ssc.scripts"?: Record<string, Script> }[]) {
if (component?.["comp.typename"] !== "ScenarioSingletonComponent") continue;
Object.assign(scripts, component["ssc.scripts"]);
}
parseHierarchies<Entity>(
sc2["#hierarchy"],
root,
(hierarchy, components, parent) => {
const entity: Entity = {
name: hierarchy.name,
parent,
children: [],
hierarchy,
};
parent.children.push(entity);
for (const item of attachments.get(hierarchy.name) ?? []) {
entity.children.push({ name: item, parent: entity, children: [] });
}
for (const component of components) {
switch (component["comp.typename"]) {
case "StateSwitcherComponent": {
const names = times(
component["ssc.statesCount"],
(index) => component[`ssc.state${index}`],
);
stateNames.set(entity, names);
states.set(entity, names[component["ssc.activeState"]] ?? null);
break;
}
case "ScenarioComponent": {
const script = scripts[component["scenario.scriptId"]];
if (script) {
scenarios.push({
owner: entity,
script,
variables: new Map(
Object.entries(script.variables ?? {}).map(
([name, variable]) => [name, variable["any.value"]],
),
),
});
}
break;
}
case "ActionComponent":
times(component["ac.actionCount"], (index) => {
actions.push({
owner: entity,
action: component[index.toString().padStart(4, "0")] as Action,
});
});
break;
case "MotionComponent":
if (animate) {
entity.repeats = component["simpleMotion.repeatsCount"];
}
break;
}
}
return entity;
},
);
function runScenario(
scenario: (typeof scenarios)[number],
event: string,
value?: number,
) {
const { owner, script, variables } = scenario;
const { nodes } = script;
const links = (id: string) => Object.values(nodes[id].connections ?? {});
function input(id: string, pin: string): unknown {
const link = links(id).find((link) => link.pinIn === pin);
return link
? output(link.target, link.pinOut)
: nodes[id].node.defaults?.[pin]?.["any.value"];
}
function output(id: string, pin: string): unknown {
const { type, node } = nodes[id];
switch (type) {
case "ScenarioSceneInfoNode":
switch (node["scenarioSceneInfoNode.infoKind"]) {
case "GetCurrentEntity":
return owner;
case "GetCurrentScene":
return root;
}
return undefined;
case "ScenarioGetVarNode":
return variables.get(node["scenarioGetVarNode.varPath"] as string);
case "ScenarioSetVarNode":
return variables.get(node["scenarioSetVarNode.varPath"] as string);
case "ScenarioGetMemberNode": {
const object = input(id, "object") as Entity | undefined;
switch (node["scenarioGetMemberNode.fieldName"]) {
case "parent":
return pin === "parent" ? object?.parent : undefined;
case "name":
return object?.name;
case "stateSwitcherVisibility":
return object && isShown(object);
}
return undefined;
}
case "ScenarioFunctionNode": {
const name = node["scenarioFunctionNode.functionName"] as string;
const number = (pin: string) => Number(input(id, pin) ?? 0);
switch (`${node["scenarioFunctionNode.className"]}.${name}`) {
case "Entity.FindChildByName": {
const entity = input(id, "entity") as Entity | undefined;
return entity && findChild(entity, input(id, "name") as string);
}
case "Entity.GetVisibility": {
const entity = input(id, "arg0") as Entity | undefined;
return entity ? isShown(entity) : false;
}
case "TankComponent.GetAppliedSkinMaterialConfigName":
return preset;
case "String.Compare":
return input(id, "1") === input(id, "2");
case "String.Merge":
// graphs use Separator as the prefix and Postfix as the number
return ["Suffix", "Separator", "Postfix"]
.map((pin) => input(id, pin) ?? "")
.join("");
case "bool.Or":
return Boolean(input(id, "a")) || Boolean(input(id, "b"));
case "Random.Random":
case "Random.RandomFloat":
case "TankComponent.GetSpeed":
case "TankComponent.GetTurretAngle":
case "TankComponent.GetGunAngle":
return 0;
}
if (name === "==") return number("a") === number("b");
if (name === ">") return number("a") > number("b");
if (name === ">=") return number("a") >= number("b");
if (name === "<=") return number("a") <= number("b");
if (name.startsWith("Add")) return number("a") + number("b");
if (name.startsWith("Subtract")) return number("a") - number("b");
}
}
}
function execute(id: string, depth: number) {
if (depth > MAX_DEPTH) return;
const { type, node } = nodes[id];
let pin = "exit";
if (type === "ScenarioEventNode") {
pin = "event";
} else if (type === "ScenarioBranchNode") {
pin = input(id, "condition") ? "true" : "false";
} else if (type === "ScenarioSetVarNode") {
variables.set(
node["scenarioSetVarNode.varPath"] as string,
input(id, "set"),
);
} else if (type === "ScenarioSetAlarmClockNode") {
const entity = (input(id, "entity") as Entity | undefined) ?? owner;
// alarms re-arm themselves
if (animate && !fired.has(node)) {
fired.add(node);
pending.push(() => {
for (const other of scenarios) {
if (other.owner === entity) runScenario(other, "Alarm Clock");
}
});
}
} else {
const entity = input(id, "entity") as Entity | undefined;
switch (node["scenarioFunctionNode.functionName"]) {
case "ActivateState":
activateState(entity, Number(input(id, "state")));
break;
case "StartAnimation":
if (animate && entity) {
startAnimation(entity, Number(input(id, "repeats") ?? 0));
}
break;
case "TriggerCustomEvent":
if (animate) fireEvent(input(id, "event name") as string);
break;
}
}
for (const link of links(id)) {
if (link.pinIn === pin) execute(link.target, depth + 1);
}
}
for (const id in nodes) {
const { type, node } = nodes[id];
const name =
node["ScenarioEventNode.eventName"] === "Custom"
? node.defaults?.["Event Name"]?.["any.value"]
: node["ScenarioEventNode.eventName"];
if (
type === "ScenarioEventNode" &&
name === event &&
(event !== "State Activated" ||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
) {
execute(id, 0);
}
}
}
function fireEvent(event: string) {
if (fired.has(event)) return;
fired.add(event);
// the game runs delayed actions once their delay is up, so they go in that
// order rather than the order they're stored in
for (const { owner, action } of sortBy(
actions,
({ action }) => action["act.delay"],
)) {
if (
(animate || action["act.delay"] === 0) &&
(event === LOAD_EVENTS[0]
? action["act.event"] === SCENE_LOADED_EVENT
: action["act.event"] === USER_EVENT &&
action["act.userEventId"] === event)
) {
runAction(owner, action);
}
}
for (const scenario of scenarios) runScenario(scenario, event);
}
for (const event of LOAD_EVENTS) fireEvent(event);
for (let run = pending.shift(); run; run = pending.shift()) run();
return {
states: new Map(
[...states].map(([entity, state]) => [
entity.hierarchy as Hierarchy,
state,
]),
),
motions: new Set(
[...looping]
.filter(isShown)
.map((entity) => entity.hierarchy as Hierarchy),
),
};
}
/**
* picks each state switcher's state; delayed actions are skipped so states stay
* as they are when the tank appears
*/
export function switcherStates(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
) {
const states: Record<string, string | null> = {};
for (const [hierarchy, state] of replay(sc2, preset, attachments, false)
.states) {
states[hierarchy.name] = state;
}
return states;
}
/**
* where a customization part ends up: each state switcher's state and the
* motions left looping
*/
export function partGarage(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
) {
return replay(sc2, preset, new Map(), true);
}
+74 -22
View File
@@ -1,13 +1,25 @@
import { alias } from "@blitzkit/core"; import { alias } from "@blitzkit/core";
import { useEffect } from "react"; import { useEffect } from "react";
import type { Group, Material } from "three"; import {
AnimationMixer,
Mesh,
SkinnedMesh,
type Group,
type Material,
type Object3D,
} from "three";
import { GLTFLoader } from "three-stdlib"; import { GLTFLoader } from "three-stdlib";
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting"; import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { prepareMaterial } from "../core/blitzkit/jsxTree";
import { nameOf } from "../core/blitzkit/skinTree";
import { Tankopedia } from "../stores/tankopedia"; import { Tankopedia } from "../stores/tankopedia";
export interface Skin { export interface Skin {
scene: Group; scene: Group;
materials: Map<string, Material>; materials: Map<string, Material>;
/** customization parts by the tank node they attach to */
parts: Map<string, Object3D[]>;
mixer?: AnimationMixer;
} }
const skins = new Map<string, Promise<Skin>>(); const skins = new Map<string, Promise<Skin>>();
@@ -18,42 +30,78 @@ async function loadSkin(path: string) {
const materials: Material[] = await gltf.parser.getDependencies("material"); const materials: Material[] = await gltf.parser.getDependencies("material");
const skin = { const skin = {
scene: gltf.scene, scene: gltf.scene,
materials: new Map(materials.map((material) => [material.name, material])), materials: new Map(
materials
.filter((material) => material.userData.camouflage)
.map((material) => [material.name, material]),
),
// taken now because rendering moves the parts out of the scene
parts: new Map(
gltf.scene.children.map((target) => [target.name, [...target.children]]),
),
mixer:
gltf.animations.length > 0 ? new AnimationMixer(gltf.scene) : undefined,
}; };
// bound now for the same reason, every exported clip loops in the garage
for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play();
for (const parts of skin.parts.values()) {
for (const part of parts) preparePart(part);
}
loadedSkins.set(path, skin); loadedSkins.set(path, skin);
return skin; return skin;
} }
// parts render as they are, so what jsxTree and skinTree would do happens here
function preparePart(part: Object3D) {
part.traverse((object) => {
if (object.userData.state !== undefined) {
for (const child of object.children) {
child.visible = nameOf(child) === object.userData.state;
}
}
if (!(object instanceof Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
// bounds are from the bind pose
if (object instanceof SkinnedMesh) object.frustumCulled = false;
prepareMaterial(object.material);
});
}
function skinPath(id: number, skin: number) { function skinPath(id: number, skin: number) {
return alias("models_legendary", `/${id}/skins/${skin}.glb`); return alias("models_legendary", `/${id}/skins/${skin}.glb`);
} }
export function useSkin(id: number) { // attachments load the same way as camouflages and can be shown with one
const requestedSkin = Tankopedia.use((state) => state.requestedSkin); export function useSkin(id: number, loader: "skin" | "attachment" = "skin") {
const skin = Tankopedia.use((state) => state.skin); const requested = Tankopedia.use((state) => state.loaders[loader].requested);
const loaded = Tankopedia.use((state) => state.loaders[loader].loaded);
useEffect(() => { useEffect(() => {
const path = if (requested === Tankopedia.state.loaders[loader].loaded) return;
requestedSkin === undefined ? undefined : skinPath(id, requestedSkin);
const path = requested === undefined ? undefined : skinPath(id, requested);
const cached = path === undefined || loadedSkins.has(path);
let settled = false; let settled = false;
if (path === undefined || loadedSkins.has(path)) { if (!cached) {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.loadedSkin = requestedSkin; draft.revealed = false;
}); });
return;
if (!skins.has(path)) skins.set(path, loadSkin(path));
} }
Tankopedia.mutate((draft) => { // swaps halfway through the lighting transition
draft.revealed = false;
});
if (!skins.has(path)) skins.set(path, loadSkin(path));
Promise.all([ Promise.all([
skins.get(path), path && skins.get(path),
new Promise((resolve) => new Promise((resolve) =>
setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3),
), ),
@@ -62,9 +110,8 @@ export function useSkin(id: number) {
settled = true; settled = true;
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.loadedSkin = requestedSkin; draft.loaders[loader].loaded = requested;
draft.skin = requestedSkin; if (!cached) draft.revealed = true;
draft.revealed = true;
}); });
}); });
@@ -72,11 +119,16 @@ export function useSkin(id: number) {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (cached) return;
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.revealed = true; draft.revealed = true;
}); });
}; };
}, [id, requestedSkin]); }, [id, loader, requested]);
return skin === undefined ? undefined : loadedSkins.get(skinPath(id, skin)); return loaded === undefined
? undefined
: loadedSkins.get(skinPath(id, loaded));
} }
@@ -0,0 +1,13 @@
import { invalidate, useFrame } from "@react-three/fiber";
import type { Skin } from "./useSkin";
export function useSkinAnimation(skins: Skin[]) {
useFrame((_, delta) => {
for (const { mixer } of skins) {
if (!mixer) continue;
mixer.update(delta);
invalidate();
}
});
}
@@ -6,6 +6,7 @@ import { literals } from "@blitzkit/i18n/src/literals";
import { Box, Flex, Link } from "@radix-ui/themes"; import { Box, Flex, Link } from "@radix-ui/themes";
import { AesonPlug } from "../../../components/AesonPlug"; import { AesonPlug } from "../../../components/AesonPlug";
import { Beg } from "../../../components/Beg"; import { Beg } from "../../../components/Beg";
import { Countdown } from "../../../components/Countdown";
import HomeHero from "../../../components/HomeHero.astro"; import HomeHero from "../../../components/HomeHero.astro";
import { Plugs } from "../../../components/Plugs"; import { Plugs } from "../../../components/Plugs";
import ToolCard from "../../../components/ToolCard.astro"; import ToolCard from "../../../components/ToolCard.astro";
@@ -66,6 +67,10 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, () => {
<BaseLayout {locale}> <BaseLayout {locale}>
<HomeHero /> <HomeHero />
<Countdown client:only="react">
<Countdown skeleton slot="fallback" />
</Countdown>
<Beg /> <Beg />
{ {
@@ -24,6 +24,7 @@ import { ModuleButton } from "../../../../components/ModuleButtons/ModuleButton"
import { PageWrapper } from "../../../../components/PageWrapper"; import { PageWrapper } from "../../../../components/PageWrapper";
import { ScreenshotButton } from "../../../../components/ScreenshotButton"; import { ScreenshotButton } from "../../../../components/ScreenshotButton";
import { TankSearch } from "../../../../components/TankSearch"; import { TankSearch } from "../../../../components/TankSearch";
import { DIALOG_MAX_HEIGHT } from "../../../../constants/dialog";
import { api } from "../../../../core/blitzkit/api"; import { api } from "../../../../core/blitzkit/api";
import { curateMixer } from "../../../../core/blitzkit/curateMixer"; import { curateMixer } from "../../../../core/blitzkit/curateMixer";
import { import {
@@ -117,7 +118,7 @@ function ModuleChooser({
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
<Dialog.Title> <Dialog.Title>
{strings.website.common.tank_search.select_dialog_title} {strings.website.common.tank_search.select_dialog_title}
</Dialog.Title> </Dialog.Title>
@@ -7,6 +7,7 @@ import {
} from "@blitzkit/core"; } from "@blitzkit/core";
import { import {
Document, Document,
Material,
Node, Node,
NodeIO, NodeIO,
PropertyType, PropertyType,
@@ -19,6 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three";
import { SKIN_NUMBER } from "../../../../constants/skinNumber"; import { SKIN_NUMBER } from "../../../../constants/skinNumber";
import { api } from "../../../../core/blitzkit/api"; import { api } from "../../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
import { switcherStates } from "../../../../core/blitzkit/switcherStates";
import { vfs } from "../../../../core/blitzkit/vfs"; import { vfs } from "../../../../core/blitzkit/vfs";
import { import {
extractMaterials, extractMaterials,
@@ -67,8 +69,10 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
const [nation, tankKey] = ( const [nation, tankKey] = (
include as { vehicle: { name: string } } include as { vehicle: { name: string } }
).vehicle.name.split(":"); ).vehicle.name.split(":");
const { preset, customEntities } = camouflagesYaml[key]; const { customEntities } = camouflagesYaml[key];
const skinNumber = Number(preset?.match(/(\d+)$/)?.[1] ?? 1); const preset =
camouflagesYaml[key].preset ??
camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset;
const parameters = await vfs.yaml<TankParameters>( const parameters = await vfs.yaml<TankParameters>(
`Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
); );
@@ -88,7 +92,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
scale: Vector3Tuple; scale: Vector3Tuple;
} }
> = {}; > = {};
const states: Record<string, string | null> = {};
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
for (const component of components) { for (const component of components) {
@@ -111,43 +114,50 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
scale: component["sc.attachmentScale"] as Vector3Tuple, scale: component["sc.attachmentScale"] as Vector3Tuple,
}; };
} }
if (component["comp.typename"] !== "StateSwitcherComponent") continue;
const active = component[`ssc.state${component["ssc.activeState"]}`];
// camos without their own variant keep the default piece, but groups
// of elements to hide stay hidden
let state: string | null =
active && !/skin\d*/.test(active) && !active.includes("hide")
? active
: null;
for (let index = 0; index < component["ssc.statesCount"]; index++) {
const name = component[`ssc.state${index}`];
const match = name.match(/skin(\d*)/);
if (
match &&
!name.includes("hide") &&
Number(match[1] || 1) === skinNumber
) {
state = name;
}
}
states[hierarchy.name] = state;
} }
return null; return null;
}); });
const attachments = new Map<string, string[]>();
for (const name in customEntities) {
const slot = slots[name];
if (!slot) continue;
attachments.set(slot.target, [
...(attachments.get(slot.target) ?? []),
customEntities[name].item,
]);
}
const states = switcherStates(sc2, preset, attachments);
// attachments are layered over camouflages, so they only keep what they change
if (camouflagesXml.root.camouflages[key].kind === "customization") {
const baseline = switcherStates(sc2, preset, new Map());
for (const name in states) {
if (states[name] === baseline[name]) delete states[name];
}
}
const document = new Document(); const document = new Document();
const scene = document.createScene().setExtras({ states }); const scene = document.createScene();
const targets = new Map<string, Node>(); const targets = new Map<string, Node>();
document.createBuffer(); document.createBuffer();
if (preset) await extractMaterials(document, sc2, basePath, preset); if (preset) {
const materials = await extractMaterials(document, sc2, basePath, preset);
// parts may share these names, so mark the ones that replace the tank's
for (const material of materials.values()) {
if (!(material instanceof Material)) continue;
material.setExtras({ ...material.getExtras(), camouflage: true });
}
}
for (const name in customEntities) { for (const name in customEntities) {
const slot = slots[name]; const slot = slots[name];
@@ -162,7 +172,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
continue; continue;
} }
const part = await extractModel(vfs, path, true); const part = await extractModel(vfs, path, true, preset);
const map = mergeDocuments(document, part); const map = mergeDocuments(document, part);
const partScene = map.get(part.getRoot().listScenes()[0]) as Scene; const partScene = map.get(part.getRoot().listScenes()[0]) as Scene;
const node = document const node = document
@@ -186,6 +196,8 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
target.addChild(node); target.addChild(node);
} }
scene.setExtras({ states });
applySkinNumber(document, SKIN_NUMBER); applySkinNumber(document, SKIN_NUMBER);
await document.transform( await document.transform(
@@ -8,18 +8,35 @@ import {
toUniqueId, toUniqueId,
VertexAttribute, VertexAttribute,
type ConfigArchive, type ConfigArchive,
type Hierarchy,
type TankParameters, type TankParameters,
type Textures, type Textures,
type VehicleDefinitionList, type VehicleDefinitionList,
} from "@blitzkit/core"; } from "@blitzkit/core";
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core"; import {
Document,
Material,
Node,
NodeIO,
Scene,
type Skin,
} from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, prune } from "@gltf-transform/functions"; import { dedup, prune } from "@gltf-transform/functions";
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { isEqual, times } from "lodash-es"; import { isEqual, times } from "lodash-es";
import { dirname } from "path"; import { dirname } from "path";
import sharp from "sharp"; import sharp from "sharp";
import type { Motion } from "../../../core/blitzkit/skinAnimations/motion";
import {
bindSocket,
createMotionClip,
createSkeleton,
loadMotion,
skinPrimitive,
} from "../../../core/blitzkit/skinAnimations/skeleton";
import { partGarage } from "../../../core/blitzkit/switcherStates";
import { vfs } from "../../../core/blitzkit/vfs"; import { vfs } from "../../../core/blitzkit/vfs";
import { import {
vertexAttributeGLTFName, vertexAttributeGLTFName,
@@ -65,7 +82,13 @@ export async function GET({ props }: APIContext<{ id: number }>) {
const ERROR_ON_UNKNOWN_COMPONENT = false; const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = { const omitMeshNames = {
start: ["chassis_chassis_", "chassis_track_crash_", "HP_"], start: [
"chassis_chassis_",
"chassis_track_crash_",
"HP_",
// mastery marks a new tank doesn't have yet
"custom_gun_marks_",
],
end: ["_POINT"], end: ["_POINT"],
}; };
@@ -73,6 +96,7 @@ export async function extractModel(
vfs: AbstractVFS, vfs: AbstractVFS,
path: string, path: string,
isPart = false, isPart = false,
preset?: string,
) { ) {
const sc2Path = `Data/3d/${path}.sc2`; const sc2Path = `Data/3d/${path}.sc2`;
const scgPath = `Data/3d/${path}.scg`; const scgPath = `Data/3d/${path}.scg`;
@@ -86,6 +110,35 @@ export async function extractModel(
const scene = document.createScene(); const scene = document.createScene();
const buffer = document.createBuffer(); const buffer = document.createBuffer();
const materials = await extractMaterials(document, sc2, path); const materials = await extractMaterials(document, sc2, path);
const skeletons = new Map<Hierarchy, ReturnType<typeof createSkeleton>>();
const jointBindings: [
owner: Node,
entity: string,
joint: Node,
skin: Skin,
][] = [];
const motions = new Map<Hierarchy, Motion>();
const garage = isPart ? partGarage(sc2, preset) : undefined;
if (isPart) {
const loading: Promise<void>[] = [];
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
for (const component of components) {
if (component["comp.typename"] !== "MotionComponent") continue;
loading.push(
loadMotion(vfs, path, component).then((motion) => {
motions.set(hierarchy, motion);
}),
);
}
return null;
});
await Promise.all(loading);
}
parseHierarchies<Scene | Node>( parseHierarchies<Scene | Node>(
sc2["#hierarchy"], sc2["#hierarchy"],
@@ -99,6 +152,40 @@ export async function extractModel(
const node = document.createNode(hierarchy.name); const node = document.createNode(hierarchy.name);
if (isPart) {
const skeleton = components.find(
(component) => component["comp.typename"] === "SkeletonComponent",
);
const motionComponent = components.find(
(component) => component["comp.typename"] === "MotionComponent",
);
const motion = motions.get(hierarchy);
if (skeleton) {
const created = createSkeleton(
document,
buffer,
node,
skeleton,
motion,
);
skeletons.set(hierarchy, created);
// only what loops in the garage is exported
if (motion && motionComponent && garage?.motions.has(hierarchy)) {
createMotionClip(
document,
buffer,
`motion${document.getRoot().listAnimations().length}`,
created.joints,
motion,
motionComponent,
);
}
}
}
components.forEach((component) => { components.forEach((component) => {
switch (component["comp.typename"]) { switch (component["comp.typename"]) {
case "LodComponent": case "LodComponent":
@@ -122,17 +209,44 @@ export async function extractModel(
case "StateSwitcherComponent": case "StateSwitcherComponent":
node.setExtras({ node.setExtras({
state: component[`ssc.state${component["ssc.activeState"]}`], // a part's scenarios can pick its state as it loads
state: garage
? (garage.states.get(hierarchy) ?? null)
: component[`ssc.state${component["ssc.activeState"]}`],
}); });
break; break;
case "SkeletonJointBindingComponent": {
const skeleton = skeletons.get(hierarchy);
if (!skeleton) break;
times(component.count, (index) => {
const binding = component.map[
index.toString().padStart(4, "0")
] as unknown as { entityUid: string; jointUid: string };
const joint = skeleton.joints.get(binding.jointUid);
if (!joint) throw new Error(`Missing joint ${binding.jointUid}`);
jointBindings.push([
node,
binding.entityUid,
joint,
skeleton.skin,
]);
});
break;
}
case "RenderComponent": { case "RenderComponent": {
const renderObject = component["rc.renderObj"]; const renderObject = component["rc.renderObj"];
times(renderObject["ro.batchCount"], (batchIndex): void => { times(renderObject["ro.batchCount"], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
if (lodIndex !== 0) return; // -1 means the batch has no lods and is always drawn
if (lodIndex !== 0 && !(isPart && lodIndex === -1)) return;
const batchKey = batchIndex.toString().padStart(4, "0"); const batchKey = batchIndex.toString().padStart(4, "0");
const batch = renderObject["ro.batches"][batchKey]; const batch = renderObject["ro.batches"][batchKey];
@@ -193,10 +307,24 @@ export async function extractModel(
primitive.setAttribute(name, attributeAccessor); primitive.setAttribute(name, attributeAccessor);
}); });
const skeleton = skeletons.get(hierarchy);
const mesh = document const mesh = document
.createMesh(batch["##name"]) .createMesh(batch["##name"])
.addPrimitive(primitive); .addPrimitive(primitive);
lodNode.setMesh(mesh); lodNode.setMesh(mesh);
if (
skeleton &&
skinPrimitive(
document,
buffer,
primitive,
attributes,
renderObject[`skinnedObject.batch${batchIndex}.targetsData`],
)
) {
lodNode.setSkin(skeleton.skin);
}
node.addChild(lodNode); node.addChild(lodNode);
}); });
@@ -219,6 +347,20 @@ export async function extractModel(
}, },
); );
for (const [owner, entity, joint, skin] of jointBindings) {
// the game only binds the skeleton entity's own children
const socket = owner
.listChildren()
.find(
(child) =>
child.getName() === entity && !skin.listJoints().includes(child),
);
if (!socket) throw new Error(`Missing socket ${entity}`);
bindSocket(socket, joint, skin);
}
scene.addChild(document.createNode("test")); scene.addChild(document.createNode("test"));
await document.transform(prune({ keepAttributes: true }), dedup()); await document.transform(prune({ keepAttributes: true }), dedup());
@@ -345,6 +487,27 @@ export async function extractMaterials(
} }
} }
const enabledPresets =
node.enabledPresets ?? defaultConfigArchive?.enabledPresets;
const fxName = node.fxName ?? defaultConfigArchive?.fxName;
if (enabledPresets?.AlphaBlend || enabledPresets?.AlphaBlendAdditive) {
material.setAlphaMode("BLEND");
}
if (enabledPresets?.AlphaBlendAdditive) {
// gltf has no additive blending so the viewer applies it
material.setExtras({ blending: "additive" });
}
if (fxName?.endsWith("/Textured.material")) {
// unlit in game, emissive so the viewer can fade it with the lights
material
.setEmissiveTexture(material.getBaseColorTexture())
.setEmissiveFactor([1, 1, 1])
.setBaseColorFactor([0, 0, 0, 1]);
}
if (textures.miscMap) { if (textures.miscMap) {
material.setOcclusionTexture( material.setOcclusionTexture(
document document
@@ -8,6 +8,7 @@ export interface CompareMember extends DuelMember {
export interface CompareEphemeral { export interface CompareEphemeral {
crewSkills: Record<string, number>; crewSkills: Record<string, number>;
equalize: boolean; equalize: boolean;
syncShells: boolean;
members: CompareMember[]; members: CompareMember[];
sorting?: { sorting?: {
direction: "ascending" | "descending"; direction: "ascending" | "descending";
@@ -21,5 +22,14 @@ export const CompareEphemeral = new Varuna<
>((crewSkills) => ({ >((crewSkills) => ({
crewSkills, crewSkills,
equalize: false, equalize: false,
syncShells: false,
members: [], members: [],
})); }));
export function syncShellSlot(members: CompareMember[], slot: number) {
for (const member of members) {
const shell = member.gun.shells[slot];
if (shell) member.shell = shell;
}
}
+15 -5
View File
@@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst {
All, All,
} }
interface LoaderState<Type> {
requested: Type;
loaded: Type;
}
interface Tankopedia { interface Tankopedia {
disturbed: boolean; disturbed: boolean;
revealed: boolean; revealed: boolean;
modelRequested: boolean; loaders: {
model: LoaderState<boolean>;
skin: LoaderState<number | undefined>;
attachment: LoaderState<number | undefined>;
};
shot?: Shot; shot?: Shot;
skills: Record<string, number>; skills: Record<string, number>;
relativeAgainst: TankopediaRelativeAgainst; relativeAgainst: TankopediaRelativeAgainst;
@@ -103,9 +112,6 @@ interface Tankopedia {
); );
xpMultiplier: (typeof XP_MULTIPLIERS)[number]; xpMultiplier: (typeof XP_MULTIPLIERS)[number];
customShell?: ShellDefinition; customShell?: ShellDefinition;
requestedSkin?: number;
loadedSkin?: number;
skin?: number;
requestedDisplay: TankopediaDisplay; requestedDisplay: TankopediaDisplay;
display: TankopediaDisplay; display: TankopediaDisplay;
statSearch?: string; statSearch?: string;
@@ -116,7 +122,11 @@ const skillDefinitions = await api.skillDefinitions();
export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({ export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({
disturbed: false, disturbed: false,
revealed: false, revealed: false,
modelRequested: App.state.autoLoadModels, loaders: {
model: { requested: App.state.autoLoadModels, loaded: false },
skin: { requested: undefined, loaded: undefined },
attachment: { requested: undefined, loaded: undefined },
},
relativeAgainst: TankopediaRelativeAgainst.Class, relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false, editStatic: false,
skills: createDefaultSkills(skillDefinitions), skills: createDefaultSkills(skillDefinitions),
+2
View File
@@ -0,0 +1,2 @@
cd packages/scripts
bun src/protos.ts
+2
View File
@@ -0,0 +1,2 @@
cd packages/website
bun x --bun astro build
+4
View File
@@ -0,0 +1,4 @@
rm -rf \
packages/website/.astro \
packages/website/node_modules/.astro \
packages/website/node_modules/.vite
+2
View File
@@ -0,0 +1,2 @@
cd packages/website
bun x --bun astro dev
+2
View File
@@ -0,0 +1,2 @@
cd packages/scripts
bun src/discoverIds/index.ts
+2
View File
@@ -0,0 +1,2 @@
cd packages/scripts
bun src/downloadClient/index.ts
+2
View File
@@ -0,0 +1,2 @@
cd packages/closed
bun src/dava/downloadSmartDLC.ts
+2
View File
@@ -0,0 +1,2 @@
cd packages/website
bun x --bun astro check
+2
View File
@@ -0,0 +1,2 @@
cd packages/website
bun x --bun astro preview
+2
View File
@@ -0,0 +1,2 @@
cd packages/scripts
bun src/recordPerformance/index.ts
+2
View File
@@ -0,0 +1,2 @@
chmod +x scripts/*.sh
bun install
Executable
+2
View File
@@ -0,0 +1,2 @@
cd packages/scripts
bun src/vfs/index.ts