diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 5ada3d32f..dba93a9d8 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -2,6 +2,11 @@ name: Website on: push: + branches: + - main + - production + + pull_request: schedule: - cron: "0 4 * * *" @@ -93,7 +98,7 @@ jobs: strategy: fail-fast: true matrix: - chunk: [default, api] + chunk: [default, api, models_tanks, models_legendary, models_maps] steps: - name: Free disk space @@ -102,7 +107,8 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.branch || 'production' }} + # 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 }} @@ -122,9 +128,14 @@ jobs: - name: Install dependencies run: bun install - - name: Download Client + - name: Download client run: bun run download-client + - name: Download SmartDLC + if: ${{ env.CLIENT_PROVIDER == 'steam' }} + + run: bun run download-smart-dlc + - name: Build run: bun run --bun build env: @@ -139,13 +150,15 @@ jobs: - name: Trigger deployment - # Deploy if: - # 1. Running cron from main using content from production to target production - # 2. Running manually and targeting non-production + # workflow should trigger if: + # - is by cron and is on main: triggering to production + # - is by push and is on production: triggering to production + # - is by manual and is on anything: triggering to anything if: >- ${{ - github.ref == 'refs/heads/production' || - (inputs.environment != '' && inputs.environment != 'production') + (github.event_name == 'schedule' && github.ref == 'refs/heads/main') + || (github.event_name == 'push' && github.ref == 'refs/heads/production') + || (github.event_name == 'workflow_dispatch') }} env: diff --git a/docs/about/credits.md b/docs/about/credits.md deleted file mode 100644 index ae2ac076a..000000000 --- a/docs/about/credits.md +++ /dev/null @@ -1,47 +0,0 @@ -# BlitzKit Credits - -BlitzKit is a one man band **Tr猫sAbhi** but I am far from solo. Helpers, in the order of contribution over time: - -### S0AP - -BlitzKit Discord bot verification, testing, advertisement, and the initial encouragement to create BlitzKit. - -### Prince_NA - -BlitzKit Discord bot and website testing and general advise. - -### Vovko - -BlitzKit Discord bot and website rendering pipeline. - -### Maddox - -Game file decompression. - -### Pyogenics - -Game 3D model parsing. - -### Aeson - -Game file layout, general advise, advertisement, and Tankopedia pre-release testing. - -### Minitelrose, ChickenMan7777 - -3-caliber rule edge cases. - -### HisRoyalFatness, Synx - -Embed UX. - -### [OPTML] clan, \_34, xkdkw, Droodles Blitz - -Tankopedia pre-release testing. - -### GonnaHetzMe - -Redacted by request. - -### Mark Greene - -GitHub contributor on bug fixes and feature additions. diff --git a/docs/changelogs/1.29.10.md b/docs/changelogs/1.29.10.md index d96d78fd8..8ba65ad99 100644 --- a/docs/changelogs/1.29.10.md +++ b/docs/changelogs/1.29.10.md @@ -1,14 +1,14 @@ # BlitzKit 1.29.10 -Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update. +Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update. ## Features - Combined game mode passive and active abilities into a single dropdown -- Added tier filters to the Guess game [Mark Greene] +- Added tier filters to the Guess game [HitJack] - Changing tiers will break your streak ## Fixes -- module damage is now properly shown when sorting with it in the tank search [Mark Greene] +- module damage is now properly shown when sorting with it in the tank search [HitJack] - Duplicate abilities in the tank filters are now removed diff --git a/docs/changelogs/1.29.8.md b/docs/changelogs/1.29.8.md index a32e7d558..8f18b3474 100644 --- a/docs/changelogs/1.29.8.md +++ b/docs/changelogs/1.29.8.md @@ -1,6 +1,6 @@ # BlitzKit 1.29.8 -Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update. +Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update. ## Features @@ -15,5 +15,5 @@ Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions ## Fixes - The Tankopedia now smoothly resizes when enlarging on interaction -- Stock turrets now correctly mount the corresponding stock gun [Mark Greene] +- Stock turrets now correctly mount the corresponding stock gun [HitJack] - The consumable and provision buttons in Compare are no longer tiny diff --git a/docs/changelogs/1.29.9.md b/docs/changelogs/1.29.9.md index c426194e1..3ec9b1333 100644 --- a/docs/changelogs/1.29.9.md +++ b/docs/changelogs/1.29.9.md @@ -1,14 +1,14 @@ # BlitzKit 1.29.9 -Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update. +Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update. ## Features - Reworked tank search filters to be far more compact to make way for new filters -- Added the ability to filter tanks by consumables [Mark Greene] +- Added the ability to filter tanks by consumables [HitJack] - Added the ability to filter tanks by provisions and game-mode abilities and powers ## Fixes - Consumable and provision icons in Compare are now properly sized -- Provisions now properly affect terrain resistances [Mark Greene] +- Provisions now properly affect terrain resistances [HitJack] diff --git a/docs/changelogs/1.30.7.md b/docs/changelogs/1.30.7.md index c42589bff..c045cf5a5 100644 --- a/docs/changelogs/1.30.7.md +++ b/docs/changelogs/1.30.7.md @@ -1,6 +1,6 @@ # BlitzKit 1.30.7 -Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update. +Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update. ## Features @@ -8,13 +8,13 @@ Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions ## Fixes -- Clicking on the armor multiple times with HE no longer crashes BlitzKit [Mark Greene] -- Ricochets that later intersect with external modules no longer falsely claim a successful damaging penetration [Mark Greene] +- Clicking on the armor multiple times with HE no longer crashes BlitzKit [HitJack] +- Ricochets that later intersect with external modules no longer falsely claim a successful damaging penetration [HitJack] - The lighting animation no longer stops prematurely when BlitzKit is unfocused - The spot light angle has been increased to more evenly light up larger tanks - BlitzKit once again uses soft shadows, reducing the pixelated shadows - Fixed the weird patterns that formed on the tracks of tanks under self-shadows - Increased the floor size and light decay to create the illusion that the floor is infinite -- Safari no longer crashes when unloading textures for unloaded tanks [Mark Greene] +- Safari no longer crashes when unloading textures for unloaded tanks [HitJack] - The gun depression transition between two constraints (like often behind tanks) now uses a gentler zoned and can never be 0 -- Armor plate names in Static Armor are now properly capitalized in English and translated to other languages [Mark Greene] +- Armor plate names in Static Armor are now properly capitalized in English and translated to other languages [HitJack] diff --git a/docs/changelogs/1.32.0.md b/docs/changelogs/1.32.0.md index ce86475ee..9ffbce4ef 100644 --- a/docs/changelogs/1.32.0.md +++ b/docs/changelogs/1.32.0.md @@ -1,75 +1,75 @@ # BlitzKit 1.32.0 -Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update. +Thank you to [HitJack](https://github.com/Hitjack007) for your contributions this update. ## Features ### Tankopedia & Armor -- Added a spall liner button to the Tankopedia beside the other equipment [Mark Greene] -- Listed track repair times in the Tankopedia [Mark Greene] -- The penetration chance is now displayed when penetration testing armor [Mark Greene] -- Added a new setting to disable automatically loading tanks in Tankopedia [Mark Greene] +- Added a spall liner button to the Tankopedia beside the other equipment [HitJack] +- Listed track repair times in the Tankopedia [HitJack] +- The penetration chance is now displayed when penetration testing armor [HitJack] +- Added a new setting to disable automatically loading tanks in Tankopedia [HitJack] ### Tank Performance & Mechanics -- Added Peekaboo DPM [Mark Greene] +- Added Peekaboo DPM [HitJack] - Your Peekaboo DPM is lower than your normal DPM if your reload is faster than 10s as you'll have to wait for the remaining 10s for you to get unspotted before you can poke and shoot ### Tank Filters -- The test and owned tank filters are now more intuitive as clicking on them now turns the clicked option on instead of off [Mark Greene] +- The test and owned tank filters are now more intuitive as clicking on them now turns the clicked option on instead of off [HitJack] ### Game Compatibility -- Added compatibility for tier XI tanks [Mark Greene] +- Added compatibility for tier XI tanks [HitJack] ## Fixes ### Tankopedia & Armor -- The armor no longer disappears when you turn on Dynamic Armor System [Mark Greene] -- Camouflage values in Tankopedia are now more descriptive when shooting [Mark Greene] -- The penetration chance is now displayed when penetration testing armor [Mark Greene] -- Listed track repair times in the Tankopedia [Mark Greene] -- Added a spall liner button to the Tankopedia beside the other equipment [Mark Greene] -- The flags in the Tankopedia tech tree are no longer offset to the top left [Mark Greene] -- The loading bar below the name of the tanks in Tankopedia is now sufficiently spaced below [Mark Greene] +- The armor no longer disappears when you turn on Dynamic Armor System [HitJack] +- Camouflage values in Tankopedia are now more descriptive when shooting [HitJack] +- The penetration chance is now displayed when penetration testing armor [HitJack] +- Listed track repair times in the Tankopedia [HitJack] +- Added a spall liner button to the Tankopedia beside the other equipment [HitJack] +- The flags in the Tankopedia tech tree are no longer offset to the top left [HitJack] +- The loading bar below the name of the tanks in Tankopedia is now sufficiently spaced below [HitJack] - Removed the vertical sliver of pixels found to the right of the Equalizer toggle in Tankopedia ### Tank Search & Filters -- The search bar no longer redirects to a seemingly random tank when clicking enter when searching [Mark Greene] -- Tank search now properly handles the full names of tanks like the Super Pershing [Mark Greene] -- The tank search now remains consistent in order even when an added filter does not add/remove tanks from the list [Mark Greene] -- The test and owned tank filters are now more intuitive as clicking on them now turns the clicked option on instead of off [Mark Greene] +- The search bar no longer redirects to a seemingly random tank when clicking enter when searching [HitJack] +- Tank search now properly handles the full names of tanks like the Super Pershing [HitJack] +- The tank search now remains consistent in order even when an added filter does not add/remove tanks from the list [HitJack] +- The test and owned tank filters are now more intuitive as clicking on them now turns the clicked option on instead of off [HitJack] ### Visuals & UI -- Removed the Beta badge from the Equalizer button [Mark Greene] +- Removed the Beta badge from the Equalizer button [HitJack] - Updated the background image for the Equalizer button to the actual banner of the new game mode -- The contact shadows on the ground no longer grow & darken when switching between tabs [Mark Greene] -- Additionally, the contact shadows stays visible at low angles [Mark Greene] +- The contact shadows on the ground no longer grow & darken when switching between tabs [HitJack] +- Additionally, the contact shadows stays visible at low angles [HitJack] - The "Help Translate BlitzKit" text on the home page is now properly centered vertically -- The footer now says "Branch" instead of "CDN" [Mark Greene] -- The game version in the footer now displays a shorthand version instead of the entire version number [Mark Greene] -- The reload visualizer now displays just 1 decimal place [Mark Greene] -- Plot controls for the Static armor tab no longer pokes in from the right on non-Static armor tabs in some languages [Mark Greene] -- Mixer's controllers now scale better on smaller screens [Mark Greene] -- The list of available tanks in the Playlist is no longer excessively wide [Mark Greene] +- The footer now says "Branch" instead of "CDN" [HitJack] +- The game version in the footer now displays a shorthand version instead of the entire version number [HitJack] +- The reload visualizer now displays just 1 decimal place [HitJack] +- Plot controls for the Static armor tab no longer pokes in from the right on non-Static armor tabs in some languages [HitJack] +- Mixer's controllers now scale better on smaller screens [HitJack] +- The list of available tanks in the Playlist is no longer excessively wide [HitJack] ### Tank Mechanics & Models -- The gun depression visualizer now properly handles the inherent tilt of tanks like the Minotauro [Mark Greene] -- When moving the tracks of tanks, the textures no longer move at different rates [Mark Greene] +- The gun depression visualizer now properly handles the inherent tilt of tanks like the Minotauro [HitJack] +- When moving the tracks of tanks, the textures no longer move at different rates [HitJack] ### Localization -- Users with a preference on a language are once again redirected to the correct localized pages [Mark Greene] +- Users with a preference on a language are once again redirected to the correct localized pages [HitJack] ### Session & Game Compatibility -- BlitzKit Session now properly handles tanks removed from the game [Mark Greene] +- BlitzKit Session now properly handles tanks removed from the game [HitJack] ## Technical Fixes @@ -78,7 +78,7 @@ Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions - This was the last bug standing in the way of BlitzKit Previews - Welcome back, BlitzKit Previews! - Removed some dead code from the BlitzKit model mangler -- The player discovery loop now persists for longer than 1 iteration [Mark Greene] +- The player discovery loop now persists for longer than 1 iteration [HitJack] - Bun is now forced to run scripts with the `--bun` flag ## Technical Features @@ -94,5 +94,5 @@ Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions - This was the last bug standing in the way of BlitzKit Previews - Welcome back, BlitzKit Previews! - Removed some dead code from the BlitzKit model mangler -- The player discovery loop now persists for longer than 1 iteration [Mark Greene] +- The player discovery loop now persists for longer than 1 iteration [HitJack] - Bun is now forced to run scripts with the `--bun` flag diff --git a/environments/beta.json b/environments/beta.json index d5ff3f5d5..c9cb9adfd 100644 --- a/environments/beta.json +++ b/environments/beta.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-beta.blitzkit.app", - "api": "blitzkit/deploy-node-api.beta.blitzkit.app" + + "api": "blitzkit/deploy-node-api.beta.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.beta.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.beta.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.beta.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.beta.blitzkit.app", - "static": "https://static.beta.blitzkit.app" + + "models_tanks": "https://tanks.models.beta.blitzkit.app", + "models_legendary": "https://legendary.models.beta.blitzkit.app", + "models_maps": "https://maps.models.beta.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/development.json b/environments/development.json index 742768ff4..fbeeab959 100644 --- a/environments/development.json +++ b/environments/development.json @@ -4,11 +4,25 @@ "paths": { "default": null, - "api": "api" + + "api": "api", + + "models_tanks": "models/tanks", + "models_legendary": "models/legendary", + "models_maps": "models/maps" }, + "nodes": {}, + "aliases": { + "default": "/", + "api": "/api", + + "models_tanks": "/models/tanks", + "models_legendary": "/models/legendary", + "models_maps": "/models/maps", + "static": "https://static.blitzkit.app" } } diff --git a/environments/opentest.json b/environments/opentest.json index 0c29b792f..8aa51ea16 100644 --- a/environments/opentest.json +++ b/environments/opentest.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-opentest.blitzkit.app", - "api": "blitzkit/deploy-node-api.opentest.blitzkit.app" + + "api": "blitzkit/deploy-node-api.opentest.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.opentest.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.opentest.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.opentest.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.opentest.blitzkit.app", - "static": "https://static.opentest.blitzkit.app" + + "models_tanks": "https://tanks.models.opentest.blitzkit.app", + "models_legendary": "https://legendary.models.opentest.blitzkit.app", + "models_maps": "https://maps.models.opentest.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/preview.json b/environments/preview.json index a3d980559..bcc9b2644 100644 --- a/environments/preview.json +++ b/environments/preview.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-preview.blitzkit.app", - "api": "blitzkit/deploy-node-api.preview.blitzkit.app" + + "api": "blitzkit/deploy-node-api.preview.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.preview.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.preview.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.preview.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.preview.blitzkit.app", - "static": "https://static.preview.blitzkit.app" + + "models_tanks": "https://tanks.models.preview.blitzkit.app", + "models_legendary": "https://legendary.models.preview.blitzkit.app", + "models_maps": "https://maps.models.preview.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/production.json b/environments/production.json index 89d8e9a5e..d66b4c7ad 100644 --- a/environments/production.json +++ b/environments/production.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-blitzkit.app", - "api": "blitzkit/deploy-node-api.blitzkit.app" + + "api": "blitzkit/deploy-node-api.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.blitzkit.app", + + "models_tanks": "https://tanks.models.blitzkit.app", + "models_legendary": "https://legendary.models.blitzkit.app", + "models_maps": "https://maps.models.blitzkit.app", + "static": "https://static.blitzkit.app" } } diff --git a/package.json b/package.json index d8dc478c8..399d17fd9 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,18 @@ { "name": "blitzkit", - "version": "1.32.0", + "version": "1.33.0-beta.1", "license": "BlitzKit License", "private": true, "scripts": { "dev": "cd packages/website && bun --bun run dev", - "build": "cd packages/website && bun run build", + "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", - "clean-astro": "rm -rf packages/website/.astro packages/website/node_modules/.astro packages/website/node_modules/.vite" + "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": [ diff --git a/packages/core/src/blitzkit/api/server/0_base.ts b/packages/core/src/blitzkit/api/server/0_base.ts index 3bb3c17f3..b8dc459ed 100644 --- a/packages/core/src/blitzkit/api/server/0_base.ts +++ b/packages/core/src/blitzkit/api/server/0_base.ts @@ -253,6 +253,27 @@ export abstract class ServerBlitzKitAPI0 extends AbstractBlitzKitAPI { return this; } + protected getTankFilterIds( + name: string, + definitionKey: string, + filter: "include" | "exclude", + ) { + const keys = name.trim().split(/\s+/).filter(Boolean); + + return keys.flatMap((key) => { + const id = this.tankStringIdMap[key]; + + if (id === undefined) { + console.warn( + `Skipping unavailable tank "${key}" in ${filter} vehicle filter for "${definitionKey}"`, + ); + return []; + } + + return [id]; + }); + } + protected getString(name: string) { const collection: Record = { [locales.default]: this.stringsI18n[locales.default][name], @@ -271,6 +292,8 @@ export abstract class ServerBlitzKitAPI0 extends AbstractBlitzKitAPI { collection[locale] = localizedString; } + collection[locales.default] = collection[locales.default] ?? name; + return { locales: collection } satisfies I18nString; } } diff --git a/packages/core/src/blitzkit/api/server/1_tanks.ts b/packages/core/src/blitzkit/api/server/1_tanks.ts index 428ec68ae..f2c5975c6 100644 --- a/packages/core/src/blitzkit/api/server/1_tanks.ts +++ b/packages/core/src/blitzkit/api/server/1_tanks.ts @@ -390,7 +390,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { : TankType.TANK_TYPE_RESEARCHABLE, tier: tank.level, class: tankClass, - testing: tankTags.includes("testTank"), + testing: tankTags.includes("testTank") || tank.level === 11, deprecated: tankTags.includes("deprecated"), price: tankPrice, camouflage_still: tankDefinition.root.invisibility.still, @@ -598,9 +598,25 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { for (const shellKey in gunListEntry.shots) { const gunShellEntry = gunListEntry.shots[shellKey]; const shell = shellList.root[shellKey]; + + // Newer client data stores shot properties on the shell rather + // than repeating them under every gun's entry. + const speed = + typeof gunShellEntry === "string" + ? shell.speed + : gunShellEntry.speed; + const maxDistance = + typeof gunShellEntry === "string" + ? shell.maxDistance + : gunShellEntry.maxDistance; + const piercingPower = + typeof gunShellEntry === "string" + ? shell.piercingPower + : gunShellEntry.piercingPower; + const shellId = toUniqueId(nation, shell.id); const shellName = this.getString(shell.userString); - const penetrationRaw = gunShellEntry.piercingPower + const penetrationRaw = piercingPower .split(" ") .filter((penetrationString) => penetrationString !== "") .map(Number); @@ -610,7 +626,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { ].shells.push({ id: shellId, name: shellName, - velocity: gunShellEntry.speed, + velocity: speed, armor_damage: shell.damage.armor, module_damage: shell.damage.devices, caliber: shell.caliber, @@ -626,7 +642,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { near: penetrationRaw[0], far: penetrationRaw[1], }, - range: gunShellEntry.maxDistance, + range: maxDistance, }); } diff --git a/packages/core/src/blitzkit/api/server/2_camouflages.ts b/packages/core/src/blitzkit/api/server/2_camouflages.ts index 441bc3c95..d709cdb19 100644 --- a/packages/core/src/blitzkit/api/server/2_camouflages.ts +++ b/packages/core/src/blitzkit/api/server/2_camouflages.ts @@ -1,15 +1,60 @@ import { CamouflageDefinitions } from "@blitzkit/protos"; +import { + Sc2ReadStream, + type TankParameters, +} from "@blitzkit/core"; +import { isEqual, times } from "lodash-es"; import { Cache } from "./0_base"; import { ServerBlitzKitAPI1 } from "./1_tanks"; export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { + private async skinPresets(vehicle: string) { + const [nation, tankKey] = vehicle.split(":"); + const parameters = await this.vfs.yaml( + `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, + ); + const sc2 = new Sc2ReadStream( + ( + await this.vfs.file( + `Data/3d/${parameters.resourcesPath.blitzModelPath}`, + ) + ).buffer as ArrayBuffer, + ).sc2(); + const presets = new Set(); + + for (const node of sc2["#dataNodes"]) { + if (typeof node.configCount !== "number") continue; + + times(node.configCount, (index) => node[`configArchive_${index}`]) + .filter( + (archive) => + !isEqual(archive.textures, node.configArchive_0.textures), + ) + .forEach((archive) => presets.add(archive.configName)); + } + + return presets; + } + @Cache() async camouflages() { const camouflageDefinitions = CamouflageDefinitions.create(); + const customization = await this.vfs.yaml< + Record + >("Data/3d/Customization.yaml"); + const items = new Map( + Object.values(customization).map(({ Name, Path }) => [ + Name, + Path.replace("~res:/", "Data/").replace(/\.sc2$/, ""), + ]), + ); + const skinPresets = new Map>>(); for (const camoKey in this.camouflagesXml!.root.camouflages) { const camo = this.camouflagesXml!.root.camouflages[camoKey]; + if (!(camoKey in this.camouflagesYaml!)) continue; + const yamlEntry = this.camouflagesYaml![camoKey]; const fullName = yamlEntry.userString ? this.getString(yamlEntry.userString) @@ -20,12 +65,51 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { const resolvedTankName = shortName ?? fullName; const resolvedTankNameFull = resolvedTankName === fullName ? undefined : fullName; + const include = camo.vehicleFilter.include; + const vehicle = + include && !Array.isArray(include) && "vehicle" in include + ? include.vehicle?.name + : undefined; + let tankId: number | undefined = undefined; + + if (vehicle && vehicle in this.tankStringIdMap) { + const resolvedParts = await Promise.all( + Object.values(yamlEntry.customEntities ?? {}).map( + async ({ item }) => { + const path = items.get(item); + + return ( + path !== undefined && + (await this.vfs.resolve(`${path}.sc2`)) && + (await this.vfs.resolve(`${path}.scg`)) + ); + }, + ), + ); + + let hasPreset = false; + + if (camo.group === "legendary" && yamlEntry.preset !== undefined) { + if (!skinPresets.has(vehicle)) { + skinPresets.set(vehicle, this.skinPresets(vehicle)); + } + + hasPreset = (await skinPresets.get(vehicle)!).has(yamlEntry.preset); + } + + if (resolvedParts.some(Boolean) || hasPreset) { + tankId = this.tankStringIdMap[vehicle]; + } + } + + const name = this.getString(camo.userString); camouflageDefinitions.camouflages[camo.id] = { id: camo.id, - name: this.getString(camo.userString), + name, tank_name: resolvedTankName, tank_name_full: resolvedTankNameFull, + tank_id: tankId, }; } diff --git a/packages/core/src/blitzkit/api/server/3_models.ts b/packages/core/src/blitzkit/api/server/3_models.ts index 5b59a4a7e..d5c765eee 100644 --- a/packages/core/src/blitzkit/api/server/3_models.ts +++ b/packages/core/src/blitzkit/api/server/3_models.ts @@ -2,15 +2,23 @@ import { BlitzCrewType, ChassisDefinitionsList, GunDefinitionsList, + I_HAT, + J_HAT, + K_HAT, + parseHierarchies, + Sc2ReadStream, + ScgReadStream, TankParameters, toUniqueId, TurretDefinitionsList, VehicleDefinitionArmor, VehicleDefinitionList, VehicleDefinitions, + VertexAttribute, } from "@blitzkit/core"; import { Armor, Crew, ModelDefinitions, Vector3 } from "@blitzkit/protos"; -import { Vector3Tuple } from "three"; +import { parse as parsePath } from "path"; +import { MathUtils, Vector3 as THREEVector3, Vector3Tuple } from "three"; import { Cache } from "./0_base"; import { ServerBlitzKitAPI2 } from "./2_camouflages"; @@ -19,6 +27,81 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { return { x: tuple[0], y: tuple[1], z: -tuple[2] } satisfies Vector3; } + private async collisionBoundingBoxes( + nation: string, + tankKey: string, + turretRotation?: { yaw: number; pitch: number; roll: number }, + ) { + const boundingBoxes: Record< + string, + { min: Vector3Tuple; max: Vector3Tuple } + > = {}; + const path = `Data/3d/Tanks/CollisionMeshes/${nation}-${tankKey}`; + + if (!(await this.vfs.resolve(`${path}.sc2`))) { + throw new Error(`Missing collision mesh for ${nation}-${tankKey}`); + } + + const sc2 = new Sc2ReadStream( + (await this.vfs.file(`${path}.sc2`)).buffer as ArrayBuffer, + ).sc2(); + const scg = new ScgReadStream( + (await this.vfs.file(`${path}.scg`)).buffer as ArrayBuffer, + ).scg(); + + parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { + for (const component of components) { + if (component["comp.typename"] !== "RenderComponent") continue; + + const polygonGroup = scg.get( + component["rc.renderObj"]["ro.batches"]["0000"]["rb.datasource"], + ); + + if (!polygonGroup) continue; + + const min: Vector3Tuple = [Infinity, Infinity, Infinity]; + const max: Vector3Tuple = [-Infinity, -Infinity, -Infinity]; + const rotate = + turretRotation && hierarchy.name.toLowerCase().startsWith("turret"); + + polygonGroup.vertices.forEach((vertex) => { + vertex.forEach(({ attribute, value }) => { + if (attribute !== VertexAttribute.VERTEX) return; + + const position = new THREEVector3(...value); + + if (rotate) { + position + .applyAxisAngle( + I_HAT, + -MathUtils.degToRad(turretRotation.pitch), + ) + .applyAxisAngle(J_HAT, -MathUtils.degToRad(turretRotation.roll)) + .applyAxisAngle(K_HAT, -MathUtils.degToRad(turretRotation.yaw)); + } + + const coordinates = position.toArray(); + + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], coordinates[axis]); + max[axis] = Math.max(max[axis], coordinates[axis]); + } + }); + }); + + boundingBoxes[hierarchy.name.toLowerCase()] = { min, max }; + } + + return null; + }); + + if (!boundingBoxes.hull) { + throw new Error(`Missing hull collision mesh for ${nation}-${tankKey}`); + } + + return boundingBoxes; + } + private assignArmor( raw: VehicleDefinitionArmor[string], id: number, @@ -63,6 +146,12 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { const tankParameters = await this.vfs.yaml( `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, ); + const collisionBoundingBoxes = await this.collisionBoundingBoxes( + nation, + tankKey, + tankDefinition.root.hull.turretInitialRotation, + ); + const hullBoundingBox = collisionBoundingBoxes.hull; const turretOrigin = tankDefinition.root.hull.turretPositions.turret .split(" ") .map(Number) as Vector3Tuple; @@ -137,12 +226,8 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { } : undefined, bounding_box: { - min: this.vector3TupleToBlitzkit( - tankParameters.collision.hull.bbox.min, - ), - max: this.vector3TupleToBlitzkit( - tankParameters.collision.hull.bbox.max, - ), + min: this.vector3TupleToBlitzkit(hullBoundingBox.min), + max: this.vector3TupleToBlitzkit(hullBoundingBox.max), }, turrets: {}, tracks: {}, @@ -204,30 +289,35 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { turret.userString; + const 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}`, + ); + } + + const turretBoundingBox = collisionBoundingBoxes[ + turretCollisionName + ] ?? { + min: [0, 0, 0] as Vector3Tuple, + max: [0, 0, 0] as Vector3Tuple, + }; + modelDefinitions.models[tankId].turrets[turretId] = { armor: turretArmor, gun_origin: this.vector3TupleToBlitzkit(gunOrigin), model_id: turretModel, - yaw: - -turretYaw[0] + turretYaw[1] === 360 - ? undefined - : { min: turretYaw[0], max: turretYaw[1] }, + yaw: isFullYaw + ? undefined + : { min: turretYaw[0], max: turretYaw[1] }, guns: {}, bounding_box: { - min: this.vector3TupleToBlitzkit( - tankParameters.collision[ - this.parsePath!( - turret.hitTester.collisionModel, - ).name.toLowerCase() - ].bbox.min, - ), - max: this.vector3TupleToBlitzkit( - tankParameters.collision[ - this.parsePath!( - turret.hitTester.collisionModel, - ).name.toLowerCase() - ].bbox.max, - ), + min: this.vector3TupleToBlitzkit(turretBoundingBox.min), + max: this.vector3TupleToBlitzkit(turretBoundingBox.max), }, }; diff --git a/packages/core/src/blitzkit/api/server/6_consumables.ts b/packages/core/src/blitzkit/api/server/6_consumables.ts index 75ac735ad..abf729de6 100644 --- a/packages/core/src/blitzkit/api/server/6_consumables.ts +++ b/packages/core/src/blitzkit/api/server/6_consumables.ts @@ -39,9 +39,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 { filter_type: { $case: "ids", value: { - ids: includeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(includeRaw.name, key, "include"), }, }, }); @@ -67,9 +65,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 { filter_type: { $case: "ids", value: { - ids: excludeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"), }, }, }); diff --git a/packages/core/src/blitzkit/api/server/7_provisions.ts b/packages/core/src/blitzkit/api/server/7_provisions.ts index f97e281e3..9db94ba4f 100644 --- a/packages/core/src/blitzkit/api/server/7_provisions.ts +++ b/packages/core/src/blitzkit/api/server/7_provisions.ts @@ -8,7 +8,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { async provisions() { const provisionDefinitions = ProvisionDefinitions.create(); - Object.entries(this.provisionsCommon).forEach(([, provision]) => { + Object.entries(this.provisionsCommon).forEach(([key, provision]) => { const entry = Provision.create({ id: provision.id, exclude: [], @@ -39,9 +39,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { filter_type: { $case: "ids", value: { - ids: includeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(includeRaw.name, key, "include"), }, }, }); @@ -67,9 +65,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { filter_type: { $case: "ids", value: { - ids: excludeRaw.name - .split(/ +/) - .map((key) => this.tankStringIdMap[key]), + ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"), }, }, }); diff --git a/packages/core/src/blitzkit/index.ts b/packages/core/src/blitzkit/index.ts index 1623326ad..0ef532686 100644 --- a/packages/core/src/blitzkit/index.ts +++ b/packages/core/src/blitzkit/index.ts @@ -26,6 +26,7 @@ export * from "./staticAsset"; export * from "./sum"; export * from "./tankDefinitions"; export * from "./tankEnvironment"; +export * from "./tankBoundingBox"; export * from "./tankIcon"; export * from "./tankNames"; export * from "./unionBoundingBox"; diff --git a/packages/core/src/blitzkit/tankBoundingBox.ts b/packages/core/src/blitzkit/tankBoundingBox.ts new file mode 100644 index 000000000..9e3408d74 --- /dev/null +++ b/packages/core/src/blitzkit/tankBoundingBox.ts @@ -0,0 +1,32 @@ +import { ModelDefinition, TurretModelDefinition } from "../protos"; +import { unionBoundingBox } from "./unionBoundingBox"; + +export function tankBoundingBox( + tankModelDefinition: ModelDefinition, + turretModelDefinition: TurretModelDefinition, +) { + const hull = tankModelDefinition.bounding_box!; + const turret = turretModelDefinition.bounding_box!; + const origin = tankModelDefinition.turret_origin!; + + if ( + turret.min!.x === turret.max!.x && + turret.min!.y === turret.max!.y && + turret.min!.z === turret.max!.z + ) { + return unionBoundingBox(hull, hull); + } + + return unionBoundingBox(hull, { + min: { + x: turret.min!.x + origin.x, + y: turret.min!.y + origin.z, + z: turret.min!.z + origin.y, + }, + max: { + x: turret.max!.x + origin.x, + y: turret.max!.y + origin.z, + z: turret.max!.z + origin.y, + }, + }); +} diff --git a/packages/core/src/blitzkit/tankDefinitions/constants.ts b/packages/core/src/blitzkit/tankDefinitions/constants.ts index 13230094e..ff8a5b33f 100644 --- a/packages/core/src/blitzkit/tankDefinitions/constants.ts +++ b/packages/core/src/blitzkit/tankDefinitions/constants.ts @@ -1,4 +1,4 @@ -import { CrewType, ShellType, TankClass, TankType } from "@blitzkit/protos"; +import { CrewType, ShellType } from "@blitzkit/protos"; import { times } from "lodash-es"; export const SHELL_NAMES: Record = { @@ -15,33 +15,6 @@ export const CREW_MEMBER_NAMES = { [CrewType.CREW_TYPE_RADIOMAN]: "radioman", } as const; -/** - * @deprecated use svg - */ -export const TANK_ICONS: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/BIHSEH0.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/CSNha5V.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/wvf3ltm.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/ECeqlZa.png", -}; -/** - * @deprecated use svg - */ -export const TANK_ICONS_PREMIUM: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/TCu3EdR.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/zdkpTRb.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/3z7eHX6.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/P3vbmyA.png", -}; -/** - * @deprecated use svg - */ -export const TANK_ICONS_COLLECTOR: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/WTjeirB.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/EwhtKkU.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/u8YDMBh.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/8xRf3nc.png", -}; export const TIERS = times(10, (index) => index + 1); export const flags: Record = { @@ -55,8 +28,3 @@ export const flags: Record = { european: "馃嚜馃嚭", other: "<:other:1218421572243558482>", }; -export const TREE_TYPE_ICONS: Record> = { - [TankType.TANK_TYPE_RESEARCHABLE]: TANK_ICONS, - [TankType.TANK_TYPE_PREMIUM]: TANK_ICONS_PREMIUM, - [TankType.TANK_TYPE_COLLECTOR]: TANK_ICONS_COLLECTOR, -}; diff --git a/packages/core/src/imgur/imgur.ts b/packages/core/src/imgur/imgur.ts deleted file mode 100644 index 8135d678c..000000000 --- a/packages/core/src/imgur/imgur.ts +++ /dev/null @@ -1,50 +0,0 @@ -export enum ImgurSize { - /** - * The default size - */ - Default = '', - /** - * Unknown size but really really small - */ - UltraSmall = 'ss', - /** - * 90x90 - */ - SmallSquare = 's', - /** - * 160x160 - */ - BigSquare = 'b', - /** - * 160x160 - */ - SmallThumbnail = 't', - /** - * 320x320 - */ - MediumThumbnail = 'm', - /** - * 640x640 - */ - LargeThumbnail = 'l', - /** - * 1024x1024 - */ - HugeThumbnail = 'h', -} - -interface ImgurOptions { - format: 'jpeg' | 'png'; - size: ImgurSize; -} - -const defaultImgurOptions: ImgurOptions = { - format: 'png', - size: ImgurSize.Default, -}; - -export function imgur(id: string, imgurOptions?: Partial) { - const options = { ...defaultImgurOptions, ...imgurOptions }; - - return `https://i.imgur.com/${id}${options.size}.${options.format}`; -} diff --git a/packages/core/src/imgur/index.ts b/packages/core/src/imgur/index.ts deleted file mode 100644 index 6daa175fb..000000000 --- a/packages/core/src/imgur/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './imgur'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 18c510f77..39c2c5660 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,6 @@ export * from "./api"; export * from "./blitz"; export * from "./blitzkit"; -export * from "./config"; -export * from "./imgur"; export * from "./math"; export * from "./patreon"; export * from "./statistics"; diff --git a/packages/core/src/streams/pvr.ts b/packages/core/src/streams/pvr.ts index 8703f5cd7..d55e8aaad 100644 --- a/packages/core/src/streams/pvr.ts +++ b/packages/core/src/streams/pvr.ts @@ -88,6 +88,7 @@ enum PvrChannelType { enum ResolvedBitRatePixelFormat { R4G4B4A4, + R8G8B8A8, R5G6B5A0, } @@ -137,6 +138,27 @@ export class PvrReadStream extends ReadStream { }; } + case ResolvedBitRatePixelFormat.R8G8B8A8: { + const data = Buffer.alloc(pixelCount * 4); + + times(pixelCount, (index) => { + const buffer = this.consumeUint8Array(4); + const bufferIndex = index * 4; + + data[bufferIndex] = buffer[0]; // R + data[bufferIndex + 1] = buffer[1]; // G + data[bufferIndex + 2] = buffer[2]; // B + data[bufferIndex + 3] = buffer[3]; // A + }); + + return { + data, + width: header.width, + height: header.height, + channels: 4 as const, + }; + } + case ResolvedBitRatePixelFormat.R5G6B5A0: { const data = Buffer.alloc(pixelCount * 3); @@ -208,10 +230,13 @@ export class PvrReadStream extends ReadStream { if (pixelFormat.order === "rgba") { if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) { return ResolvedBitRatePixelFormat.R4G4B4A4; - } else + } else if (isEqual(pixelFormat.bitRates, [8, 8, 8, 8])) { + return ResolvedBitRatePixelFormat.R8G8B8A8; + } else { throw new TypeError( `Unhandled rgba bit rate ${pixelFormat.bitRates.join(", ")}`, ); + } } else if (pixelFormat.order === "rgb\0") { if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) { return ResolvedBitRatePixelFormat.R5G6B5A0; diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index 8b4837bab..73e2a2147 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -90,6 +90,28 @@ export interface Hierarchy { name: string; } +export function parseHierarchies( + hierarchies: Hierarchy[], + parent: Parent, + parseHierarchy: ( + hierarchy: Hierarchy, + components: Component[], + parent: Parent, + ) => Parent | undefined, +) { + for (const hierarchy of hierarchies) { + const components = Array.from( + { length: hierarchy.components.count }, + (_, index) => hierarchy.components[index.toString().padStart(4, "0")], + ); + const child = parseHierarchy(hierarchy, components, parent); + + if (child !== undefined && hierarchy["#hierarchy"]) { + parseHierarchies(hierarchy["#hierarchy"], child, parseHierarchy); + } + } +} + // 0001, 0002, 0003...: Component type Components = { count: number; diff --git a/packages/core/src/types/camouflagesYaml.ts b/packages/core/src/types/camouflagesYaml.ts index 7671432fe..1be674afa 100644 --- a/packages/core/src/types/camouflagesYaml.ts +++ b/packages/core/src/types/camouflagesYaml.ts @@ -3,5 +3,7 @@ export type CamouflagesYaml = Record< { userString?: string; shortUserString?: string; + preset?: string; + customEntities?: Record; } >; diff --git a/packages/core/src/types/gunDefinitionsList.ts b/packages/core/src/types/gunDefinitionsList.ts index 005238d10..51b2c939d 100644 --- a/packages/core/src/types/gunDefinitionsList.ts +++ b/packages/core/src/types/gunDefinitionsList.ts @@ -20,11 +20,13 @@ export interface GunDefinitionsList { burst?: { count: number; rate: number }; clip?: { count: number; rate: number }; shots: { - [key: string]: { - speed: number; - piercingPower: string; - maxDistance: number; - }; + [key: string]: + | "" + | { + speed: number; + piercingPower: string; + maxDistance: number; + }; }; }; }; diff --git a/packages/core/src/types/shellDefinitionsList.ts b/packages/core/src/types/shellDefinitionsList.ts index d9e5f3e6e..e8ec812c1 100644 --- a/packages/core/src/types/shellDefinitionsList.ts +++ b/packages/core/src/types/shellDefinitionsList.ts @@ -10,6 +10,9 @@ export type ShellDefinitionsList = Record< normalizationAngle?: number; ricochetAngle?: number; explosionRadius?: number; + speed: number; + maxDistance?: number; + piercingPower: string; } > & { icons: Record; diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index c3b9c7624..2b5c9bda3 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -275,7 +275,7 @@ "branches": { "beta": "Beta", - "development": "Development", + "development": "Post Malone Edition", "opentest": "OpenTest", "preview": "Preview", "production": "Production" @@ -532,11 +532,10 @@ "footer": { "motto": "Everything World of Tanks Blitz", - "copyright": "Unaffiliated with Wargaming or any service", + "association": "An Open Collective Project", "about": { "title": "About", - "credits": "Credits", "discord": "Discord", "github": "GitHub", "patches": "Patches" diff --git a/packages/i18n/strings/pl.json b/packages/i18n/strings/pl.json index de8d7a5d0..dc8c72f22 100644 --- a/packages/i18n/strings/pl.json +++ b/packages/i18n/strings/pl.json @@ -220,190 +220,190 @@ "clear": "Clear", "tier": "Tier {tier}", "passive": "{name} (Passive)", - "active": "{name} (Active)", + "active": "{name} (Aktywne)", "no_tanks_found": { - "body": "No tanks found.", + "body": "Nie znaleziono czo艂gu.", "search_again": "", "clear_filters": "Try clearing filters." }, "sort": { - "meta.none": "none", - "survivability.health": "health", - "survivability.viewRange": "view range", - "survivability.camouflageStill": "camouflage still", - "survivability.camouflageMoving": "camouflage moving", - "survivability.camouflageShooting": "camouflage shooting", - "survivability.volume": "volume", - "survivability.length": "length", - "fire.dpm": "standard DPM", - "fire.dpmPremium": "premium DPM", - "fire.reload": "reload", - "fire.caliber": "caliber", - "fire.standardPenetration": "standard penetration", - "fire.premiumPenetration": "premium penetration", + "meta.none": "brak", + "survivability.health": "punkty.偶ycia", + "survivability.viewRange": "zasi臋g widzenia", + "survivability.camouflageStill": "kamufla偶 w bezruchu", + "survivability.camouflageMoving": "kamufla偶 w trakcie ruchu", + "survivability.camouflageShooting": "kamufla偶 podczas strza艂u", + "survivability.volume": "warto艣膰", + "survivability.length": "d艂ugo艣膰", + "fire.dpm": "DPM pocisk贸w podstawowych", + "fire.dpmPremium": "DPM pocisk贸w premium", + "fire.reload": "prze艂adowanie", + "fire.caliber": "kaliber", + "fire.standardPenetration": "Penetracja pocisk贸w standardowych", + "fire.premiumPenetration": "Penetracja pocisk贸w premium", "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.damage": "uszkodzenia", + "fire.moduleDamage": "Uszkodzenia modu艂贸w", + "fire.shellVelocity": "Pr臋dko艣膰 pocisku", + "fire.shellRange": "Zasi臋g pocisku", + "fire.shellCapacity": "Maksymalna ilo艣膰 pocisk贸w", + "fire.aimTime": "Czas celowania", "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", + "fire.gunDepression": "Depresja dzia艂a", + "fire.gunElevation": "Elewacja dzia艂a", + "maneuverability.forwardsSpeed": "Maksymalna pr臋dko艣膰 do przodu", + "maneuverability.backwardsSpeed": "Maksymalna pr臋dko艣膰 do ty艂u", + "maneuverability.power": "Moc silnika", + "maneuverability.powerToWeight": "Proporcja mocy do masy", + "maneuverability.weight": "Masa czo艂gu", "maneuverability.traverseSpeed": "traverse speed" }, "sort_dropdown": { - "label": "Sort", + "label": "Filtruj", "groups": { "sort": { - "no_sort": "No sort", - "label": "Sort by", - "fire": "Fire", - "maneuverability": "Maneuverability", - "survivability": "Survivability" + "no_sort": "Brak filtr贸w", + "label": "Filtruj wed艂ug", + "fire": "Si艂a ognia", + "maneuverability": "Mobilno艣膰 ", + "survivability": "Przetrwanie" }, "direction": { "label": "Direction", - "ascending": "Ascending", - "descending": "Descending" + "ascending": "Rosn膮co", + "descending": "Malej膮co" } } } }, "warnings": { - "test": "Tanks in testing are subject to change and may not represent the final product." + "test": "Warto艣ci statystyk czo艂g贸w w fazie testowej mog膮 ulec zmianie." } }, "home": { "discord": { - "join_short": "Join cool people!", - "join_long": "Join super duper cool people!", + "join_short": "Do艂膮cz do elity!", + "join_long": "Do艂膮cz do topowej elity!", "action": "Discord" }, "hero": { - "credit": "By {name} 鉂わ笍" + "credit": "Wed艂ug {name} 鈾ワ笍" }, "hot_tanks": { - "title": "Popular tanks", - "subtitle": "Last {days} days" + "title": "Popularne czo艂gi", + "subtitle": "Ostatnie {days} dni" }, "other_projects": { - "title": "Other cool projects" + "title": "Inne epickie projekty" }, - "help_translate": "Please help translate {name} to {language}!", - "seo_description": "Tools for everything World of Tanks Blitz" + "help_translate": "Prosz臋 pom贸偶 przet艂umaczy膰 {name} na {language}!", + "seo_description": "Narz臋dzia do wszystkiego World of Tanks Blitz" }, "docs": { "patches": { - "name": "BlitzKit Changelogs", - "seo_description": "Changelogs for BlitzKit Discord bot and website" + "name": "Zapisy zmian BlitzKit", + "seo_description": "Zapisy zmian BlitzKit: Discord bot i strona internetowa" } }, "tools": { "playlist": { - "reset": "Reset", - "shuffle": "Shuffle", - "remove": "Remove played", - "tracking": "BlitzKit is tracking your battles", + "reset": "Resetuj", + "shuffle": "Wymieszaj", + "remove": "Usu艅 czo艂gi, kt贸rymi gra艂em", + "tracking": "BlitzKit zapisuje twoje bitwy", "sort": { - "button": "Sort by", - "last_played": "Last played", - "least_played": "Least played" + "button": "Filtruj wed艂ug", + "last_played": "Ostatnio u偶ywane czo艂gi", + "least_played": "Najmniej u偶ywane czo艂gi" }, "table": { "index": "#", - "never": "Never", - "tank": "Tank", - "last_played": "Last played", - "battles": "Career battles", + "never": "Nigdy", + "tank": "Czo艂g", + "last_played": "Ostatnio u偶ywane czo艂gi", + "battles": "Wszystkie bitwy na koncie", "unrecorded": "--", "wins": "Zwyci臋stwa" }, "large_warning": { - "title": "That's a lot of tanks!", - "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" + "title": "To jest mn贸stwo czo艂g贸w!", + "description": "Czy na pewno chcesz stworzy膰 playlist臋 z艂o偶on膮 z {count} czo艂g贸w? To {time} godzin gry przy 50% zwyci臋stw!", + "cancel": "Anuluj", + "proceed": "Zatwierd藕" }, - "generate": "Generate playlist", - "name": "Playlist", - "description": "Tank playlist generator", - "seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!", - "button": "Generate", - "pool_size": "{count} possible tanks" + "generate": "Wygeneruj playlist臋", + "name": "Playlista", + "description": "Generator playlisty czo艂g贸w", + "seo_description": "Nie wiesz, kt贸rym czo艂giem gra膰 w World of Tanks Blitz? U偶yj funkcji playlisty BlitzKit, i u艂贸偶 w艂asn膮 list臋 czo艂g贸w do gry!", + "button": "Wygeneruj", + "pool_size": "{count} mo偶liwe czo艂gi" }, "mixer": { - "name": "Mixer", + "name": "Mikser", "button": "Mix and match", "description": "Mix tank modules", "seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz", - "screenshot": "Screenshot", - "randomize": "Randomize" + "screenshot": "Zrzut ekranu", + "randomize": "Wymieszaj" }, "guess": { - "name": "Guess", + "name": "Zgadnij", "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": "Type to guess...", - "skip": "Skip", - "guess": "Guess", - "next": "Next", + "placeholder": "Wpisz odpowied藕", + "skip": "Pomi艅", + "guess": "Zgaduj臋", + "next": "Nast臋pny", "no_results": "Brak wynik贸w" }, "stats": "{correct} / {total} correct, {streak} streak", "help": { - "title": "Partially reveal tank?", + "title": "Ods艂oni膰 cz臋艣膰 czo艂gu?", "description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!", - "cancel": "Cancel", - "reveal": "Reveal" + "cancel": "Anuluj", + "reveal": "Poka偶" }, - "clear": "Clear", - "select_all": "Select all", - "tier": "Tier {tier}" + "clear": "Wyczy艣膰", + "select_all": "Zaznacz wszystko ", + "tier": "Poziom czo艂gu {tier}" }, "tankopedia": { - "search": "Search a stat...", + "search": "Wyszukaj statystyk臋...", "name": "Tankopedia", - "button": "Find tanks", - "description": "Blitz tank encyclopedia", + "button": "Znajd藕 czo艂g", + "description": "Encyklopedia czo艂g贸w w WoTb", "seo_description": "Statistics, armor, and more for all tanks in World of Tanks Blitz", "tank_seo_title": "{name} - Tier {tier} {nationality} {class} | {website} Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_description": "{name} armor, statistics, equipment, provisions, and more. {dpm} DPM, {alpha}hp alpha, {penetration}mm penetration, {speed}kph top speed, {health}hp health.", "visualizers": { "reload": { - "skip": "Skip" + "skip": "Pomi艅" }, "assault": { "title": "Assault shells" }, "rotator": { - "hull": "Hull", - "turret": "Turret", - "hard": "Hard", + "hull": "Kad艂ub", + "turret": "Wie偶a", + "hard": "Utwardzony", "medium": "艢redni", - "soft": "Soft", - "effective": "Effective" + "soft": "Mi臋kki", + "effective": "Rzeczywisty" }, "ricochet": { - "angle": "Angle", + "angle": "K膮t", "normalization": "Normalization", - "effective_angle": "Effective angle", - "armor": "Armor", - "no_ricochet": "Explosive shells don't ricochet" + "effective_angle": "Rzeczywisty k膮t nachylenia pancerza", + "armor": "Pancerz", + "no_ricochet": "Pociski od艂amkowe nie rykoszetuj膮." }, "view_range": { - "camo": "Camo", - "ghost_factory": "Ghost Factory", - "dead_rail": "Dead Rail" + "camo": "Kamufla偶", + "ghost_factory": "Fabryka duch贸w", + "dead_rail": "Martwy tor" }, "flexibility": { "yaw": "Yaw", @@ -656,10 +656,10 @@ "loader": "Loader" }, "maneuverability": { - "title": "Maneuverability" + "title": "Mobilno艣膰 " }, "survivability": { - "title": "Survivability" + "title": "Przetrwanie" } }, "compare": { diff --git a/packages/i18n/strings/pt.json b/packages/i18n/strings/pt.json index 197d34a38..f11d04508 100644 --- a/packages/i18n/strings/pt.json +++ b/packages/i18n/strings/pt.json @@ -41,7 +41,7 @@ "development": "Development", "opentest": "TesteAberto", "preview": "Preview", - "production": "Production" + "production": "Produ莽茫o" }, "locales": { "en": "Ingl锚s", @@ -187,11 +187,11 @@ }, "website": { "redirect": { - "title": "301: moved permanently", + "title": "301: movido permanentemente", "body": "This page has moved. Redirecting..." }, "settings": { - "title": "Settings", + "title": "Configura莽玫es", "description": "Change BlitzKit settings", "dev_mode": "Modo de desenvolvimento", "auto_load_models": "Auto-load models", @@ -207,9 +207,9 @@ "copied": "Copiado!" }, "tank_search": { - "select_dialog_title": "Search for tanks", + "select_dialog_title": "Procura por tanque", "swap_dialog_title": "Swap tank", - "clear_filters": "Clear filters", + "clear_filters": "Limpar filtros", "login": "Login to use this", "search_bar_hint": "Procurar tanques...", "recent": "Visto recentemente", diff --git a/packages/protos/src/blitzkit/camouflage_definitions.proto b/packages/protos/src/blitzkit/camouflage_definitions.proto index 093ccec46..170c71b4c 100644 --- a/packages/protos/src/blitzkit/camouflage_definitions.proto +++ b/packages/protos/src/blitzkit/camouflage_definitions.proto @@ -13,4 +13,5 @@ message Camouflage { I18nString name = 2; optional I18nString tank_name = 3; optional I18nString tank_name_full = 4; + optional uint32 tank_id = 5; } \ No newline at end of file diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 70765e5f0..4afa98947 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -5,7 +5,9 @@ "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-client": "bun src/downloadClient/index.ts", + "download-smart-dlc": "bun src/downloadSmartDLC/index.ts", + "vfs": "bun src/vfs/index.ts" }, "dependencies": { "@blitzkit/closed": "workspace:*", diff --git a/packages/scripts/src/downloadClient/index.ts b/packages/scripts/src/downloadClient/index.ts index e3412bffe..c9eb68b61 100644 --- a/packages/scripts/src/downloadClient/index.ts +++ b/packages/scripts/src/downloadClient/index.ts @@ -9,6 +9,8 @@ import { } from "./constants"; import { QuickGitHubRelease } from "./types"; +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); @@ -43,11 +45,27 @@ if (provider === "steam") { const username = assertSecret(import.meta.env.STEAM_USERNAME); const password = assertSecret(import.meta.env.STEAM_PASSWORD); - await $`${DEPOT_DOWNLOADER_EXECUTABLE} -os ${ - platform - } -app ${id} -username ${username} -password ${ - password - } -dir ${dir} -max-downloads ${DEPOT_DOWNLOADER_MAX_DOWNLOADS}`; + 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); diff --git a/packages/scripts/src/applyPatches.ts b/packages/scripts/src/downloadSmartDLC/index.ts similarity index 60% rename from packages/scripts/src/applyPatches.ts rename to packages/scripts/src/downloadSmartDLC/index.ts index 18aaf6ba3..24b13f068 100644 --- a/packages/scripts/src/applyPatches.ts +++ b/packages/scripts/src/downloadSmartDLC/index.ts @@ -2,9 +2,16 @@ import { dvp } from "@blitzkit/closed"; import { assertSecret } from "@blitzkit/core"; import { mkdir, writeFile } from "fs/promises"; import { parse as parsePath } from "path"; -import ProgressBar from "progress"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { PATCHES_ROOT, vfs } from "./buildAssets/constants"; +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 @@ -18,9 +25,7 @@ console.log(`Installing patches for ${currentVersion}...`); let patchIndex = 1; while (true) { const response = await fetch( - `${assertSecret( - import.meta.env.WOTB_DLC_CDN, - )}/dlc/s${currentVersion}_${patchIndex}.yaml`, + `${smartDlcUrl}/s${currentVersion}_${patchIndex}.yaml`, ); console.log(patchIndex, response.status); @@ -30,51 +35,34 @@ while (true) { console.log(`Applying patch ${patchIndex}...`); const data = parseYaml(await response.text()); - const dvpm = await fetch( - `${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${data.dx11}`, - ).then((response) => response.arrayBuffer()); + const dvpm = await fetch(`${smartDlcUrl}/${data.dx11}`).then((response) => + response.arrayBuffer(), + ); const dvpd = await fetch( - `${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${data.dx11.replace( - ".dvpm", - ".dvpd", - )}`, + `${smartDlcUrl}/${data.dx11.replace(".dvpm", ".dvpd")}`, ).then((response) => response.arrayBuffer()); const files = await dvp(dvpm, dvpd); - const bar = new ProgressBar( - `Patching ${files.length} files :bar`, - files.length, - ); for (const { path, data } of files) { console.log(`Patching "${path}"...`); const { dir } = parsePath(path); - try { - await mkdir(`${PATCHES_ROOT}/Data/${dir}`, { recursive: true }); - } catch (error) { - console.warn(`Failed to make directory "${dir}"`); - } + await mkdir(`${CLIENT_DIR}/Data/${dir}`, { recursive: true }); const buffer = Buffer.from(data); - await writeFile(`${PATCHES_ROOT}/Data/${path}`, new Uint8Array(buffer)); - - bar.tick(); + await writeFile(`${CLIENT_DIR}/Data/${path}`, new Uint8Array(buffer)); } console.log("patches done for patch"); if ("dynamicContentLocalizationsDir" in data) { console.log( - `${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${ - data.dynamicContentLocalizationsDir - }/en.yaml`, + `${smartDlcUrl}/${data.dynamicContentLocalizationsDir}/en.yaml`, ); const localizationsResponse = await fetch( - `${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${ - data.dynamicContentLocalizationsDir - }/en.yaml`, + `${smartDlcUrl}/${data.dynamicContentLocalizationsDir}/en.yaml`, ); console.log("fetched"); const newStrings = parseYaml(await localizationsResponse.text()); @@ -86,10 +74,10 @@ while (true) { const patchedContent = stringifyYaml(patchedStrings); console.log("stringified"); - await mkdir(`${PATCHES_ROOT}/Data/Strings`, { recursive: true }); + await mkdir(`${CLIENT_DIR}/Data/Strings`, { recursive: true }); console.log("mkdir"); await writeFile( - `${PATCHES_ROOT}/Data/Strings/en.yaml`, + `${CLIENT_DIR}/Data/Strings/en.yaml`, new Uint8Array(Buffer.from(patchedContent)), ); console.log("file"); diff --git a/packages/scripts/src/types/regionsPublish.ts b/packages/scripts/src/types/regionsPublish.ts new file mode 100644 index 000000000..728de0f14 --- /dev/null +++ b/packages/scripts/src/types/regionsPublish.ts @@ -0,0 +1,7 @@ +export interface RegionsPublish { + Common: Common; +} + +interface Common { + smartDlcUrls: string[]; +} diff --git a/packages/scripts/src/vfs/index.ts b/packages/scripts/src/vfs/index.ts new file mode 100644 index 000000000..cdf8b1fe8 --- /dev/null +++ b/packages/scripts/src/vfs/index.ts @@ -0,0 +1,23 @@ +import { vfs } from "./vfs"; + +const command = Bun.argv[2]; +const path = Bun.argv[3]; + +if (!path) { + console.error("Gimme a path to assess dumb ass"); + process.exit(1); +} + +if (command === "cat") { + const content = await vfs.file(path); + await Bun.write(Bun.stdout, content); +} else if (command === "ls") { + const files = await vfs.dir(path); + + for (const file of files) { + console.log(file); + } +} else if (command === "yaml") { + const content = await vfs.yaml(path); + await Bun.write(Bun.stdout, JSON.stringify(content, null, 2)); +} diff --git a/packages/scripts/src/vfs/vfs.ts b/packages/scripts/src/vfs/vfs.ts new file mode 100644 index 000000000..a94b39bae --- /dev/null +++ b/packages/scripts/src/vfs/vfs.ts @@ -0,0 +1,4 @@ +import { assertSecret } from "@blitzkit/core"; +import { LocalVFS } from "@blitzkit/core/src/blitzkit/vfs/local"; + +export const vfs = new LocalVFS(assertSecret(import.meta.env.CLIENT_DIR)); diff --git a/packages/website/public/media/tools/banners/credits.webp b/packages/website/public/media/tools/banners/credits.webp new file mode 100644 index 000000000..59a61c284 Binary files /dev/null and b/packages/website/public/media/tools/banners/credits.webp differ diff --git a/packages/website/public/media/users/34wotb.jpg b/packages/website/public/media/users/34wotb.jpg new file mode 100644 index 000000000..216cad339 Binary files /dev/null and b/packages/website/public/media/users/34wotb.jpg differ diff --git a/packages/website/public/media/users/aeson000.png b/packages/website/public/media/users/aeson000.png new file mode 100644 index 000000000..a003dc074 Binary files /dev/null and b/packages/website/public/media/users/aeson000.png differ diff --git a/packages/website/public/media/users/avenir.webp b/packages/website/public/media/users/avenir.webp new file mode 100644 index 000000000..146130324 Binary files /dev/null and b/packages/website/public/media/users/avenir.webp differ diff --git a/packages/website/public/media/users/chickenman7777.webp b/packages/website/public/media/users/chickenman7777.webp new file mode 100644 index 000000000..0cbf54d78 Binary files /dev/null and b/packages/website/public/media/users/chickenman7777.webp differ diff --git a/packages/website/public/media/users/droodles-blitz.jpg b/packages/website/public/media/users/droodles-blitz.jpg new file mode 100644 index 000000000..f5793cb80 Binary files /dev/null and b/packages/website/public/media/users/droodles-blitz.jpg differ diff --git a/packages/website/public/media/users/hisroyalfatness.jpg b/packages/website/public/media/users/hisroyalfatness.jpg new file mode 100644 index 000000000..411a5808c Binary files /dev/null and b/packages/website/public/media/users/hisroyalfatness.jpg differ diff --git a/packages/website/public/media/users/minitelrose.webp b/packages/website/public/media/users/minitelrose.webp new file mode 100644 index 000000000..208a82c15 Binary files /dev/null and b/packages/website/public/media/users/minitelrose.webp differ diff --git a/packages/website/public/media/users/prince-na.webp b/packages/website/public/media/users/prince-na.webp new file mode 100644 index 000000000..2f515e76f Binary files /dev/null and b/packages/website/public/media/users/prince-na.webp differ diff --git a/packages/website/public/media/users/s0ap.webp b/packages/website/public/media/users/s0ap.webp new file mode 100644 index 000000000..4717c29ef Binary files /dev/null and b/packages/website/public/media/users/s0ap.webp differ diff --git a/packages/website/public/media/users/synx.webp b/packages/website/public/media/users/synx.webp new file mode 100644 index 000000000..5b6a506df Binary files /dev/null and b/packages/website/public/media/users/synx.webp differ diff --git a/packages/website/public/media/users/tj-duff.jpg b/packages/website/public/media/users/tj-duff.jpg new file mode 100644 index 000000000..9dbf4fb5c Binary files /dev/null and b/packages/website/public/media/users/tj-duff.jpg differ diff --git a/packages/website/public/media/users/xkdkw.webp b/packages/website/public/media/users/xkdkw.webp new file mode 100644 index 000000000..7d7998c59 Binary files /dev/null and b/packages/website/public/media/users/xkdkw.webp differ diff --git a/packages/website/src/components/ArticlePlug.astro b/packages/website/src/components/ArticlePlug.astro deleted file mode 100644 index 4ef838462..000000000 --- a/packages/website/src/components/ArticlePlug.astro +++ /dev/null @@ -1,38 +0,0 @@ ---- -import { tankIcon } from "@blitzkit/core"; -import { Flex, Heading, Link, Strong, Text } from "@radix-ui/themes"; ---- - - - - - - - - Christmas 2025 - - Win the GSOR the TANK and more! - - - - - diff --git a/packages/website/src/components/Beg/index.module.css b/packages/website/src/components/Beg/index.module.css new file mode 100644 index 000000000..5bcfacd77 --- /dev/null +++ b/packages/website/src/components/Beg/index.module.css @@ -0,0 +1,36 @@ +.outer { + padding: 0 var(--space-4); + margin-top: var(--space-3); + + img { + width: 1.25em; + height: 1.25em; + + border-radius: 50%; + } + + .center { + position: relative; + + background: linear-gradient(var(--pink-3), var(--purple-2)); + box-shadow: var(--shadow-3); + + max-width: 40rem; + + padding: var(--space-5); + border-radius: var(--radius-4); + + .important { + position: absolute; + + top: 0; + left: 50%; + + transform: translate(-50%, -50%); + + background-color: inherit; + + padding: var(--space-3); + } + } +} diff --git a/packages/website/src/components/Beg/index.tsx b/packages/website/src/components/Beg/index.tsx new file mode 100644 index 000000000..7f905c616 --- /dev/null +++ b/packages/website/src/components/Beg/index.tsx @@ -0,0 +1,48 @@ +import { HeartFilledIcon } from "@radix-ui/react-icons"; +import { Button, Em, Flex, Strong, Text } from "@radix-ui/themes"; +import { donationTiers } from "../../config/donations"; +import styles from "./index.module.css"; + +export function Beg() { + return ( + + + + + We need your attention + + + + + + + We can no longer keep BlitzKit sustainable for free + . + + + + We want to add tank modules, player statistics, rebalancing patch + notes, bring back the Discord bot, and more. + + + + But paying out of pocket makes that impossible. We are an earnest + non-profit, so consider donating to make the above possible. 100% of + your money goes back into BlitzKit. + + + + + {donationTiers.map(({ name, value, link }) => ( + + + + ))} + + + + ); +} diff --git a/packages/website/src/components/Giveaway.tsx b/packages/website/src/components/Countdown.tsx similarity index 79% rename from packages/website/src/components/Giveaway.tsx rename to packages/website/src/components/Countdown.tsx index edd64c67a..77468ccc8 100644 --- a/packages/website/src/components/Giveaway.tsx +++ b/packages/website/src/components/Countdown.tsx @@ -3,9 +3,9 @@ import { useEffect, useState, type ReactNode } from "react"; import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps"; import { InlineSkeleton } from "./InlineSkeleton"; -const TIME = 1764898200 * 1000; +const TIME = 1792598400 * 1000; -export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { +export function Countdown({ skeleton }: MaybeSkeletonComponentProps) { const [timeLeft, setTimeLeft] = useState(TIME - Date.now()); useEffect(() => { @@ -31,14 +31,14 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { string = ( <> - in{" "} + revealed in{" "} {hours}h {minutes}m {seconds}s ); } else { - string = "has ended. Come again later!"; + string = "have been revealed."; } return ( @@ -47,7 +47,7 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { color="gray" highContrast style={{ display: "content" }} - href="https://discord.gg/njQdBhNS?event=1446208951136813117" + href="https://preview.blitzkit.app/tanks" underline="none" > - Music Box giveaway {!skeleton && string}{" "} + Tier XIs {!skeleton && string}{" "} {skeleton && } - Join the BlitzKit Discord server event + Get ready to go to the Tankopedia! diff --git a/packages/website/src/components/CreditsPromo.astro b/packages/website/src/components/CreditsPromo.astro new file mode 100644 index 000000000..65f2186d3 --- /dev/null +++ b/packages/website/src/components/CreditsPromo.astro @@ -0,0 +1,135 @@ +--- +import { Grid, Text } from "@radix-ui/themes"; +import { credits } from "../config/credits"; +import { getStrings } from "../core/i18n/getStrings"; + +interface Props { + locale: string; +} + +const { locale } = Astro.props; + +const strings = getStrings(locale); +--- + + + +
+
+ + { + credits.map((person) => ( + + +
diff --git a/packages/website/src/components/Footer.astro b/packages/website/src/components/Footer.astro index 533151623..e1dd67841 100644 --- a/packages/website/src/components/Footer.astro +++ b/packages/website/src/components/Footer.astro @@ -42,9 +42,9 @@ const strings = await api.strings(locale); }} > BlitzKit - {strings.website.footer.motto} - - {strings.website.footer.copyright} + {strings.website.footer.motto} + + {strings.website.footer.association} @@ -83,9 +83,6 @@ const strings = await api.strings(locale); sm: "start", }} > - - {strings.website.footer.about.credits} - state.highGraphics); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); + const revealed = Tankopedia.use((state) => state.revealed); + const skinPending = Tankopedia.use( + (state) => state.loadedSkin !== state.skin, + ); const animationTime = useRef(REVEAL_ANIMATION_TIME); const t0 = useRef(performance.now() / 1e3 - animationTime.current); + const holding = useRef(false); const isRevealing = useRef(true); + const isFirstReveal = useRef(true); const [animate, setAnimate] = useState(true); @@ -71,6 +77,37 @@ export function Lighting({ hasPbr }: Props) { setAnimate(true); }, [requestedDisplay]); + useEffect(() => { + if (isFirstReveal.current) { + isFirstReveal.current = false; + return; + } + + if (!revealed) { + animationTime.current = TRANSITION_ANIMATION_TIME; + t0.current = performance.now() / 1e3; + holding.current = true; + } else if (holding.current) { + animationTime.current = REVEAL_ANIMATION_TIME; + t0.current = performance.now() / 1e3 - animationTime.current; + holding.current = false; + } else { + return; + } + + setAnimate(true); + invalidate(); + }, [revealed]); + + useEffect(() => { + if (!skinPending) return; + + animationTime.current = TRANSITION_ANIMATION_TIME; + t0.current = performance.now() / 1e3; + + setAnimate(true); + }, [skinPending]); + return ( <> { setAnimate(false); @@ -145,11 +183,19 @@ interface AnimatorProps { t0: RefObject; animationTime: RefObject; + holding: RefObject; wrapper: RefObject; hasPbr: boolean; } -function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { +function Animator({ + stop, + t0, + animationTime, + holding, + wrapper, + hasPbr, +}: AnimatorProps) { const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const display = Tankopedia.use((state) => state.display); @@ -176,7 +222,7 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { useFrame(({ invalidate }) => { const dt = performance.now() / 1e3 - t0.current; - const x = clamp(dt / animationTime.current, 0, 2); + const x = clamp(dt / animationTime.current, 0, holding.current ? 1 : 2); if (x === 2) { apply(1); @@ -188,6 +234,9 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { const t = (0.5 * Math.sin(Math.PI * (x + 0.5)) + 0.5) ** 2; apply(t); + + if (holding.current && x === 1) return; + invalidate(); if (x >= 1 && Tankopedia.state.display !== requestedDisplay) { @@ -195,6 +244,12 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { draft.display = requestedDisplay; }); } + + if (x >= 1 && Tankopedia.state.skin !== Tankopedia.state.loadedSkin) { + Tankopedia.mutate((draft) => { + draft.skin = draft.loadedSkin; + }); + } }); return null; diff --git a/packages/website/src/components/Markdown.tsx b/packages/website/src/components/Markdown.tsx index cc198c317..c4ba7685f 100644 --- a/packages/website/src/components/Markdown.tsx +++ b/packages/website/src/components/Markdown.tsx @@ -1,13 +1,20 @@ import { Link, Table } from '@radix-ui/themes'; import MarkdownToJSX from 'markdown-to-jsx'; import type { ComponentProps } from 'react'; +import { docImageUrl, isExternalImage } from '../core/blitzkit/docImages'; interface MarkdownProps { children: string; } -function Image(props: ComponentProps<'img'>) { - return ; +function Image({ src, ...props }: ComponentProps<'img'>) { + return ( + + ); } export function Markdown({ children }: MarkdownProps) { diff --git a/packages/website/src/components/Navbar.astro b/packages/website/src/components/Navbar.astro index 5ef876793..ece7cba64 100644 --- a/packages/website/src/components/Navbar.astro +++ b/packages/website/src/components/Navbar.astro @@ -5,7 +5,7 @@ import { GearIcon, HamburgerMenuIcon, } from "@radix-ui/react-icons"; -import { Box, Code, Flex, IconButton, Link } from "@radix-ui/themes"; +import { Box, Code, Flex, Grid, IconButton, Link } from "@radix-ui/themes"; import { resolveBranchName } from "../core/blitzkit/branches"; import { getStrings } from "../core/i18n/getStrings"; import { Var } from "../core/radix/var"; @@ -103,7 +103,7 @@ const strings = getStrings(locale); @@ -131,42 +131,6 @@ const strings = getStrings(locale); - - { - toolsFiltered.map(([id, tool]) => { - const selected = Astro.url.pathname.includes( - `/${tool.path ?? id}`, - ); - const toolStrings = - strings.website.tools[ - (tool.strings ?? id) as keyof typeof strings.website.tools - ]; - - return ( - - {toolStrings.name} - - ); - }) - } - - - - - + @@ -199,11 +163,10 @@ const strings = getStrings(locale); - { toolsFiltered.map(([id, tool]) => { @@ -228,7 +191,7 @@ const strings = getStrings(locale); }} > + diff --git a/packages/website/src/components/StupidCat.tsx b/packages/website/src/components/StupidCat.tsx deleted file mode 100644 index 86c5be34d..000000000 --- a/packages/website/src/components/StupidCat.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function StupidCat() { - return ; -} diff --git a/packages/website/src/components/TankModel.tsx b/packages/website/src/components/TankModel.tsx index 8a0819b4e..61e3469f5 100644 --- a/packages/website/src/components/TankModel.tsx +++ b/packages/website/src/components/TankModel.tsx @@ -2,13 +2,14 @@ import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber"; import { useRef } from "react"; import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three"; import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits"; -import { jsxTree } from "../core/blitzkit/jsxTree"; import { modelTransformEvent } from "../core/blitzkit/modelTransform"; +import { skinTree } from "../core/blitzkit/skinTree"; import { controlsEnabledEvent } from "../core/controlsEnabled"; import { DuelModule, getDuel, useDuel } from "../hooks/useDuel"; import { useDuelModel } from "../hooks/useDuelModel"; import { hasEquipment } from "../hooks/useEquipment"; import { useModel } from "../hooks/useModel"; +import { useSkin } from "../hooks/useSkin"; import { useTankTransform } from "../hooks/useTankTransform"; import { Tankopedia } from "../stores/tankopedia"; import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants"; @@ -27,6 +28,7 @@ export function TankModel() { const gunModelDefinition = turretModelDefinition.guns[protagonist.gun]; const { gltf } = useModel(protagonist.tank); const nodes = Object.values(gltf.nodes); + const skinGltf = useSkin(protagonist.tank); useTankTransform(track, turret, turretContainer, gunContainer); @@ -103,7 +105,7 @@ export function TankModel() { window.removeEventListener("pointerup", handlePointerUp); } - return jsxTree(node, { + return skinTree(node, skinGltf, { mesh(_, props, key) { return ( & { + selected: boolean; + src: string; + alt: string; + imageStyle?: CSSProperties; +}; + +export function SidebarIconGroup({ style, ...props }: FlexProps) { + return ( + + ); +} + +export function SidebarIconButton({ + selected, + src, + alt, + imageStyle, + ...props +}: SidebarIconButtonProps) { + return ( + + {alt} + + ); +} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx new file mode 100644 index 000000000..762ed5ca8 --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx @@ -0,0 +1,83 @@ +import { alias } from "@blitzkit/core"; +import { Flex, Tooltip } from "@radix-ui/themes"; +import { uniqBy } from "lodash-es"; +import { SKIN_NUMBER } from "../../../../../../constants/skinNumber"; +import { api } from "../../../../../../core/blitzkit/api"; +import { useLocale } from "../../../../../../hooks/useLocale"; +import { Duel } from "../../../../../../stores/duel"; +import { Tankopedia } from "../../../../../../stores/tankopedia"; +import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants"; +import { SidebarIconButton } from "./SidebarIconButton"; + +const camouflageDefinitions = await api.camouflageDefinitions(); + +export function SkinSwitcher() { + const tank = Duel.use((state) => state.protagonist.tank); + const skin = Tankopedia.use((state) => state.requestedSkin); + const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); + const disturbed = Tankopedia.use((state) => state.disturbed); + const { unwrap } = useLocale(); + const skins = uniqBy( + Object.values(camouflageDefinitions.camouflages).filter( + (camouflage) => camouflage.tank_id === tank.id, + ), + (camouflage) => unwrap(camouflage.name!), + ); + + if (skins.length === 0) return null; + + return ( + + {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"; + + return ( + + { + Tankopedia.mutate((draft) => { + draft.requestedSkin = + draft.requestedSkin === camouflage.id + ? undefined + : camouflage.id; + }); + }} + /> + + ); + })} + + ); +} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx index 372ae4582..c78d930a3 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx @@ -46,6 +46,11 @@ import { TankSearch } from "../../../../TankSearch"; import { CustomShellButton } from "./components/CustomShellButton"; import { DynamicArmorSwitcher } from "./components/DynamicArmorSwitcher"; import { QuickInputs } from "./components/QuickInputs"; +import { + SidebarIconButton, + SidebarIconGroup, +} from "./components/SidebarIconButton"; +import { SkinSwitcher } from "./components/SkinSwitcher"; import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher"; import { Thicknesses } from "./components/Thicknesses"; @@ -87,6 +92,8 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { + + )} - + {antagonistGun.shells.map((thisShell) => ( - { Duel.mutate((draft) => { draft.antagonist.shell = thisShell; @@ -208,19 +204,10 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { draft.customShell = undefined; }); }} - > - {unwrap(thisShell.name!)} - + /> ))} - + state.modelRequested); const protagonist = Duel.use((state) => state.protagonist.tank); const revealed = Tankopedia.use((state) => state.revealed); const disturbed = Tankopedia.use((state) => state.disturbed); - const name = unwrap(protagonist.name!); + const hasRevealed = useRef(false); + const skin = Tankopedia.use((state) => state.requestedSkin); + 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; const fontSize = revealed ? disturbed ? "1.5rem" : "2rem" : `min(12vh, ${75 / name.length}vw)`; + useEffect(() => { + if (revealed) hasRevealed.current = true; + }, [revealed]); + return ( { event.preventDefault(); - if (!modelRequested) return; + if (!modelRequested || hasRevealed.current) return; Tankopedia.mutate((draft) => { draft.revealed = true; diff --git a/packages/website/src/config/credits.ts b/packages/website/src/config/credits.ts new file mode 100644 index 000000000..5f51e3b23 --- /dev/null +++ b/packages/website/src/config/credits.ts @@ -0,0 +1,102 @@ +interface CreditPeople { + name: string; + description: string; + url?: string; + image: string; +} + +export const credits: CreditPeople[] = [ + { + name: "Tr猫sAbhi", + description: "Founder and lead developer", + url: "https://abhi-deep.com/", + image: "https://github.com/tresabhi.png", + }, + { + name: "HitJack", + description: "Developer on website", + url: "https://marksstuff.com", + image: "https://github.com/Hitjack007.png", + }, + { + name: "aeson000", + description: "Advisor on game and website", + url: "https://discord.gg/WHdER7ZPAD", + image: "/media/users/aeson000.png", + }, + { + name: "GonnaHetzMe", + description: "Developer and advisor on back end", + url: "https://github.com/karelpak1", + image: "https://github.com/karelpak1.png", + }, + { + name: "ChickenMan7777", + description: "Moderator and former advisor on game mechanics", + url: "https://discord.gg/rEsUbXCqD9", + image: "/media/users/chickenman7777.webp", + }, + { + name: "Avenir", + description: "Moderator and elite Advanced Recon Commando", + image: "/media/users/avenir.webp", + }, + { + name: "Pyogenics", + description: "Former advisor on game 3D format", + url: "https://github.com/Pyogenics", + image: "https://github.com/Pyogenics.png", + }, + { + name: "Prince_NA", + description: "Former advisor on bot and website", + image: "/media/users/prince-na.webp", + }, + { + name: "Vovko", + description: "Former advisor on bot", + url: "https://amth.one/", + image: "https://github.com/cufee.png", + }, + { + name: "S0AP", + description: "Former advisor on and advertiser for bot", + url: "https://discord.gg/3KfH93um3Y", + image: "/media/users/s0ap.webp", + }, + { + name: "Synx", + description: "Former advisor on bot and website", + url: "https://www.youtube.com/@Synx_Blitz", + image: "/media/users/synx.webp", + }, + { + name: "34wotb", + description: "Former advisor on bot", + url: "https://www.twitch.tv/34wotb/", + image: "/media/users/34wotb.jpg", + }, + { + name: "HisRoyalFatness", + description: "Former advisor on website", + url: "https://www.youtube.com/@hisroyalfatness", + image: "/media/users/hisroyalfatness.jpg", + }, + { + name: "Minitelrose", + description: "Former advisor on game mechanics", + url: "https://wotinspector.com/", + image: "/media/users/minitelrose.webp", + }, + { + name: "Maddox", + description: "Game asset decompression", + url: "https://github.com/Maddoxkkm", + image: "https://github.com/Maddoxkkm.png", + }, + { + name: "TJ Duff", + description: "Chief executive cheer leader", + image: "/media/users/tj-duff.jpg", + }, +]; diff --git a/packages/website/src/config/donations.ts b/packages/website/src/config/donations.ts new file mode 100644 index 000000000..8b0d9c63d --- /dev/null +++ b/packages/website/src/config/donations.ts @@ -0,0 +1,23 @@ +// TODO: source this from the open collective api, if even possible +export const donationTiers = [ + { + name: "The Backer", + value: 5, + link: "https://opencollective.com/blitzkit/contribute/backers-106312/checkout", + }, + { + name: "The Engineer", + value: 10, + link: "https://opencollective.com/blitzkit/contribute/the-engineer-107057/checkout", + }, + { + name: "The Patron", + value: 25, + link: "https://opencollective.com/blitzkit/contribute/the-patron-107056/checkout", + }, + { + name: "The Sponsor", + value: null, + link: "https://opencollective.com/blitzkit/donate", + }, +]; diff --git a/packages/website/src/constants/skinNumber.ts b/packages/website/src/constants/skinNumber.ts new file mode 100644 index 000000000..42d0f4020 --- /dev/null +++ b/packages/website/src/constants/skinNumber.ts @@ -0,0 +1 @@ +export const SKIN_NUMBER = "0417"; diff --git a/packages/website/src/constants/tools.ts b/packages/website/src/constants/tools.ts index e54fcfd7b..a4bb9b9b6 100644 --- a/packages/website/src/constants/tools.ts +++ b/packages/website/src/constants/tools.ts @@ -73,4 +73,8 @@ export const tools: Record = { id: "changelogs", banner_background_position: "center", }, + credits: { + id: "credits", + button: "bronze", + }, }; diff --git a/packages/website/src/core/blitzkit/docImages.ts b/packages/website/src/core/blitzkit/docImages.ts new file mode 100644 index 000000000..634bc1708 --- /dev/null +++ b/packages/website/src/core/blitzkit/docImages.ts @@ -0,0 +1,32 @@ +import { alias } from "@blitzkit/core"; + +const EXTERNAL_IMAGE = /^https?:\/\//; +const MARKDOWN_IMAGES = [ + /!\[[^\]]*\]\((\S+?)(?:\s+"[^"]*")?\)/g, + /]*\ssrc=["']([^"']+)["']/g, +]; + +export function isExternalImage(src: string) { + return EXTERNAL_IMAGE.test(src); +} + +export function docImageId(url: string) { + let hash = 0x811c9dc5; + + for (let index = 0; index < url.length; index++) { + hash ^= url.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(16).padStart(8, "0"); +} + +export function docImageUrl(url: string) { + return alias("api", `/docs/images/${docImageId(url)}.webp`); +} + +export function externalDocImages(markdown: string) { + return MARKDOWN_IMAGES.flatMap((pattern) => + Array.from(markdown.matchAll(pattern), (match) => match[1]), + ).filter(isExternalImage); +} diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx new file mode 100644 index 000000000..f22336518 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinTree.tsx @@ -0,0 +1,46 @@ +import type { Material, Object3D } from "three"; +import type { Skin } from "../../hooks/useSkin"; +import { jsxTree } from "./jsxTree"; + +type Mergers = Parameters[1]; + +export function skinTree( + node: Object3D, + skin: Skin | undefined, + mergers: Mergers, +) { + const skinNodes = new Map( + skin?.scene.children.map((node) => [node.name, node]), + ); + const skinMergers: Mergers = { + mesh(mesh, props, key) { + const material = skin?.materials.get((mesh.material as Material).name); + + 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 ( + + {isSwitch + ? object3d.children + .filter((child) => child.name === state) + .map((child) => jsxTree(child, skinMergers)) + : props.children} + {skinNode?.children.map((child) => jsxTree(child, mergers))} + + ); + }, + }; + + return jsxTree(node, skinMergers); +} diff --git a/packages/website/src/core/blitzkit/tankCharacteristics.ts b/packages/website/src/core/blitzkit/tankCharacteristics.ts index 67569155a..5b2f67c41 100644 --- a/packages/website/src/core/blitzkit/tankCharacteristics.ts +++ b/packages/website/src/core/blitzkit/tankCharacteristics.ts @@ -8,8 +8,8 @@ import { resolvePenetrationCoefficient, ShellType, sum, + tankBoundingBox, TankClass, - unionBoundingBox, type EngineDefinition, type EquipmentDefinitions, type GunDefinition, @@ -336,10 +336,7 @@ export function tankCharacteristics( ); const size = normalizeBoundingBox( - unionBoundingBox( - tankModelDefinition.bounding_box!, - turretModelDefinition.bounding_box!, - ), + tankBoundingBox(tankModelDefinition, turretModelDefinition), ); const weightKg = tank.weight + engine.weight + track.weight + turret.weight + gun.weight; diff --git a/packages/website/src/hooks/useArmor.ts b/packages/website/src/hooks/useArmor.ts index 94656886b..ee522b924 100644 --- a/packages/website/src/hooks/useArmor.ts +++ b/packages/website/src/hooks/useArmor.ts @@ -4,7 +4,7 @@ import { GLTFLoader } from "three-stdlib"; import { useDispose } from "./useDispose"; export function useArmor(id: number) { - const path = alias("api", `/tanks/${id}/collision.glb`); + const path = alias("models_tanks", `/${id}/collision.glb`); const gltf = useLoader(GLTFLoader, path); useDispose(gltf, path); diff --git a/packages/website/src/hooks/useModel.ts b/packages/website/src/hooks/useModel.ts index 96db9fd91..3e1ec5bdc 100644 --- a/packages/website/src/hooks/useModel.ts +++ b/packages/website/src/hooks/useModel.ts @@ -5,7 +5,7 @@ import { GLTFLoader } from "three-stdlib"; import { useDispose } from "./useDispose"; export function useModel(id: number) { - const path = alias("api", `/tanks/${id}/model.glb`); + const path = alias("models_tanks", `/${id}/model.glb`); const gltf = useLoader(GLTFLoader, path); useDispose(gltf, path); diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts new file mode 100644 index 000000000..6514d2482 --- /dev/null +++ b/packages/website/src/hooks/useSkin.ts @@ -0,0 +1,82 @@ +import { alias } from "@blitzkit/core"; +import { useEffect } from "react"; +import type { Group, Material } from "three"; +import { GLTFLoader } from "three-stdlib"; +import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting"; +import { Tankopedia } from "../stores/tankopedia"; + +export interface Skin { + scene: Group; + materials: Map; +} + +const skins = new Map>(); +const loadedSkins = new Map(); + +async function loadSkin(path: string) { + const gltf = await new GLTFLoader().loadAsync(path); + const materials: Material[] = await gltf.parser.getDependencies("material"); + const skin = { + scene: gltf.scene, + materials: new Map(materials.map((material) => [material.name, material])), + }; + + loadedSkins.set(path, skin); + + return skin; +} + +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); + + useEffect(() => { + const path = + requestedSkin === undefined ? undefined : skinPath(id, requestedSkin); + let settled = false; + + if (path === undefined || loadedSkins.has(path)) { + Tankopedia.mutate((draft) => { + draft.loadedSkin = requestedSkin; + }); + return; + } + + Tankopedia.mutate((draft) => { + draft.revealed = false; + }); + + if (!skins.has(path)) skins.set(path, loadSkin(path)); + + Promise.all([ + skins.get(path), + new Promise((resolve) => + setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), + ), + ]).then(() => { + if (settled) return; + + settled = true; + Tankopedia.mutate((draft) => { + draft.loadedSkin = requestedSkin; + draft.skin = requestedSkin; + draft.revealed = true; + }); + }); + + return () => { + if (settled) return; + + settled = true; + Tankopedia.mutate((draft) => { + draft.revealed = true; + }); + }; + }, [id, requestedSkin]); + + return skin === undefined ? undefined : loadedSkins.get(skinPath(id, skin)); +} diff --git a/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts b/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts new file mode 100644 index 000000000..0a87357f0 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts @@ -0,0 +1,39 @@ +import type { APIContext, GetStaticPathsItem } from "astro"; +import sharp from "sharp"; +import { + docImageId, + externalDocImages, +} from "../../../../core/blitzkit/docImages"; +import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; +import { getStaticPaths as _getStaticPaths } from "../../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const documents = import.meta.glob( + "../../../../../../../docs/**/*.md", + { eager: true, query: "?raw", import: "default" }, + ); + const urls = new Set(Object.values(documents).flatMap(externalDocImages)); + + return Array.from( + urls, + (url) => + ({ + params: { id: docImageId(url) }, + props: { url }, + }) satisfies GetStaticPathsItem, + ); +}); + +export async function GET({ props }: APIContext<{ url: string }>) { + const response = await fetch(props.url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${props.url}: ${response.status}`); + } + + const image = await sharp(Buffer.from(await response.arrayBuffer())) + .webp() + .toBuffer(); + + return new Response(new Uint8Array(image)); +} diff --git a/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts b/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts new file mode 100644 index 000000000..caff69054 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts @@ -0,0 +1,53 @@ +import type { CamouflagesXml } from "@blitzkit/core"; +import type { APIContext, GetStaticPathsItem } from "astro"; +import sharp from "sharp"; +import { extractPackedIcon } from "../../../../core/blitz/extractPackedIcon"; +import { parsePackedSpriteRect } from "../../../../core/blitz/parsePackedSpriteRect"; +import { api } from "../../../../core/blitzkit/api"; +import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; +import { vfs } from "../../../../core/blitzkit/vfs"; +import { getStaticPaths as _getStaticPaths } from "../../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const camouflageDefinitions = await api.camouflageDefinitions(); + const camouflagesXml = await vfs.xml<{ root: CamouflagesXml }>( + "Data/XML/item_defs/vehicles/common/camouflages.xml", + ); + const icons = new Map( + Object.values(camouflagesXml.root.camouflages).map((camouflage) => [ + camouflage.id, + camouflage.icon, + ]), + ); + const paths: GetStaticPathsItem[] = []; + + for (const camouflage of Object.values(camouflageDefinitions.camouflages)) { + const icon = icons.get(camouflage.id); + + if (camouflage.tank_id === undefined || !icon) continue; + + paths.push({ + params: { id: camouflage.id }, + props: { + path: icon.replace("~res:/", "Data/").replace(/\.txt$/, ""), + }, + }); + } + + return paths; +}); + +export async function GET({ props }: APIContext<{ path: string }>) { + const path = (await vfs.resolve(`${props.path}@2x.packed.webp`)) + ? `${props.path}@2x.packed.webp` + : `${props.path}.packed.webp`; + const packed = await vfs.file(path); + const texture = sharp(packed); + const sizes = parsePackedSpriteRect(packed); + const image = sizes + ? sharp(await extractPackedIcon(texture, sizes)) + : texture; + const bytes = await image.webp().toBuffer(); + + return new Response(new Uint8Array(bytes)); +} diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/credits/index.astro b/packages/website/src/pages/[...chunk_default]/[...locale]/credits/index.astro new file mode 100644 index 000000000..0193b018b --- /dev/null +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/credits/index.astro @@ -0,0 +1,13 @@ +--- +import CreditsPromo from "../../../../components/CreditsPromo.astro"; +import { tools } from "../../../../constants/tools"; +import ToolLayout from "../../../../layouts/ToolLayout.astro"; + +export { getStaticPaths } from "../index.astro"; + +const { locale } = Astro.props; +--- + + + + 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 new file mode 100644 index 000000000..0d64498f4 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -0,0 +1,237 @@ +import { + parseHierarchies, + Sc2ReadStream, + type CamouflagesXml, + type CamouflagesYaml, + type TankParameters, +} from "@blitzkit/core"; +import { + Document, + Node, + NodeIO, + PropertyType, + Scene, +} from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { dedup, mergeDocuments, unpartition } from "@gltf-transform/functions"; +import type { APIContext } from "astro"; +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 { vfs } from "../../../../core/blitzkit/vfs"; +import { + extractMaterials, + extractModel, +} from "../../../[...chunk_models_tanks]/[id]/model.glb"; +import { getStaticPaths as _getStaticPaths } from "../../_index"; + +const SKIN_NUMBER_SLOTS = 5; +const DIGIT_COLUMNS = 5; +const DIGIT_ROWS = 2; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const camouflages = await api.camouflageDefinitions(); + + return Object.values(camouflages.camouflages) + .filter((camouflage) => camouflage.tank_id) + .map((camouflage) => ({ + params: { id: camouflage.tank_id!, skin: camouflage.id }, + props: { skin: camouflage.id }, + })); +}); + +const camouflagesXml = await vfs.xml<{ root: CamouflagesXml }>( + "Data/XML/item_defs/vehicles/common/camouflages.xml", +); +const camouflagesYaml = await vfs.yaml( + "Data/camouflages.yaml", +); +const customization = await vfs.yaml< + Record +>("Data/3d/Customization.yaml"); + +export async function GET({ props }: APIContext<{ skin: number }>) { + const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS); + + const items = new Map( + Object.values(customization).map(({ Name, Path }) => [ + Name, + Path.replace("~res:/3d/", "").replace(/\.sc2$/, ""), + ]), + ); + const key = Object.keys(camouflagesXml.root.camouflages).find( + (key) => camouflagesXml.root.camouflages[key].id === props.skin, + )!; + const include = camouflagesXml.root.camouflages[key].vehicleFilter.include; + 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 parameters = await vfs.yaml( + `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, + ); + const basePath = parameters.resourcesPath.blitzModelPath.replace( + /\.sc2$/, + "", + ); + const sc2 = new Sc2ReadStream( + (await vfs.file(`Data/3d/${basePath}.sc2`)).buffer as ArrayBuffer, + ).sc2(); + const slots: Record< + string, + { + target: string; + position: Vector3Tuple; + rotation: Vector4Tuple; + scale: Vector3Tuple; + } + > = {}; + const states: Record = {}; + + parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { + for (const component of components) { + if (component["comp.typename"] === "NewSlotComponent") { + for (const slot of Object.values(component.slots)) { + slots[slot.name] = { + target: hierarchy.name, + position: slot.transform.position, + rotation: slot.transform.rotation, + scale: slot.transform.scale, + }; + } + } + + if (component["comp.typename"] === "SlotComponent") { + slots[component["sc.slotName"]] = { + target: hierarchy.name, + position: component["sc.attachmentTranslation"] as Vector3Tuple, + rotation: component["sc.attachmentRotation"] as Vector4Tuple, + 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 document = new Document(); + const scene = document.createScene().setExtras({ states }); + const targets = new Map(); + + document.createBuffer(); + + if (preset) await extractMaterials(document, sc2, basePath, preset); + + for (const name in customEntities) { + const slot = slots[name]; + const path = items.get(customEntities[name].item); + + if ( + !slot || + !path || + !(await vfs.resolve(`Data/3d/${path}.sc2`)) || + !(await vfs.resolve(`Data/3d/${path}.scg`)) + ) { + continue; + } + + const part = await extractModel(vfs, path, true); + const map = mergeDocuments(document, part); + const partScene = map.get(part.getRoot().listScenes()[0]) as Scene; + const node = document + .createNode(name) + .setTranslation(slot.position) + .setRotation(slot.rotation) + .setScale(slot.scale); + let target = targets.get(slot.target); + + if (!target) { + target = document.createNode(slot.target); + targets.set(slot.target, target); + scene.addChild(target); + } + + for (const child of partScene.listChildren()) { + node.addChild(child as Node); + } + + partScene.dispose(); + target.addChild(node); + } + + applySkinNumber(document, SKIN_NUMBER); + + await document.transform( + dedup({ propertyTypes: [PropertyType.ACCESSOR, PropertyType.TEXTURE] }), + unpartition(), + ); + + return new Response(await nodeIO.writeBinary(document)); +} + +function applySkinNumber(document: Document, number: string) { + const start = Math.ceil((SKIN_NUMBER_SLOTS - number.length) / 2) + 1; + + for (const node of document.getRoot().listNodes()) { + const match = node.getName().match(/_number_\d+_(\d+)$/); + + if (!match) continue; + + const digit = number[Number(match[1]) - start]; + + if (digit === undefined) { + node.dispose(); + continue; + } + + const cell = (Number(digit) + 9) % 10; + const column = cell % DIGIT_COLUMNS; + const row = Math.floor(cell / DIGIT_COLUMNS); + + node.traverse((child) => { + for (const primitive of child.getMesh()?.listPrimitives() ?? []) { + const uvs = primitive.getAttribute("TEXCOORD_0")?.clone(); + + if (!uvs) continue; + + for (let index = 0; index < uvs.getCount(); index++) { + const [u, v] = uvs.getElement(index, [0, 0]) as [number, number]; + + uvs.setElement(index, [ + (column + u) / DIGIT_COLUMNS, + (row + v) / DIGIT_ROWS, + ]); + } + + primitive.setAttribute("TEXCOORD_0", uvs); + } + }); + } +} diff --git a/packages/website/src/pages/[...chunk_models_legendary]/_index.ts b/packages/website/src/pages/[...chunk_models_legendary]/_index.ts new file mode 100644 index 000000000..58fd25670 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_legendary]/_index.ts @@ -0,0 +1,3 @@ +import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths"; + +export const getStaticPaths = chunkedStaticPaths("models_legendary"); diff --git a/packages/website/src/pages/[...chunk_models_maps]/.gitkeep b/packages/website/src/pages/[...chunk_models_maps]/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts new file mode 100644 index 000000000..15a341f98 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts @@ -0,0 +1,12 @@ +import { api } from "../../../core/blitzkit/api"; +import { mixStaticPaths } from "../../../core/blitzkit/mixStaticPaths"; +import { getStaticPaths as _getStaticPaths } from "../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const tanks = await api.tankDefinitions(); + + return Object.values(tanks.tanks).map((tank) => ({ + params: { id: tank.id }, + props: { id: tank.id }, + })); +}); diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts similarity index 91% rename from packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts rename to packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts index 7bff4a081..a6d89abc4 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts @@ -1,17 +1,16 @@ import { + parseHierarchies, Sc2ReadStream, ScgReadStream, toUniqueId, VertexAttribute, vertexAttributeVectorSizes, - type Hierarchy, type VehicleDefinitionList, } from "@blitzkit/core"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core"; import type { APIContext } from "astro"; -import { times } from "lodash-es"; -import { vfs } from "../../../../core/blitzkit/vfs"; +import { vfs } from "../../../core/blitzkit/vfs"; export { getStaticPaths } from "./_index"; @@ -56,13 +55,10 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { const scene = document.createScene(); const buffer = document.createBuffer(); - function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { - hierarchies.forEach((hierarchy) => { - const components = times( - hierarchy.components.count, - (index) => hierarchy.components[index.toString().padStart(4, "0")], - ); - + parseHierarchies( + sc2["#hierarchy"], + scene, + (hierarchy, components, parent) => { components.forEach((component) => { switch (component["comp.typename"]) { case "TransformComponent": @@ -163,13 +159,9 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { } }); - if (hierarchy["#hierarchy"]) { - parseHierarchies(hierarchy["#hierarchy"], parent); - } - }); - } - - parseHierarchies(sc2["#hierarchy"], scene); + return parent; + }, + ); return document; } diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts similarity index 88% rename from packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts rename to packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index 8a56b41fa..c4f5a5392 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -1,13 +1,13 @@ import { bufferToBigInt, DdsReadStream, + parseHierarchies, PvrReadStream, Sc2ReadStream, ScgReadStream, toUniqueId, VertexAttribute, type ConfigArchive, - type Hierarchy, type TankParameters, type Textures, type VehicleDefinitionList, @@ -17,10 +17,10 @@ import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { dedup, prune } from "@gltf-transform/functions"; import type { APIContext } from "astro"; -import { times } from "lodash-es"; +import { isEqual, times } from "lodash-es"; import { dirname } from "path"; import sharp from "sharp"; -import { vfs } from "../../../../core/blitzkit/vfs"; +import { vfs } from "../../../core/blitzkit/vfs"; import { vertexAttributeGLTFName, vertexAttributeGltfVectorSizes, @@ -69,7 +69,11 @@ const omitMeshNames = { end: ["_POINT"], }; -async function extractModel(vfs: AbstractVFS, path: string) { +export async function extractModel( + vfs: AbstractVFS, + path: string, + isPart = false, +) { const sc2Path = `Data/3d/${path}.sc2`; const scgPath = `Data/3d/${path}.scg`; const sc2 = new Sc2ReadStream( @@ -81,6 +85,153 @@ async function extractModel(vfs: AbstractVFS, path: string) { const document = new Document(); const scene = document.createScene(); const buffer = document.createBuffer(); + const materials = await extractMaterials(document, sc2, path); + + parseHierarchies( + sc2["#hierarchy"], + scene, + (hierarchy, components, parent) => { + if ( + omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) || + omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit)) + ) + return; + + const node = document.createNode(hierarchy.name); + + components.forEach((component) => { + switch (component["comp.typename"]) { + case "LodComponent": + // found and used later by transform component + // TODO: lod component always shows up before transform component; cache it before parsing transform + break; + + case "TransformComponent": { + const localTranslation = component["tc.localTranslation"]; + // The game resets top-level node translation to [0, 0, 0] on load. + // Child nodes and customization parts must keep their authored + // local offsets. + node.setTranslation( + parent instanceof Scene && !isPart ? [0, 0, 0] : localTranslation, + ); + node.setRotation(component["tc.localRotation"]); + node.setScale(component["tc.localScale"]); + + break; + } + + case "StateSwitcherComponent": + node.setExtras({ + state: component[`ssc.state${component["ssc.activeState"]}`], + }); + break; + + case "RenderComponent": { + const renderObject = component["rc.renderObj"]; + + times(renderObject["ro.batchCount"], (batchIndex): void => { + const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; + + if (lodIndex !== 0) return; + + const batchKey = batchIndex.toString().padStart(4, "0"); + const batch = renderObject["ro.batches"][batchKey]; + const material = materials.get(batch["rb.nmatname"]); + const polygonGroup = scg.get(batch["rb.datasource"]); + + if (!(material instanceof Material)) { + // probably shadow material + return; + } + if (polygonGroup === undefined) { + throw new Error( + `Missing polygon group ${batch["rb.datasource"]}`, + ); + } + + const lodNode = document.createNode(batchKey); + const indicesAccessor = document + .createAccessor() + .setType("SCALAR") + .setArray(new Uint16Array(polygonGroup.indices)) + .setBuffer(buffer); + const primitive = document + .createPrimitive() + .setIndices(indicesAccessor) + .setMaterial(material) + .setName(batchKey); + + const attributes = new Map(); + + polygonGroup.vertices.forEach((vertex) => { + vertex.forEach(({ attribute, value }) => { + if (!attributes.has(attribute)) { + attributes.set(attribute, []); + } + + attributes.get(attribute)!.push(value); + }); + }); + + attributes.forEach((value, attribute) => { + const name = vertexAttributeGLTFName[attribute]; + + if ( + name === undefined || + primitive.getAttribute(name) !== null + ) { + return; + } + + const vertexSize = vertexAttributeGltfVectorSizes[attribute]; + const attributeAccessor = document + .createAccessor(name) + .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) + .setArray(new Float32Array(value.flat())) + .setBuffer(buffer); + + primitive.setAttribute(name, attributeAccessor); + }); + + const mesh = document + .createMesh(batch["##name"]) + .addPrimitive(primitive); + lodNode.setMesh(mesh); + node.addChild(lodNode); + }); + + break; + } + + default: { + if (ERROR_ON_UNKNOWN_COMPONENT) { + throw new Error( + `Unhandled component type: ${component["comp.typename"]}`, + ); + } + } + } + }); + + parent.addChild(node); + + return node; + }, + ); + + scene.addChild(document.createNode("test")); + + await document.transform(prune({ keepAttributes: true }), dedup()); + + return document; +} + +export async function extractMaterials( + document: Document, + sc2: ReturnType, + path: string, + config?: string, +) { const materials = new Map(); for (const node of sc2["#dataNodes"]) { @@ -95,15 +246,32 @@ async function extractModel(vfs: AbstractVFS, path: string) { continue; } - const material = document.createMaterial(node.materialName); let textures: Textures | undefined = undefined; - if (node.textures) { + if (config !== undefined) { + if (typeof node.configCount !== "number") continue; + + const archive = times( + node.configCount, + (index) => node[`configArchive_${index}`], + ).find((archive) => archive.configName === config); + + if ( + !archive || + isEqual(archive.textures, node.configArchive_0.textures) + ) { + continue; + } + + textures = archive.textures; + } else if (node.textures) { textures = node.textures; } else if (typeof node.configCount === "number") { textures = node.configArchive_0.textures; } + const material = document.createMaterial(node.materialName); + if (textures) { const baseColor = await readBaseColor( `Data/3d/${dirname(path)}/${textures.baseColorMap ?? textures.albedo}`, @@ -240,157 +408,7 @@ async function extractModel(vfs: AbstractVFS, path: string) { materials.set(id, resolvedMaterial); }); - function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { - hierarchies.forEach((hierarchy) => { - if ( - omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) || - omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit)) - ) - return; - - const node = document.createNode(hierarchy.name); - const components = times( - hierarchy.components.count, - (index) => hierarchy.components[index.toString().padStart(4, "0")], - ); - - components.forEach((component) => { - switch (component["comp.typename"]) { - case "LodComponent": - // found and used later by transform component - // TODO: lod component always shows up before transform component; cache it before parsing transform - break; - - case "TransformComponent": { - const t = component["tc.localTranslation"]; - const r = component["tc.localRotation"]; - - node.setTranslation([t[0], t[2], -t[1]]); - node.setRotation([r[0], r[2], -r[1], r[3]]); - node.setScale(component["tc.localScale"]); - - break; - } - - case "RenderComponent": { - const renderObject = component["rc.renderObj"]; - - times(renderObject["ro.batchCount"], (batchIndex): void => { - const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; - - if (lodIndex !== 0) return; - - const batchKey = batchIndex.toString().padStart(4, "0"); - const batch = renderObject["ro.batches"][batchKey]; - const material = materials.get(batch["rb.nmatname"]); - const polygonGroup = scg.get(batch["rb.datasource"]); - - if (!(material instanceof Material)) { - // probably shadow material - return; - } - if (polygonGroup === undefined) { - throw new Error( - `Missing polygon group ${batch["rb.datasource"]}`, - ); - } - - const lodNode = document.createNode(batchKey); - const indicesAccessor = document - .createAccessor() - .setType("SCALAR") - .setArray(new Uint16Array(polygonGroup.indices)) - .setBuffer(buffer); - const primitive = document - .createPrimitive() - .setIndices(indicesAccessor) - .setMaterial(material) - .setName(batchKey); - - const attributes = new Map(); - - polygonGroup.vertices.forEach((vertex) => { - vertex.forEach(({ attribute, value }) => { - if (!attributes.has(attribute)) { - attributes.set(attribute, []); - } - - attributes.get(attribute)!.push(value); - }); - }); - - attributes.forEach((value, attribute) => { - const name = vertexAttributeGLTFName[attribute]; - - if ( - name === undefined || - primitive.getAttribute(name) !== null - ) { - return; - } - - const vertexSize = vertexAttributeGltfVectorSizes[attribute]; - const processed = new Float32Array(value.flat()); - - if ( - attribute === VertexAttribute.VERTEX || - attribute === VertexAttribute.NORMAL || - attribute === VertexAttribute.TANGENT || - attribute === VertexAttribute.BINORMAL - ) { - for (let i = 0; i < processed.length; i += 3) { - const y = processed[i + 1]; - const z = processed[i + 2]; - - processed[i + 1] = z; - processed[i + 2] = -y; - } - } - - const attributeAccessor = document - .createAccessor(name) - .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) - .setArray(processed) - .setBuffer(buffer); - - primitive.setAttribute(name, attributeAccessor); - }); - - const mesh = document - .createMesh(batch["##name"]) - .addPrimitive(primitive); - lodNode.setMesh(mesh); - node.addChild(lodNode); - }); - - break; - } - - default: { - if (ERROR_ON_UNKNOWN_COMPONENT) { - throw new Error( - `Unhandled component type: ${component["comp.typename"]}`, - ); - } - } - } - }); - - if (hierarchy["#hierarchy"]) { - parseHierarchies(hierarchy["#hierarchy"], node); - } - - parent.addChild(node); - }); - } - - parseHierarchies(sc2["#hierarchy"], scene); - - scene.addChild(document.createNode("test")); - - await document.transform(prune({ keepAttributes: true }), dedup()); - - return document; + return materials; } async function readBaseColor(path: string) { diff --git a/packages/website/src/pages/[...chunk_models_tanks]/_index.ts b/packages/website/src/pages/[...chunk_models_tanks]/_index.ts new file mode 100644 index 000000000..7523da3c6 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_tanks]/_index.ts @@ -0,0 +1,3 @@ +import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths"; + +export const getStaticPaths = chunkedStaticPaths("models_tanks"); diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index 343d03b3a..02677e916 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -105,6 +105,9 @@ interface Tankopedia { ); xpMultiplier: (typeof XP_MULTIPLIERS)[number]; customShell?: ShellDefinition; + requestedSkin?: number; + loadedSkin?: number; + skin?: number; requestedDisplay: TankopediaDisplay; display: TankopediaDisplay; statSearch?: string;