mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-08 10:20:37 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d3c59e97 | ||
|
|
1cb760e227 | ||
|
|
ce27388c00 | ||
|
|
e8b2651595 | ||
|
|
3695e2cd5e | ||
|
|
01445cf845 | ||
|
|
b121147b43 | ||
|
|
b3b56462d1 | ||
|
|
d42cccc3c2 | ||
|
|
c592501f48 | ||
|
|
5f4cf15500 | ||
|
|
232b3e7770 | ||
|
|
99373872ed | ||
|
|
fddf407cc0 | ||
|
|
88346308cb | ||
|
|
ebedac0642 | ||
|
|
a2b97380ac | ||
|
|
de4094554e | ||
|
|
cdcec8548a | ||
|
|
5ffe5a0a9f | ||
|
|
7fb73edb87 | ||
|
|
1bc0561dbc | ||
|
|
c82d2ccf1c | ||
|
|
15a08b934b | ||
|
|
f4894655b0 | ||
|
|
e4b49e1600 | ||
|
|
7bfd930cea | ||
|
|
71e9e6fd5b | ||
|
|
be0be90bdb | ||
|
|
62637418e5 | ||
|
|
ff507ed4e6 | ||
|
|
b65229dc80 | ||
|
|
4a6a5b3d34 | ||
|
|
2150fb3758 | ||
|
|
682f039c6c | ||
|
|
0632899ecf | ||
|
|
72758e9ff7 | ||
|
|
8566b2cc2f | ||
|
|
8028446788 | ||
|
|
e216b4cd6b | ||
|
|
c535811c1d | ||
|
|
c772d47a07 | ||
|
|
523fee5010 | ||
|
|
e5be69dd37 | ||
|
|
bddcf10e90 | ||
|
|
484d4ae3da | ||
|
|
0f299335d6 | ||
|
|
091607b0a8 | ||
|
|
ffb5a1f9c8 | ||
|
|
85e03d3b42 | ||
|
|
a0c54b50fc | ||
|
|
6e30da4978 | ||
|
|
b45f281cb9 | ||
|
|
2288c089e8 | ||
|
|
d93b202ee8 |
No files matched your search
@@ -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.
|
||||
options:
|
||||
- Production
|
||||
- Beta
|
||||
- Preview
|
||||
- OpenTest
|
||||
default: 0
|
||||
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Set up script
|
||||
run: scripts/setup.sh
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Discover IDs
|
||||
run: bun run discover-ids
|
||||
run: scripts/discover-ids.sh
|
||||
env:
|
||||
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
|
||||
GH_TOKEN: ${{ secrets.IDS_TOKEN }}
|
||||
@@ -89,6 +89,9 @@ env:
|
||||
# wargaming
|
||||
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
|
||||
|
||||
# workflow
|
||||
SHOULD_USE_BLITZKIT_CLOSED: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build website
|
||||
@@ -107,10 +110,19 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# the PR's ref, or the manual input, or production (selected when cron)
|
||||
ref: ${{ github.event.pull_request.head.sha || inputs.branch || 'production' }}
|
||||
submodules: recursive
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
submodules: false
|
||||
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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -125,19 +137,21 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y aria2 libarchive-tools
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Set up script
|
||||
run: scripts/setup.sh
|
||||
|
||||
- name: Lint
|
||||
run: scripts/lint.sh
|
||||
|
||||
- name: Download client
|
||||
run: bun run download-client
|
||||
run: scripts/download-client.sh
|
||||
|
||||
- name: Download SmartDLC
|
||||
if: ${{ env.CLIENT_PROVIDER == 'steam' }}
|
||||
|
||||
run: bun run download-smart-dlc
|
||||
if: ${{ env.CLIENT_PROVIDER == 'steam' && env.SHOULD_USE_BLITZKIT_CLOSED == 'true' }}
|
||||
run: scripts/download-smart-dlc.sh
|
||||
|
||||
- name: Build
|
||||
run: bun run --bun build
|
||||
run: scripts/build-website.sh
|
||||
env:
|
||||
CHUNK: ${{ matrix.chunk }}
|
||||
|
||||
|
||||
@@ -44,16 +44,16 @@ cd blitzkit
|
||||
> [!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.
|
||||
|
||||
Then, download the dependencies with Bun.
|
||||
Then, grand execution permission to the setup script.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
chmod +x scripts/setup.sh
|
||||
```
|
||||
|
||||
Finally, compile the proto files into TypeScript.
|
||||
Finally, run the setup script.
|
||||
|
||||
```bash
|
||||
build build-protos
|
||||
scripts/setup.sh
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
scripts/dev-website.sh
|
||||
```
|
||||
|
||||
If you ever make a change to any proto file, make sure to rebuild them.
|
||||
|
||||
```bash
|
||||
build build-protos
|
||||
scripts/build-protos.sh
|
||||
```
|
||||
@@ -55,7 +55,6 @@
|
||||
"packages/scripts": {
|
||||
"name": "@blitzkit/scripts",
|
||||
"dependencies": {
|
||||
"@blitzkit/closed": "workspace:*",
|
||||
"@blitzkit/core": "workspace:*",
|
||||
"@blitzkit/i18n": "workspace:*",
|
||||
"@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=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ=="],
|
||||
|
||||
"jpeg-js": ["[email protected]", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
@@ -1951,7 +1948,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -2733,8 +2730,6 @@
|
||||
|
||||
"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/vscode-languageserver": ["[email protected]", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="],
|
||||
|
||||
@@ -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
@@ -1,19 +1,8 @@
|
||||
{
|
||||
"name": "blitzkit",
|
||||
"version": "1.33.0-beta.1",
|
||||
"version": "1.33.0",
|
||||
"license": "BlitzKit License",
|
||||
"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",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+1
-1
Submodule packages/closed updated: f89f0e0772...182fcdb273.
@@ -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;
|
||||
|
||||
if (camo.group === "legendary" && yamlEntry.preset !== undefined) {
|
||||
if (preset !== undefined) {
|
||||
if (!skinPresets.has(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) {
|
||||
@@ -110,6 +115,7 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
|
||||
tank_name: resolvedTankName,
|
||||
tank_name_full: resolvedTankNameFull,
|
||||
tank_id: tankId,
|
||||
attachment: camo.kind === "customization",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,14 +79,8 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
|
||||
I_HAT,
|
||||
-MathUtils.degToRad(turretRotation.pitch),
|
||||
)
|
||||
.applyAxisAngle(
|
||||
J_HAT,
|
||||
-MathUtils.degToRad(turretRotation.roll),
|
||||
)
|
||||
.applyAxisAngle(
|
||||
K_HAT,
|
||||
-MathUtils.degToRad(turretRotation.yaw),
|
||||
);
|
||||
.applyAxisAngle(J_HAT, -MathUtils.degToRad(turretRotation.roll))
|
||||
.applyAxisAngle(K_HAT, -MathUtils.degToRad(turretRotation.yaw));
|
||||
}
|
||||
|
||||
const coordinates = position.toArray();
|
||||
@@ -298,23 +292,29 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
|
||||
|
||||
turret.userString;
|
||||
|
||||
const turretCollisionName = parsePath(
|
||||
let turretCollisionName = parsePath(
|
||||
turret.hitTester.collisionModel,
|
||||
).name.toLowerCase();
|
||||
const isFullYaw = -turretYaw[0] + turretYaw[1] === 360;
|
||||
|
||||
if (!collisionBoundingBoxes[turretCollisionName] && isFullYaw) {
|
||||
throw new Error(
|
||||
`Missing turret collision mesh for ${nation}-${tankKey} ${turretKey}`,
|
||||
turretCollisionName = "turret_01";
|
||||
|
||||
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[
|
||||
turretCollisionName
|
||||
] ?? {
|
||||
min: [0, 0, 0] as Vector3Tuple,
|
||||
max: [0, 0, 0] as Vector3Tuple,
|
||||
};
|
||||
let turretBoundingBox = collisionBoundingBoxes[turretCollisionName];
|
||||
|
||||
if (turretBoundingBox === undefined) {
|
||||
console.warn("HACK: missing collision; using 0's");
|
||||
turretBoundingBox = { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
}
|
||||
|
||||
modelDefinitions.models[tankId].turrets[turretId] = {
|
||||
armor: turretArmor,
|
||||
@@ -342,9 +342,18 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 {
|
||||
)
|
||||
.split(" ")
|
||||
.map(Number) as [number, number];
|
||||
const gunModel = Number(
|
||||
let gunModel = Number(
|
||||
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
|
||||
? gun.extraPitchLimits.front.split(" ").map(Number)
|
||||
: undefined;
|
||||
|
||||
@@ -14,4 +14,5 @@ message Camouflage {
|
||||
optional I18nString tank_name = 3;
|
||||
optional I18nString tank_name_full = 4;
|
||||
optional uint32 tank_id = 5;
|
||||
optional bool attachment = 6;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface Camouflage {
|
||||
tank_name?: I18nString | undefined;
|
||||
tank_name_full?: I18nString | undefined;
|
||||
tank_id?: number | undefined;
|
||||
attachment?: boolean | undefined;
|
||||
}
|
||||
|
||||
function createBaseCamouflageDefinitions(): CamouflageDefinitions {
|
||||
@@ -192,7 +193,7 @@ export const CamouflageDefinitions_CamouflagesEntry: MessageFns<CamouflageDefini
|
||||
};
|
||||
|
||||
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> = {
|
||||
@@ -212,6 +213,9 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
if (message.tank_id !== undefined && message.tank_id !== 0) {
|
||||
writer.uint32(40).uint32(message.tank_id);
|
||||
}
|
||||
if (message.attachment !== undefined && message.attachment !== false) {
|
||||
writer.uint32(48).bool(message.attachment);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -262,6 +266,14 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
message.tank_id = reader.uint32();
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 48) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.attachment = reader.bool();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -278,6 +290,7 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
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_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) {
|
||||
obj.tank_id = Math.round(message.tank_id);
|
||||
}
|
||||
if (message.attachment !== undefined && message.attachment !== false) {
|
||||
obj.attachment = message.attachment;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -317,6 +333,7 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
? I18nString.fromPartial(object.tank_name_full)
|
||||
: undefined;
|
||||
message.tank_id = object.tank_id ?? 0;
|
||||
message.attachment = object.attachment ?? false;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ConfigArchive {
|
||||
|
||||
interface EnabledPresets {
|
||||
AlphaTest: boolean;
|
||||
AlphaBlend?: boolean;
|
||||
AlphaBlendAdditive?: boolean;
|
||||
}
|
||||
|
||||
interface Flags {
|
||||
@@ -156,7 +158,11 @@ export type Component =
|
||||
"ro.notShadowOnly": boolean;
|
||||
"ro.sOclCull": boolean;
|
||||
"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";
|
||||
|
||||
@@ -5,5 +5,10 @@ export type CamouflagesYaml = Record<
|
||||
shortUserString?: string;
|
||||
preset?: string;
|
||||
customEntities?: Record<string, { item: string }>;
|
||||
// nation -> tank
|
||||
override?: Record<
|
||||
string,
|
||||
{ override?: Record<string, { preset?: string }> }
|
||||
>;
|
||||
}
|
||||
>;
|
||||
@@ -744,7 +744,8 @@
|
||||
"none": "No deltas",
|
||||
"percentage": "Percentage",
|
||||
"absolute": "Absolute"
|
||||
}
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
},
|
||||
|
||||
"no_tanks": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"shells": {
|
||||
"ap": "AP",
|
||||
"ap_cr": "APCR",
|
||||
"hc": "AEAC",
|
||||
"hc": "HEAT",
|
||||
"he": "AE"
|
||||
},
|
||||
"player_count_period": {
|
||||
@@ -187,7 +187,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Cambiar configuración de BlitzKit",
|
||||
"description": "Change BlitzKit settings",
|
||||
"dev_mode": "Modo de desarrollador",
|
||||
"auto_load_models": "Auto-load models",
|
||||
"language": "Idioma"
|
||||
@@ -679,8 +679,7 @@
|
||||
"none": "Sin deltas",
|
||||
"percentage": "Porcentaje",
|
||||
"absolute": "Absoluto"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "No hay tanques seleccionados",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Todo sobre World of Tanks Blitz",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "No afiliado con Wargaming u otros servicios",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"d_1": "Hier"
|
||||
},
|
||||
"branches": {
|
||||
"beta": "Bêta",
|
||||
"beta": "Beta",
|
||||
"development": "Development",
|
||||
"opentest": "Test ouvert",
|
||||
"preview": "Aperçu",
|
||||
"opentest": "OpenTest",
|
||||
"preview": "Preview",
|
||||
"production": "Production"
|
||||
},
|
||||
"locales": {
|
||||
@@ -189,7 +189,7 @@
|
||||
"title": "Paramètres",
|
||||
"description": "Modifier les paramètres de BlitzKit",
|
||||
"dev_mode": "Mode développement",
|
||||
"auto_load_models": "Modèles à chargement automatique",
|
||||
"auto_load_models": "Auto-load models",
|
||||
"language": "Langue"
|
||||
},
|
||||
"common": {
|
||||
@@ -236,69 +236,69 @@
|
||||
"fire.caliber": "calibration",
|
||||
"fire.standardPenetration": "pénétration standard",
|
||||
"fire.premiumPenetration": "pénétration premium",
|
||||
"fire.tertiaryPenetration": "pénétration tertiaire",
|
||||
"fire.damage": "dégât",
|
||||
"fire.moduleDamage": "Dégâts aux modules",
|
||||
"fire.shellVelocity": "Vitesse des obus",
|
||||
"fire.shellRange": "Portée des obus",
|
||||
"fire.shellCapacity": "Capacité d'obus",
|
||||
"fire.aimTime": "Temps de visé",
|
||||
"fire.dispersionStill": "Dispersion à l'arrêt",
|
||||
"fire.dispersionMoving": "Dispersion en mouvement",
|
||||
"fire.gunDepression": "Dépression du canon",
|
||||
"fire.gunElevation": "élévation du canon",
|
||||
"maneuverability.forwardsSpeed": "Vitesse en marche avant",
|
||||
"maneuverability.backwardsSpeed": "Vitesse en marche arrière",
|
||||
"maneuverability.power": "Puissance",
|
||||
"maneuverability.powerToWeight": "Rapport puissance/poids",
|
||||
"maneuverability.weight": "Poids",
|
||||
"maneuverability.traverseSpeed": "vitesse de traverse"
|
||||
"fire.tertiaryPenetration": "tertiary penetration",
|
||||
"fire.damage": "damage",
|
||||
"fire.moduleDamage": "module damage",
|
||||
"fire.shellVelocity": "shell velocity",
|
||||
"fire.shellRange": "shell range",
|
||||
"fire.shellCapacity": "shell capacity",
|
||||
"fire.aimTime": "aim time",
|
||||
"fire.dispersionStill": "dispersion still",
|
||||
"fire.dispersionMoving": "dispersion moving",
|
||||
"fire.gunDepression": "gun depression",
|
||||
"fire.gunElevation": "gun elevation",
|
||||
"maneuverability.forwardsSpeed": "forwards speed",
|
||||
"maneuverability.backwardsSpeed": "backwards speed",
|
||||
"maneuverability.power": "power",
|
||||
"maneuverability.powerToWeight": "power to weight",
|
||||
"maneuverability.weight": "weight",
|
||||
"maneuverability.traverseSpeed": "traverse speed"
|
||||
},
|
||||
"sort_dropdown": {
|
||||
"label": "Trier",
|
||||
"label": "Sort",
|
||||
"groups": {
|
||||
"sort": {
|
||||
"no_sort": "Pas trier",
|
||||
"label": "Trier par",
|
||||
"fire": "Feu",
|
||||
"maneuverability": "Manœuvrabilité",
|
||||
"survivability": "Survivabilité"
|
||||
"no_sort": "No sort",
|
||||
"label": "Sort by",
|
||||
"fire": "Fire",
|
||||
"maneuverability": "Maneuverability",
|
||||
"survivability": "Survivability"
|
||||
},
|
||||
"direction": {
|
||||
"label": "Direction",
|
||||
"ascending": "Croissant",
|
||||
"descending": "Décroissant"
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"test": "Les chars en phase de test sont susceptibles de changer et peuvent ne pas représenter le produit final."
|
||||
"test": "Tanks in testing are subject to change and may not represent the final product."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"discord": {
|
||||
"join_short": "Rejoins des gens géniaux",
|
||||
"join_long": "Rejoins des gens super géniaux",
|
||||
"join_short": "Join cool people!",
|
||||
"join_long": "Join super duper cool people!",
|
||||
"action": "Discord"
|
||||
},
|
||||
"hero": {
|
||||
"credit": "Par {name}❤️"
|
||||
"credit": "By {name} ❤️"
|
||||
},
|
||||
"hot_tanks": {
|
||||
"title": "Chars populaire",
|
||||
"subtitle": "Dernier {days} jours"
|
||||
"title": "Popular tanks",
|
||||
"subtitle": "Last {days} days"
|
||||
},
|
||||
"other_projects": {
|
||||
"title": "Autres projets géniaux"
|
||||
"title": "Other cool projects"
|
||||
},
|
||||
"help_translate": "Aidez-nous à traduire {name} en {language} !",
|
||||
"seo_description": "Outils pour tout World of Tanks Blitz"
|
||||
"help_translate": "Please help translate {name} to {language}!",
|
||||
"seo_description": "Tools for everything World of Tanks Blitz"
|
||||
},
|
||||
"docs": {
|
||||
"patches": {
|
||||
"name": "Journal des modifications",
|
||||
"seo_description": "Changelogs pour le bot Discord BlitzKit et site Web"
|
||||
"name": "BlitzKit Changelogs",
|
||||
"seo_description": "Changelogs for BlitzKit Discord bot and website"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -314,22 +314,22 @@
|
||||
"remove": "Remove played",
|
||||
"tracking": "BlitzKit is tracking your battles",
|
||||
"sort": {
|
||||
"button": "Trier par",
|
||||
"last_played": "Dernier joué",
|
||||
"button": "Sort by",
|
||||
"last_played": "Last played",
|
||||
"least_played": "Least played"
|
||||
},
|
||||
"table": {
|
||||
"index": "#",
|
||||
"never": "Jamais",
|
||||
"tank": "Char d'assaut",
|
||||
"last_played": "Dernier joué",
|
||||
"never": "Never",
|
||||
"tank": "Tank",
|
||||
"last_played": "Last played",
|
||||
"battles": "Career battles",
|
||||
"unrecorded": "--",
|
||||
"wins": "Victoires"
|
||||
},
|
||||
"large_warning": {
|
||||
"title": "That's a lot of tanks!",
|
||||
"description": "Êtes-vous sûr de vouloir créer une playlist de {count} chars ? C'est {time} heures de jeu à 50% de taux de victoire !",
|
||||
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
|
||||
"cancel": "Cancel",
|
||||
"proceed": "Proceed"
|
||||
},
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"stats": "{correct} / {total} guessed correctly",
|
||||
"help": {
|
||||
"title": "Révéler partiellement le char ?",
|
||||
"title": "Partially reveal tank?",
|
||||
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
|
||||
"cancel": "Cancel",
|
||||
"reveal": "Reveal"
|
||||
@@ -373,7 +373,7 @@
|
||||
},
|
||||
"tankopedia": {
|
||||
"search": "Search a stat...",
|
||||
"name": "Tankopédie",
|
||||
"name": "Tankopedia",
|
||||
"button": "Find tanks",
|
||||
"description": "Blitz tank encyclopedia",
|
||||
"seo_description": "Statistics, armor, and more for all tanks in World of Tanks Blitz",
|
||||
@@ -657,10 +657,10 @@
|
||||
"loader": "Loader"
|
||||
},
|
||||
"maneuverability": {
|
||||
"title": "Manœuvrabilité"
|
||||
"title": "Maneuverability"
|
||||
},
|
||||
"survivability": {
|
||||
"title": "Survivabilité"
|
||||
"title": "Survivability"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
@@ -679,8 +679,7 @@
|
||||
"none": "No deltas",
|
||||
"percentage": "Percentage",
|
||||
"absolute": "Absolute"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "No tanks selected",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Everything World of Tanks Blitz",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "Unaffiliated with Wargaming or any service",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -679,8 +679,7 @@
|
||||
"none": "差分なし",
|
||||
"percentage": "割合",
|
||||
"absolute": "絶対値"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "車輌が選択されていません",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Everything World of Tanks Blitz",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "Wargamingやその他のサービスとは関係ありません",
|
||||
"about": {
|
||||
"title": "概要",
|
||||
"discord": "Discord",
|
||||
|
||||
+357
-358
File diff suppressed because it is too large.
Load diff
@@ -679,8 +679,7 @@
|
||||
"none": "Sem deltas",
|
||||
"percentage": "Porcentagem",
|
||||
"absolute": "Absoluto"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "Nenhum tanque selecionado",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Tudo World of Tanks Blitz",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "Não afiliado à Wargaming ou qualquer serviço",
|
||||
"about": {
|
||||
"title": "Sobre",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"gun_types": {
|
||||
"auto_loader": "Барабан",
|
||||
"auto_reloader": "Автомат заряжания",
|
||||
"auto_reloader": "Барабан с дозарядкой",
|
||||
"regular": "Цикличный"
|
||||
},
|
||||
"shells": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"branches": {
|
||||
"beta": "Бета",
|
||||
"development": "Разработка",
|
||||
"opentest": "OpenTest",
|
||||
"opentest": "Открытый тест",
|
||||
"preview": "Предпросмотр",
|
||||
"production": "Производство"
|
||||
},
|
||||
@@ -304,9 +304,9 @@
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "Посмотреть людей",
|
||||
"description": "Участники проекта blitzkit",
|
||||
"seo_description": "BlitzKit создан усилиями многих"
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Сброс",
|
||||
@@ -624,12 +624,12 @@
|
||||
"hullTraverseSoftTerrain": "На мягком грунте",
|
||||
"health": "Прочность",
|
||||
"fireChance": "Вероятность возгорания",
|
||||
"trackRepairTime": "Время ремонта гусениц",
|
||||
"trackRepairTime": "Track repair time",
|
||||
"viewRange": "Радиус обзора",
|
||||
"camouflageStill": "Когда неподвижен",
|
||||
"camouflageMoving": "В движении",
|
||||
"camouflageShootingStill": "Стрельба без движения",
|
||||
"camouflageShootingMoving": "Стрельба в движении",
|
||||
"camouflageShootingStill": "Shooting while still",
|
||||
"camouflageShootingMoving": "Shooting while moving",
|
||||
"camouflageCaughtOnFire": "При пожаре",
|
||||
"width": "Ширина",
|
||||
"height": "Высота",
|
||||
@@ -679,8 +679,7 @@
|
||||
"none": "Без дельты",
|
||||
"percentage": "Проценты",
|
||||
"absolute": "Абсолютные"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "Нет выбранных танков",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Всё о World of Tanks Blitz",
|
||||
"association": "Открытый коллективный проект",
|
||||
"copyright": "Не связан с Wargaming или иным сервисом",
|
||||
"about": {
|
||||
"title": "О сайте",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"beta": "Бета",
|
||||
"development": "Розробка",
|
||||
"opentest": "Відкритий тест",
|
||||
"preview": "Перегляд",
|
||||
"production": "Виробництво"
|
||||
"preview": "Preview",
|
||||
"production": "Production"
|
||||
},
|
||||
"locales": {
|
||||
"en": "Англійська",
|
||||
@@ -206,13 +206,13 @@
|
||||
"swap_dialog_title": "Резервуар підміни",
|
||||
"clear_filters": "Очистити",
|
||||
"login": "Увійдіть, щоб скористатися",
|
||||
"search_bar_hint": "Пошук техніки...",
|
||||
"search_bar_hint": "Шукайте техніку...",
|
||||
"recent": "Недавно переглянуті",
|
||||
"select_all": "Вибрати все",
|
||||
"count_singular": "1 танк",
|
||||
"count_plural": "Танків: {count} ",
|
||||
"sorting_by": "Відсортувати за {name}",
|
||||
"clear": "Очистити",
|
||||
"clear": "Clear",
|
||||
"tier": "Рівень {tier}",
|
||||
"passive": "",
|
||||
"active": "{name}{name}",
|
||||
@@ -238,7 +238,7 @@
|
||||
"fire.premiumPenetration": "преміум пробиття",
|
||||
"fire.tertiaryPenetration": "tertiary penetration",
|
||||
"fire.damage": "шкода",
|
||||
"fire.moduleDamage": "шкода по модулям",
|
||||
"fire.moduleDamage": "шкода по модулях",
|
||||
"fire.shellVelocity": "швидкість польоту снаряда",
|
||||
"fire.shellRange": "дальність польоту",
|
||||
"fire.shellCapacity": "кількість снарядів",
|
||||
@@ -250,7 +250,7 @@
|
||||
"maneuverability.forwardsSpeed": "максимальна швидкість вперед",
|
||||
"maneuverability.backwardsSpeed": "максимальна швидкість назад",
|
||||
"maneuverability.power": "потужність двигуна",
|
||||
"maneuverability.powerToWeight": "питома потужність",
|
||||
"maneuverability.powerToWeight": "притома потужність",
|
||||
"maneuverability.weight": "маса",
|
||||
"maneuverability.traverseSpeed": "швидкість повороту"
|
||||
},
|
||||
@@ -304,7 +304,7 @@
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "Переглянути людей",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
@@ -349,25 +349,25 @@
|
||||
"randomize": "Рандомізувати"
|
||||
},
|
||||
"guess": {
|
||||
"name": "Вгадайка",
|
||||
"button": "Що це може бути?",
|
||||
"description": "Мінігра Силует",
|
||||
"seo_description": "Зіграти",
|
||||
"name": "Guess",
|
||||
"button": "What could it be?",
|
||||
"description": "Silhouette mini-game",
|
||||
"seo_description": "Play a tank guessing game with a silhouette in World of Tanks Blitz",
|
||||
"search": {
|
||||
"placeholder": "Час Відгадувати",
|
||||
"placeholder": "Type to guess...",
|
||||
"skip": "Пропустити",
|
||||
"guess": "Відгадати",
|
||||
"guess": "Guess",
|
||||
"next": "Наступний",
|
||||
"no_results": "Немає результатів"
|
||||
},
|
||||
"stats": "{correct} / {total} відгадано, {streak} серія",
|
||||
"stats": "{correct} / {total} correct, {streak} streak",
|
||||
"help": {
|
||||
"title": "Розкрити танк частково?",
|
||||
"description": "Показує танк без назви. Цей раунд",
|
||||
"cancel": "Скасувати",
|
||||
"reveal": "Показати"
|
||||
"title": "Partially reveal tank?",
|
||||
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
|
||||
"cancel": "",
|
||||
"reveal": "Рівень"
|
||||
},
|
||||
"clear": "Очистити",
|
||||
"clear": "Clear",
|
||||
"select_all": "Вибрати все",
|
||||
"tier": "Рівень {tier}"
|
||||
},
|
||||
@@ -389,9 +389,9 @@
|
||||
"rotator": {
|
||||
"hull": "Корпус",
|
||||
"turret": "Башта",
|
||||
"hard": "По дорозі",
|
||||
"medium": "По ґрунту",
|
||||
"soft": "По воді",
|
||||
"hard": "Жорстко",
|
||||
"medium": "Середній",
|
||||
"soft": "Ніжно",
|
||||
"effective": "Ефективний"
|
||||
},
|
||||
"ricochet": {
|
||||
@@ -437,7 +437,7 @@
|
||||
"dynamic": {
|
||||
"name": "Динамічна броня",
|
||||
"custom_shell": {
|
||||
"type": "Тип",
|
||||
"type": "Вид",
|
||||
"caliber": "Калібр",
|
||||
"penetration": "Бронепробиття",
|
||||
"normalization": "Нормалізація",
|
||||
@@ -493,13 +493,13 @@
|
||||
"nation": "Нація",
|
||||
"class": "Клас",
|
||||
"tier": "Рівень",
|
||||
"type": "Тип",
|
||||
"type": "Вид",
|
||||
"dev_id": "DEV: ID",
|
||||
"dev_name": "DEV: NAME",
|
||||
"purchase": "Ціна покупки",
|
||||
"purchase": "Purchase price",
|
||||
"restoration": "Restoration price",
|
||||
"sale": "Ціна продажу",
|
||||
"research": "Дослідження"
|
||||
"sale": "Sale price",
|
||||
"research": "Research XP"
|
||||
},
|
||||
"tech_tree": {
|
||||
"title": "Звичайний",
|
||||
@@ -511,7 +511,7 @@
|
||||
"title": "Здібності ігрового режиму"
|
||||
},
|
||||
"review": {
|
||||
"title": "Відеоогляди",
|
||||
"title": "Review videos",
|
||||
"join": "Apply to {name} YouTuber program"
|
||||
},
|
||||
"configuration": {
|
||||
@@ -522,12 +522,12 @@
|
||||
},
|
||||
"modules": {
|
||||
"title": "Модулі",
|
||||
"stock": "Базові",
|
||||
"upgrade": "Топовий"
|
||||
"stock": "Stock",
|
||||
"upgrade": "Upgrade"
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Обладнання",
|
||||
"clear": "Очистити"
|
||||
"clear": "Clear"
|
||||
},
|
||||
"provisions": {
|
||||
"title": "Амуніція",
|
||||
@@ -553,88 +553,88 @@
|
||||
"compare": {
|
||||
"label": "Compare against",
|
||||
"class": "{tier} {class}",
|
||||
"tier": "Весь рівень{tier}",
|
||||
"tier": "All tier {tier}",
|
||||
"all": "Усі танки"
|
||||
},
|
||||
"values": {
|
||||
"shell_index": "Shell {index}",
|
||||
"at_distance": "At {distance}m",
|
||||
"at_distance_slider_label": "Відстань",
|
||||
"dispersion_at_distance": "Розкид на {distance}м",
|
||||
"gun_flexibility": "Кути наведення",
|
||||
"dispersion_at_distance": "Dispersion at {distance}m",
|
||||
"gun_flexibility": "Gun flexibility",
|
||||
"speed": "Швидкість",
|
||||
"terrain_coefficients": "Прохідність",
|
||||
"terrain_coefficients": "Terrain coefficients",
|
||||
"raw_terrain_coefficients": "Raw terrain coefficients",
|
||||
"power_to_weight_ratio": "Питома потужність",
|
||||
"hull_traverse_speed": "Швидкість обертання корпуса",
|
||||
"power_to_weight_ratio": "Power to weight ratio",
|
||||
"hull_traverse_speed": "Hull traverse speed",
|
||||
"camouflage": "Камуфляж",
|
||||
"reload": "Час перезаряджання",
|
||||
"crewCount": "Розмір екіпажу",
|
||||
"crewCount": "Crew count",
|
||||
"shellNormalization": "Нормалізація",
|
||||
"shellRicochet": "Рикошет",
|
||||
"dpm": "DPM",
|
||||
"dpmEffective": "Effective at 60s",
|
||||
"peekabooDpm": "Peekaboo DPM",
|
||||
"peekabooDpmInfo": "Шкода на хвилину за умови пострілу тільки після повторного маскування (або після повного перезаряджання, якщо воно триваліше) — не менше 10 секунд між пострілами.",
|
||||
"peekabooDpmInfo": "Damage per minute assuming you only fire after resetting camouflage (or the reload finishes, if it's slower) — at least 10 seconds between shots.",
|
||||
"shells": "Снаряди",
|
||||
"mostOptimalShellIndex": "Most optimal shell",
|
||||
"shellReloads": "Reloads",
|
||||
"shellReload": "Час перезаряджання",
|
||||
"intraClip": "Між пострілами",
|
||||
"intraClip": "Intra-clip",
|
||||
"caliber": "Калібр",
|
||||
"penetration": "Бронепробиття",
|
||||
"damage": "Шкода",
|
||||
"damageWithoutAssault": "Шкода без урахування золотих снарядів",
|
||||
"clipDamage": "Шкода за барабан",
|
||||
"moduleDamage": "Шкода по модулям",
|
||||
"clipDamage": "Clipping potential",
|
||||
"moduleDamage": "Шкода по модулях",
|
||||
"explosionRadius": "Радіус розльоту уламків",
|
||||
"shellVelocity": "Швидкість польоту снаряда",
|
||||
"aimTime": "Час зведення",
|
||||
"dispersion": "Нерухомо",
|
||||
"dispersionMoving": "У русі",
|
||||
"dispersionHullTraversing": "Обертання корпусу",
|
||||
"dispersionHullTraversing": "Hull traversing",
|
||||
"dispersionTurretTraversing": "Обертання башти",
|
||||
"dispersionShooting": "Після пострілу",
|
||||
"dispersionGunDamaged": "Гармата пошкоджена",
|
||||
"gunDepression": "Вниз",
|
||||
"gunElevation": "Вгору",
|
||||
"gunFrontalDepression": "КВН вниз спереду",
|
||||
"gunFrontalElevation": "КВН вгору спереду",
|
||||
"gunRearDepression": "КВН вниз позаду",
|
||||
"gunRearElevation": "КВН вгору позаду",
|
||||
"dispersionShooting": "Shooting",
|
||||
"dispersionGunDamaged": "Gun damaged",
|
||||
"gunDepression": "Depression",
|
||||
"gunElevation": "Elevation",
|
||||
"gunFrontalDepression": "Frontal depression",
|
||||
"gunFrontalElevation": "Frontal elevation",
|
||||
"gunRearDepression": "Rear depression",
|
||||
"gunRearElevation": "Rear elevation",
|
||||
"azimuthLeft": "Azimuth left",
|
||||
"azimuthRight": "Azimuth right",
|
||||
"speedForwards": "Вперед",
|
||||
"speedBackwards": "Назад",
|
||||
"hardTerrainCoefficient": "По дорозі",
|
||||
"hardTerrainCoefficientRaw": "По дорозі",
|
||||
"mediumTerrainCoefficient": "По ґрунту",
|
||||
"mediumTerrainCoefficientRaw": "По ґрунту",
|
||||
"softTerrainCoefficient": "По воді",
|
||||
"softTerrainCoefficientRaw": "По воді",
|
||||
"enginePower": "Потужність двигуна",
|
||||
"powerToWeightRatioHardTerrain": "По дорозі",
|
||||
"powerToWeightRatioMediumTerrain": "По ґрунту",
|
||||
"powerToWeightRatioSoftTerrain": "По воді",
|
||||
"speedForwards": "Forwards",
|
||||
"speedBackwards": "Backwards",
|
||||
"hardTerrainCoefficient": "Hard",
|
||||
"hardTerrainCoefficientRaw": "Hard",
|
||||
"mediumTerrainCoefficient": "Середній",
|
||||
"mediumTerrainCoefficientRaw": "Середній",
|
||||
"softTerrainCoefficient": "Soft",
|
||||
"softTerrainCoefficientRaw": "Soft",
|
||||
"enginePower": "Engine power",
|
||||
"powerToWeightRatioHardTerrain": "Hard",
|
||||
"powerToWeightRatioMediumTerrain": "Середній",
|
||||
"powerToWeightRatioSoftTerrain": "Soft",
|
||||
"weight": "Маса",
|
||||
"turretTraverseSpeed": "Швидкість обертання башти",
|
||||
"turretTraverseSpeed": "Turret traverse speed",
|
||||
"gunTraverseSpeed": "Gun traverse speed",
|
||||
"hullTraverseHardTerrain": "Поворот корпусу на дорозі",
|
||||
"hullTraverseMediumTerrain": "По ґрунту",
|
||||
"hullTraverseSoftTerrain": "По воді",
|
||||
"hullTraverseHardTerrain": "Hard",
|
||||
"hullTraverseMediumTerrain": "Середній",
|
||||
"hullTraverseSoftTerrain": "Soft",
|
||||
"health": "Здоров'я",
|
||||
"fireChance": "Імовірність загоряння",
|
||||
"trackRepairTime": "Час ремонту гусениці",
|
||||
"viewRange": "Радіус огляду",
|
||||
"fireChance": "Fire chance",
|
||||
"trackRepairTime": "Track repair time",
|
||||
"viewRange": "View range",
|
||||
"camouflageStill": "Нерухомо",
|
||||
"camouflageMoving": "У русі",
|
||||
"camouflageShootingStill": "Постріл в нерухомості",
|
||||
"camouflageShootingMoving": "Постріл у русі",
|
||||
"camouflageCaughtOnFire": "Під час загоряння",
|
||||
"camouflageShootingStill": "Нерухомо",
|
||||
"camouflageShootingMoving": "У русі",
|
||||
"camouflageCaughtOnFire": "Caught on fire",
|
||||
"width": "Ширина",
|
||||
"height": "Висота",
|
||||
"length": "Довжина",
|
||||
"volume": "Об'єм",
|
||||
"height": "Height",
|
||||
"length": "Length",
|
||||
"volume": "Volume",
|
||||
"shellRange": "Shell range",
|
||||
"shellCapacity": "Shell capacity",
|
||||
"gunType": "Тип гармати",
|
||||
@@ -644,16 +644,16 @@
|
||||
}
|
||||
},
|
||||
"firepower": {
|
||||
"title": "Вогнева міць"
|
||||
"title": "Firepower"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Майстерність екіпажу",
|
||||
"info": "відображає інші ролі, які може виконувати член екіпажу.",
|
||||
"count": "Розмір команди",
|
||||
"title": "Crew training",
|
||||
"info": "represents the other roles a crew member can take.",
|
||||
"count": "Crew count",
|
||||
"commander": "Командир",
|
||||
"gunner": "Навідник",
|
||||
"driver": "Водій",
|
||||
"radioman": "Радист",
|
||||
"radioman": "Radioman",
|
||||
"loader": "Заряджаючий"
|
||||
},
|
||||
"maneuverability": {
|
||||
@@ -666,21 +666,20 @@
|
||||
"compare": {
|
||||
"name": "Порівняти",
|
||||
"button": "Порівняти танки",
|
||||
"description": "Порівняти статистику танків",
|
||||
"description": "Compare tank statistics",
|
||||
"seo_description": "Compare tanks statistics and loadouts in World of Tanks Blitz",
|
||||
"actions": {
|
||||
"add": {
|
||||
"button": "Додати",
|
||||
"title": "Додати танки",
|
||||
"description": "Виберіть танки, щоб додати для порівняння"
|
||||
"title": "Add tanks",
|
||||
"description": "Select tanks to add to comparison"
|
||||
},
|
||||
"clear": "Очистити",
|
||||
"clear": "Clear",
|
||||
"deltas": {
|
||||
"none": "No deltas",
|
||||
"percentage": "Відсоток",
|
||||
"absolute": "Absolute"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "Не вибрано жодного танку",
|
||||
@@ -688,7 +687,7 @@
|
||||
},
|
||||
"table": {
|
||||
"equipment": {
|
||||
"clear": "Очистити",
|
||||
"clear": "Clear",
|
||||
"apply_to_all": "Apply to all"
|
||||
},
|
||||
"skills": {
|
||||
@@ -697,8 +696,8 @@
|
||||
"equalize": "Equalize"
|
||||
},
|
||||
"modules": {
|
||||
"stock": "Базові",
|
||||
"upgrade": "Топовий"
|
||||
"stock": "Stock",
|
||||
"upgrade": "Upgrade"
|
||||
},
|
||||
"provisions": {
|
||||
"clear": "Clear this tank",
|
||||
@@ -711,9 +710,9 @@
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"name": "Результативність",
|
||||
"name": "Performance",
|
||||
"button": "Переглянути танки",
|
||||
"description": "Статистика Результативності танків",
|
||||
"description": "Tank performance statistics",
|
||||
"seo_description": "Best tanks and their performances in World of Tanks Blitz",
|
||||
"estimation": {
|
||||
"body": "Estimated stats based on {count} players. Updated {age} ago.",
|
||||
@@ -721,7 +720,7 @@
|
||||
"minutes": "{count} minutes"
|
||||
},
|
||||
"player_count": {
|
||||
"label": "Кількість гравців"
|
||||
"label": "Player count"
|
||||
},
|
||||
"table": {
|
||||
"tanks": {
|
||||
@@ -739,8 +738,8 @@
|
||||
"accuracy": "Точність",
|
||||
"shots": "Постріли",
|
||||
"hits": "Пробиття",
|
||||
"damage_ratio": "Коефіцієнт шкоди",
|
||||
"damage_taken": "Шкоди отримано",
|
||||
"damage_ratio": "Dmg. ratio",
|
||||
"damage_taken": "Dmg. taken",
|
||||
"capture_points": "Cap points"
|
||||
}
|
||||
}
|
||||
@@ -850,7 +849,7 @@
|
||||
"name": "Breakdown",
|
||||
"options": {
|
||||
"width": "Ширина",
|
||||
"height": "Висота",
|
||||
"height": "Height",
|
||||
"showTotal": "Show total",
|
||||
"listGap": "List gap",
|
||||
"listMaxTanks": "Max tanks",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "Everything World of Tanks Blitz",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "Unaffiliated with Wargaming or any service",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -679,8 +679,7 @@
|
||||
"none": "无变化",
|
||||
"percentage": "百分比",
|
||||
"absolute": "绝对值"
|
||||
},
|
||||
"sync_shells": "Sync shells"
|
||||
}
|
||||
},
|
||||
"no_tanks": {
|
||||
"title": "未选择坦克",
|
||||
@@ -921,7 +920,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"motto": "《坦克世界:闪击战》全方位工具",
|
||||
"association": "An Open Collective Project",
|
||||
"copyright": "本网站与 Wargaming战游网 或任何服务无关",
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"discord": "Discord",
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
{
|
||||
"name": "@blitzkit/scripts",
|
||||
"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": {
|
||||
"@blitzkit/closed": "workspace:*",
|
||||
"@blitzkit/core": "workspace:*",
|
||||
"@blitzkit/i18n": "workspace:*",
|
||||
"@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);
|
||||
@@ -1,13 +1,6 @@
|
||||
{
|
||||
"name": "@blitzkit/website",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"lint": "astro check",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.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 type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
|
||||
import { CompareCellDirection } from "../CompareCell";
|
||||
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
|
||||
import { CompareRow } from "./CompareRow";
|
||||
@@ -50,7 +50,11 @@ export function Body({ stats }: BodyProps) {
|
||||
}}
|
||||
onClick={() => {
|
||||
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 { 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 { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
import { CompareEphemeral } from "../../stores/compareEphemeral";
|
||||
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
|
||||
import {
|
||||
ComparePersistent,
|
||||
type DeltaMode,
|
||||
@@ -22,6 +30,7 @@ export function Controls({
|
||||
onAddTankDialogOpenChange,
|
||||
}: ControlsProps) {
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const syncShells = CompareEphemeral.use((state) => state.syncShells);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -36,11 +45,11 @@ export function Controls({
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title align="center">
|
||||
{strings.website.tools.compare.actions.add.title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description align="center">
|
||||
<Dialog.Description align="center" mb="4">
|
||||
{strings.website.tools.compare.actions.add.description}
|
||||
</Dialog.Description>
|
||||
|
||||
@@ -111,6 +120,31 @@ export function Controls({
|
||||
{strings.website.tools.compare.actions.deltas.absolute}
|
||||
</SegmentedControl.Item>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ExternalLinkIcon, LoopIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import { Dialog, Flex, IconButton } from "@radix-ui/themes";
|
||||
import { useState } from "react";
|
||||
import { DIALOG_MAX_HEIGHT } from "../../constants/dialog";
|
||||
import { api } from "../../core/blitzkit/api";
|
||||
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
|
||||
import { useLocale } from "../../hooks/useLocale";
|
||||
@@ -43,7 +44,7 @@ export function TankControl({ index, slug }: TankControlProps) {
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title align="center">
|
||||
{strings.website.common.tank_search.swap_dialog_title}
|
||||
</Dialog.Title>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
|
||||
import { InlineSkeleton } from "./InlineSkeleton";
|
||||
|
||||
const TIME = 1792598400 * 1000;
|
||||
const TIME = 1793898000 * 1000;
|
||||
const HREF = "https://preview.blitzkit.app/tanks";
|
||||
|
||||
export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now());
|
||||
@@ -31,7 +32,7 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
|
||||
string = (
|
||||
<>
|
||||
revealed in{" "}
|
||||
in{" "}
|
||||
<Code highContrast>
|
||||
{hours}h {minutes}m {seconds}s
|
||||
</Code>
|
||||
@@ -47,7 +48,7 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
color="gray"
|
||||
highContrast
|
||||
style={{ display: "content" }}
|
||||
href="https://preview.blitzkit.app/tanks"
|
||||
href={timeLeft <= 0 ? HREF : undefined}
|
||||
underline="none"
|
||||
>
|
||||
<Flex
|
||||
@@ -75,11 +76,11 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
|
||||
<Flex direction="column">
|
||||
<Heading size={{ initial: "4", sm: "6" }}>
|
||||
Tier XIs {!skeleton && string}{" "}
|
||||
More Tier XIs {!skeleton && string}{" "}
|
||||
{skeleton && <InlineSkeleton width="10rem" />}
|
||||
</Heading>
|
||||
<Text size={{ initial: "2", sm: "3" }}>
|
||||
Get ready to go to the Tankopedia!
|
||||
Catch the next 3 tanks on BlitzKit Preview
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const turret = Duel.use((state) => state.protagonist.turret);
|
||||
const gun = Duel.use((state) => state.protagonist.gun);
|
||||
const modelRequested = Tankopedia.use((state) => state.modelRequested);
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
|
||||
const tankModel = modelDefinition.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
|
||||
+30
-8
@@ -1,5 +1,5 @@
|
||||
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 { memo, useMemo, type ComponentProps, type ReactNode } from "react";
|
||||
import { api } from "../../../../../../core/blitzkit/api";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TankopediaRelativeAgainst,
|
||||
} from "../../../../../../stores/tankopedia";
|
||||
import { Info, type InfoProps } from "./Info";
|
||||
import { StatScale } from "./StatScale";
|
||||
|
||||
type InfoWithDeltaProps = Omit<InfoProps, "name"> & {
|
||||
stats: TankCharacteristics;
|
||||
@@ -122,6 +123,15 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
|
||||
return othersValue !== undefined;
|
||||
}) as TankCharacteristics[];
|
||||
}, [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 othersValue =
|
||||
typeof props.value === "function"
|
||||
@@ -161,13 +171,25 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
|
||||
|
||||
{!noRanking && (
|
||||
<Flex pl={indent ? "2" : "0"} align="center" gap="2">
|
||||
<Progress
|
||||
variant="soft"
|
||||
size="1"
|
||||
value={goodness * 100}
|
||||
color={color}
|
||||
style={{ height: "0.125rem", opacity: 0.5 }}
|
||||
/>
|
||||
<Box position="relative" flexGrow="1">
|
||||
<StatScale
|
||||
values={values}
|
||||
value={uhWhatDoICallThisVariable}
|
||||
lowerIsBetter={deltaType === "lowerIsBetter"}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{clamp(betterTanks.length + 1, 1, others.length)} /{" "}
|
||||
|
||||
+91
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
+59
-36
@@ -13,7 +13,10 @@ const camouflageDefinitions = await api.camouflageDefinitions();
|
||||
|
||||
export function SkinSwitcher() {
|
||||
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 disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const { unwrap } = useLocale();
|
||||
@@ -24,11 +27,18 @@ export function SkinSwitcher() {
|
||||
(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;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
top="50%"
|
||||
right={
|
||||
disturbed && requestedDisplay === TankopediaDisplay.Model
|
||||
@@ -42,42 +52,55 @@ export function SkinSwitcher() {
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{skins.map((camouflage, index) => {
|
||||
const name = unwrap(camouflage.name!).replace(
|
||||
"%(camo_num)",
|
||||
SKIN_NUMBER,
|
||||
);
|
||||
const top = index === 0 ? "var(--radius-full)" : "0";
|
||||
const bottom = index === skins.length - 1 ? "var(--radius-full)" : "0";
|
||||
{groups.map((group) => (
|
||||
<Flex key={group[0].id} direction="column">
|
||||
{group.map((camouflage, index) => {
|
||||
const name = unwrap(camouflage.name!).replace(
|
||||
"%(camo_num)",
|
||||
SKIN_NUMBER,
|
||||
);
|
||||
const top = index === 0 ? "var(--radius-full)" : "0";
|
||||
const bottom =
|
||||
index === group.length - 1 ? "var(--radius-full)" : "0";
|
||||
|
||||
return (
|
||||
<Tooltip key={camouflage.id} content={name} side="left">
|
||||
<SidebarIconButton
|
||||
selected={skin === camouflage.id}
|
||||
alt={name}
|
||||
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
|
||||
size={{ initial: "3", sm: "4" }}
|
||||
radius="full"
|
||||
style={{
|
||||
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
|
||||
}}
|
||||
imageStyle={{
|
||||
width: "70%",
|
||||
height: "70%",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.requestedSkin =
|
||||
draft.requestedSkin === camouflage.id
|
||||
? undefined
|
||||
: camouflage.id;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Tooltip key={camouflage.id} content={name} side="left">
|
||||
<SidebarIconButton
|
||||
selected={
|
||||
(camouflage.attachment ? attachment : skin) ===
|
||||
camouflage.id
|
||||
}
|
||||
alt={name}
|
||||
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
|
||||
size={{ initial: "3", sm: "4" }}
|
||||
radius="full"
|
||||
style={{
|
||||
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
|
||||
}}
|
||||
imageStyle={{
|
||||
width: "70%",
|
||||
height: "70%",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
const loader =
|
||||
draft.loaders[
|
||||
camouflage.attachment ? "attachment" : "skin"
|
||||
];
|
||||
|
||||
loader.requested =
|
||||
loader.requested === camouflage.id
|
||||
? undefined
|
||||
: camouflage.id;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Tooltip,
|
||||
} from "@radix-ui/themes";
|
||||
import { Suspense, useState, type RefObject } from "react";
|
||||
import { DIALOG_MAX_HEIGHT } from "../../../../../constants/dialog";
|
||||
import { Pose, poseEvent } from "../../../../../core/blitzkit/pose";
|
||||
import { useEquipment } from "../../../../../hooks/useEquipment";
|
||||
import { useFullScreen } from "../../../../../hooks/useFullScreen";
|
||||
@@ -82,7 +83,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
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 equalizer = Duel.use((state) => state.antagonist.tank.equalizer);
|
||||
|
||||
@@ -472,7 +475,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title align="center">
|
||||
{strings.website.common.tank_search.select_dialog_title}
|
||||
</Dialog.Title>
|
||||
|
||||
+13
-6
@@ -13,18 +13,25 @@ const camouflageDefinitions = await api.camouflageDefinitions();
|
||||
|
||||
export function Title({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
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 revealed = Tankopedia.use((state) => state.revealed);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const hasRevealed = useRef(false);
|
||||
const skin = Tankopedia.use((state) => state.requestedSkin);
|
||||
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
|
||||
const camouflage =
|
||||
skin === undefined ? undefined : camouflageDefinitions.camouflages[skin];
|
||||
const tankName = unwrap(protagonist.name!);
|
||||
const name = camouflage
|
||||
? `${tankName} ${unwrap(camouflage.name!).replace("%(camo_num)", SKIN_NUMBER)}`
|
||||
: tankName;
|
||||
// legendary camos carry the tank's in-battle name
|
||||
const name = (
|
||||
camouflage
|
||||
? camouflage.tank_name
|
||||
? unwrap(camouflage.tank_name)
|
||||
: `${tankName} ${unwrap(camouflage.name!)}`
|
||||
: tankName
|
||||
).replace("%(camo_num)", SKIN_NUMBER);
|
||||
const fontSize = revealed
|
||||
? disturbed
|
||||
? "1.5rem"
|
||||
@@ -102,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.modelRequested = true;
|
||||
draft.loaders.model.requested = true;
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
+10
-7
@@ -48,7 +48,9 @@ export function Lighting({ hasPbr }: Props) {
|
||||
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
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);
|
||||
@@ -63,6 +65,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
useEffect(() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = true;
|
||||
draft.loaders.model.loaded = true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -162,6 +165,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
key={index}
|
||||
position={position}
|
||||
intensity={intensity}
|
||||
userData={{ intensity }}
|
||||
penumbra={1}
|
||||
castShadow={highGraphics}
|
||||
decay={1}
|
||||
@@ -212,6 +216,11 @@ function Animator({
|
||||
for (const child of wrapper.current.children) {
|
||||
if (child instanceof SpotLight) {
|
||||
child.angle = ANGLE * t * factor;
|
||||
child.intensity = child.userData.intensity;
|
||||
|
||||
if (child.angle === 0) {
|
||||
child.intensity = 0;
|
||||
}
|
||||
} else if (child instanceof HemisphereLight) {
|
||||
child.intensity = HEMISPHERE_INTENSITY * t * factor;
|
||||
}
|
||||
@@ -244,12 +253,6 @@ function Animator({
|
||||
draft.display = requestedDisplay;
|
||||
});
|
||||
}
|
||||
|
||||
if (x >= 1 && Tankopedia.state.skin !== Tankopedia.state.loadedSkin) {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skin = draft.loadedSkin;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
+75
-4
@@ -1,5 +1,6 @@
|
||||
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 { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
|
||||
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
|
||||
@@ -8,12 +9,14 @@ import { skinTree } from "../../../../../../core/blitzkit/skinTree";
|
||||
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
|
||||
import { useModel } from "../../../../../../hooks/useModel";
|
||||
import { useSkin } from "../../../../../../hooks/useSkin";
|
||||
import { useSkinAnimation } from "../../../../../../hooks/useSkinAnimation";
|
||||
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
|
||||
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
|
||||
import { Duel } from "../../../../../../stores/duel";
|
||||
import { Tankopedia } from "../../../../../../stores/tankopedia";
|
||||
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
|
||||
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
|
||||
import { transitionEvent } from "./Lighting";
|
||||
|
||||
export function TankModel() {
|
||||
const protagonist = Duel.use((draft) => draft.protagonist);
|
||||
@@ -30,8 +33,49 @@ export function TankModel() {
|
||||
const { gltf } = useModel(protagonist.tank.id);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
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);
|
||||
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 (
|
||||
<ModelTankWrapper ref={hullContainer}>
|
||||
@@ -94,7 +138,7 @@ export function TankModel() {
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<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);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<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);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<mesh
|
||||
@@ -264,6 +326,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
</group>
|
||||
|
||||
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null!);
|
||||
const isFullScreen = useFullScreen();
|
||||
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 entries = Object.values(tankDefinitions.tanks);
|
||||
const filtered = entries.filter(
|
||||
|
||||
@@ -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() {
|
||||
return false;
|
||||
|
||||
const data = new Date();
|
||||
return data.getMonth() === 9 && data.getDate() === 31;
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
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(
|
||||
node: Object3D,
|
||||
@@ -14,6 +30,7 @@ export function jsxTree(
|
||||
props: ComponentProps<"group">,
|
||||
key: string,
|
||||
) => ReactNode;
|
||||
primitive?: (object3d: Object3D, key: string) => ReactNode;
|
||||
},
|
||||
): ReactNode {
|
||||
if (node instanceof Mesh) {
|
||||
@@ -25,9 +42,7 @@ export function jsxTree(
|
||||
scale: node.scale,
|
||||
};
|
||||
|
||||
if (props.material instanceof MeshStandardMaterial) {
|
||||
props.material.depthWrite = true;
|
||||
}
|
||||
prepareMaterial(props.material);
|
||||
|
||||
return mergers?.mesh ? (
|
||||
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;
|
||||
}
|
||||
@@ -4,39 +4,66 @@ import { jsxTree } from "./jsxTree";
|
||||
|
||||
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(
|
||||
node: Object3D,
|
||||
skin: Skin | undefined,
|
||||
mergers: Mergers,
|
||||
skins: Skin[],
|
||||
mergers: NonNullable<Mergers>,
|
||||
) {
|
||||
const skinNodes = new Map(
|
||||
skin?.scene.children.map((node) => [node.name, node]),
|
||||
);
|
||||
const materials = new Map<string, Material>();
|
||||
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 = {
|
||||
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,
|
||||
material ? { ...props, material } : 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 (
|
||||
<group key={key} {...props}>
|
||||
{isSwitch
|
||||
? object3d.children
|
||||
.filter((child) => child.name === state)
|
||||
.map((child) => jsxTree(child, skinMergers))
|
||||
: props.children}
|
||||
{skinNode?.children.map((child) => jsxTree(child, mergers))}
|
||||
{switchChildren(object3d) ?? props.children}
|
||||
{skins.flatMap((skin) =>
|
||||
(skin.parts.get(object3d.name) ?? []).map((part) =>
|
||||
mergers.primitive ? (
|
||||
mergers.primitive(part, part.uuid)
|
||||
) : (
|
||||
<primitive key={part.uuid} object={part} />
|
||||
),
|
||||
),
|
||||
)}
|
||||
</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);
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
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 { 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";
|
||||
|
||||
export interface Skin {
|
||||
scene: Group;
|
||||
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>>();
|
||||
@@ -18,42 +30,78 @@ async function loadSkin(path: string) {
|
||||
const materials: Material[] = await gltf.parser.getDependencies("material");
|
||||
const skin = {
|
||||
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);
|
||||
|
||||
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) {
|
||||
return alias("models_legendary", `/${id}/skins/${skin}.glb`);
|
||||
}
|
||||
|
||||
export function useSkin(id: number) {
|
||||
const requestedSkin = Tankopedia.use((state) => state.requestedSkin);
|
||||
const skin = Tankopedia.use((state) => state.skin);
|
||||
// attachments load the same way as camouflages and can be shown with one
|
||||
export function useSkin(id: number, loader: "skin" | "attachment" = "skin") {
|
||||
const requested = Tankopedia.use((state) => state.loaders[loader].requested);
|
||||
const loaded = Tankopedia.use((state) => state.loaders[loader].loaded);
|
||||
|
||||
useEffect(() => {
|
||||
const path =
|
||||
requestedSkin === undefined ? undefined : skinPath(id, requestedSkin);
|
||||
if (requested === Tankopedia.state.loaders[loader].loaded) return;
|
||||
|
||||
const path = requested === undefined ? undefined : skinPath(id, requested);
|
||||
const cached = path === undefined || loadedSkins.has(path);
|
||||
let settled = false;
|
||||
|
||||
if (path === undefined || loadedSkins.has(path)) {
|
||||
if (!cached) {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.loadedSkin = requestedSkin;
|
||||
draft.revealed = false;
|
||||
});
|
||||
return;
|
||||
|
||||
if (!skins.has(path)) skins.set(path, loadSkin(path));
|
||||
}
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = false;
|
||||
});
|
||||
|
||||
if (!skins.has(path)) skins.set(path, loadSkin(path));
|
||||
|
||||
// swaps halfway through the lighting transition
|
||||
Promise.all([
|
||||
skins.get(path),
|
||||
path && skins.get(path),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3),
|
||||
),
|
||||
@@ -62,9 +110,8 @@ export function useSkin(id: number) {
|
||||
|
||||
settled = true;
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.loadedSkin = requestedSkin;
|
||||
draft.skin = requestedSkin;
|
||||
draft.revealed = true;
|
||||
draft.loaders[loader].loaded = requested;
|
||||
if (!cached) draft.revealed = true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,11 +119,16 @@ export function useSkin(id: number) {
|
||||
if (settled) return;
|
||||
|
||||
settled = true;
|
||||
|
||||
if (cached) return;
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
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 { AesonPlug } from "../../../components/AesonPlug";
|
||||
import { Beg } from "../../../components/Beg";
|
||||
import { Countdown } from "../../../components/Countdown";
|
||||
import HomeHero from "../../../components/HomeHero.astro";
|
||||
import { Plugs } from "../../../components/Plugs";
|
||||
import ToolCard from "../../../components/ToolCard.astro";
|
||||
@@ -66,6 +67,10 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, () => {
|
||||
<BaseLayout {locale}>
|
||||
<HomeHero />
|
||||
|
||||
<Countdown client:only="react">
|
||||
<Countdown skeleton slot="fallback" />
|
||||
</Countdown>
|
||||
|
||||
<Beg />
|
||||
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ModuleButton } from "../../../../components/ModuleButtons/ModuleButton"
|
||||
import { PageWrapper } from "../../../../components/PageWrapper";
|
||||
import { ScreenshotButton } from "../../../../components/ScreenshotButton";
|
||||
import { TankSearch } from "../../../../components/TankSearch";
|
||||
import { DIALOG_MAX_HEIGHT } from "../../../../constants/dialog";
|
||||
import { api } from "../../../../core/blitzkit/api";
|
||||
import { curateMixer } from "../../../../core/blitzkit/curateMixer";
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ function ModuleChooser({
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Content maxHeight={DIALOG_MAX_HEIGHT}>
|
||||
<Dialog.Title>
|
||||
{strings.website.common.tank_search.select_dialog_title}
|
||||
</Dialog.Title>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@blitzkit/core";
|
||||
import {
|
||||
Document,
|
||||
Material,
|
||||
Node,
|
||||
NodeIO,
|
||||
PropertyType,
|
||||
@@ -19,6 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three";
|
||||
import { SKIN_NUMBER } from "../../../../constants/skinNumber";
|
||||
import { api } from "../../../../core/blitzkit/api";
|
||||
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
|
||||
import { switcherStates } from "../../../../core/blitzkit/switcherStates";
|
||||
import { vfs } from "../../../../core/blitzkit/vfs";
|
||||
import {
|
||||
extractMaterials,
|
||||
@@ -67,8 +69,10 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
const [nation, tankKey] = (
|
||||
include as { vehicle: { name: string } }
|
||||
).vehicle.name.split(":");
|
||||
const { preset, customEntities } = camouflagesYaml[key];
|
||||
const skinNumber = Number(preset?.match(/(\d+)$/)?.[1] ?? 1);
|
||||
const { customEntities } = camouflagesYaml[key];
|
||||
const preset =
|
||||
camouflagesYaml[key].preset ??
|
||||
camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset;
|
||||
const parameters = await vfs.yaml<TankParameters>(
|
||||
`Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
|
||||
);
|
||||
@@ -88,7 +92,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
> = {};
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
|
||||
for (const component of components) {
|
||||
@@ -111,43 +114,50 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
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;
|
||||
});
|
||||
|
||||
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 scene = document.createScene().setExtras({ states });
|
||||
const scene = document.createScene();
|
||||
const targets = new Map<string, Node>();
|
||||
|
||||
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) {
|
||||
const slot = slots[name];
|
||||
@@ -162,7 +172,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const part = await extractModel(vfs, path, true);
|
||||
const part = await extractModel(vfs, path, true, preset);
|
||||
const map = mergeDocuments(document, part);
|
||||
const partScene = map.get(part.getRoot().listScenes()[0]) as Scene;
|
||||
const node = document
|
||||
@@ -186,6 +196,8 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
target.addChild(node);
|
||||
}
|
||||
|
||||
scene.setExtras({ states });
|
||||
|
||||
applySkinNumber(document, SKIN_NUMBER);
|
||||
|
||||
await document.transform(
|
||||
|
||||
@@ -8,18 +8,35 @@ import {
|
||||
toUniqueId,
|
||||
VertexAttribute,
|
||||
type ConfigArchive,
|
||||
type Hierarchy,
|
||||
type TankParameters,
|
||||
type Textures,
|
||||
type VehicleDefinitionList,
|
||||
} from "@blitzkit/core";
|
||||
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 { dedup, prune } from "@gltf-transform/functions";
|
||||
import type { APIContext } from "astro";
|
||||
import { isEqual, times } from "lodash-es";
|
||||
import { dirname } from "path";
|
||||
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 {
|
||||
vertexAttributeGLTFName,
|
||||
@@ -65,7 +82,13 @@ export async function GET({ props }: APIContext<{ id: number }>) {
|
||||
const ERROR_ON_UNKNOWN_COMPONENT = false;
|
||||
|
||||
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"],
|
||||
};
|
||||
|
||||
@@ -73,6 +96,7 @@ export async function extractModel(
|
||||
vfs: AbstractVFS,
|
||||
path: string,
|
||||
isPart = false,
|
||||
preset?: string,
|
||||
) {
|
||||
const sc2Path = `Data/3d/${path}.sc2`;
|
||||
const scgPath = `Data/3d/${path}.scg`;
|
||||
@@ -86,6 +110,35 @@ export async function extractModel(
|
||||
const scene = document.createScene();
|
||||
const buffer = document.createBuffer();
|
||||
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>(
|
||||
sc2["#hierarchy"],
|
||||
@@ -99,6 +152,40 @@ export async function extractModel(
|
||||
|
||||
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) => {
|
||||
switch (component["comp.typename"]) {
|
||||
case "LodComponent":
|
||||
@@ -122,17 +209,44 @@ export async function extractModel(
|
||||
|
||||
case "StateSwitcherComponent":
|
||||
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;
|
||||
|
||||
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": {
|
||||
const renderObject = component["rc.renderObj"];
|
||||
|
||||
times(renderObject["ro.batchCount"], (batchIndex): void => {
|
||||
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 batch = renderObject["ro.batches"][batchKey];
|
||||
@@ -193,10 +307,24 @@ export async function extractModel(
|
||||
primitive.setAttribute(name, attributeAccessor);
|
||||
});
|
||||
|
||||
const skeleton = skeletons.get(hierarchy);
|
||||
const mesh = document
|
||||
.createMesh(batch["##name"])
|
||||
.addPrimitive(primitive);
|
||||
lodNode.setMesh(mesh);
|
||||
|
||||
if (
|
||||
skeleton &&
|
||||
skinPrimitive(
|
||||
document,
|
||||
buffer,
|
||||
primitive,
|
||||
attributes,
|
||||
renderObject[`skinnedObject.batch${batchIndex}.targetsData`],
|
||||
)
|
||||
) {
|
||||
lodNode.setSkin(skeleton.skin);
|
||||
}
|
||||
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"));
|
||||
|
||||
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) {
|
||||
material.setOcclusionTexture(
|
||||
document
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CompareMember extends DuelMember {
|
||||
export interface CompareEphemeral {
|
||||
crewSkills: Record<string, number>;
|
||||
equalize: boolean;
|
||||
syncShells: boolean;
|
||||
members: CompareMember[];
|
||||
sorting?: {
|
||||
direction: "ascending" | "descending";
|
||||
@@ -21,5 +22,14 @@ export const CompareEphemeral = new Varuna<
|
||||
>((crewSkills) => ({
|
||||
crewSkills,
|
||||
equalize: false,
|
||||
syncShells: false,
|
||||
members: [],
|
||||
}));
|
||||
|
||||
export function syncShellSlot(members: CompareMember[], slot: number) {
|
||||
for (const member of members) {
|
||||
const shell = member.gun.shells[slot];
|
||||
|
||||
if (shell) member.shell = shell;
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst {
|
||||
All,
|
||||
}
|
||||
|
||||
interface LoaderState<Type> {
|
||||
requested: Type;
|
||||
loaded: Type;
|
||||
}
|
||||
|
||||
interface Tankopedia {
|
||||
disturbed: boolean;
|
||||
revealed: boolean;
|
||||
modelRequested: boolean;
|
||||
loaders: {
|
||||
model: LoaderState<boolean>;
|
||||
skin: LoaderState<number | undefined>;
|
||||
attachment: LoaderState<number | undefined>;
|
||||
};
|
||||
shot?: Shot;
|
||||
skills: Record<string, number>;
|
||||
relativeAgainst: TankopediaRelativeAgainst;
|
||||
@@ -103,9 +112,6 @@ interface Tankopedia {
|
||||
);
|
||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||
customShell?: ShellDefinition;
|
||||
requestedSkin?: number;
|
||||
loadedSkin?: number;
|
||||
skin?: number;
|
||||
requestedDisplay: TankopediaDisplay;
|
||||
display: TankopediaDisplay;
|
||||
statSearch?: string;
|
||||
@@ -116,7 +122,11 @@ const skillDefinitions = await api.skillDefinitions();
|
||||
export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({
|
||||
disturbed: 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,
|
||||
editStatic: false,
|
||||
skills: createDefaultSkills(skillDefinitions),
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/scripts
|
||||
bun src/protos.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/website
|
||||
bun x --bun astro build
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
rm -rf \
|
||||
packages/website/.astro \
|
||||
packages/website/node_modules/.astro \
|
||||
packages/website/node_modules/.vite
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/website
|
||||
bun x --bun astro dev
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/scripts
|
||||
bun src/discoverIds/index.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/scripts
|
||||
bun src/downloadClient/index.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/closed
|
||||
bun src/dava/downloadSmartDLC.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/website
|
||||
bun x --bun astro check
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/website
|
||||
bun x --bun astro preview
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/scripts
|
||||
bun src/recordPerformance/index.ts
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
chmod +x scripts/*.sh
|
||||
bun install
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
cd packages/scripts
|
||||
bun src/vfs/index.ts
|
||||
Reference in new issue
Block a user