Compare commits

...
22 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
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
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
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
34 changed files with 1042 additions and 248 deletions

No files matched your search

+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 }}
+23 -9
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,11 +110,20 @@ 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 || github.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=="],
-11
View File
@@ -3,17 +3,6 @@
"version": "1.33.0", "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/*"
@@ -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;
+5 -1
View File
@@ -158,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";
-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",
@@ -9,6 +9,7 @@ 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";
@@ -36,12 +37,17 @@ export function TankModel() {
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined); const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
useTankTransform(track, turret, turretContainer, gunContainer); useTankTransform(track, turret, turretContainer, gunContainer);
useSkinAnimation(skins);
useEffect(() => { useEffect(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>(); const emissiveMaterials = new Set<MeshStandardMaterial>();
const roots = [
gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
];
for (const scene of [gltf.scene, ...skins.map((skin) => skin.scene)]) { for (const root of roots) {
scene.traverse((object) => { root.traverse((object) => {
if ( if (
object instanceof Mesh && object instanceof Mesh &&
object.material instanceof MeshStandardMaterial && object.material instanceof MeshStandardMaterial &&
@@ -144,6 +150,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
@@ -221,6 +236,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
@@ -302,6 +326,15 @@ export function TankModel() {
/> />
); );
}, },
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
}); });
})} })}
</group> </group>
@@ -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 -8
View File
@@ -1,5 +1,21 @@
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { AdditiveBlending, 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,13 +42,7 @@ export function jsxTree(
scale: node.scale, scale: node.scale,
}; };
if (props.material instanceof MeshStandardMaterial) { prepareMaterial(props.material);
props.material.depthWrite = true;
if (props.material.userData.blending === "additive") {
props.material.blending = AdditiveBlending;
}
}
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;
}
+20 -26
View File
@@ -5,24 +5,24 @@ import { jsxTree } from "./jsxTree";
type Mergers = Parameters<typeof jsxTree>[1]; type Mergers = Parameters<typeof jsxTree>[1];
// names get sanitized and made unique on load // names get sanitized and made unique on load
function nameOf(object3d: Object3D): string { export function nameOf(object3d: Object3D): string {
return object3d.userData.name ?? object3d.name; return object3d.userData.name ?? object3d.name;
} }
/** /**
* skins are layered in order, so an attachment's states win over the * skins are layered in order, so an attachment's states win over the
* camouflage's * camouflage's; `primitive` renders customization parts as they are so their
* bones can animate
*/ */
export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) { export function skinTree(
const skinNodes = new Map<string, Object3D[]>(); node: Object3D,
skins: Skin[],
mergers: NonNullable<Mergers>,
) {
const materials = new Map<string, Material>(); const materials = new Map<string, Material>();
const states: Record<string, string | null> = {}; const states: Record<string, string | null> = {};
for (const skin of skins) { for (const skin of skins) {
for (const node of skin.scene.children) {
skinNodes.set(node.name, [...(skinNodes.get(node.name) ?? []), node]);
}
for (const [name, material] of skin.materials) { for (const [name, material] of skin.materials) {
materials.set(name, material); materials.set(name, material);
} }
@@ -30,7 +30,7 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
Object.assign(states, skin.scene.userData.states); Object.assign(states, skin.scene.userData.states);
} }
function switchChildren(object3d: Object3D, childMergers: Mergers) { function switchChildren(object3d: Object3D) {
if (object3d.userData.state === undefined) return; if (object3d.userData.state === undefined) return;
const name = nameOf(object3d); const name = nameOf(object3d);
@@ -38,37 +38,31 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
return object3d.children return object3d.children
.filter((child) => nameOf(child) === state) .filter((child) => nameOf(child) === state)
.map((child) => jsxTree(child, childMergers)); .map((child) => jsxTree(child, skinMergers));
} }
const partMergers: Mergers = {
...mergers,
group(object3d, props, key) {
return (
<group key={key} {...props}>
{switchChildren(object3d, partMergers) ?? props.children}
</group>
);
},
};
const skinMergers: Mergers = { const skinMergers: Mergers = {
mesh(mesh, props, key) { mesh(mesh, props, key) {
const material = 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 parts = skinNodes.get(object3d.name) ?? [];
return ( return (
<group key={key} {...props}> <group key={key} {...props}>
{switchChildren(object3d, skinMergers) ?? props.children} {switchChildren(object3d) ?? props.children}
{parts.flatMap((part) => {skins.flatMap((skin) =>
part.children.map((child) => jsxTree(child, partMergers)), (skin.parts.get(object3d.name) ?? []).map((part) =>
mergers.primitive ? (
mergers.primitive(part, part.uuid)
) : (
<primitive key={part.uuid} object={part} />
),
),
)} )}
</group> </group>
); );
@@ -1,5 +1,9 @@
import { parseHierarchies, type Sc2ReadStream } from "@blitzkit/core"; import {
import { times } from "lodash-es"; parseHierarchies,
type Hierarchy,
type Sc2ReadStream,
} from "@blitzkit/core";
import { sortBy, times } from "lodash-es";
interface Value { interface Value {
"any.type": string; "any.type": string;
@@ -17,12 +21,15 @@ interface ScriptNode {
interface Script { interface Script {
nodes: Record<string, ScriptNode>; nodes: Record<string, ScriptNode>;
variables?: Record<string, Value>;
} }
interface Action { interface Action {
"act.delay": number; "act.delay": number;
"act.entityName": string; "act.entityName": string;
"act.event": number; "act.event": number;
"act.eventToTrigger": string;
"act.stopAfterNRepeats": number;
"act.switchIndex": number; "act.switchIndex": number;
"act.type": number; "act.type": number;
"act.userEventId": string; "act.userEventId": string;
@@ -32,12 +39,17 @@ interface Entity {
name: string; name: string;
parent?: Entity; parent?: Entity;
children: Entity[]; children: Entity[];
hierarchy?: Hierarchy;
repeats?: number;
} }
const SELF_ENTITY = "*** Self ***"; const SELF_ENTITY = "*** Self ***";
const SCENE_LOADED_EVENT = 1; const SCENE_LOADED_EVENT = 1;
const USER_EVENT = 2; const USER_EVENT = 2;
const ANIMATION_END_EVENTS = [3, 8];
const START_ANIMATION_ACTION = 6;
const SWITCH_ACTIONS = [8, 9]; const SWITCH_ACTIONS = [8, 9];
const TRIGGER_EVENT_ACTION = 10;
// what the game fires as a tank is shown in the garage, in order // what the game fires as a tank is shown in the garage, in order
const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"]; const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"];
const MAX_DEPTH = 64; const MAX_DEPTH = 64;
@@ -72,25 +84,141 @@ function resolveTarget(owner: Entity, entityName: string) {
} }
/** /**
* replays what picks each state switcher's state as a tank is shown in the * replays what the game does as a tank is shown in the garage, with the camo's
* garage, with the camo's preset applied and its items attached; delayed * preset applied and its items attached; `animate` follows customization parts
* actions are skipped so states stay as they are when the tank appears * through their motions, delays and alarms until they settle
*/ */
export function switcherStates( function replay(
sc2: ReturnType<Sc2ReadStream["sc2"]>, sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined, preset: string | undefined,
attachments: Map<string, string[]>, attachments: Map<string, string[]>,
animate: boolean,
) { ) {
const scripts: Record<string, Script> = {}; const scripts: Record<string, Script> = {};
const root: Entity = { name: "", children: [] };
const stateNames = new Map<Entity, string[]>(); const stateNames = new Map<Entity, string[]>();
const scenarios: { owner: Entity; script: Script }[] = []; const scenarios: {
owner: Entity;
script: Script;
variables: Map<string, unknown>;
}[] = [];
const actions: { owner: Entity; action: Action }[] = []; const actions: { owner: Entity; action: Action }[] = [];
const states: Record<string, string | null> = {}; // 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) { function activateState(entity: Entity | undefined, state: number) {
const names = entity && stateNames.get(entity); const names = entity && stateNames.get(entity);
if (names) states[entity.name] = names[state] ?? null; 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( for (const component of Object.values(
@@ -103,9 +231,14 @@ export function switcherStates(
parseHierarchies<Entity>( parseHierarchies<Entity>(
sc2["#hierarchy"], sc2["#hierarchy"],
{ name: "", children: [] }, root,
(hierarchy, components, parent) => { (hierarchy, components, parent) => {
const entity: Entity = { name: hierarchy.name, parent, children: [] }; const entity: Entity = {
name: hierarchy.name,
parent,
children: [],
hierarchy,
};
parent.children.push(entity); parent.children.push(entity);
@@ -122,14 +255,24 @@ export function switcherStates(
); );
stateNames.set(entity, names); stateNames.set(entity, names);
states[entity.name] = names[component["ssc.activeState"]] ?? null; states.set(entity, names[component["ssc.activeState"]] ?? null);
break; break;
} }
case "ScenarioComponent": { case "ScenarioComponent": {
const script = scripts[component["scenario.scriptId"]]; const script = scripts[component["scenario.scriptId"]];
if (script) scenarios.push({ owner: entity, script }); if (script) {
scenarios.push({
owner: entity,
script,
variables: new Map(
Object.entries(script.variables ?? {}).map(
([name, variable]) => [name, variable["any.value"]],
),
),
});
}
break; break;
} }
@@ -141,6 +284,12 @@ export function switcherStates(
}); });
}); });
break; break;
case "MotionComponent":
if (animate) {
entity.repeats = component["simpleMotion.repeatsCount"];
}
break;
} }
} }
@@ -149,9 +298,11 @@ export function switcherStates(
); );
function runScenario( function runScenario(
{ owner, script }: (typeof scenarios)[number], scenario: (typeof scenarios)[number],
event: string, event: string,
value?: number,
) { ) {
const { owner, script, variables } = scenario;
const { nodes } = script; const { nodes } = script;
const links = (id: string) => Object.values(nodes[id].connections ?? {}); const links = (id: string) => Object.values(nodes[id].connections ?? {});
@@ -168,30 +319,78 @@ export function switcherStates(
switch (type) { switch (type) {
case "ScenarioSceneInfoNode": case "ScenarioSceneInfoNode":
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentEntity" switch (node["scenarioSceneInfoNode.infoKind"]) {
? owner case "GetCurrentEntity":
: undefined; return owner;
case "GetCurrentScene":
return root;
}
return undefined;
case "ScenarioGetMemberNode": case "ScenarioGetVarNode":
return node["scenarioGetMemberNode.fieldName"] === "parent" && return variables.get(node["scenarioGetVarNode.varPath"] as string);
pin === "parent"
? (input(id, "object") as Entity | undefined)?.parent
: undefined;
case "ScenarioFunctionNode": case "ScenarioSetVarNode":
switch ( return variables.get(node["scenarioSetVarNode.varPath"] as string);
`${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`
) { 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": { case "Entity.FindChildByName": {
const entity = input(id, "entity") as Entity | undefined; const entity = input(id, "entity") as Entity | undefined;
return entity && findChild(entity, input(id, "name") as string); 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": case "TankComponent.GetAppliedSkinMaterialConfigName":
return preset; return preset;
case "String.Compare": case "String.Compare":
return input(id, "1") === input(id, "2"); 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");
} }
} }
} }
@@ -206,13 +405,41 @@ export function switcherStates(
pin = "event"; pin = "event";
} else if (type === "ScenarioBranchNode") { } else if (type === "ScenarioBranchNode") {
pin = input(id, "condition") ? "true" : "false"; pin = input(id, "condition") ? "true" : "false";
} else if ( } else if (type === "ScenarioSetVarNode") {
node["scenarioFunctionNode.functionName"] === "ActivateState" variables.set(
) { node["scenarioSetVarNode.varPath"] as string,
activateState( input(id, "set"),
input(id, "entity") as Entity | undefined,
Number(input(id, "state")),
); );
} 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)) { for (const link of links(id)) {
@@ -227,29 +454,87 @@ export function switcherStates(
? node.defaults?.["Event Name"]?.["any.value"] ? node.defaults?.["Event Name"]?.["any.value"]
: node["ScenarioEventNode.eventName"]; : node["ScenarioEventNode.eventName"];
if (type === "ScenarioEventNode" && name === event) execute(id, 0); if (
type === "ScenarioEventNode" &&
name === event &&
(event !== "State Activated" ||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
) {
execute(id, 0);
}
} }
} }
for (const event of LOAD_EVENTS) { function fireEvent(event: string) {
for (const { owner, action } of actions) { 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 ( if (
action["act.delay"] === 0 && (animate || action["act.delay"] === 0) &&
SWITCH_ACTIONS.includes(action["act.type"]) &&
(event === LOAD_EVENTS[0] (event === LOAD_EVENTS[0]
? action["act.event"] === SCENE_LOADED_EVENT ? action["act.event"] === SCENE_LOADED_EVENT
: action["act.event"] === USER_EVENT && : action["act.event"] === USER_EVENT &&
action["act.userEventId"] === event) action["act.userEventId"] === event)
) { ) {
activateState( runAction(owner, action);
resolveTarget(owner, action["act.entityName"]),
action["act.switchIndex"],
);
} }
} }
for (const scenario of scenarios) runScenario(scenario, event); 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; 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);
}
+46 -1
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>>();
@@ -23,13 +35,46 @@ async function loadSkin(path: string) {
.filter((material) => material.userData.camouflage) .filter((material) => material.userData.camouflage)
.map((material) => [material.name, material]), .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`);
} }
@@ -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();
}
});
}
@@ -172,13 +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 partSc2 = new Sc2ReadStream(
(await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer,
).sc2();
Object.assign(states, switcherStates(partSc2, preset, new Map()));
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
@@ -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,
@@ -79,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`;
@@ -92,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"],
@@ -105,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":
@@ -128,10 +209,36 @@ 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"];
@@ -200,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);
}); });
@@ -226,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());
+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