diff --git a/.github/workflows/ids.yaml b/.github/workflows/ids.yaml index c5c88b464..78a60e25a 100644 --- a/.github/workflows/ids.yaml +++ b/.github/workflows/ids.yaml @@ -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 }} diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 26dd75fd6..f4e27767b 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -43,9 +43,10 @@ on: type: choice required: true - default: steam + default: wgc options: + - wgc - steam - zip - wgpkg @@ -65,10 +66,13 @@ concurrency: cancel-in-progress: true env: + # workflow + HAS_ROOT_REPO_CONTEXT: ${{ github.event_name != 'pull_request' }} + # client STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }} - CLIENT_PROVIDER: ${{ inputs.client_provider || 'steam' }} + CLIENT_PROVIDER: ${{ inputs.client_provider || (github.event_name != 'pull_request' && 'steam' || 'wgc') }} CLIENT_SOURCE: ${{ inputs.client_source || '444200/windows' }} CLIENT_DIR: ../../temp/game @@ -78,11 +82,6 @@ env: PUBLIC_STATIC_ASSET_BASE: https://assets.blitzkit.app PUBLIC_GUIDE_WEBSITE: https://guidesblitz.com - # blitz - WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }} - WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }} - WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }} - # google PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }} @@ -107,11 +106,20 @@ 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 + 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 [ "$HAS_ROOT_REPO_CONTEXT" = "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,22 +133,24 @@ 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: Build protos run: bun run build-protos - 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.HAS_ROOT_REPO_CONTEXT == 'true' }} + run: scripts/download-smart-dlc.sh - name: Build - run: bun run --bun build + run: scripts/build-website.sh env: CHUNK: ${{ matrix.chunk }} @@ -159,7 +169,8 @@ jobs: # - is by manual and is on anything: triggering to anything if: >- ${{ - (github.event_name == 'schedule' && github.ref == 'refs/heads/main') + env.HAS_ROOT_REPO_CONTEXT == 'true' + || (github.event_name == 'schedule' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/production') || (github.event_name == 'workflow_dispatch') }} diff --git a/README.md b/README.md index 1568ac800..945b9448e 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/bun.lock b/bun.lock index eb056aeb0..8df2f797b 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,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": ["jimp@1.6.0", "", { "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": ["jiti@2.6.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1951,7 +1948,7 @@ "potpack": ["potpack@1.0.2", "", {}, "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": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], @@ -2739,8 +2736,6 @@ "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "yaml-language-server/prettier": ["prettier@2.8.7", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw=="], - "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/vscode-languageserver": ["vscode-languageserver@7.0.0", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="], diff --git a/package.json b/package.json index 79ebe1629..b165e16cf 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,6 @@ "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/*" diff --git a/packages/closed b/packages/closed index f89f0e077..182fcdb27 160000 --- a/packages/closed +++ b/packages/closed @@ -1 +1 @@ -Subproject commit f89f0e0772d5c851151d53ea78e05bfa16d8ea8d +Subproject commit 182fcdb27348934c148cd979766321c5b7b3a770 diff --git a/packages/core/src/blitz/fetchGlossary.ts b/packages/core/src/blitz/fetchGlossary.ts index 00309ea98..049e2a0d3 100644 --- a/packages/core/src/blitz/fetchGlossary.ts +++ b/packages/core/src/blitz/fetchGlossary.ts @@ -1,11 +1,11 @@ -import { assertSecret } from "../blitzkit"; import type { BlitzGlossary } from "../types"; +export const WOTB_GLOSSARY_DOMAIN = "https://na.wotblitz.com"; +export const WOTB_GLOSSARY_PATH = "api/glossary/stuff"; + export async function fetchGlossary(locale: string) { const response = await fetch( - `${assertSecret( - import.meta.env.WOTB_GLOSSARY_DOMAIN, - )}/${locale}${assertSecret(import.meta.env.WOTB_GLOSSARY_PATH)}`, + `${WOTB_GLOSSARY_DOMAIN}/${locale}/${WOTB_GLOSSARY_PATH}`, ); return (await response.json()) as BlitzGlossary; } diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index db490d602..712fe37b2 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -158,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"; diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 4afa98947..f1f22308d 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -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", diff --git a/packages/scripts/src/config/wgcClient.ts b/packages/scripts/src/config/wgcClient.ts new file mode 100644 index 000000000..dd8062fde --- /dev/null +++ b/packages/scripts/src/config/wgcClient.ts @@ -0,0 +1,4 @@ +export const WGC_CLIENT_GUID = "WOTB.WW.PRODUCTION"; +export const WGC_CLIENT_URL = "https://wgus-eu.wargaming.net"; +export const WGC_METADATA_PROTOCOL_VERSION = "7.10"; +export const WGC_PATCHES_CHAIN_PROTOCOL_VERSION = "1.11"; diff --git a/packages/scripts/src/downloadClient/index.ts b/packages/scripts/src/downloadClient/index.ts index c9eb68b61..44b5531bd 100644 --- a/packages/scripts/src/downloadClient/index.ts +++ b/packages/scripts/src/downloadClient/index.ts @@ -1,117 +1,38 @@ import { assertSecret } from "@blitzkit/core"; -import { $ } from "bun"; import { mkdir, rm } from "node:fs/promises"; -import { - DEPOT_DOWNLOADER_EXECUTABLE, - DEPOT_DOWNLOADER_MAX_DOWNLOADS, - DEPOT_DOWNLOADER_OUTPUT, - WGPKG_DIR, -} from "./constants"; -import { QuickGitHubRelease } from "./types"; +import { downloadSteamClient } from "./steam"; +import { downloadWGCClient } from "./wgc"; +import { downloadWGPKGClient } from "./wgpkg"; +import { downloadZipClient } from "./zip"; -const DEPOT_DOWNLOADER_RETRY_TIME = 30_000; +export const DEPOT_DOWNLOADER_RETRY_TIME = 30_000; -const provider = assertSecret(import.meta.env.CLIENT_PROVIDER); -const source = assertSecret(import.meta.env.CLIENT_SOURCE); -const dir = assertSecret(import.meta.env.CLIENT_DIR); +const provider = import.meta.env.CLIENT_PROVIDER; +export const source = import.meta.env.CLIENT_SOURCE; +export const dir = assertSecret(import.meta.env.CLIENT_DIR); await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true }); -if (provider === "steam") { - const [id, platform] = source.split("/"); +switch (provider) { + // steam, identical to wgc + case "steam": + await downloadSteamClient(); + break; - console.log("Downloading Depot Downloader..."); + // from zip, usually has a root directory for opentest + case "zip": + await downloadZipClient(); + break; - const releases = await fetch( - "https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest", - ).then((response) => response.json() as Promise); - const asset = releases.assets.find((asset) => - asset.name.includes("linux-x64"), - ); + // effectively the same as wgc, but direct for preview + case "wgpkg": + await downloadWGPKGClient(); + break; - if (!asset) { - throw new Error("No linux-x64 asset found for Steam DepotDownloader"); - } - - await rm(DEPOT_DOWNLOADER_OUTPUT, { recursive: true, force: true }); - - await $`curl -L "${asset.browser_download_url}" -o "${DEPOT_DOWNLOADER_OUTPUT}.zip"`; - await $`unzip "${DEPOT_DOWNLOADER_OUTPUT}.zip" -d "${DEPOT_DOWNLOADER_OUTPUT}"`; - await $`chmod +x "${DEPOT_DOWNLOADER_EXECUTABLE}"`; - - console.log("Downloading client..."); - - const username = assertSecret(import.meta.env.STEAM_USERNAME); - const password = assertSecret(import.meta.env.STEAM_PASSWORD); - - while (true) { - const result = await $`${ - DEPOT_DOWNLOADER_EXECUTABLE - } -os ${platform} -app ${id} -username ${ - username - } -password ${password} -dir ${dir} -max-downloads ${ - DEPOT_DOWNLOADER_MAX_DOWNLOADS - }`.nothrow(); - - if (result.exitCode === 0) { - console.log("Client downloaded successfully!"); - break; - } - - console.error( - `DepotDownloader failed (exit ${result.exitCode}). Retrying in ${DEPOT_DOWNLOADER_RETRY_TIME / 1000} seconds...`, - ); - await new Promise((resolve) => - setTimeout(resolve, DEPOT_DOWNLOADER_RETRY_TIME), - ); - } -} else if (provider === "zip") { - const url = assertSecret(import.meta.env.CLIENT_SOURCE); - - console.log("Downloading and extracting zipped client..."); - - await mkdir(dir, { recursive: true }); - await $`curl -L "${url}" | bsdtar -xf- --strip-components=1 -C ${dir}`; -} else if (provider === "wgpkg") { - let index = 1; - const urls: string[] = []; - - console.log("Discovering packages..."); - - while (true) { - const url = `${source}.${index.toString().padStart(3, "0")}`; - const response = await fetch(url); - - if (response.status === 200) { - urls.push(url); - } else if (response.status === 404) { - break; - } else { - throw new Error(`Wargaming response error status: ${response.status}`); - } - - index++; - } - - console.log(`Downloading ${urls.length} packages...`); - - await rm(WGPKG_DIR, { recursive: true, force: true }); - await mkdir(WGPKG_DIR, { recursive: true }); - - const inputContent = urls - .map((url) => { - const name = url.split("/").pop()!; - return `${url}\n out=${name}\n dir=${WGPKG_DIR}`; - }) - .join("\n"); - - await $`echo ${inputContent} | aria2c -j ${urls.length} -x 2 -s 2 -i -`; - - const firstChunk = `${WGPKG_DIR}/${urls[0].split("/").pop()!}`; - await $`7z x "${firstChunk}" -o${dir} -y`; - - await rm(WGPKG_DIR, { recursive: true }); -} else { - throw new Error(`Unknown provider: ${provider}`); + default: + console.log("Unknown client provider specified, using latest wgc client"); + case "wgc": + await downloadWGCClient(); + break; } diff --git a/packages/scripts/src/downloadClient/steam.ts b/packages/scripts/src/downloadClient/steam.ts new file mode 100644 index 000000000..0b58b921d --- /dev/null +++ b/packages/scripts/src/downloadClient/steam.ts @@ -0,0 +1,57 @@ +import { assertSecret } from "@blitzkit/core"; +import { $ } from "bun"; +import { rm } from "node:fs/promises"; +import { DEPOT_DOWNLOADER_RETRY_TIME, dir, source } from "."; +import { + DEPOT_DOWNLOADER_EXECUTABLE, + DEPOT_DOWNLOADER_MAX_DOWNLOADS, + DEPOT_DOWNLOADER_OUTPUT, +} from "./constants"; +import { QuickGitHubRelease } from "./types"; + +export async function downloadSteamClient() { + const [id, platform] = source.split("/"); + + console.log("Downloading Depot Downloader..."); + + const releases = await fetch( + "https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest", + ).then((response) => response.json() as Promise); + const asset = releases.assets.find((asset) => + asset.name.includes("linux-x64"), + ); + + if (!asset) { + throw new Error("No linux-x64 asset found for Steam DepotDownloader"); + } + + await rm(DEPOT_DOWNLOADER_OUTPUT, { recursive: true, force: true }); + + await $`curl -L "${asset.browser_download_url}" -o "${DEPOT_DOWNLOADER_OUTPUT}.zip"`; + await $`unzip "${DEPOT_DOWNLOADER_OUTPUT}.zip" -d "${DEPOT_DOWNLOADER_OUTPUT}"`; + await $`chmod +x "${DEPOT_DOWNLOADER_EXECUTABLE}"`; + + console.log("Downloading client..."); + + const username = assertSecret(import.meta.env.STEAM_USERNAME); + const password = assertSecret(import.meta.env.STEAM_PASSWORD); + + while (true) { + const result = + await $`${DEPOT_DOWNLOADER_EXECUTABLE} -os ${platform} -app ${id} -username ${username} -password ${password} -dir ${dir} -max-downloads ${DEPOT_DOWNLOADER_MAX_DOWNLOADS}`.nothrow(); + + if (result.exitCode === 0) { + console.log("Client downloaded successfully!"); + break; + } + + console.error( + `DepotDownloader failed (exit ${result.exitCode}). Retrying in ${ + DEPOT_DOWNLOADER_RETRY_TIME / 1000 + } seconds...`, + ); + await new Promise((resolve) => + setTimeout(resolve, DEPOT_DOWNLOADER_RETRY_TIME), + ); + } +} diff --git a/packages/scripts/src/downloadClient/wgc/index.ts b/packages/scripts/src/downloadClient/wgc/index.ts new file mode 100644 index 000000000..2cb0a1ef3 --- /dev/null +++ b/packages/scripts/src/downloadClient/wgc/index.ts @@ -0,0 +1,62 @@ +import locales from "@blitzkit/i18n/locales.json"; +import { XMLParser } from "fast-xml-parser"; +import { + WGC_CLIENT_GUID, + WGC_CLIENT_URL, + WGC_METADATA_PROTOCOL_VERSION, + WGC_PATCHES_CHAIN_PROTOCOL_VERSION, +} from "../../config/wgcClient"; +import { downloadWgParts } from "../wgpkg"; +import { ClientMetadata, PatchesChain } from "./types"; + +const parser = new XMLParser(); + +export async function downloadWGCClient() { + const metadataUrl = new URL("api/v1/metadata", WGC_CLIENT_URL); + + metadataUrl.searchParams.set("guid", WGC_CLIENT_GUID); + metadataUrl.searchParams.set("chain_id", "unknown"); + metadataUrl.searchParams.set( + "protocol_version", + WGC_METADATA_PROTOCOL_VERSION, + ); + + const metadata = await fetch(`${metadataUrl}`) + .then((response) => response.text()) + .then((text) => parser.parse(text) as Promise); + + const patchesChainUrl = new URL("api/v1/patches_chain", WGC_CLIENT_URL); + + patchesChainUrl.searchParams.set( + "protocol_version", + WGC_PATCHES_CHAIN_PROTOCOL_VERSION, + ); + patchesChainUrl.searchParams.set("client_type", "full"); + patchesChainUrl.searchParams.set("lang", locales.default); + patchesChainUrl.searchParams.set( + "metadata_version", + `${metadata.protocol.version}`, + ); + patchesChainUrl.searchParams.set( + "metadata_protocol_version", + WGC_METADATA_PROTOCOL_VERSION, + ); + patchesChainUrl.searchParams.set( + "chain_id", + metadata.protocol.predefined_section.chain_id, + ); + patchesChainUrl.searchParams.set("game_installation", "true"); + patchesChainUrl.searchParams.set("game_id", WGC_CLIENT_GUID); + patchesChainUrl.searchParams.set("client_current_version", "0"); + + const patchesChain = await fetch(`${patchesChainUrl}`) + .then((response) => response.text()) + .then((text) => parser.parse(text) as Promise); + + const urls = patchesChain.protocol.patches_chain.patch.files.file.map( + (file) => + new URL(file.name, patchesChain.protocol.web_seeds.url).toString(), + ); + + await downloadWgParts(urls); +} diff --git a/packages/scripts/src/downloadClient/wgc/types.ts b/packages/scripts/src/downloadClient/wgc/types.ts new file mode 100644 index 000000000..c5ba3b367 --- /dev/null +++ b/packages/scripts/src/downloadClient/wgc/types.ts @@ -0,0 +1,87 @@ +export interface ClientMetadata { + protocol: { + version: number; + predefined_section: PredefinedSection; + generated_section: GeneratedSection; + }; +} + +interface GeneratedSection { + launch_forbidden: string; + stop_supporting: string; +} + +interface PredefinedSection { + app_id: string; + chain_id: string; + supported_languages: string; + default_language: string; + name: string; + fs_name: string; + shortcut_name: string; + executables: { executable: string }; + keep_patches_interval: number; + client_types: { client_type: ClientType }; + login_enabled: boolean; + realms_settings: RealmsSettings; + localization: Localization; +} + +interface ClientType { + initial_app_type: string; + final_app_type: string; + min_supported_os: string; + client_parts: { client_part: string }; +} + +interface Localization { + item: { text: string[] }; +} + +interface RealmsSettings { + instances: { instance: Instance[] }; +} + +interface Instance { + game_id: string; + title_id: string; + platform_realm_id: string; + options: { option: string }; + content_service_url: string; +} + +export interface PatchesChain { + protocol: { + parts_info: { part: Part }; + patches_chain: { patch: Patch }; + delay_preload: string; + web_seeds: { url: string }; + meta_need_update: string; + version_name: string; + }; +} + +interface Part { + name: string; + version: number; + status: string; +} + +interface Patch { + files: { file: File[] }; + torrent: Torrent; + part: string; + version_from: number; + version_to: string; +} + +interface File { + name: string; + size: number; + unpacked_size: number; +} + +interface Torrent { + hash: string; + urls: { url: string }; +} diff --git a/packages/scripts/src/downloadClient/wgpkg.ts b/packages/scripts/src/downloadClient/wgpkg.ts new file mode 100644 index 000000000..6c3f2b239 --- /dev/null +++ b/packages/scripts/src/downloadClient/wgpkg.ts @@ -0,0 +1,49 @@ +import { $ } from "bun"; +import { mkdir, rm } from "node:fs/promises"; +import { dir, source } from "."; +import { WGPKG_DIR } from "./constants"; + +export async function downloadWGPKGClient() { + let index = 1; + const urls: string[] = []; + + console.log("Discovering packages..."); + + while (true) { + const url = `${source}.${index.toString().padStart(3, "0")}`; + const response = await fetch(url); + + if (response.status === 200) { + urls.push(url); + } else if (response.status === 404) { + break; + } else { + throw new Error(`Wargaming response error status: ${response.status}`); + } + + index++; + } + + console.log(`Downloading ${urls.length} packages...`); + + await downloadWgParts(urls); +} + +export async function downloadWgParts(urls: string[]) { + await rm(WGPKG_DIR, { recursive: true, force: true }); + await mkdir(WGPKG_DIR, { recursive: true }); + + const inputContent = urls + .map((url) => { + const name = url.split("/").pop()!; + return `${url}\n out=${name}\n dir=${WGPKG_DIR}`; + }) + .join("\n"); + + await $`echo ${inputContent} | aria2c -j ${urls.length} -x 2 -s 2 -i -`; + + const firstChunk = `${WGPKG_DIR}/${urls[0].split("/").pop()!}`; + await $`7z x "${firstChunk}" -o${dir} -y`; + + await rm(WGPKG_DIR, { recursive: true }); +} diff --git a/packages/scripts/src/downloadClient/zip.ts b/packages/scripts/src/downloadClient/zip.ts new file mode 100644 index 000000000..8aed35271 --- /dev/null +++ b/packages/scripts/src/downloadClient/zip.ts @@ -0,0 +1,13 @@ +import { assertSecret } from "@blitzkit/core"; +import { $ } from "bun"; +import { mkdir } from "node:fs/promises"; +import { dir } from "."; + +export async function downloadZipClient() { + const url = assertSecret(import.meta.env.CLIENT_SOURCE); + + console.log("Downloading and extracting zipped client..."); + + await mkdir(dir, { recursive: true }); + await $`curl -L "${url}" | bsdtar -xf- --strip-components=1 -C ${dir}`; +} diff --git a/packages/scripts/src/downloadSmartDLC/index.ts b/packages/scripts/src/downloadSmartDLC/index.ts deleted file mode 100644 index 24b13f068..000000000 --- a/packages/scripts/src/downloadSmartDLC/index.ts +++ /dev/null @@ -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( - "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>(`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); diff --git a/packages/website/package.json b/packages/website/package.json index 93ef11fea..51ecb457d 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -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", diff --git a/packages/website/src/components/SceneProps/index.tsx b/packages/website/src/components/SceneProps/index.tsx index ca26e8c94..49acf7566 100644 --- a/packages/website/src/components/SceneProps/index.tsx +++ b/packages/website/src/components/SceneProps/index.tsx @@ -47,10 +47,10 @@ export function SceneProps() { ${shader.fragmentShader} `.replace( - "#include ", + "#include ", ` - #include + #include vec3 scaled = vWorldPosition / size; float radius = length(scaled.xz); @@ -59,7 +59,10 @@ export function SceneProps() { discard; } - diffuseColor.a *= (1.0 - radius); + float x = 1.0 - radius; + float y = x * x; + + gl_FragColor.rgb *= y; `, ); }, @@ -78,7 +81,6 @@ export function SceneProps() { diff --git a/packages/website/src/components/TankModel.tsx b/packages/website/src/components/TankModel.tsx index d21cc9a9a..e1b3a8102 100644 --- a/packages/website/src/components/TankModel.tsx +++ b/packages/website/src/components/TankModel.tsx @@ -11,6 +11,7 @@ import { useDuelModel } from "../hooks/useDuelModel"; import { hasEquipment } from "../hooks/useEquipment"; import { useModel } from "../hooks/useModel"; import { useSkin } from "../hooks/useSkin"; +import { useSkinAnimation } from "../hooks/useSkinAnimation"; import { useTankTransform } from "../hooks/useTankTransform"; import { Tankopedia } from "../stores/tankopedia"; import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants"; @@ -34,12 +35,17 @@ export function TankModel() { const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined); useTankTransform(track, turret, turretContainer, gunContainer); + useSkinAnimation(skins); useEffect(() => { const emissiveMaterials = new Set(); + const roots = [ + gltf.scene, + ...skins.flatMap((skin) => [...skin.parts.values()].flat()), + ]; - for (const scene of [gltf.scene, ...skins.map((skin) => skin.scene)]) { - scene.traverse((object) => { + for (const root of roots) { + root.traverse((object) => { if ( object instanceof Mesh && object.material instanceof MeshStandardMaterial && @@ -145,6 +151,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} @@ -224,6 +239,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} @@ -307,6 +331,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} diff --git a/packages/website/src/core/blitzkit/isHalloween.ts b/packages/website/src/core/blitzkit/isHalloween.ts index c05deb5f5..c436a6a26 100644 --- a/packages/website/src/core/blitzkit/isHalloween.ts +++ b/packages/website/src/core/blitzkit/isHalloween.ts @@ -1,4 +1,6 @@ export function isHalloween() { + return false; + const data = new Date(); return data.getMonth() === 9 && data.getDate() === 31; } diff --git a/packages/website/src/core/blitzkit/jsxTree.tsx b/packages/website/src/core/blitzkit/jsxTree.tsx index 9cf52a79b..96ab50c03 100644 --- a/packages/website/src/core/blitzkit/jsxTree.tsx +++ b/packages/website/src/core/blitzkit/jsxTree.tsx @@ -1,5 +1,21 @@ import type { ComponentProps, ReactNode } from "react"; -import { AdditiveBlending, Mesh, MeshStandardMaterial, Object3D } from "three"; +import { + AdditiveBlending, + Mesh, + MeshStandardMaterial, + Object3D, + type Material, +} from "three"; + +export function prepareMaterial(material: Material | Material[]) { + if (!(material instanceof MeshStandardMaterial)) return; + + material.depthWrite = true; + + if (material.userData.blending === "additive") { + material.blending = AdditiveBlending; + } +} export function jsxTree( 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,13 +42,7 @@ export function jsxTree( scale: node.scale, }; - if (props.material instanceof MeshStandardMaterial) { - props.material.depthWrite = true; - - if (props.material.userData.blending === "additive") { - props.material.blending = AdditiveBlending; - } - } + prepareMaterial(props.material); return mergers?.mesh ? ( mergers.mesh(node, props, node.uuid) diff --git a/packages/website/src/core/blitzkit/skinAnimations/motion.ts b/packages/website/src/core/blitzkit/skinAnimations/motion.ts new file mode 100644 index 000000000..71b2afab3 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/motion.ts @@ -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>; +} + +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(); + + 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; + } +} diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts new file mode 100644 index 000000000..c6a006414 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -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, + 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, + 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; +} diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx index 5a2020be4..81c5a46d6 100644 --- a/packages/website/src/core/blitzkit/skinTree.tsx +++ b/packages/website/src/core/blitzkit/skinTree.tsx @@ -5,24 +5,24 @@ import { jsxTree } from "./jsxTree"; type Mergers = Parameters[1]; // names get sanitized and made unique on load -function nameOf(object3d: Object3D): string { +export function nameOf(object3d: Object3D): string { return object3d.userData.name ?? object3d.name; } /** * skins are layered in order, so an attachment's states win over the - * camouflage's + * camouflage's; `primitive` renders customization parts as they are so their + * bones can animate */ -export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) { - const skinNodes = new Map(); +export function skinTree( + node: Object3D, + skins: Skin[], + mergers: NonNullable, +) { const materials = new Map(); const states: Record = {}; for (const skin of skins) { - for (const node of skin.scene.children) { - skinNodes.set(node.name, [...(skinNodes.get(node.name) ?? []), node]); - } - for (const [name, material] of skin.materials) { materials.set(name, material); } @@ -30,7 +30,7 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) { Object.assign(states, skin.scene.userData.states); } - function switchChildren(object3d: Object3D, childMergers: Mergers) { + function switchChildren(object3d: Object3D) { if (object3d.userData.state === undefined) return; const name = nameOf(object3d); @@ -38,37 +38,31 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) { return object3d.children .filter((child) => nameOf(child) === state) - .map((child) => jsxTree(child, childMergers)); + .map((child) => jsxTree(child, skinMergers)); } - const partMergers: Mergers = { - ...mergers, - group(object3d, props, key) { - return ( - - {switchChildren(object3d, partMergers) ?? props.children} - - ); - }, - }; const skinMergers: Mergers = { mesh(mesh, props, key) { 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 parts = skinNodes.get(object3d.name) ?? []; - return ( - {switchChildren(object3d, skinMergers) ?? props.children} - {parts.flatMap((part) => - part.children.map((child) => jsxTree(child, partMergers)), + {switchChildren(object3d) ?? props.children} + {skins.flatMap((skin) => + (skin.parts.get(object3d.name) ?? []).map((part) => + mergers.primitive ? ( + mergers.primitive(part, part.uuid) + ) : ( + + ), + ), )} ); diff --git a/packages/website/src/core/blitzkit/switcherStates.ts b/packages/website/src/core/blitzkit/switcherStates.ts index 568675946..f645b9e4a 100644 --- a/packages/website/src/core/blitzkit/switcherStates.ts +++ b/packages/website/src/core/blitzkit/switcherStates.ts @@ -1,5 +1,9 @@ -import { parseHierarchies, type Sc2ReadStream } from "@blitzkit/core"; -import { times } from "lodash-es"; +import { + parseHierarchies, + type Hierarchy, + type Sc2ReadStream, +} from "@blitzkit/core"; +import { sortBy, times } from "lodash-es"; interface Value { "any.type": string; @@ -17,12 +21,15 @@ interface ScriptNode { interface Script { nodes: Record; + variables?: Record; } 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; @@ -32,12 +39,17 @@ 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; @@ -72,25 +84,141 @@ function resolveTarget(owner: Entity, entityName: string) { } /** - * replays what picks each state switcher's state as a tank is shown in the - * garage, with the camo's preset applied and its items attached; delayed - * actions are skipped so states stay as they are when the tank appears + * 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 */ -export function switcherStates( +function replay( sc2: ReturnType, preset: string | undefined, attachments: Map, + animate: boolean, ) { const scripts: Record = {}; + const root: Entity = { name: "", children: [] }; const stateNames = new Map(); - const scenarios: { owner: Entity; script: Script }[] = []; + const scenarios: { + owner: Entity; + script: Script; + variables: Map; + }[] = []; const actions: { owner: Entity; action: Action }[] = []; - const states: Record = {}; + // names can repeat, so states are kept per entity + const states = new Map(); + const looping = new Set(); + const finished = new Set(); + const pending: (() => void)[] = []; + const fired = new Set(); function activateState(entity: Entity | undefined, state: number) { const names = entity && stateNames.get(entity); - if (names) states[entity.name] = names[state] ?? null; + if (!names) return; + + const previous = states.get(entity); + + states.set(entity, names[state] ?? null); + + if (!animate || previous === states.get(entity)) return; + + for (const scenario of scenarios) { + if (scenario.owner === entity) { + runScenario(scenario, "State Activated", state); + } + } + } + + function isShown(entity: Entity) { + for (let child = entity; child.parent; child = child.parent) { + if ( + stateNames.get(child.parent)?.includes(child.name) && + states.get(child.parent) !== child.name + ) { + return false; + } + } + + return true; + } + + function startAnimation(entity: Entity, repeats?: number) { + if (entity.repeats === undefined) { + for (const child of entity.children) startAnimation(child, repeats); + return; + } + + if (looping.has(entity)) return; + + looping.add(entity); + + // some clips restart themselves when they end, so each finishes once + if ((repeats ?? entity.repeats) > 0 && !finished.has(entity)) { + pending.push(() => finishAnimation(entity)); + } + } + + function finishAnimation(entity: Entity) { + const statesBefore = new Map(states); + const shownBefore = [...looping].filter(isShown); + + looping.delete(entity); + finished.add(entity); + + for (const { owner, action } of actions) { + if ( + owner === entity && + ANIMATION_END_EVENTS.includes(action["act.event"]) + ) { + runAction(owner, action); + } + } + + for ( + let ancestor: Entity | undefined = entity; + ancestor; + ancestor = ancestor.parent + ) { + const owned = scenarios.filter(({ owner }) => owner === ancestor); + + for (const scenario of owned) { + runScenario(scenario, "Motion Fragment Stop"); + runScenario(scenario, "Motion Sequence Stop"); + } + + if (owned.length > 0) break; + } + + // as in game, a clip stops on whatever it switched to or revealed; one that + // changes nothing is the only animation there is, so it keeps looping + if ( + [...states].every( + ([other, state]) => statesBefore.get(other) === state, + ) && + [...looping].every( + (other) => !isShown(other) || shownBefore.includes(other), + ) + ) { + looping.add(entity); + } + } + + function runAction(owner: Entity, action: Action) { + const target = resolveTarget(owner, action["act.entityName"]); + + if (SWITCH_ACTIONS.includes(action["act.type"])) { + // what shows for a while in game stays, as its animation loops here + if (action["act.delay"] > 0 && action["act.switchIndex"] < 0) return; + + activateState(target, action["act.switchIndex"]); + } else if (animate && action["act.type"] === START_ANIMATION_ACTION) { + if (target) { + const repeats = action["act.stopAfterNRepeats"]; + + startAnimation(target, repeats > 0 ? repeats : undefined); + } + } else if (animate && action["act.type"] === TRIGGER_EVENT_ACTION) { + fireEvent(action["act.eventToTrigger"]); + } } for (const component of Object.values( @@ -103,9 +231,14 @@ export function switcherStates( parseHierarchies( sc2["#hierarchy"], - { name: "", children: [] }, + root, (hierarchy, components, parent) => { - const entity: Entity = { name: hierarchy.name, parent, children: [] }; + const entity: Entity = { + name: hierarchy.name, + parent, + children: [], + hierarchy, + }; parent.children.push(entity); @@ -122,14 +255,24 @@ export function switcherStates( ); stateNames.set(entity, names); - states[entity.name] = names[component["ssc.activeState"]] ?? null; + states.set(entity, names[component["ssc.activeState"]] ?? null); break; } case "ScenarioComponent": { const script = scripts[component["scenario.scriptId"]]; - if (script) scenarios.push({ owner: entity, script }); + if (script) { + scenarios.push({ + owner: entity, + script, + variables: new Map( + Object.entries(script.variables ?? {}).map( + ([name, variable]) => [name, variable["any.value"]], + ), + ), + }); + } break; } @@ -141,6 +284,12 @@ export function switcherStates( }); }); break; + + case "MotionComponent": + if (animate) { + entity.repeats = component["simpleMotion.repeatsCount"]; + } + break; } } @@ -149,9 +298,11 @@ export function switcherStates( ); function runScenario( - { owner, script }: (typeof scenarios)[number], + 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 ?? {}); @@ -168,31 +319,79 @@ export function switcherStates( switch (type) { case "ScenarioSceneInfoNode": - return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentEntity" - ? owner - : undefined; + switch (node["scenarioSceneInfoNode.infoKind"]) { + case "GetCurrentEntity": + return owner; + case "GetCurrentScene": + return root; + } + return undefined; - case "ScenarioGetMemberNode": - return node["scenarioGetMemberNode.fieldName"] === "parent" && - pin === "parent" - ? (input(id, "object") as Entity | undefined)?.parent - : undefined; + case "ScenarioGetVarNode": + return variables.get(node["scenarioGetVarNode.varPath"] as string); - case "ScenarioFunctionNode": - switch ( - `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}` - ) { + 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"); + } } } @@ -206,13 +405,41 @@ export function switcherStates( pin = "event"; } else if (type === "ScenarioBranchNode") { pin = input(id, "condition") ? "true" : "false"; - } else if ( - node["scenarioFunctionNode.functionName"] === "ActivateState" - ) { - activateState( - input(id, "entity") as Entity | undefined, - Number(input(id, "state")), + } 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)) { @@ -227,29 +454,87 @@ export function switcherStates( ? node.defaults?.["Event Name"]?.["any.value"] : node["ScenarioEventNode.eventName"]; - if (type === "ScenarioEventNode" && name === event) execute(id, 0); + if ( + type === "ScenarioEventNode" && + name === event && + (event !== "State Activated" || + Number(node.defaults?.State?.["any.value"] ?? 0) === value) + ) { + execute(id, 0); + } } } - for (const event of LOAD_EVENTS) { - for (const { owner, action } of actions) { + 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 ( - action["act.delay"] === 0 && - SWITCH_ACTIONS.includes(action["act.type"]) && + (animate || action["act.delay"] === 0) && (event === LOAD_EVENTS[0] ? action["act.event"] === SCENE_LOADED_EVENT : action["act.event"] === USER_EVENT && action["act.userEventId"] === event) ) { - activateState( - resolveTarget(owner, action["act.entityName"]), - action["act.switchIndex"], - ); + 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, + preset: string | undefined, + attachments: Map, +) { + const states: Record = {}; + + 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, + preset: string | undefined, +) { + return replay(sc2, preset, new Map(), true); +} diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index ca11744c8..402a05de3 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -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/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; + /** customization parts by the tank node they attach to */ + parts: Map; + mixer?: AnimationMixer; } const skins = new Map>(); @@ -23,13 +35,46 @@ async function loadSkin(path: string) { .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`); } diff --git a/packages/website/src/hooks/useSkinAnimation.ts b/packages/website/src/hooks/useSkinAnimation.ts new file mode 100644 index 000000000..1c686bd28 --- /dev/null +++ b/packages/website/src/hooks/useSkinAnimation.ts @@ -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(); + } + }); +} diff --git a/packages/website/src/pages/[...chunk_api]/icons/currencies/[id].webp.ts b/packages/website/src/pages/[...chunk_api]/icons/currencies/[id].webp.ts index 32008a17e..c6161dfe8 100644 --- a/packages/website/src/pages/[...chunk_api]/icons/currencies/[id].webp.ts +++ b/packages/website/src/pages/[...chunk_api]/icons/currencies/[id].webp.ts @@ -1,4 +1,9 @@ -import { assertSecret, type BlitzGlossary } from "@blitzkit/core"; +import { + WOTB_GLOSSARY_DOMAIN, + WOTB_GLOSSARY_PATH, + type BlitzGlossary, +} from "@blitzkit/core"; +import locales from "@blitzkit/i18n/locales.json"; import type { APIContext, GetStaticPathsItem } from "astro"; import sharp from "sharp"; import { mixStaticPaths } from "../../../../astro/mixStaticPaths"; @@ -30,7 +35,9 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { }); } - const glossary = await fetch(assertSecret(import.meta.env.WOTB_GLOSSARY)) + const glossary = await fetch( + `${WOTB_GLOSSARY_DOMAIN}/${locales.default}/${WOTB_GLOSSARY_PATH}`, + ) .then((response) => response.json() as Promise) .then((glossary) => Object.values(glossary) diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index e0209c622..e82374fbd 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -172,13 +172,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) { continue; } - const part = await extractModel(vfs, path, true); - const partSc2 = new Sc2ReadStream( - (await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer, - ).sc2(); - - Object.assign(states, switcherStates(partSc2, preset, new Map())); - + 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 diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index b9dd437d4..a3ec84e39 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -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, @@ -79,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`; @@ -92,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>(); + const jointBindings: [ + owner: Node, + entity: string, + joint: Node, + skin: Skin, + ][] = []; + const motions = new Map(); + const garage = isPart ? partGarage(sc2, preset) : undefined; + + if (isPart) { + const loading: Promise[] = []; + + 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( sc2["#hierarchy"], @@ -105,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": @@ -125,10 +206,36 @@ 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"]; @@ -214,10 +321,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); }); @@ -240,6 +361,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()); diff --git a/packages/website/template.env b/packages/website/template.env index e2f4d103f..1cc1dd9c3 100644 --- a/packages/website/template.env +++ b/packages/website/template.env @@ -26,10 +26,5 @@ PUBLIC_PROMOTE_OPENTEST=false # Legacy variable; this will be removed soon PUBLIC_STATIC_ASSET_BASE=https://assets.blitzkit.app -# Proprietary Wargaming fields; you shouldn't need these for 99% of the tasks -WOTB_GLOSSARY= -WOTB_GLOSSARY_DOMAIN= -WOTB_GLOSSARY_PATH= - # Generate your own application id: https://developers.wargaming.net PUBLIC_WARGAMING_APPLICATION_ID= \ No newline at end of file diff --git a/scripts/build-protos.sh b/scripts/build-protos.sh new file mode 100755 index 000000000..f0c44a7de --- /dev/null +++ b/scripts/build-protos.sh @@ -0,0 +1,2 @@ +cd packages/scripts +bun src/protos.ts \ No newline at end of file diff --git a/scripts/build-website.sh b/scripts/build-website.sh new file mode 100755 index 000000000..e0810c59a --- /dev/null +++ b/scripts/build-website.sh @@ -0,0 +1,2 @@ +cd packages/website +bun x --bun astro build \ No newline at end of file diff --git a/scripts/clean-astro.sh b/scripts/clean-astro.sh new file mode 100755 index 000000000..9ecfeea66 --- /dev/null +++ b/scripts/clean-astro.sh @@ -0,0 +1,4 @@ +rm -rf \ + packages/website/.astro \ + packages/website/node_modules/.astro \ + packages/website/node_modules/.vite \ No newline at end of file diff --git a/scripts/dev-website.sh b/scripts/dev-website.sh new file mode 100755 index 000000000..69b8adc76 --- /dev/null +++ b/scripts/dev-website.sh @@ -0,0 +1,2 @@ +cd packages/website +bun x --bun astro dev \ No newline at end of file diff --git a/scripts/discover-ids.sh b/scripts/discover-ids.sh new file mode 100755 index 000000000..a6480f0de --- /dev/null +++ b/scripts/discover-ids.sh @@ -0,0 +1,2 @@ +cd packages/scripts +bun src/discoverIds/index.ts \ No newline at end of file diff --git a/scripts/download-client.sh b/scripts/download-client.sh new file mode 100755 index 000000000..d5cfad5e7 --- /dev/null +++ b/scripts/download-client.sh @@ -0,0 +1,2 @@ +cd packages/scripts +bun src/downloadClient/index.ts \ No newline at end of file diff --git a/scripts/download-smart-dlc.sh b/scripts/download-smart-dlc.sh new file mode 100755 index 000000000..f4e61ecad --- /dev/null +++ b/scripts/download-smart-dlc.sh @@ -0,0 +1,2 @@ +cd packages/closed +bun src/dava/downloadSmartDLC.ts \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 000000000..67752c3f5 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,2 @@ +cd packages/website +bun x --bun astro check \ No newline at end of file diff --git a/scripts/preview-website.sh b/scripts/preview-website.sh new file mode 100755 index 000000000..6bc9cee00 --- /dev/null +++ b/scripts/preview-website.sh @@ -0,0 +1,2 @@ +cd packages/website +bun x --bun astro preview \ No newline at end of file diff --git a/scripts/record-performance.sh b/scripts/record-performance.sh new file mode 100755 index 000000000..15e42722a --- /dev/null +++ b/scripts/record-performance.sh @@ -0,0 +1,2 @@ +cd packages/scripts +bun src/recordPerformance/index.ts \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 000000000..5f89e1ae5 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,2 @@ +chmod +x scripts/*.sh +bun install diff --git a/scripts/vfs.sh b/scripts/vfs.sh new file mode 100755 index 000000000..3ac48cf99 --- /dev/null +++ b/scripts/vfs.sh @@ -0,0 +1,2 @@ +cd packages/scripts +bun src/vfs/index.ts \ No newline at end of file