Feature: Looping garage animations for legendary camouflages
Legendary camo add-ons now loop their garage animation in the Tankopedia viewer. It's always on, with no settings. - The garage replay now also works out which motions end up looping, and the export writes only those clips - Customization parts export skeletons, skin weights and sockets - Parts render as primitives so their bones can animate, with attachments layered on top - Base tank models are unchanged
No files matched your search
@@ -26,6 +26,7 @@ body:
|
||||
description: What branch of BlitzKit were you using? Please see the "Branch" section of the footer at the very bottom of any page.
|
||||
options:
|
||||
- Production
|
||||
- Beta
|
||||
- Preview
|
||||
- OpenTest
|
||||
default: 0
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -246,6 +246,27 @@ export abstract class ServerBlitzKitAPI0 extends BlitzKitAPI {
|
||||
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<string, string> = {
|
||||
[locales.default]: this.stringsI18n[locales.default][name],
|
||||
@@ -264,6 +285,8 @@ export abstract class ServerBlitzKitAPI0 extends BlitzKitAPI {
|
||||
collection[locale] = localizedString;
|
||||
}
|
||||
|
||||
collection[locales.default] = collection[locales.default] ?? name;
|
||||
|
||||
return { locales: collection } satisfies I18nString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +387,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,
|
||||
@@ -595,9 +595,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 <shots> 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);
|
||||
@@ -607,7 +623,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,
|
||||
@@ -623,7 +639,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
|
||||
near: penetrationRaw[0],
|
||||
far: penetrationRaw[1],
|
||||
},
|
||||
range: gunShellEntry.maxDistance,
|
||||
range: maxDistance,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
|
||||
);
|
||||
const sc2 = new Sc2ReadStream(
|
||||
(
|
||||
await this.vfs.file(`Data/3d/${parameters.resourcesPath.blitzModelPath}`)
|
||||
await this.vfs.file(
|
||||
`Data/3d/${parameters.resourcesPath.blitzModelPath}`,
|
||||
)
|
||||
).buffer as ArrayBuffer,
|
||||
).sc2();
|
||||
const presets = new Set<string>();
|
||||
@@ -51,6 +53,8 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
|
||||
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)
|
||||
@@ -83,14 +87,19 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
|
||||
),
|
||||
);
|
||||
|
||||
const [nation, tankKey] = vehicle.split(":");
|
||||
// some unique camos only set the preset for their one tank
|
||||
const preset =
|
||||
yamlEntry.preset ??
|
||||
yamlEntry.override?.[nation]?.override?.[tankKey]?.preset;
|
||||
let hasPreset = false;
|
||||
|
||||
if (camo.group === "legendary" && yamlEntry.preset !== undefined) {
|
||||
if (preset !== undefined) {
|
||||
if (!skinPresets.has(vehicle)) {
|
||||
skinPresets.set(vehicle, this.skinPresets(vehicle));
|
||||
}
|
||||
|
||||
hasPreset = (await skinPresets.get(vehicle)!).has(yamlEntry.preset);
|
||||
hasPreset = (await skinPresets.get(vehicle)!).has(preset);
|
||||
}
|
||||
|
||||
if (resolvedParts.some(Boolean) || hasPreset) {
|
||||
@@ -98,12 +107,15 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
attachment: camo.kind === "customization",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -72,9 +70,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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 {
|
||||
async provisionDefinitions() {
|
||||
const provisionDefinitions = ProvisionDefinitions.create();
|
||||
|
||||
Object.entries(this.provisionsCommon).forEach(([, provision]) => {
|
||||
Object.entries(this.provisionsCommon).forEach(([key, provision]) => {
|
||||
const entry: Provision = {
|
||||
id: provision.id,
|
||||
exclude: [],
|
||||
@@ -42,9 +42,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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -70,9 +68,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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,4 +14,5 @@ message Camouflage {
|
||||
optional I18nString tank_name = 3;
|
||||
optional I18nString tank_name_full = 4;
|
||||
optional uint32 tank_id = 5;
|
||||
optional bool attachment = 6;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface Camouflage {
|
||||
tank_name?: I18nString | undefined;
|
||||
tank_name_full?: I18nString | undefined;
|
||||
tank_id?: number | undefined;
|
||||
attachment?: boolean | undefined;
|
||||
}
|
||||
|
||||
function createBaseCamouflageDefinitions(): CamouflageDefinitions {
|
||||
@@ -192,7 +193,7 @@ export const CamouflageDefinitions_CamouflagesEntry: MessageFns<CamouflageDefini
|
||||
};
|
||||
|
||||
function createBaseCamouflage(): Camouflage {
|
||||
return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0 };
|
||||
return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0, attachment: false };
|
||||
}
|
||||
|
||||
export const Camouflage: MessageFns<Camouflage> = {
|
||||
@@ -212,6 +213,9 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
if (message.tank_id !== undefined && message.tank_id !== 0) {
|
||||
writer.uint32(40).uint32(message.tank_id);
|
||||
}
|
||||
if (message.attachment !== undefined && message.attachment !== false) {
|
||||
writer.uint32(48).bool(message.attachment);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -262,6 +266,14 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
message.tank_id = reader.uint32();
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 48) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.attachment = reader.bool();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -278,6 +290,7 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
tank_name: isSet(object.tank_name) ? I18nString.fromJSON(object.tank_name) : undefined,
|
||||
tank_name_full: isSet(object.tank_name_full) ? I18nString.fromJSON(object.tank_name_full) : undefined,
|
||||
tank_id: isSet(object.tank_id) ? globalThis.Number(object.tank_id) : 0,
|
||||
attachment: isSet(object.attachment) ? globalThis.Boolean(object.attachment) : false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -298,6 +311,9 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
if (message.tank_id !== undefined && message.tank_id !== 0) {
|
||||
obj.tank_id = Math.round(message.tank_id);
|
||||
}
|
||||
if (message.attachment !== undefined && message.attachment !== false) {
|
||||
obj.attachment = message.attachment;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -317,6 +333,7 @@ export const Camouflage: MessageFns<Camouflage> = {
|
||||
? I18nString.fromPartial(object.tank_name_full)
|
||||
: undefined;
|
||||
message.tank_id = object.tank_id ?? 0;
|
||||
message.attachment = object.attachment ?? false;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ConfigArchive {
|
||||
|
||||
interface EnabledPresets {
|
||||
AlphaTest: boolean;
|
||||
AlphaBlend?: boolean;
|
||||
AlphaBlendAdditive?: boolean;
|
||||
}
|
||||
|
||||
interface Flags {
|
||||
@@ -156,7 +158,11 @@ export type Component =
|
||||
"ro.notShadowOnly": boolean;
|
||||
"ro.sOclCull": boolean;
|
||||
"ro.sOclIndex": number;
|
||||
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number>;
|
||||
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number> &
|
||||
Record<
|
||||
`skinnedObject.batch${number}.targetsData`,
|
||||
ArrayBuffer | undefined
|
||||
>;
|
||||
}
|
||||
| {
|
||||
"comp.typename": "SlotComponent";
|
||||
|
||||
@@ -5,5 +5,10 @@ export type CamouflagesYaml = Record<
|
||||
shortUserString?: string;
|
||||
preset?: string;
|
||||
customEntities?: Record<string, { item: string }>;
|
||||
// nation -> tank
|
||||
override?: Record<
|
||||
string,
|
||||
{ override?: Record<string, { preset?: string }> }
|
||||
>;
|
||||
}
|
||||
>;
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,9 @@ export type ShellDefinitionsList = Record<
|
||||
normalizationAngle?: number;
|
||||
ricochetAngle?: number;
|
||||
explosionRadius?: number;
|
||||
speed: number;
|
||||
maxDistance?: number;
|
||||
piercingPower: string;
|
||||
}
|
||||
> & {
|
||||
icons: Record<string, string>;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "test",
|
||||
"long": "This is a long test string. You MUST NEVER see this in the UI. My favorite fruit is {fruit} because it has the color of {color}. I like it so much that I will say its name 3 times! {fruit}! {fruit}! {fruit}! Once again, it's {color}, not {not_color}.",
|
||||
"fox": "The quick brown fox jumps over the lazy dog."
|
||||
},
|
||||
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
|
||||
@@ -44,7 +38,7 @@
|
||||
|
||||
"branches": {
|
||||
"beta": "Beta",
|
||||
"development": "Development",
|
||||
"development": "Post Malone Edition",
|
||||
"opentest": "OpenTest",
|
||||
"preview": "Preview",
|
||||
"production": "Production"
|
||||
@@ -338,6 +332,13 @@
|
||||
},
|
||||
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
|
||||
"playlist": {
|
||||
"reset": "Reset",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -461,18 +462,6 @@
|
||||
"sandbox": {
|
||||
"load_model": "Load model",
|
||||
|
||||
"skin_events": {
|
||||
"garage": "Idle",
|
||||
"shoot": "Shoot",
|
||||
"kill": "Kill",
|
||||
"damage": "Take damage",
|
||||
"damage_enemy": "Damage enemy",
|
||||
"no_pierce": "Ricochet / No penetration",
|
||||
"move": "Start moving",
|
||||
"consumable": "Consumable",
|
||||
"reload": "Reload"
|
||||
},
|
||||
|
||||
"poses": {
|
||||
"hull_down": "Hull down",
|
||||
"face_hug": "Face hug",
|
||||
@@ -1004,11 +993,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"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "test",
|
||||
"long": "This is a long test string. You MUST NEVER see this in the UI. My favorite fruit is {fruit} because it has the color of {color}. I like it so much that I will say its name 3 times! {fruit}! {fruit}! {fruit}! Once again, it's {color}, not {not_color}.",
|
||||
"fox": "El rápido zorro marrón salta sobre el perro perezoso."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Restablecer",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "No afiliado con Wargaming u otros servicios",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"credits": "Credits",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Parches"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "teste",
|
||||
"long": "Ceci est une longue chaîne de test. Vous ne DEVEZ JAMAIS le voir dans l'interface utilisateur. Mon fruit préféré est {fruit}, car il a la couleur {color}. Je l'aime tellement que je vais dire son nom 3 fois ! {fruit}! {fruit}! {fruit} ! Encore une fois, c'est {color}, pas {not_color}.",
|
||||
"fox": "Le renard brun et rapide saute par-dessus le chien paresseux."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Reset",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Unaffiliated with Wargaming or any service",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"credits": "Credits",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Patches"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "test",
|
||||
"long": "This is a long test string. You MUST NEVER see this in the UI. My favorite fruit is {fruit} because it has the color of {color}. I like it so much that I will say its name 3 times! {fruit}! {fruit}! {fruit}! Once again, it's {color}, not {not_color}.",
|
||||
"fox": "すばしっこい茶色のキツネが怠け者の犬を飛び越した。"
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "リセット",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Wargamingやその他のサービスとは関係ありません",
|
||||
"about": {
|
||||
"title": "概要",
|
||||
"credits": "クレジット",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "パッチノート"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "test",
|
||||
"long": "This is a long test string. You MUST NEVER see this in the UI. My favorite fruit is {fruit} because it has the color of {color}. I like it so much that I will say its name 3 times! {fruit}! {fruit}! {fruit}! Once again, it's {color}, not {not_color}.",
|
||||
"fox": "Szybki brązowy lis przeskoczył nad leniwym psem."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -19,7 +14,7 @@
|
||||
"gun_types": {
|
||||
"auto_loader": "Samo ładowacz",
|
||||
"auto_reloader": "Automatyczne przeładowanie",
|
||||
"regular": "Regular"
|
||||
"regular": "Regularny"
|
||||
},
|
||||
"shells": {
|
||||
"ap": "AP",
|
||||
@@ -38,10 +33,10 @@
|
||||
},
|
||||
"branches": {
|
||||
"beta": "Beta",
|
||||
"development": "Development",
|
||||
"opentest": "OpenTest",
|
||||
"preview": "Preview",
|
||||
"production": "Production"
|
||||
"development": "Rozwój",
|
||||
"opentest": "OtwartyTest",
|
||||
"preview": "Przegląd",
|
||||
"production": "Produkcja"
|
||||
},
|
||||
"locales": {
|
||||
"en": "Angielski",
|
||||
@@ -102,28 +97,28 @@
|
||||
"other": "Hybryda"
|
||||
},
|
||||
"tank_class_short": {
|
||||
"-1": "unrecognized",
|
||||
"-1": "nierozpoznany",
|
||||
"0": "Lekki",
|
||||
"1": "Średni",
|
||||
"2": "Ciężki",
|
||||
"3": "TD"
|
||||
"3": "NC"
|
||||
},
|
||||
"tank_class_short_plural_lowercase": {
|
||||
"0": "lekkie",
|
||||
"1": "średnie",
|
||||
"2": "ciężkie",
|
||||
"3": "TDs"
|
||||
"3": "NC-ów"
|
||||
},
|
||||
"tank_class_medium": {
|
||||
"-1": "unrecognized",
|
||||
"-1": "nierozpoznany",
|
||||
"0": "Lekki",
|
||||
"1": "Średni",
|
||||
"2": "Ciężki",
|
||||
"3": "Niszczyciel czołgów"
|
||||
},
|
||||
"tree_type": {
|
||||
"-1": "unrecognized",
|
||||
"0": "Tech tree",
|
||||
"-1": "nierozpoznany",
|
||||
"0": "Drzewko technologiczne",
|
||||
"1": "Premium",
|
||||
"2": "Kolekcjonerski"
|
||||
},
|
||||
@@ -187,44 +182,44 @@
|
||||
},
|
||||
"website": {
|
||||
"redirect": {
|
||||
"title": "301: moved permanently",
|
||||
"body": "This page has moved. Redirecting..."
|
||||
"title": "301: przeniesiony na stałe",
|
||||
"body": "Ta strona została przeniesiona. Przekierowywanie..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Change BlitzKit settings",
|
||||
"dev_mode": "Development mode",
|
||||
"auto_load_models": "Auto-load models",
|
||||
"language": "Language"
|
||||
"title": "Ustawienia",
|
||||
"description": "Zmień ustawienia BlitzKit",
|
||||
"dev_mode": "Tryb deweloperski",
|
||||
"auto_load_models": "Automatycznie wczytaj modele",
|
||||
"language": "Język"
|
||||
},
|
||||
"common": {
|
||||
"player_search": {
|
||||
"hint": "Search players...",
|
||||
"no_results": "No results",
|
||||
"searching": "Searching..."
|
||||
"hint": "Szukaj graczy...",
|
||||
"no_results": "Brak wyników",
|
||||
"searching": "Wyszukiwanie..."
|
||||
},
|
||||
"copy_button": {
|
||||
"copied": "Copied!"
|
||||
"copied": "Skopiowano!"
|
||||
},
|
||||
"tank_search": {
|
||||
"select_dialog_title": "Search for tanks",
|
||||
"swap_dialog_title": "Swap tank",
|
||||
"clear_filters": "Clear filters",
|
||||
"login": "Login to use this",
|
||||
"search_bar_hint": "Search tanks...",
|
||||
"recent": "Recently viewed",
|
||||
"select_all": "Select all",
|
||||
"count_singular": "1 tank",
|
||||
"count_plural": "{count} tanks",
|
||||
"sorting_by": "Sorting by {name}",
|
||||
"clear": "Clear",
|
||||
"tier": "Tier {tier}",
|
||||
"passive": "{name} (Passive)",
|
||||
"select_dialog_title": "Szukaj czołgów",
|
||||
"swap_dialog_title": "Zamień czołg",
|
||||
"clear_filters": "Wyczyść filtry",
|
||||
"login": "Zaloguj się, aby tego użyć",
|
||||
"search_bar_hint": "Szukaj czołgów...",
|
||||
"recent": "Ostatnio oglądane",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"count_singular": "1 czołg",
|
||||
"count_plural": "{count} czołgi",
|
||||
"sorting_by": "Sortowanie według {name}",
|
||||
"clear": "Wyczyść",
|
||||
"tier": "Poziom czołgu {tier}",
|
||||
"passive": "{name} (Pasywny)",
|
||||
"active": "{name} (Aktywne)",
|
||||
"no_tanks_found": {
|
||||
"body": "Nie znaleziono czołgu.",
|
||||
"search_again": "",
|
||||
"clear_filters": "Try clearing filters."
|
||||
"clear_filters": "Spróbuj wyczyścić filtry."
|
||||
},
|
||||
"sort": {
|
||||
"meta.none": "brak",
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Resetuj",
|
||||
"shuffle": "Wymieszaj",
|
||||
@@ -443,71 +444,71 @@
|
||||
"ricochet": "Ricochet",
|
||||
"explosion_radius": "Explosion radius",
|
||||
"exit": "Exit",
|
||||
"reset": "Reset"
|
||||
"reset": "Zresetuj"
|
||||
},
|
||||
"advanced": "Advanced highlighting",
|
||||
"advanced": "Zaawansowane podświetlanie",
|
||||
"shot_card": {
|
||||
"element": {
|
||||
"primary": "Primary",
|
||||
"primary": "Główny",
|
||||
"spaced": "Spaced",
|
||||
"external": "External",
|
||||
"gap": "Gap",
|
||||
"gun": "Gun",
|
||||
"track": "Tracks"
|
||||
"external": "Zewnętrzny",
|
||||
"gap": "Odstęp",
|
||||
"gun": "Działo",
|
||||
"track": "Gąsienice"
|
||||
},
|
||||
"status": {
|
||||
"penetration": "{damage}hp penetration",
|
||||
"blocked": "Blocked",
|
||||
"ricochet": "Ricochet",
|
||||
"splash": "{damage}hp splash"
|
||||
"penetration": "{damage} penetracja punktów wytrzymałości",
|
||||
"blocked": "Zablokowany",
|
||||
"ricochet": "Rykoszet",
|
||||
"splash": "{damage} rozprysk punktów wytrzymałości"
|
||||
},
|
||||
"stats": {
|
||||
"thickness_and_angle": "{thickness}mm {angle}°",
|
||||
"nominal": "{thickness}mm nominal",
|
||||
"ricochet_loss": "{loss}% penetration"
|
||||
"thickness_and_angle": "{thickness}mm {angle}",
|
||||
"nominal": "{thickness}mm nominalny",
|
||||
"ricochet_loss": "{loss}% penetracji"
|
||||
}
|
||||
}
|
||||
},
|
||||
"static": {
|
||||
"name": "Static armor",
|
||||
"primary": "Primary",
|
||||
"spaced": "Spaced",
|
||||
"external": "External",
|
||||
"name": "Pancerz statyczny",
|
||||
"primary": "Główny Pancerz",
|
||||
"spaced": "Ekran przeciwkumulacyjny",
|
||||
"external": "Zewnętrzny",
|
||||
"edit": {
|
||||
"label": "Edit",
|
||||
"reset": "Reset"
|
||||
"label": "Edytuj",
|
||||
"reset": "Zresetuj"
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"back": "Back",
|
||||
"compare": "Compare",
|
||||
"back": "Powrót",
|
||||
"compare": "Porównaj",
|
||||
"swap": {
|
||||
"button": "Swap",
|
||||
"title": "Swap tanks"
|
||||
"button": "Zamień",
|
||||
"title": "Zamień czołgi"
|
||||
},
|
||||
"vanilla": "Vanilla",
|
||||
"opentest": "OpenTest",
|
||||
"full_name": "Full name",
|
||||
"full_name": "Pełna nazwa",
|
||||
"nation": "Naród",
|
||||
"class": "Klasa",
|
||||
"tier": "Poziom",
|
||||
"type": "Rodzaj",
|
||||
"dev_id": "DEV: ID",
|
||||
"dev_name": "DEV: NAME",
|
||||
"purchase": "Purchase price",
|
||||
"restoration": "Restoration price",
|
||||
"dev_name": "DEV: NAZWA",
|
||||
"purchase": "Cena zakupu",
|
||||
"restoration": "Cena odzyskania",
|
||||
"sale": "Sale price",
|
||||
"research": "Research XP"
|
||||
},
|
||||
"tech_tree": {
|
||||
"title": "Tech tree",
|
||||
"battle": "1 battle",
|
||||
"battles": "{count} battles",
|
||||
"route": "Route {index} of {count}"
|
||||
"title": "Drzewko technologiczne",
|
||||
"battle": "1 bitwa",
|
||||
"battles": "{count} bitew",
|
||||
"route": "Ścieżka {index} z {count}"
|
||||
},
|
||||
"game_modes": {
|
||||
"title": "Game mode abilities"
|
||||
"title": "Umiejętności trybu gry"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review videos",
|
||||
@@ -520,26 +521,26 @@
|
||||
"equalize": "Equalize"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"stock": "Stock",
|
||||
"upgrade": "Upgrade"
|
||||
"title": "Moduły",
|
||||
"stock": "Standardowy",
|
||||
"upgrade": "Ulepsz"
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Equipment",
|
||||
"clear": "Clear"
|
||||
"title": "Wyposażenie",
|
||||
"clear": "Wyczyść"
|
||||
},
|
||||
"provisions": {
|
||||
"title": "Provisions",
|
||||
"clear": "Clear"
|
||||
"title": "Materiały eksploatacyjne",
|
||||
"clear": "Wyczyść"
|
||||
},
|
||||
"consumables": {
|
||||
"title": "Consumables",
|
||||
"clear": "Clear",
|
||||
"clear": "Wyczyść",
|
||||
"info": "Yellow consumables do not affect the statistics."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Crew skills",
|
||||
"clear": "Clear",
|
||||
"clear": "Wyczyść",
|
||||
"maximize": "Maximize",
|
||||
"info": "Yellow skills only apply under special circumstances. Hold Space to quickly toggle between level 0 and 7."
|
||||
},
|
||||
@@ -557,49 +558,49 @@
|
||||
},
|
||||
"values": {
|
||||
"shell_index": "Shell {index}",
|
||||
"at_distance": "At {distance}m",
|
||||
"at_distance_slider_label": "Distance",
|
||||
"dispersion_at_distance": "Dispersion at {distance}m",
|
||||
"gun_flexibility": "Gun flexibility",
|
||||
"speed": "Speed",
|
||||
"terrain_coefficients": "Terrain coefficients",
|
||||
"raw_terrain_coefficients": "Raw terrain coefficients",
|
||||
"power_to_weight_ratio": "Power to weight ratio",
|
||||
"hull_traverse_speed": "Hull traverse speed",
|
||||
"camouflage": "Camouflage",
|
||||
"reload": "Reload",
|
||||
"crewCount": "Crew count",
|
||||
"at_distance": "Z odległości {distance}m",
|
||||
"at_distance_slider_label": "Zasięg widzenia",
|
||||
"dispersion_at_distance": "Rozrzut na {distance}m",
|
||||
"gun_flexibility": "Elastyczność działa",
|
||||
"speed": "Prędkość",
|
||||
"terrain_coefficients": "Współczynniki terenu",
|
||||
"raw_terrain_coefficients": "Surowe współczynniki terenu",
|
||||
"power_to_weight_ratio": "Stosunek mocy do masy",
|
||||
"hull_traverse_speed": "Prędkość obrotu kadłuba",
|
||||
"camouflage": "Kamuflaż",
|
||||
"reload": "Przeładowanie",
|
||||
"crewCount": "Liczba członków załogi",
|
||||
"shellNormalization": "Normalization",
|
||||
"shellRicochet": "Ricochet",
|
||||
"dpm": "DPM",
|
||||
"dpmEffective": "Effective at 60s",
|
||||
"peekabooDpm": "Peekaboo DPM",
|
||||
"peekabooDpmInfo": "Damage per minute assuming you only fire after resetting camouflage (or the reload finishes, if it's slower) — at least 10 seconds between shots.",
|
||||
"shells": "Shells",
|
||||
"mostOptimalShellIndex": "Most optimal shell",
|
||||
"shellReloads": "Reloads",
|
||||
"shellReload": "Reload",
|
||||
"intraClip": "Intra-clip",
|
||||
"dpm": "Uszkodzenia na minutę",
|
||||
"dpmEffective": "Efektywne w 60s",
|
||||
"peekabooDpm": "Obrażenia na minutę w stylu „wychyl i schowaj”",
|
||||
"peekabooDpmInfo": "Obrażenia na minutę przy założeniu, że strzelisz tylko po zresetowaniu kamuflażu (lub ponownym załadowaniu, jeśli jest wolniejszy) — co najmniej 10 sekund pomiędzy strzałami.",
|
||||
"shells": "Pociski",
|
||||
"mostOptimalShellIndex": "Optymalny pocisk",
|
||||
"shellReloads": "Przeładowania",
|
||||
"shellReload": "Przeładowanie",
|
||||
"intraClip": "Czas przeładowania pocisku",
|
||||
"caliber": "Caliber",
|
||||
"penetration": "Penetration",
|
||||
"damage": "Uszkodzenia",
|
||||
"damageWithoutAssault": "Damage without assault shells",
|
||||
"clipDamage": "Clipping potential",
|
||||
"moduleDamage": "Module damage",
|
||||
"explosionRadius": "Explosion radius",
|
||||
"shellVelocity": "Shell velocity",
|
||||
"aimTime": "Aim time",
|
||||
"damageWithoutAssault": "Uszkodzenia bez pocisków szturmowych",
|
||||
"clipDamage": "Potencjał magazynu",
|
||||
"moduleDamage": "Uszkodzenia modułów",
|
||||
"explosionRadius": "Promień eksplozji",
|
||||
"shellVelocity": "Prędkość pocisku",
|
||||
"aimTime": "Czas celowania",
|
||||
"dispersion": "Still",
|
||||
"dispersionMoving": "Moving",
|
||||
"dispersionHullTraversing": "Hull traversing",
|
||||
"dispersionTurretTraversing": "Turret traversing",
|
||||
"dispersionShooting": "Shooting",
|
||||
"dispersionGunDamaged": "Gun damaged",
|
||||
"gunDepression": "Depression",
|
||||
"gunElevation": "Elevation",
|
||||
"gunFrontalDepression": "Frontal depression",
|
||||
"gunFrontalElevation": "Frontal elevation",
|
||||
"gunRearDepression": "Rear depression",
|
||||
"dispersionMoving": "Podczas ruchu",
|
||||
"dispersionHullTraversing": "Obrót kadłuba",
|
||||
"dispersionTurretTraversing": "Obrót wieży",
|
||||
"dispersionShooting": "Strzelanie",
|
||||
"dispersionGunDamaged": "Uszkodzone Działo",
|
||||
"gunDepression": "Limit obrotu działa w dół",
|
||||
"gunElevation": "Limit obrotu działa w górę",
|
||||
"gunFrontalDepression": "Przedni limit obrotu działa w dół",
|
||||
"gunFrontalElevation": "Przedni limit obrotu działa w górę",
|
||||
"gunRearDepression": "Tylne opuszczanie działa",
|
||||
"gunRearElevation": "Rear elevation",
|
||||
"azimuthLeft": "Azimuth left",
|
||||
"azimuthRight": "Azimuth right",
|
||||
@@ -626,7 +627,7 @@
|
||||
"trackRepairTime": "Track repair time",
|
||||
"viewRange": "View range",
|
||||
"camouflageStill": "Still",
|
||||
"camouflageMoving": "Moving",
|
||||
"camouflageMoving": "Podczas ruchu",
|
||||
"camouflageShootingStill": "Shooting while still",
|
||||
"camouflageShootingMoving": "Shooting while moving",
|
||||
"camouflageCaughtOnFire": "Caught on fire",
|
||||
@@ -648,7 +649,7 @@
|
||||
"crew": {
|
||||
"title": "Crew training",
|
||||
"info": "represents the other roles a crew member can take.",
|
||||
"count": "Crew count",
|
||||
"count": "Liczba członków załogi",
|
||||
"commander": "Commander",
|
||||
"gunner": "Gunner",
|
||||
"driver": "Driver",
|
||||
@@ -663,7 +664,7 @@
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"name": "Compare",
|
||||
"name": "Porównaj",
|
||||
"button": "Compare tanks",
|
||||
"description": "Compare tank statistics",
|
||||
"seo_description": "Compare tanks statistics and loadouts in World of Tanks Blitz",
|
||||
@@ -673,7 +674,7 @@
|
||||
"title": "Add tanks",
|
||||
"description": "Select tanks to add to comparison"
|
||||
},
|
||||
"clear": "Clear",
|
||||
"clear": "Wyczyść",
|
||||
"deltas": {
|
||||
"none": "No deltas",
|
||||
"percentage": "Percentage",
|
||||
@@ -686,17 +687,17 @@
|
||||
},
|
||||
"table": {
|
||||
"equipment": {
|
||||
"clear": "Clear",
|
||||
"clear": "Wyczyść",
|
||||
"apply_to_all": "Apply to all"
|
||||
},
|
||||
"skills": {
|
||||
"clear": "Clear",
|
||||
"clear": "Wyczyść",
|
||||
"maximize": "Maximize",
|
||||
"equalize": "Equalize"
|
||||
},
|
||||
"modules": {
|
||||
"stock": "Stock",
|
||||
"upgrade": "Upgrade"
|
||||
"stock": "Standardowy",
|
||||
"upgrade": "Ulepsz"
|
||||
},
|
||||
"provisions": {
|
||||
"clear": "Clear this tank",
|
||||
@@ -761,14 +762,14 @@
|
||||
"seo_description": "Track your stats in real time as you play World of Tanks Blitz",
|
||||
"search": {
|
||||
"lookup": "Look up a player to get started",
|
||||
"hint": "Search players...",
|
||||
"searching": "Searching...",
|
||||
"hint": "Szukaj graczy...",
|
||||
"searching": "Wyszukiwanie...",
|
||||
"no_results": "No players found"
|
||||
},
|
||||
"controls": {
|
||||
"title": "Tracking {name}",
|
||||
"subtitle": "Since {time}",
|
||||
"reset": "Reset",
|
||||
"reset": "Zresetuj",
|
||||
"change": "Change"
|
||||
},
|
||||
"no_cc": {
|
||||
@@ -792,7 +793,7 @@
|
||||
"description": "Rank Blitz tanks",
|
||||
"seo_description": "Explore tier lists ranking tanks from best to worst, based on player ratings and statistical performance in World of Tanks Blitz",
|
||||
"buttons": {
|
||||
"reset": "Reset",
|
||||
"reset": "Zresetuj",
|
||||
"share": "Share"
|
||||
}
|
||||
},
|
||||
@@ -828,7 +829,7 @@
|
||||
"success": {
|
||||
"title": "Copy URL",
|
||||
"info": "Generated the URL for {name}. The embed will start tracking as soon as the URL is pasted. Reset at any time by right-clicking on PC or long-pressing on mobile and then selecting \"Reset\".",
|
||||
"back": "Back",
|
||||
"back": "Powrót",
|
||||
"copy": "Copy URL"
|
||||
}
|
||||
},
|
||||
@@ -839,7 +840,7 @@
|
||||
},
|
||||
"customize": {
|
||||
"title": "Customize",
|
||||
"reset": "Reset",
|
||||
"reset": "Zresetuj",
|
||||
"description": "Bells and whistles of your embed"
|
||||
}
|
||||
},
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Unaffiliated with Wargaming or any service",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"credits": "Credits",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Patches"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "teste",
|
||||
"long": "This is a long test string. You MUST NEVER see this in the UI. My favorite fruit is {fruit} because it has the color of {color}. I like it so much that I will say its name 3 times! {fruit}! {fruit}! {fruit}! Once again, it's {color}, not {not_color}.",
|
||||
"fox": "A ágil raposa castanha salta por cima do cão preguiçoso."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Restaurar",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Não afiliado à Wargaming ou qualquer serviço",
|
||||
"about": {
|
||||
"title": "Sobre",
|
||||
"credits": "Créditos",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Atualizações"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "тест",
|
||||
"long": "Это длинная тестовая строка. Вы НИКОГДА не должны видеть её в пользовательском интерфейсе. Мой любимый фрукт — {fruit}, потому что это цвет {color}. Он мне так нравится, так что я скажу его 3 раза {fruit}! {fruit}! {fruit}! И ещё раз, это {color}, а не {not_color}.",
|
||||
"fox": "Съешь же ещё этих мягких французских булок, да выпей чаю."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -38,10 +33,10 @@
|
||||
},
|
||||
"branches": {
|
||||
"beta": "Бета",
|
||||
"development": "Development",
|
||||
"development": "Разработка",
|
||||
"opentest": "Открытый тест",
|
||||
"preview": "Preview",
|
||||
"production": "Production"
|
||||
"preview": "Предпросмотр",
|
||||
"production": "Производство"
|
||||
},
|
||||
"locales": {
|
||||
"en": "Английский",
|
||||
@@ -194,7 +189,7 @@
|
||||
"title": "Настройки",
|
||||
"description": "Изменить настройки BlitzKit",
|
||||
"dev_mode": "Режим разработчика",
|
||||
"auto_load_models": "Auto-load models",
|
||||
"auto_load_models": "Автозагрузка моделей",
|
||||
"language": "Язык"
|
||||
},
|
||||
"common": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Сброс",
|
||||
"shuffle": "Перемешать",
|
||||
@@ -413,7 +414,7 @@
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"load_model": "Load model",
|
||||
"load_model": "Загрузить модель",
|
||||
"poses": {
|
||||
"hull_down": "За холмом",
|
||||
"face_hug": "В упор",
|
||||
@@ -574,7 +575,7 @@
|
||||
"dpm": "ДПМ",
|
||||
"dpmEffective": "Эффективность за 60с",
|
||||
"peekabooDpm": "Peekaboo DPM",
|
||||
"peekabooDpmInfo": "Damage per minute assuming you only fire after resetting camouflage (or the reload finishes, if it's slower) — at least 10 seconds between shots.",
|
||||
"peekabooDpmInfo": "Урон в минуту при условии, если вы сбросите камуфляж(или перезарядка окончена, если она медленнее) - не менее 10 секунд между выстрелами.",
|
||||
"shells": "Снарядов в барабане",
|
||||
"mostOptimalShellIndex": "Самый эффективный снаряд",
|
||||
"shellReloads": "Перезарядка",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Не связан с Wargaming или иным сервисом",
|
||||
"about": {
|
||||
"title": "О сайте",
|
||||
"credits": "Авторы",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Патчи"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "тест",
|
||||
"long": "{color}{fruit}",
|
||||
"fox": "Швидка бура лисиця перестрибує через лінивого собаку."
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "Скинути",
|
||||
"shuffle": "Перемішати",
|
||||
@@ -364,7 +365,7 @@
|
||||
"title": "Partially reveal tank?",
|
||||
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
|
||||
"cancel": "",
|
||||
"reveal": "Reveal"
|
||||
"reveal": "Рівень"
|
||||
},
|
||||
"clear": "Clear",
|
||||
"select_all": "Вибрати все",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "Unaffiliated with Wargaming or any service",
|
||||
"about": {
|
||||
"title": "About",
|
||||
"credits": "Credits",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "Patches"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"test": {
|
||||
"default": "测试",
|
||||
"long": "这是一个测试用长字符串。你绝对不会在用户界面中看见这些。我最喜欢的水果是{fruit}因为它的颜色是{color}的。我如此喜欢它,以至于我会把它的名字念3次!{fruit}!{fruit}!{fruit}!重申一次,它是{color}的而不是{not_color}的。",
|
||||
"fox": "褐色的狐狸迅速地跳过那只懒狗"
|
||||
},
|
||||
"common": {
|
||||
"name": "BlitzKit",
|
||||
"units": {
|
||||
@@ -307,6 +302,12 @@
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"credits": {
|
||||
"name": "Credits",
|
||||
"button": "View people",
|
||||
"description": "Contributors of BlitzKit",
|
||||
"seo_description": "BlitzKit is the work of many"
|
||||
},
|
||||
"playlist": {
|
||||
"reset": "重置",
|
||||
"shuffle": "换一批",
|
||||
@@ -922,7 +923,6 @@
|
||||
"copyright": "本网站与 Wargaming战游网 或任何服务无关",
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"credits": "鸣谢",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"patches": "补丁"
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<RegionsPublish>(
|
||||
"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");
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface RegionsPublish {
|
||||
Common: Common;
|
||||
}
|
||||
|
||||
interface Common {
|
||||
smartDlcUrls: string[];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Flex align="center" gap="4" direction="column" className={styles.outer}>
|
||||
<Text size="3" className={styles.important}>
|
||||
<Flex gap="2" align="center">
|
||||
<img src="https://github.com/tresabhi.png" />
|
||||
<Em>We need your attention</Em>
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<Flex direction="column" className={styles.center} gap="4">
|
||||
<Flex direction="column" gap="3">
|
||||
<Text color="tomato">
|
||||
We can no longer keep BlitzKit sustainable for <Strong>free</Strong>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
We want to add tank modules, player statistics, rebalancing patch
|
||||
notes, bring back the Discord bot, and more.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3">
|
||||
{donationTiers.map(({ name, value, link }) => (
|
||||
<a target="_blank" key={name} href={link}>
|
||||
<Button size="3" color="crimson">
|
||||
{value === null && <HeartFilledIcon />}
|
||||
{value === null ? "Custom" : `$${value}`}
|
||||
</Button>
|
||||
</a>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Code, Flex, Heading, Link, Text } from "@radix-ui/themes";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
|
||||
import { InlineSkeleton } from "./InlineSkeleton";
|
||||
|
||||
const TIME = 1792598400 * 1000;
|
||||
|
||||
export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(TIME - Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
let string: ReactNode;
|
||||
|
||||
if (timeLeft > 0) {
|
||||
const hours = Math.floor(timeLeft / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((timeLeft / (1000 * 60)) % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const seconds = Math.floor((timeLeft / 1000) % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
|
||||
string = (
|
||||
<>
|
||||
revealed in{" "}
|
||||
<Code highContrast>
|
||||
{hours}h {minutes}m {seconds}s
|
||||
</Code>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
string = "have been revealed.";
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="center" px="4">
|
||||
<Link
|
||||
color="gray"
|
||||
highContrast
|
||||
style={{ display: "content" }}
|
||||
href="https://preview.blitzkit.app/tanks"
|
||||
underline="none"
|
||||
>
|
||||
<Flex
|
||||
mt="3"
|
||||
px="5"
|
||||
py="4"
|
||||
style={{
|
||||
borderRadius: "var(--radius-3)",
|
||||
backgroundImage: "url(https://i.imgur.com/xgngxoy.png)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
boxSizing: "border-box",
|
||||
boxShadow: "0 0 var(--space-7) var(--white-a2)",
|
||||
}}
|
||||
align="center"
|
||||
gap="5"
|
||||
>
|
||||
<img
|
||||
src="https://i.imgur.com/kXV3r3P.png"
|
||||
style={{
|
||||
height: "min(15vw, 5rem)",
|
||||
filter: "drop-shadow(0.5rem 0.5rem 1rem #00000080)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex direction="column">
|
||||
<Heading size={{ initial: "4", sm: "6" }}>
|
||||
Tier XIs {!skeleton && string}{" "}
|
||||
{skeleton && <InlineSkeleton width="10rem" />}
|
||||
</Heading>
|
||||
<Text size={{ initial: "2", sm: "3" }}>
|
||||
Get ready to go to the Tankopedia!
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Link>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
---
|
||||
|
||||
<style>
|
||||
.credits {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
padding: var(--space-4);
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
width: 100%;
|
||||
max-width: 80rem;
|
||||
|
||||
.list {
|
||||
gap: var(--space-5);
|
||||
|
||||
.link {
|
||||
display: contents;
|
||||
|
||||
.person {
|
||||
--profile-size: 5rem;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: var(--space-4);
|
||||
padding-top: 0;
|
||||
margin-top: calc(var(--profile-size) / 2);
|
||||
gap: var(--space-1);
|
||||
|
||||
box-shadow: var(--shadow-3);
|
||||
border-radius: var(--radius-3);
|
||||
|
||||
.contain {
|
||||
border-radius: inherit;
|
||||
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.hint {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
filter: saturate(20) brightness(0.125) blur(4rem);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
|
||||
position: relative;
|
||||
|
||||
margin-top: calc(var(--profile-size) / -2);
|
||||
|
||||
object-fit: cover;
|
||||
width: var(--profile-size);
|
||||
height: var(--profile-size);
|
||||
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="credits">
|
||||
<div class="inner">
|
||||
<Grid
|
||||
className="list"
|
||||
py="4"
|
||||
columns="repeat(auto-fill, minmax(16rem, 1fr))"
|
||||
flow="dense"
|
||||
gap="4"
|
||||
gapY="8"
|
||||
>
|
||||
{
|
||||
credits.map((person) => (
|
||||
<a target="_blank" href={person.url} class="link">
|
||||
<div class="person">
|
||||
<div class="contain">
|
||||
<div
|
||||
class="hint"
|
||||
style={`background-image: url(${person.image})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img src={person.image} />
|
||||
|
||||
<Text weight="medium" className="text" color="gray" highContrast>
|
||||
{person.name}
|
||||
</Text>
|
||||
<Text className="text" color="gray" wrap="pretty">
|
||||
{person.description}
|
||||
</Text>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,9 +43,9 @@ const strings = getStrings(locale);
|
||||
}}
|
||||
>
|
||||
<Heading>BlitzKit</Heading>
|
||||
<Text color="gray">{strings.website.footer.motto}</Text>
|
||||
<Text size="1" color="gray">
|
||||
{strings.website.footer.copyright}
|
||||
<Text size="2" color="gray">{strings.website.footer.motto}</Text>
|
||||
<Text size="2" color="gray">
|
||||
{strings.website.footer.association}
|
||||
</Text>
|
||||
|
||||
<Box mt="2">
|
||||
@@ -84,9 +84,6 @@ const strings = getStrings(locale);
|
||||
sm: "start",
|
||||
}}
|
||||
>
|
||||
<LinkI18n {locale} href="/docs/about/credits" color="gray">
|
||||
{strings.website.footer.about.credits}
|
||||
</LinkI18n>
|
||||
<Link
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
|
||||
@@ -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";
|
||||
@@ -20,7 +20,7 @@ export interface Props {
|
||||
|
||||
const { integrated = false } = Astro.props;
|
||||
const toolsFiltered = Object.values(tools).filter(
|
||||
(tool) => !tool.href && !tool.disabled
|
||||
(tool) => !tool.href && !tool.disabled,
|
||||
);
|
||||
const { locale } = Astro.props;
|
||||
const branchName = resolveBranchName(locale);
|
||||
@@ -105,7 +105,7 @@ const strings = getStrings(locale);
|
||||
<Flex
|
||||
className="content"
|
||||
width="100%"
|
||||
p={{ initial: "4", sm: "3" }}
|
||||
p="3"
|
||||
direction="column"
|
||||
align="center"
|
||||
>
|
||||
@@ -133,41 +133,6 @@ const strings = getStrings(locale);
|
||||
|
||||
<Box flexGrow="1" />
|
||||
|
||||
<Flex
|
||||
align="center"
|
||||
gap={{ initial: "3", md: "6" }}
|
||||
display={{ initial: "none", sm: "flex" }}
|
||||
justify="center"
|
||||
className="tools"
|
||||
>
|
||||
{
|
||||
toolsFiltered.map((tool) => {
|
||||
const selected = Astro.url.pathname.includes(`/${tool.id}`);
|
||||
const toolStrings =
|
||||
strings.website.tools[
|
||||
(tool.strings ??
|
||||
tool.id) as keyof typeof strings.website.tools
|
||||
];
|
||||
|
||||
return (
|
||||
<LinkI18n
|
||||
{locale}
|
||||
weight={selected ? "medium" : "regular"}
|
||||
color="gray"
|
||||
highContrast={selected}
|
||||
size={{ initial: "1", md: "2" }}
|
||||
href={`/${tool.id}`}
|
||||
underline="hover"
|
||||
>
|
||||
{toolStrings.name}
|
||||
</LinkI18n>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Flex>
|
||||
|
||||
<Box flexGrow="1" />
|
||||
|
||||
<Flex align="center" gap="4">
|
||||
<Link
|
||||
style={{ display: "flex", alignContent: "center" }}
|
||||
@@ -191,7 +156,7 @@ const strings = getStrings(locale);
|
||||
|
||||
<NavbarAccount {locale} client:idle />
|
||||
|
||||
<Flex align="center" display={{ initial: "flex", sm: "none" }}>
|
||||
<Flex align="center">
|
||||
<IconButton variant="ghost" color="gray" className="hamburger">
|
||||
<Cross1Icon data-expanded />
|
||||
<HamburgerMenuIcon data-closed />
|
||||
@@ -200,11 +165,10 @@ const strings = getStrings(locale);
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
<Grid
|
||||
data-expanded
|
||||
columns="repeat(auto-fill, minmax(30rem, 1fr))"
|
||||
gap="3"
|
||||
display={{ initial: "flex", sm: "none" }}
|
||||
>
|
||||
{
|
||||
toolsFiltered.map((tool) => {
|
||||
@@ -229,7 +193,7 @@ const strings = getStrings(locale);
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
py="4"
|
||||
p="6"
|
||||
justify="end"
|
||||
flexGrow="1"
|
||||
style={{
|
||||
@@ -243,7 +207,7 @@ const strings = getStrings(locale);
|
||||
);
|
||||
})
|
||||
}
|
||||
</Flex>
|
||||
</Grid>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const turret = Duel.use((state) => state.protagonist.turret);
|
||||
const gun = Duel.use((state) => state.protagonist.gun);
|
||||
const modelRequested = Tankopedia.use((state) => state.modelRequested);
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
|
||||
const tankModel = modelDefinition.models[tank.id];
|
||||
const turretModel = tankModel.turrets[turret.id];
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import type { BlitzKitStrings } from "@blitzkit/i18n";
|
||||
import { Flex, Tooltip } from "@radix-ui/themes";
|
||||
import { GARAGE_EVENT } from "../../../../../../core/blitzkit/skinAnimations/runtime";
|
||||
import { useLocale } from "../../../../../../hooks/useLocale";
|
||||
import { skinAnimationEvent } from "../../../../../../hooks/useSkinAnimation";
|
||||
import { Tankopedia } from "../../../../../../stores/tankopedia";
|
||||
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
|
||||
import { SidebarIconButton } from "./SidebarIconButton";
|
||||
|
||||
type SkinEventGroup =
|
||||
keyof BlitzKitStrings["website"]["tools"]["tankopedia"]["sandbox"]["skin_events"];
|
||||
|
||||
// the camo column is one button wide
|
||||
const SKIN_COLUMN_OFFSET = {
|
||||
initial: "calc(var(--space-3) + 40px + var(--space-2))",
|
||||
sm: "calc(var(--space-3) + 48px + var(--space-2))",
|
||||
};
|
||||
|
||||
// icons are served from /icons/skin-events/[group].webp
|
||||
const SKIN_EVENT_GROUPS: {
|
||||
group: SkinEventGroup;
|
||||
events: string[];
|
||||
}[] = [
|
||||
{ group: "garage", events: [GARAGE_EVENT] },
|
||||
{
|
||||
group: "shoot",
|
||||
events: ["OnShoot", "OnShootFirstInBurst", "OnShootFirstInBurstSniper"],
|
||||
},
|
||||
{
|
||||
group: "kill",
|
||||
events: [
|
||||
"KillEnemy",
|
||||
"KillEnemyAmmoBayDestroyed",
|
||||
"KillEnemyRam",
|
||||
"OnKillEnemyAmmoBayDestroyed",
|
||||
"OnKillEnemyRam",
|
||||
],
|
||||
},
|
||||
{ group: "damage", events: ["PlayerHasDamageFromEnemy"] },
|
||||
{ group: "damage_enemy", events: ["AttackerDamagedEnemy"] },
|
||||
// camos play the same animation for both
|
||||
{
|
||||
group: "no_pierce",
|
||||
events: ["AttackerDidNotPierceEnemy", "AttackerRichochetEmeny"],
|
||||
},
|
||||
{ group: "move", events: ["OnStartMovement"] },
|
||||
{
|
||||
group: "consumable",
|
||||
events: [
|
||||
"ConsumableUsedAfterburning",
|
||||
"ConsumableUsedFreeAfterburning",
|
||||
"ConsumableUsedAbilityAfterburning",
|
||||
"ConsumableUsedImprovedAfterburning",
|
||||
],
|
||||
},
|
||||
{ group: "reload", events: ["ReloadStarted"] },
|
||||
];
|
||||
|
||||
export function SkinAnimationSwitcher() {
|
||||
const { strings } = useLocale();
|
||||
const skinEvents = Tankopedia.use((state) => state.skinEvents);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
|
||||
// one animation at a time; overlapping triggers look broken
|
||||
const disabled = Tankopedia.use((state) => state.skinAnimating);
|
||||
const groups = SKIN_EVENT_GROUPS.flatMap(({ events, ...group }) => {
|
||||
// the first event the camo listens to stands in for its group
|
||||
const event = events.find((event) => skinEvents.includes(event));
|
||||
|
||||
return event ? [{ ...group, event }] : [];
|
||||
});
|
||||
// replaying the garage animation sits apart from the battle triggers
|
||||
const sections = [
|
||||
groups.filter(({ group }) => group === "garage"),
|
||||
groups.filter(({ group }) => group !== "garage"),
|
||||
].filter((section) => section.length > 0);
|
||||
const visible =
|
||||
disturbed &&
|
||||
requestedDisplay === TankopediaDisplay.Model &&
|
||||
groups.length > 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
top="50%"
|
||||
right={visible ? SKIN_COLUMN_OFFSET : "-4rem"}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: "translateY(-50%)",
|
||||
transitionDuration: "200ms",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<Flex key={section[0].group} direction="column">
|
||||
{section.map(({ group, event }, index) => {
|
||||
const name =
|
||||
strings.website.tools.tankopedia.sandbox.skin_events[group];
|
||||
const top = index === 0 ? "var(--radius-full)" : "0";
|
||||
const bottom =
|
||||
index === section.length - 1 ? "var(--radius-full)" : "0";
|
||||
|
||||
return (
|
||||
<Tooltip key={group} content={name} side="left">
|
||||
<SidebarIconButton
|
||||
selected={false}
|
||||
alt={name}
|
||||
src={alias("api", `/icons/skin-events/${group}.webp`)}
|
||||
size={{ initial: "3", sm: "4" }}
|
||||
radius="full"
|
||||
style={{
|
||||
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
|
||||
}}
|
||||
disabled={disabled}
|
||||
imageStyle={{
|
||||
objectFit: "contain",
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
transition: "opacity 200ms",
|
||||
}}
|
||||
onClick={() => skinAnimationEvent.dispatch(event)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,10 @@ const camouflageDefinitions = await api.camouflageDefinitions();
|
||||
|
||||
export function SkinSwitcher() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const skin = Tankopedia.use((state) => state.skin);
|
||||
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
|
||||
const attachment = Tankopedia.use(
|
||||
(state) => state.loaders.attachment.requested,
|
||||
);
|
||||
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const { unwrap } = useLocale();
|
||||
@@ -24,11 +27,18 @@ export function SkinSwitcher() {
|
||||
(camouflage) => unwrap(camouflage.name!),
|
||||
);
|
||||
|
||||
// attachments aren't camouflages so they get their own group
|
||||
const groups = [
|
||||
skins.filter((camouflage) => !camouflage.attachment),
|
||||
skins.filter((camouflage) => camouflage.attachment),
|
||||
].filter((group) => group.length > 0);
|
||||
|
||||
if (skins.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
top="50%"
|
||||
right={
|
||||
disturbed && requestedDisplay === TankopediaDisplay.Model
|
||||
@@ -42,40 +52,55 @@ export function SkinSwitcher() {
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{skins.map((camouflage, index) => {
|
||||
const name = unwrap(camouflage.name!).replace(
|
||||
"%(camo_num)",
|
||||
SKIN_NUMBER,
|
||||
);
|
||||
const top = index === 0 ? "var(--radius-full)" : "0";
|
||||
const bottom = index === skins.length - 1 ? "var(--radius-full)" : "0";
|
||||
{groups.map((group) => (
|
||||
<Flex key={group[0].id} direction="column">
|
||||
{group.map((camouflage, index) => {
|
||||
const name = unwrap(camouflage.name!).replace(
|
||||
"%(camo_num)",
|
||||
SKIN_NUMBER,
|
||||
);
|
||||
const top = index === 0 ? "var(--radius-full)" : "0";
|
||||
const bottom =
|
||||
index === group.length - 1 ? "var(--radius-full)" : "0";
|
||||
|
||||
return (
|
||||
<Tooltip key={camouflage.id} content={name} side="left">
|
||||
<SidebarIconButton
|
||||
selected={skin === camouflage.id}
|
||||
alt={name}
|
||||
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
|
||||
size={{ initial: "3", sm: "4" }}
|
||||
radius="full"
|
||||
style={{
|
||||
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
|
||||
}}
|
||||
imageStyle={{
|
||||
width: "70%",
|
||||
height: "70%",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skin =
|
||||
draft.skin === camouflage.id ? undefined : camouflage.id;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Tooltip key={camouflage.id} content={name} side="left">
|
||||
<SidebarIconButton
|
||||
selected={
|
||||
(camouflage.attachment ? attachment : skin) ===
|
||||
camouflage.id
|
||||
}
|
||||
alt={name}
|
||||
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
|
||||
size={{ initial: "3", sm: "4" }}
|
||||
radius="full"
|
||||
style={{
|
||||
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
|
||||
}}
|
||||
imageStyle={{
|
||||
width: "70%",
|
||||
height: "70%",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
const loader =
|
||||
draft.loaders[
|
||||
camouflage.attachment ? "attachment" : "skin"
|
||||
];
|
||||
|
||||
loader.requested =
|
||||
loader.requested === camouflage.id
|
||||
? undefined
|
||||
: camouflage.id;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
SidebarIconButton,
|
||||
SidebarIconGroup,
|
||||
} from "./components/SidebarIconButton";
|
||||
import { SkinAnimationSwitcher } from "./components/SkinAnimationSwitcher";
|
||||
import { SkinSwitcher } from "./components/SkinSwitcher";
|
||||
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
|
||||
import { Thicknesses } from "./components/Thicknesses";
|
||||
@@ -83,7 +82,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
|
||||
const modelRequested = Tankopedia.use((state) => state.modelRequested);
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
const equalize = Duel.use((state) => state.equalize);
|
||||
const equalizer = Duel.use((state) => state.antagonist.tank.equalizer);
|
||||
|
||||
@@ -94,7 +95,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
||||
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
|
||||
|
||||
<SkinSwitcher />
|
||||
<SkinAnimationSwitcher />
|
||||
|
||||
<Flex
|
||||
gap="2"
|
||||
|
||||
@@ -13,18 +13,25 @@ const camouflageDefinitions = await api.camouflageDefinitions();
|
||||
|
||||
export function Title({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const { unwrap, strings } = useLocale();
|
||||
const modelRequested = Tankopedia.use((state) => state.modelRequested);
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
const disturbed = Tankopedia.use((state) => state.disturbed);
|
||||
const hasRevealed = useRef(false);
|
||||
const skin = Tankopedia.use((state) => state.skin);
|
||||
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
|
||||
const camouflage =
|
||||
skin === undefined ? undefined : camouflageDefinitions.camouflages[skin];
|
||||
const tankName = unwrap(protagonist.name!);
|
||||
const name = camouflage
|
||||
? `${tankName} ${unwrap(camouflage.name!).replace("%(camo_num)", SKIN_NUMBER)}`
|
||||
: tankName;
|
||||
// legendary camos carry the tank's in-battle name
|
||||
const name = (
|
||||
camouflage
|
||||
? camouflage.tank_name
|
||||
? unwrap(camouflage.tank_name)
|
||||
: `${tankName} ${unwrap(camouflage.name!)}`
|
||||
: tankName
|
||||
).replace("%(camo_num)", SKIN_NUMBER);
|
||||
const fontSize = revealed
|
||||
? disturbed
|
||||
? "1.5rem"
|
||||
@@ -102,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.modelRequested = true;
|
||||
draft.loaders.model.requested = true;
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -47,6 +47,11 @@ export function Lighting({ hasPbr }: Props) {
|
||||
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
|
||||
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
const skinPending = Tankopedia.use(
|
||||
(state) =>
|
||||
state.loaders.skin.requested !== state.loaders.skin.loaded ||
|
||||
state.loaders.attachment.requested !== state.loaders.attachment.loaded,
|
||||
);
|
||||
|
||||
const animationTime = useRef(REVEAL_ANIMATION_TIME);
|
||||
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
|
||||
@@ -60,6 +65,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
useEffect(() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = true;
|
||||
draft.loaders.model.loaded = true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -96,6 +102,15 @@ export function Lighting({ hasPbr }: Props) {
|
||||
invalidate();
|
||||
}, [revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skinPending) return;
|
||||
|
||||
animationTime.current = TRANSITION_ANIMATION_TIME;
|
||||
t0.current = performance.now() / 1e3;
|
||||
|
||||
setAnimate(true);
|
||||
}, [skinPending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContactShadows
|
||||
@@ -150,6 +165,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
key={index}
|
||||
position={position}
|
||||
intensity={intensity}
|
||||
userData={{ intensity }}
|
||||
penumbra={1}
|
||||
castShadow={highGraphics}
|
||||
decay={1}
|
||||
@@ -200,6 +216,11 @@ function Animator({
|
||||
for (const child of wrapper.current.children) {
|
||||
if (child instanceof SpotLight) {
|
||||
child.angle = ANGLE * t * factor;
|
||||
child.intensity = child.userData.intensity;
|
||||
|
||||
if (child.angle === 0) {
|
||||
child.intensity = 0;
|
||||
}
|
||||
} else if (child instanceof HemisphereLight) {
|
||||
child.intensity = HEMISPHERE_INTENSITY * t * factor;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import type { QuicklimeEvent } from "quicklime";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
|
||||
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
|
||||
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
|
||||
@@ -15,6 +16,7 @@ import { Duel } from "../../../../../../stores/duel";
|
||||
import { Tankopedia } from "../../../../../../stores/tankopedia";
|
||||
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
|
||||
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
|
||||
import { transitionEvent } from "./Lighting";
|
||||
|
||||
export function TankModel() {
|
||||
const protagonist = Duel.use((draft) => draft.protagonist);
|
||||
@@ -30,11 +32,50 @@ export function TankModel() {
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const { gltf } = useModel(protagonist.tank.id);
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
const skin = Tankopedia.use((state) => state.skin);
|
||||
const skinGltf = useSkin(protagonist.tank.id, skin);
|
||||
const skinGltf = useSkin(protagonist.tank.id);
|
||||
const attachmentGltf = useSkin(protagonist.tank.id, "attachment");
|
||||
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useSkinAnimation(skinGltf);
|
||||
useSkinAnimation(skins);
|
||||
|
||||
useEffect(() => {
|
||||
const emissiveMaterials = new Set<MeshStandardMaterial>();
|
||||
const roots = [
|
||||
gltf.scene,
|
||||
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
|
||||
];
|
||||
|
||||
for (const root of roots) {
|
||||
root.traverse((object) => {
|
||||
if (
|
||||
object instanceof Mesh &&
|
||||
object.material instanceof MeshStandardMaterial &&
|
||||
object.material.emissiveMap
|
||||
) {
|
||||
emissiveMaterials.add(object.material);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// emissive parts fade with the lights instead of glowing in the dark
|
||||
function applyIntensity(intensity: number) {
|
||||
for (const material of emissiveMaterials) {
|
||||
material.emissiveIntensity = intensity;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransitionEvent(event: QuicklimeEvent<number>) {
|
||||
applyIntensity(event.data);
|
||||
}
|
||||
|
||||
applyIntensity(transitionEvent.last!);
|
||||
transitionEvent.on(handleTransitionEvent);
|
||||
|
||||
return () => {
|
||||
transitionEvent.off(handleTransitionEvent);
|
||||
};
|
||||
}, [gltf, skinGltf, attachmentGltf]);
|
||||
|
||||
return (
|
||||
<ModelTankWrapper ref={hullContainer}>
|
||||
@@ -97,7 +138,7 @@ export function TankModel() {
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<mesh
|
||||
@@ -183,7 +224,7 @@ export function TankModel() {
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<mesh
|
||||
@@ -273,7 +314,7 @@ export function TankModel() {
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return skinTree(node, skinGltf, {
|
||||
return skinTree(node, skins, {
|
||||
mesh(_, props, key) {
|
||||
return (
|
||||
<mesh
|
||||
|
||||
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null!);
|
||||
const isFullScreen = useFullScreen();
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const modelRequested = Tankopedia.use((state) => state.modelRequested);
|
||||
const modelRequested = Tankopedia.use(
|
||||
(state) => state.loaders.model.requested,
|
||||
);
|
||||
const thicknessRange = useMemo(() => {
|
||||
const entries = Object.values(tankDefinitions.tanks);
|
||||
const filtered = entries.filter(
|
||||
|
||||
@@ -0,0 +1,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",
|
||||
},
|
||||
];
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -74,4 +74,8 @@ export const tools: Record<string, Tool> = {
|
||||
href: "https://discord.gg/nDt7AjGJQH",
|
||||
button: "plum",
|
||||
},
|
||||
credits: {
|
||||
id: "credits",
|
||||
button: "bronze",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Mesh, MeshStandardMaterial, Object3D } from "three";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
type Material,
|
||||
} from "three";
|
||||
|
||||
export function prepareMaterial(material: Material | Material[]) {
|
||||
if (!(material instanceof MeshStandardMaterial)) return;
|
||||
|
||||
material.depthWrite = true;
|
||||
|
||||
if (material.userData.blending === "additive") {
|
||||
material.blending = AdditiveBlending;
|
||||
}
|
||||
}
|
||||
|
||||
export function jsxTree(
|
||||
node: Object3D,
|
||||
@@ -25,9 +41,7 @@ export function jsxTree(
|
||||
scale: node.scale,
|
||||
};
|
||||
|
||||
if (props.material instanceof MeshStandardMaterial) {
|
||||
props.material.depthWrite = true;
|
||||
}
|
||||
prepareMaterial(props.material);
|
||||
|
||||
return mergers?.mesh ? (
|
||||
mergers.mesh(node, props, node.uuid)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Node, Scene } from "@gltf-transform/core";
|
||||
|
||||
export function addExtras(property: Node | Scene, extras: object) {
|
||||
property.setExtras({ ...property.getExtras(), ...extras });
|
||||
}
|
||||
|
||||
/** glTF extras must be plain JSON; sc2 data holds bigints */
|
||||
export function toExtras(value: unknown) {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_, item) =>
|
||||
typeof item === "bigint"
|
||||
? Number(item)
|
||||
: item instanceof ArrayBuffer
|
||||
? undefined
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,834 +0,0 @@
|
||||
import {
|
||||
AnimationMixer,
|
||||
LoopRepeat,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
type AnimationAction,
|
||||
type AnimationClip,
|
||||
} from "three";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import type { SkinMotion } from "./skeleton";
|
||||
|
||||
interface Action {
|
||||
"act.delay": number;
|
||||
"act.delayVariation": number;
|
||||
"act.entityName": string;
|
||||
"act.event": number;
|
||||
"act.eventToTrigger": string;
|
||||
"act.motionSpeed"?: number;
|
||||
"act.stopAfterNRepeats": number;
|
||||
"act.switchIndex": number;
|
||||
"act.type": number;
|
||||
"act.userEventId": string;
|
||||
}
|
||||
|
||||
interface Value {
|
||||
"any.type": string;
|
||||
"any.value": unknown;
|
||||
}
|
||||
|
||||
interface ScriptNode {
|
||||
type: string;
|
||||
node: Record<string, unknown> & { defaults?: Record<string, Value> };
|
||||
connections?: Record<
|
||||
string,
|
||||
{ pinIn: string; pinOut: string; target: string }
|
||||
>;
|
||||
}
|
||||
|
||||
interface Script {
|
||||
nodes: Record<string, ScriptNode>;
|
||||
variables?: Record<string, Value>;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
owner: Object3D;
|
||||
script: Script;
|
||||
variables: Map<string, unknown>;
|
||||
}
|
||||
|
||||
const SELF_ENTITY = "*** Self ***";
|
||||
const MAX_DEPTH = 256;
|
||||
/** events firing events, as a last guard against graphs that loop */
|
||||
const MAX_NESTING = 32;
|
||||
|
||||
const SCENE_LOADED_EVENT = 1;
|
||||
const USER_EVENT = 2;
|
||||
const ANIMATION_END_EVENTS = [3, 8];
|
||||
|
||||
const START_ANIMATION_ACTION = 6;
|
||||
const STOP_ANIMATION_ACTION = 7;
|
||||
const STATE_SWITCH_ACTION = 9;
|
||||
const TRIGGER_EVENT_ACTION = 10;
|
||||
const ANIMATING_ACTIONS = [
|
||||
START_ANIMATION_ACTION,
|
||||
STOP_ANIMATION_ACTION,
|
||||
STATE_SWITCH_ACTION,
|
||||
TRIGGER_EVENT_ACTION,
|
||||
];
|
||||
const ANIMATING_FUNCTIONS = [
|
||||
"StartAnimation",
|
||||
"StopAnimation",
|
||||
"ActivateState",
|
||||
"MotionUpdateSpeed",
|
||||
"MotionResume",
|
||||
"SetRotation",
|
||||
"TriggerCustomEvent",
|
||||
];
|
||||
// the pins graph execution leaves a node through
|
||||
const EXEC_OUTPUTS = ["event", "exit", "true", "false"];
|
||||
|
||||
/** fired once the camo is shown, like entering the garage */
|
||||
export const HANGAR_EVENTS = ["TankAliveInHangar", "TankIsShownInNewScreen"];
|
||||
/** not a game event; replays the garage animations */
|
||||
export const GARAGE_EVENT = "Garage";
|
||||
|
||||
/** events the game fires around battles, never offered as triggers */
|
||||
const IGNORED_EVENTS = [
|
||||
...HANGAR_EVENTS,
|
||||
"onBattleStart",
|
||||
"onBattleRestart",
|
||||
"TankAliveInBattle",
|
||||
"RestartFX",
|
||||
];
|
||||
|
||||
function nameOf(object: Object3D): string {
|
||||
return object.userData.name ?? object.name;
|
||||
}
|
||||
|
||||
function findChild(entity: Object3D, name: string): Object3D | undefined {
|
||||
for (const child of entity.children) {
|
||||
if (nameOf(child) === name) return child;
|
||||
|
||||
const found = findChild(child, name);
|
||||
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(owner: Object3D, entityName: string, part: Object3D) {
|
||||
if (entityName === SELF_ENTITY) return owner;
|
||||
|
||||
const [first, ...rest] = entityName.split("->");
|
||||
let start = findChild(owner, first);
|
||||
|
||||
// targets may also be an ancestor or one of its other descendants
|
||||
for (let ancestor = owner; ancestor !== part && !start; ) {
|
||||
ancestor = ancestor.parent!;
|
||||
start = nameOf(ancestor) === first ? ancestor : findChild(ancestor, first);
|
||||
}
|
||||
|
||||
return rest.reduce<Object3D | undefined>(
|
||||
(current, name) => current && findChild(current, name),
|
||||
start,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the animations of camo parts. Parts are passed as their roots because
|
||||
* they get rendered away from the loaded scene.
|
||||
*/
|
||||
export function createSkinRuntime(parts: Object3D[], clips: AnimationClip[]) {
|
||||
const mixer = new AnimationMixer(new Object3D());
|
||||
const partOf = new Map<Object3D, Object3D>();
|
||||
const scenarios: Scenario[] = [];
|
||||
const actions = new Map<Object3D, Action[]>();
|
||||
const playing = new Map<AnimationAction, Object3D>();
|
||||
const timers: [time: number, run: () => void, ambient: boolean][] = [];
|
||||
// garage animations are ambient: they don't block the menu and a trigger
|
||||
// stops them, unlike animations a trigger started
|
||||
const ambientActions = new Set<AnimationAction>();
|
||||
let ambient = false;
|
||||
const events = new Set<string>();
|
||||
const internalEvents = new Set<string>();
|
||||
const activeStates = new Map<Object3D, number>();
|
||||
const motionSpeeds = new Map<Object3D, number>();
|
||||
// reset whenever the menu starts a new session (a trigger or a replay)
|
||||
const firedAlarms = new Set<string>();
|
||||
const started = new Set<Object3D>();
|
||||
let nesting = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
part.traverse((object) => partOf.set(object, part));
|
||||
}
|
||||
|
||||
partOf.forEach((_, object) => {
|
||||
const { scenario, actions: objectActions } = object.userData;
|
||||
|
||||
if (objectActions) {
|
||||
actions.set(object, objectActions);
|
||||
|
||||
for (const action of objectActions as Action[]) {
|
||||
if (action["act.event"] === USER_EVENT) {
|
||||
events.add(action["act.userEventId"]);
|
||||
}
|
||||
if (action["act.type"] === TRIGGER_EVENT_ACTION) {
|
||||
internalEvents.add(action["act.eventToTrigger"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scenario === undefined) return;
|
||||
|
||||
const scripts = partOf.get(object)!.userData.scripts as
|
||||
| Record<string, Script>
|
||||
| undefined;
|
||||
const script = scripts?.[scenario];
|
||||
|
||||
if (!script) return;
|
||||
|
||||
scenarios.push({ owner: object, script, variables: new Map() });
|
||||
|
||||
for (const node of Object.values(script.nodes)) {
|
||||
if (node.node["ScenarioEventNode.eventName"] === "Custom") {
|
||||
events.add(node.node.defaults!["Event Name"]["any.value"] as string);
|
||||
}
|
||||
if (
|
||||
node.node["scenarioFunctionNode.functionName"] === "TriggerCustomEvent"
|
||||
) {
|
||||
internalEvents.add(
|
||||
node.node.defaults?.["event name"]?.["any.value"] as string,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mixer.addEventListener("finished", ({ action }) => {
|
||||
const entity = playing.get(action);
|
||||
|
||||
if (!entity) return;
|
||||
|
||||
playing.delete(action);
|
||||
|
||||
within(ambientActions.has(action), () => {
|
||||
for (const event of ANIMATION_END_EVENTS) fireAction(entity, event);
|
||||
|
||||
for (
|
||||
let ancestor: Object3D | null = entity;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
const owned = scenarios.filter(({ owner }) => owner === ancestor);
|
||||
|
||||
if (owned.length === 0) continue;
|
||||
|
||||
for (const scenario of owned) {
|
||||
fireScenario(scenario, "Motion Fragment Stop");
|
||||
fireScenario(scenario, "Motion Sequence Stop");
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function schedule(delay: number, run: () => void) {
|
||||
if (delay <= 0) {
|
||||
run();
|
||||
} else {
|
||||
timers.push([elapsed + delay, run, ambient]);
|
||||
}
|
||||
}
|
||||
|
||||
// runs follow-up work in the session (ambient or triggered) that caused it
|
||||
function within(isAmbient: boolean, run: () => void) {
|
||||
const previous = ambient;
|
||||
|
||||
ambient = isAmbient;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
ambient = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation(entity: Object3D, repeats?: number, speed = 1) {
|
||||
const motion = entity.userData.motion as SkinMotion | undefined;
|
||||
|
||||
if (!motion) {
|
||||
for (const child of entity.children) {
|
||||
startAnimation(child, repeats, speed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const clip = clips.find((clip) => clip.name === motion.clip);
|
||||
|
||||
// some clips restart themselves when they end; once per session keeps
|
||||
// them from looping forever
|
||||
if (!clip || started.has(entity)) return;
|
||||
|
||||
started.add(entity);
|
||||
|
||||
const action = mixer.clipAction(clip, partOf.get(entity));
|
||||
const count = repeats ?? motion.repeats;
|
||||
|
||||
action.reset();
|
||||
// endless loops play once so garage animations settle and the triggers
|
||||
// free up; the menu can replay them
|
||||
action.setLoop(LoopRepeat, count > 0 ? count : 1);
|
||||
action.clampWhenFinished = !motion.rewind;
|
||||
action.timeScale =
|
||||
motion.playbackRate * speed * (motionSpeeds.get(entity) ?? 1);
|
||||
action.play();
|
||||
playing.set(action, entity);
|
||||
|
||||
if (ambient) {
|
||||
ambientActions.add(action);
|
||||
} else {
|
||||
ambientActions.delete(action);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAnimation(entity: Object3D) {
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner !== entity) continue;
|
||||
|
||||
action.stop();
|
||||
playing.delete(action);
|
||||
}
|
||||
}
|
||||
|
||||
// graphs may set the speed before starting the motion
|
||||
function setMotionSpeed(entity: Object3D, speed: number) {
|
||||
motionSpeeds.set(entity, speed);
|
||||
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner !== entity) continue;
|
||||
|
||||
action.timeScale =
|
||||
(owner.userData.motion as SkinMotion).playbackRate * speed;
|
||||
}
|
||||
}
|
||||
|
||||
function resumeMotion(entity: Object3D) {
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner === entity) action.paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
function activateState(entity: Object3D, state: number) {
|
||||
const states = entity.userData.stateNames as string[] | undefined;
|
||||
|
||||
// re-activating the current state is a no-op; graphs rely on this
|
||||
if (!states || activeStates.get(entity) === state) return;
|
||||
|
||||
activeStates.set(entity, state);
|
||||
|
||||
for (const child of entity.children) {
|
||||
const index = states.indexOf(nameOf(child));
|
||||
|
||||
if (index !== -1) child.visible = index === state;
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.owner === entity) {
|
||||
fireScenario(scenario, "State Activated", state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(owner: Object3D, action: Action) {
|
||||
const target = resolveTarget(
|
||||
owner,
|
||||
action["act.entityName"],
|
||||
partOf.get(owner)!,
|
||||
);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
schedule(
|
||||
action["act.delay"] + Math.random() * action["act.delayVariation"],
|
||||
() => {
|
||||
switch (action["act.type"]) {
|
||||
case START_ANIMATION_ACTION:
|
||||
startAnimation(
|
||||
target,
|
||||
action["act.stopAfterNRepeats"] > 0
|
||||
? action["act.stopAfterNRepeats"]
|
||||
: undefined,
|
||||
action["act.motionSpeed"] || 1,
|
||||
);
|
||||
break;
|
||||
|
||||
case STOP_ANIMATION_ACTION:
|
||||
stopAnimation(target);
|
||||
break;
|
||||
|
||||
case STATE_SWITCH_ACTION:
|
||||
activateState(target, action["act.switchIndex"]);
|
||||
break;
|
||||
|
||||
case TRIGGER_EVENT_ACTION:
|
||||
trigger(action["act.eventToTrigger"]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function fireAction(entity: Object3D, event: number, name?: string) {
|
||||
for (const action of actions.get(entity) ?? []) {
|
||||
if (
|
||||
action["act.event"] === event &&
|
||||
(name === undefined || action["act.userEventId"] === name)
|
||||
) {
|
||||
runAction(entity, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireScenario(scenario: Scenario, event: string, value?: unknown) {
|
||||
if (nesting >= MAX_NESTING) return;
|
||||
|
||||
nesting++;
|
||||
try {
|
||||
runScenario(scenario, event, value);
|
||||
} catch (error) {
|
||||
// an unsupported graph only loses its own animations
|
||||
console.warn(`Skin scenario failed on "${event}"`, error);
|
||||
} finally {
|
||||
nesting--;
|
||||
}
|
||||
}
|
||||
|
||||
function runScenario(scenario: Scenario, event: string, value?: unknown) {
|
||||
const { nodes } = scenario.script;
|
||||
let depth = 0;
|
||||
|
||||
function input(id: string, pin: string): unknown {
|
||||
const node = nodes[id];
|
||||
const connection = Object.values(node.connections ?? {}).find(
|
||||
(connection) => connection.pinIn === pin,
|
||||
);
|
||||
|
||||
return connection
|
||||
? output(connection.target, connection.pinOut)
|
||||
: node.node.defaults?.[pin]?.["any.value"];
|
||||
}
|
||||
|
||||
function output(id: string, pin: string): unknown {
|
||||
const { type, node } = nodes[id];
|
||||
const get = (name: string) => input(id, name);
|
||||
const number = (name: string) => Number(get(name) ?? 0);
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioSceneInfoNode":
|
||||
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentScene"
|
||||
? partOf.get(scenario.owner)
|
||||
: scenario.owner;
|
||||
|
||||
case "ScenarioGetVarNode":
|
||||
return (
|
||||
scenario.variables.get(
|
||||
node["scenarioGetVarNode.varPath"] as string,
|
||||
) ?? 0
|
||||
);
|
||||
|
||||
case "ScenarioSetVarNode":
|
||||
return (
|
||||
scenario.variables.get(
|
||||
node["scenarioSetVarNode.varPath"] as string,
|
||||
) ?? 0
|
||||
);
|
||||
|
||||
case "ScenarioGetMemberNode": {
|
||||
const object = get("object") as Object3D | number[] | undefined;
|
||||
const field = node["scenarioGetMemberNode.fieldName"];
|
||||
|
||||
if (Array.isArray(object)) return field === "X" ? object[0] : 0;
|
||||
if (!object) return undefined;
|
||||
|
||||
switch (field) {
|
||||
case "stateSwitcherVisibility":
|
||||
return object.visible;
|
||||
case "parent":
|
||||
return object.parent ?? undefined;
|
||||
case "name":
|
||||
return nameOf(object);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "ScenarioFunctionNode": {
|
||||
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
|
||||
const entity = get("entity") as Object3D | undefined;
|
||||
|
||||
switch (name) {
|
||||
case "Entity.FindChildByName":
|
||||
return entity && findChild(entity, get("name") as string);
|
||||
case "Entity.FindParentByName": {
|
||||
const target = get("name") as string;
|
||||
|
||||
for (
|
||||
let ancestor = entity?.parent;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
if (nameOf(ancestor) === target) return ancestor;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "Entity.GetVisibility":
|
||||
return (get("arg0") as Object3D | undefined)?.visible ?? false;
|
||||
case "Random.Random":
|
||||
return Math.floor(Math.random() * number("n"));
|
||||
case "Random.RandomFloat":
|
||||
return Math.random();
|
||||
case "Vector4.Make":
|
||||
return [number("x"), number("y"), number("z"), number("w")];
|
||||
case "Vector4.Add":
|
||||
case "Vector4.Subtract": {
|
||||
const vector = (value: unknown) =>
|
||||
Array.isArray(value) ? (value as number[]) : [0, 0, 0, 0];
|
||||
const a = vector(get("a"));
|
||||
const b = vector(get("b"));
|
||||
const sign = name === "Vector4.Add" ? 1 : -1;
|
||||
|
||||
return a.map((value, index) => value + sign * b[index]);
|
||||
}
|
||||
case "Quaternion.MakeFastAroundX":
|
||||
case "Quaternion.MakeFastAroundY":
|
||||
return new Quaternion().setFromAxisAngle(
|
||||
name.endsWith("X")
|
||||
? new Vector3(1, 0, 0)
|
||||
: new Vector3(0, 1, 0),
|
||||
degToRad(number("deg")),
|
||||
);
|
||||
case "Quaternion.Multiply":
|
||||
return (get("a") as Quaternion | undefined)
|
||||
?.clone()
|
||||
.multiply(
|
||||
(get("b") as Quaternion | undefined) ?? new Quaternion(),
|
||||
);
|
||||
case "String.Merge":
|
||||
// graphs use Separator as the prefix and Postfix as the number
|
||||
return [get("Suffix"), get("Separator"), get("Postfix")]
|
||||
.map((part) => (part === undefined ? "" : String(part)))
|
||||
.join("");
|
||||
case "String.Compare":
|
||||
return get("1") === get("2");
|
||||
case "bool.Or":
|
||||
return Boolean(get("a")) || Boolean(get("b"));
|
||||
case "TankComponent.GetSpeed":
|
||||
case "TankComponent.GetTurretAngle":
|
||||
case "TankComponent.GetGunAngle":
|
||||
return 0;
|
||||
}
|
||||
|
||||
const operator = node["scenarioFunctionNode.functionName"] as string;
|
||||
const a = number("a");
|
||||
const b = number("b");
|
||||
|
||||
if (operator === "==") return a === b;
|
||||
if (operator === ">") return a > b;
|
||||
if (operator === ">=") return a >= b;
|
||||
if (operator === "<=") return a <= b;
|
||||
if (operator.startsWith("Add")) return a + b;
|
||||
if (operator.startsWith("Subtract")) return a - b;
|
||||
if (operator.startsWith("Multiply")) return a * b;
|
||||
if (operator.startsWith("Divide")) return b === 0 ? 0 : a / b;
|
||||
if (operator.startsWith("Lerp")) return a + (b - a) * number("t");
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function follow(id: string, pin: string) {
|
||||
for (const connection of Object.values(nodes[id].connections ?? {})) {
|
||||
if (connection.pinIn === pin) run(connection.target);
|
||||
}
|
||||
}
|
||||
|
||||
function run(id: string) {
|
||||
if (++depth > MAX_DEPTH) return;
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
const get = (name: string) => input(id, name);
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioBranchNode":
|
||||
follow(id, get("condition") ? "true" : "false");
|
||||
return;
|
||||
|
||||
case "ScenarioSetVarNode":
|
||||
scenario.variables.set(
|
||||
node["scenarioSetVarNode.varPath"] as string,
|
||||
get("set"),
|
||||
);
|
||||
follow(id, "exit");
|
||||
return;
|
||||
|
||||
case "ScenarioSetAlarmClockNode": {
|
||||
const entity =
|
||||
(get("entity") as Object3D | undefined) ?? scenario.owner;
|
||||
const alarm = `${scenarios.indexOf(scenario)}:${id}`;
|
||||
|
||||
// alarms that re-arm themselves would loop forever
|
||||
if (firedAlarms.has(alarm)) {
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
|
||||
firedAlarms.add(alarm);
|
||||
schedule(Number(get("delay") ?? 0), () => {
|
||||
for (const other of scenarios) {
|
||||
if (other.owner === entity) fireScenario(other, "Alarm Clock");
|
||||
}
|
||||
});
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
|
||||
case "ScenarioFunctionNode": {
|
||||
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
|
||||
const entity = get("entity") as Object3D | undefined;
|
||||
|
||||
switch (name) {
|
||||
case "Entity.StartAnimation":
|
||||
if (entity) {
|
||||
startAnimation(entity, Number(get("repeats") ?? 0));
|
||||
}
|
||||
break;
|
||||
case "Entity.StopAnimation":
|
||||
if (entity) stopAnimation(entity);
|
||||
break;
|
||||
case "Entity.ActivateState":
|
||||
if (entity) activateState(entity, Number(get("state") ?? 0));
|
||||
break;
|
||||
case "Entity.MotionUpdateSpeed":
|
||||
if (entity) setMotionSpeed(entity, Number(get("speed") ?? 1));
|
||||
break;
|
||||
case "Entity.MotionResume":
|
||||
if (entity) resumeMotion(entity);
|
||||
break;
|
||||
case "Entity.SetRotation": {
|
||||
const self = get("self") as Object3D | undefined;
|
||||
const value = get("value");
|
||||
|
||||
if (self && value instanceof Quaternion) {
|
||||
self.quaternion.copy(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Entity.TriggerCustomEvent":
|
||||
trigger(get("event name") as string);
|
||||
break;
|
||||
}
|
||||
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, { type, node }] of Object.entries(nodes)) {
|
||||
if (
|
||||
type !== "ScenarioEventNode" ||
|
||||
node["ScenarioEventNode.eventName"] !== event
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaults = node.defaults ?? {};
|
||||
|
||||
if (
|
||||
event === "Custom" &&
|
||||
defaults["Event Name"]?.["any.value"] !== value
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
event === "State Activated" &&
|
||||
Number(defaults.State?.["any.value"] ?? 0) !== value
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
follow(id, "event");
|
||||
}
|
||||
}
|
||||
|
||||
function trigger(name: string) {
|
||||
if (nesting >= MAX_NESTING) return;
|
||||
|
||||
nesting++;
|
||||
try {
|
||||
for (const scenario of scenarios) fireScenario(scenario, "Custom", name);
|
||||
for (const entity of actions.keys()) fireAction(entity, USER_EVENT, name);
|
||||
} finally {
|
||||
nesting--;
|
||||
}
|
||||
}
|
||||
|
||||
// start like the garage: initial states, load events, then hangar events
|
||||
function start() {
|
||||
partOf.forEach((_, object) => {
|
||||
const { stateNames: states, state } = object.userData as {
|
||||
stateNames?: string[];
|
||||
state?: string;
|
||||
};
|
||||
|
||||
if (!states) return;
|
||||
|
||||
// no state name means the switcher starts with nothing active
|
||||
activeStates.set(
|
||||
object,
|
||||
state === undefined ? -1 : states.indexOf(state),
|
||||
);
|
||||
|
||||
for (const child of object.children) {
|
||||
if (states.includes(nameOf(child))) {
|
||||
child.visible = nameOf(child) === state;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
scenario.variables = new Map(
|
||||
Object.entries(scenario.script.variables ?? {}).map(
|
||||
([name, variable]) => [name, variable["any.value"]],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entity of actions.keys()) {
|
||||
fireAction(entity, SCENE_LOADED_EVENT);
|
||||
}
|
||||
for (const scenario of scenarios) fireScenario(scenario, "Added");
|
||||
for (const event of HANGAR_EVENTS) trigger(event);
|
||||
}
|
||||
|
||||
within(true, start);
|
||||
|
||||
const hasGarageAnimation = playing.size > 0 || timers.length > 0;
|
||||
|
||||
// whether firing the event can move or switch anything; some events only
|
||||
// drive particles, which would make for buttons that do nothing
|
||||
function animates(event: string) {
|
||||
for (const entityActions of actions.values()) {
|
||||
for (const action of entityActions) {
|
||||
if (
|
||||
action["act.event"] === USER_EVENT &&
|
||||
action["act.userEventId"] === event &&
|
||||
ANIMATING_ACTIONS.includes(action["act.type"])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios.some(({ script: { nodes } }) => {
|
||||
const visited = new Set<string>();
|
||||
|
||||
function reaches(id: string): boolean {
|
||||
if (visited.has(id)) return false;
|
||||
|
||||
visited.add(id);
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
|
||||
if (
|
||||
type === "ScenarioSetAlarmClockNode" ||
|
||||
ANIMATING_FUNCTIONS.includes(
|
||||
node["scenarioFunctionNode.functionName"] as string,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(nodes[id].connections ?? {}).some(
|
||||
(connection) =>
|
||||
EXEC_OUTPUTS.includes(connection.pinIn) &&
|
||||
reaches(connection.target),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.entries(nodes).some(
|
||||
([id, { node }]) =>
|
||||
node["ScenarioEventNode.eventName"] === "Custom" &&
|
||||
node.defaults?.["Event Name"]?.["any.value"] === event &&
|
||||
reaches(id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/** events the camo reacts to that can be triggered from the menu */
|
||||
events: [...events].filter(
|
||||
(event) =>
|
||||
!IGNORED_EVENTS.includes(event) &&
|
||||
!internalEvents.has(event) &&
|
||||
animates(event),
|
||||
),
|
||||
|
||||
/** fires an event from the menu, cutting ambient animations short */
|
||||
trigger(name: string) {
|
||||
for (const action of ambientActions) {
|
||||
action.stop();
|
||||
playing.delete(action);
|
||||
}
|
||||
|
||||
ambientActions.clear();
|
||||
|
||||
for (let index = timers.length - 1; index >= 0; index--) {
|
||||
if (timers[index][2]) timers.splice(index, 1);
|
||||
}
|
||||
|
||||
firedAlarms.clear();
|
||||
started.clear();
|
||||
within(false, () => trigger(name));
|
||||
},
|
||||
|
||||
/** whether a triggered animation is playing or about to */
|
||||
busy() {
|
||||
return (
|
||||
[...playing.keys()].some((action) => !ambientActions.has(action)) ||
|
||||
timers.some(([, , isAmbient]) => !isAmbient)
|
||||
);
|
||||
},
|
||||
|
||||
/** whether the camo animates on its own in the garage */
|
||||
hasGarageAnimation,
|
||||
|
||||
/** plays the garage animations again from the start */
|
||||
replay() {
|
||||
mixer.stopAllAction();
|
||||
playing.clear();
|
||||
ambientActions.clear();
|
||||
timers.length = 0;
|
||||
firedAlarms.clear();
|
||||
started.clear();
|
||||
motionSpeeds.clear();
|
||||
within(true, start);
|
||||
},
|
||||
|
||||
/** returns whether anything is still animating */
|
||||
update(delta: number) {
|
||||
elapsed += delta;
|
||||
|
||||
for (let index = timers.length - 1; index >= 0; index--) {
|
||||
if (timers[index][0] > elapsed) continue;
|
||||
|
||||
const [[, run, isAmbient]] = timers.splice(index, 1);
|
||||
|
||||
within(isAmbient, run);
|
||||
}
|
||||
|
||||
mixer.update(delta);
|
||||
|
||||
return playing.size > 0 || timers.length > 0;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
mixer.stopAllAction();
|
||||
for (const part of parts) mixer.uncacheRoot(part);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SkinRuntime = ReturnType<typeof createSkinRuntime>;
|
||||
@@ -24,17 +24,9 @@ type SkeletonComponent = Extract<
|
||||
type MotionComponent = Extract<
|
||||
Component,
|
||||
{ "comp.typename": "MotionComponent" }
|
||||
> & { "simpleMotion.rewindOnFinish"?: boolean };
|
||||
>;
|
||||
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
|
||||
|
||||
export interface SkinMotion {
|
||||
clip: string;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
repeats: number;
|
||||
rewind: boolean;
|
||||
}
|
||||
|
||||
const JOINT_ROOT = 0xffffff;
|
||||
const MOTION_PATHS = {
|
||||
[MotionChannel.Translation]: "translation",
|
||||
@@ -60,11 +52,9 @@ export async function loadMotion(
|
||||
path: string,
|
||||
component: MotionComponent,
|
||||
) {
|
||||
const file = await vfs
|
||||
.file(normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!file) return;
|
||||
const file = await vfs.file(
|
||||
normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`),
|
||||
);
|
||||
|
||||
return new MotionReadStream(
|
||||
file.buffer.slice(
|
||||
@@ -153,8 +143,9 @@ export function createMotionClip(
|
||||
joints: Map<string, Node>,
|
||||
motion: Motion,
|
||||
component: MotionComponent,
|
||||
): SkinMotion {
|
||||
) {
|
||||
const animation = document.createAnimation(clip);
|
||||
const playbackRate = component["motion.playbackRate"];
|
||||
|
||||
for (const [uid, track] of motion.tracks) {
|
||||
const joint = joints.get(uid);
|
||||
@@ -165,7 +156,12 @@ export function createMotionClip(
|
||||
const sampler = document
|
||||
.createAnimationSampler()
|
||||
.setInput(
|
||||
createAccessor(document, buffer, "SCALAR", new Float32Array(times)),
|
||||
createAccessor(
|
||||
document,
|
||||
buffer,
|
||||
"SCALAR",
|
||||
new Float32Array(times.map((time) => time / playbackRate)),
|
||||
),
|
||||
)
|
||||
.setOutput(
|
||||
createAccessor(
|
||||
@@ -192,14 +188,6 @@ export function createMotionClip(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clip,
|
||||
duration: motion.duration,
|
||||
playbackRate: component["motion.playbackRate"],
|
||||
repeats: component["simpleMotion.repeatsCount"],
|
||||
rewind: component["simpleMotion.rewindOnFinish"] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,9 +249,17 @@ export function skinPrimitive(
|
||||
"VEC4",
|
||||
new Uint16Array(
|
||||
jointIndices.flatMap((indices) =>
|
||||
times(4, (index) =>
|
||||
index < indices.length ? targets[indices[index]] ?? 0 : 0,
|
||||
),
|
||||
times(4, (index) => {
|
||||
if (index >= indices.length) return 0;
|
||||
|
||||
const target = targets[indices[index]];
|
||||
|
||||
if (target === undefined) {
|
||||
throw new Error(`Missing joint target ${indices[index]}`);
|
||||
}
|
||||
|
||||
return target;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,19 +3,50 @@ import type { Material, Object3D } from "three";
|
||||
import type { Skin } from "../../hooks/useSkin";
|
||||
import { jsxTree } from "./jsxTree";
|
||||
|
||||
type Mergers = NonNullable<Parameters<typeof jsxTree>[1]> & {
|
||||
/** renders a customization part as is so its animations play */
|
||||
primitive?: (object: Object3D, key: string) => ReactNode;
|
||||
};
|
||||
type Mergers = Parameters<typeof jsxTree>[1];
|
||||
|
||||
// names get sanitized and made unique on load
|
||||
export function nameOf(object3d: Object3D): string {
|
||||
return object3d.userData.name ?? object3d.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* skins are layered in order, so an attachment's states win over the
|
||||
* camouflage's; `primitive` renders customization parts as they are so their
|
||||
* bones can animate
|
||||
*/
|
||||
export function skinTree(
|
||||
node: Object3D,
|
||||
skin: Skin | undefined,
|
||||
mergers: Mergers,
|
||||
skins: Skin[],
|
||||
mergers: Mergers & {
|
||||
primitive: (object: Object3D, key: string) => ReactNode;
|
||||
},
|
||||
) {
|
||||
const materials = new Map<string, Material>();
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
for (const skin of skins) {
|
||||
for (const [name, material] of skin.materials) {
|
||||
materials.set(name, material);
|
||||
}
|
||||
|
||||
Object.assign(states, skin.scene.userData.states);
|
||||
}
|
||||
|
||||
function switchChildren(object3d: Object3D) {
|
||||
if (object3d.userData.state === undefined) return;
|
||||
|
||||
const name = nameOf(object3d);
|
||||
const state = name in states ? states[name] : object3d.userData.state;
|
||||
|
||||
return object3d.children
|
||||
.filter((child) => nameOf(child) === state)
|
||||
.map((child) => jsxTree(child, skinMergers));
|
||||
}
|
||||
|
||||
const skinMergers: Mergers = {
|
||||
mesh(mesh, props, key) {
|
||||
const material = skin?.materials.get((mesh.material as Material).name);
|
||||
const material = materials.get((mesh.material as Material).name);
|
||||
|
||||
return mergers!.mesh!(
|
||||
mesh,
|
||||
@@ -24,24 +55,12 @@ export function skinTree(
|
||||
);
|
||||
},
|
||||
group(object3d, props, key) {
|
||||
const parts = skin?.parts.get(object3d.name);
|
||||
const isSwitch = object3d.userData.state !== undefined;
|
||||
const state = skin
|
||||
? skin.scene.userData.states[object3d.name]
|
||||
: object3d.userData.state;
|
||||
|
||||
return (
|
||||
<group key={key} {...props}>
|
||||
{isSwitch
|
||||
? object3d.children
|
||||
.filter((child) => child.name === state)
|
||||
.map((child) => jsxTree(child, skinMergers))
|
||||
: props.children}
|
||||
{parts?.map((part) =>
|
||||
mergers.primitive ? (
|
||||
mergers.primitive(part, part.uuid)
|
||||
) : (
|
||||
<primitive key={part.uuid} object={part} />
|
||||
{switchChildren(object3d) ?? props.children}
|
||||
{skins.flatMap((skin) =>
|
||||
(skin.parts.get(object3d.name) ?? []).map((part) =>
|
||||
mergers.primitive(part, part.uuid),
|
||||
),
|
||||
)}
|
||||
</group>
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
parseHierarchies,
|
||||
type Hierarchy,
|
||||
type Sc2ReadStream,
|
||||
} from "@blitzkit/core";
|
||||
import { times } from "lodash-es";
|
||||
|
||||
interface Value {
|
||||
"any.type": string;
|
||||
"any.value": unknown;
|
||||
}
|
||||
|
||||
interface ScriptNode {
|
||||
type: string;
|
||||
node: Record<string, unknown> & { defaults?: Record<string, Value> };
|
||||
connections?: Record<
|
||||
string,
|
||||
{ pinIn: string; pinOut: string; target: string }
|
||||
>;
|
||||
}
|
||||
|
||||
interface Script {
|
||||
nodes: Record<string, ScriptNode>;
|
||||
variables?: Record<string, Value>;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
"act.delay": number;
|
||||
"act.entityName": string;
|
||||
"act.event": number;
|
||||
"act.eventToTrigger": string;
|
||||
"act.stopAfterNRepeats": number;
|
||||
"act.switchIndex": number;
|
||||
"act.type": number;
|
||||
"act.userEventId": string;
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
name: string;
|
||||
parent?: Entity;
|
||||
children: Entity[];
|
||||
hierarchy?: Hierarchy;
|
||||
repeats?: number;
|
||||
}
|
||||
|
||||
const SELF_ENTITY = "*** Self ***";
|
||||
const SCENE_LOADED_EVENT = 1;
|
||||
const USER_EVENT = 2;
|
||||
const ANIMATION_END_EVENTS = [3, 8];
|
||||
const START_ANIMATION_ACTION = 6;
|
||||
const SWITCH_ACTIONS = [8, 9];
|
||||
const TRIGGER_EVENT_ACTION = 10;
|
||||
// what the game fires as a tank is shown in the garage, in order
|
||||
const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"];
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
function findChild(entity: Entity, name: string): Entity | undefined {
|
||||
for (const child of entity.children) {
|
||||
if (child.name === name) return child;
|
||||
|
||||
const found = findChild(child, name);
|
||||
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(owner: Entity, entityName: string) {
|
||||
if (entityName === SELF_ENTITY) return owner;
|
||||
|
||||
const [first, ...rest] = entityName.split("->");
|
||||
let target: Entity | undefined;
|
||||
|
||||
for (
|
||||
let ancestor: Entity | undefined = owner;
|
||||
ancestor && !target;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
target = ancestor.name === first ? ancestor : findChild(ancestor, first);
|
||||
}
|
||||
|
||||
for (const name of rest) target = target && findChild(target, name);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* replays what the game does as a tank is shown in the garage, with the camo's
|
||||
* preset applied and its items attached; `animate` follows customization parts
|
||||
* through their motions, delays and alarms until they settle
|
||||
*/
|
||||
function replay(
|
||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||
preset: string | undefined,
|
||||
attachments: Map<string, string[]>,
|
||||
animate: boolean,
|
||||
) {
|
||||
const scripts: Record<string, Script> = {};
|
||||
const root: Entity = { name: "", children: [] };
|
||||
const stateNames = new Map<Entity, string[]>();
|
||||
const scenarios: {
|
||||
owner: Entity;
|
||||
script: Script;
|
||||
variables: Map<string, unknown>;
|
||||
}[] = [];
|
||||
const actions: { owner: Entity; action: Action }[] = [];
|
||||
const states: Record<string, string | null> = {};
|
||||
const looping = new Set<Entity>();
|
||||
const pending: (() => void)[] = [];
|
||||
const fired = new Set<unknown>();
|
||||
|
||||
function activateState(entity: Entity | undefined, state: number) {
|
||||
const names = entity && stateNames.get(entity);
|
||||
|
||||
if (!names) return;
|
||||
|
||||
const previous = states[entity.name];
|
||||
|
||||
states[entity.name] = names[state] ?? null;
|
||||
|
||||
if (!animate || previous === states[entity.name]) return;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.owner === entity) {
|
||||
runScenario(scenario, "State Activated", state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isShown(entity: Entity) {
|
||||
for (let child = entity; child.parent; child = child.parent) {
|
||||
if (
|
||||
stateNames.get(child.parent)?.includes(child.name) &&
|
||||
states[child.parent.name] !== child.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function startAnimation(entity: Entity, repeats?: number) {
|
||||
if (entity.repeats === undefined) {
|
||||
for (const child of entity.children) startAnimation(child, repeats);
|
||||
return;
|
||||
}
|
||||
|
||||
// some clips restart themselves when they end
|
||||
if (looping.has(entity)) return;
|
||||
|
||||
looping.add(entity);
|
||||
|
||||
if ((repeats ?? entity.repeats) > 0) {
|
||||
pending.push(() => finishAnimation(entity));
|
||||
}
|
||||
}
|
||||
|
||||
function finishAnimation(entity: Entity) {
|
||||
const statesBefore = { ...states };
|
||||
const shownBefore = [...looping].filter(isShown);
|
||||
|
||||
for (const { owner, action } of actions) {
|
||||
if (
|
||||
owner === entity &&
|
||||
ANIMATION_END_EVENTS.includes(action["act.event"])
|
||||
) {
|
||||
runAction(owner, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (
|
||||
let ancestor: Entity | undefined = entity;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
const owned = scenarios.filter(({ owner }) => owner === ancestor);
|
||||
|
||||
for (const scenario of owned) {
|
||||
runScenario(scenario, "Motion Fragment Stop");
|
||||
runScenario(scenario, "Motion Sequence Stop");
|
||||
}
|
||||
|
||||
if (owned.length > 0) break;
|
||||
}
|
||||
|
||||
// an intro hands over to whatever it revealed; with nothing to hand over
|
||||
// to, it's the only animation there is so it keeps looping
|
||||
if (
|
||||
[...looping].some(
|
||||
(other) => isShown(other) && !shownBefore.includes(other),
|
||||
)
|
||||
) {
|
||||
looping.delete(entity);
|
||||
} else {
|
||||
Object.assign(states, statesBefore);
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(owner: Entity, action: Action) {
|
||||
const target = resolveTarget(owner, action["act.entityName"]);
|
||||
|
||||
if (SWITCH_ACTIONS.includes(action["act.type"])) {
|
||||
// what shows for a while in game stays, as its animation loops here
|
||||
if (action["act.delay"] > 0 && action["act.switchIndex"] < 0) return;
|
||||
|
||||
activateState(target, action["act.switchIndex"]);
|
||||
} else if (animate && action["act.type"] === START_ANIMATION_ACTION) {
|
||||
if (target) {
|
||||
const repeats = action["act.stopAfterNRepeats"];
|
||||
|
||||
startAnimation(target, repeats > 0 ? repeats : undefined);
|
||||
}
|
||||
} else if (animate && action["act.type"] === TRIGGER_EVENT_ACTION) {
|
||||
fireEvent(action["act.eventToTrigger"]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of Object.values(
|
||||
sc2["#sceneComponents"] as unknown as Record<string, unknown>,
|
||||
) as { "comp.typename"?: string; "ssc.scripts"?: Record<string, Script> }[]) {
|
||||
if (component?.["comp.typename"] !== "ScenarioSingletonComponent") continue;
|
||||
|
||||
Object.assign(scripts, component["ssc.scripts"]);
|
||||
}
|
||||
|
||||
parseHierarchies<Entity>(
|
||||
sc2["#hierarchy"],
|
||||
root,
|
||||
(hierarchy, components, parent) => {
|
||||
const entity: Entity = {
|
||||
name: hierarchy.name,
|
||||
parent,
|
||||
children: [],
|
||||
hierarchy,
|
||||
};
|
||||
|
||||
parent.children.push(entity);
|
||||
|
||||
for (const item of attachments.get(hierarchy.name) ?? []) {
|
||||
entity.children.push({ name: item, parent: entity, children: [] });
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
switch (component["comp.typename"]) {
|
||||
case "StateSwitcherComponent": {
|
||||
const names = times(
|
||||
component["ssc.statesCount"],
|
||||
(index) => component[`ssc.state${index}`],
|
||||
);
|
||||
|
||||
stateNames.set(entity, names);
|
||||
states[entity.name] = names[component["ssc.activeState"]] ?? null;
|
||||
break;
|
||||
}
|
||||
|
||||
case "ScenarioComponent": {
|
||||
const script = scripts[component["scenario.scriptId"]];
|
||||
|
||||
if (script) {
|
||||
scenarios.push({
|
||||
owner: entity,
|
||||
script,
|
||||
variables: new Map(
|
||||
Object.entries(script.variables ?? {}).map(
|
||||
([name, variable]) => [name, variable["any.value"]],
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ActionComponent":
|
||||
times(component["ac.actionCount"], (index) => {
|
||||
actions.push({
|
||||
owner: entity,
|
||||
action: component[index.toString().padStart(4, "0")] as Action,
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case "MotionComponent":
|
||||
if (animate) {
|
||||
entity.repeats = component["simpleMotion.repeatsCount"];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
},
|
||||
);
|
||||
|
||||
function runScenario(
|
||||
scenario: (typeof scenarios)[number],
|
||||
event: string,
|
||||
value?: number,
|
||||
) {
|
||||
const { owner, script, variables } = scenario;
|
||||
const { nodes } = script;
|
||||
const links = (id: string) => Object.values(nodes[id].connections ?? {});
|
||||
|
||||
function input(id: string, pin: string): unknown {
|
||||
const link = links(id).find((link) => link.pinIn === pin);
|
||||
|
||||
return link
|
||||
? output(link.target, link.pinOut)
|
||||
: nodes[id].node.defaults?.[pin]?.["any.value"];
|
||||
}
|
||||
|
||||
function output(id: string, pin: string): unknown {
|
||||
const { type, node } = nodes[id];
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioSceneInfoNode":
|
||||
switch (node["scenarioSceneInfoNode.infoKind"]) {
|
||||
case "GetCurrentEntity":
|
||||
return owner;
|
||||
case "GetCurrentScene":
|
||||
return root;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "ScenarioGetVarNode":
|
||||
return variables.get(node["scenarioGetVarNode.varPath"] as string);
|
||||
|
||||
case "ScenarioSetVarNode":
|
||||
return variables.get(node["scenarioSetVarNode.varPath"] as string);
|
||||
|
||||
case "ScenarioGetMemberNode": {
|
||||
const object = input(id, "object") as Entity | undefined;
|
||||
|
||||
switch (node["scenarioGetMemberNode.fieldName"]) {
|
||||
case "parent":
|
||||
return pin === "parent" ? object?.parent : undefined;
|
||||
case "name":
|
||||
return object?.name;
|
||||
case "stateSwitcherVisibility":
|
||||
return object && isShown(object);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "ScenarioFunctionNode": {
|
||||
const name = node["scenarioFunctionNode.functionName"] as string;
|
||||
const number = (pin: string) => Number(input(id, pin) ?? 0);
|
||||
|
||||
switch (`${node["scenarioFunctionNode.className"]}.${name}`) {
|
||||
case "Entity.FindChildByName": {
|
||||
const entity = input(id, "entity") as Entity | undefined;
|
||||
return entity && findChild(entity, input(id, "name") as string);
|
||||
}
|
||||
|
||||
case "Entity.GetVisibility": {
|
||||
const entity = input(id, "arg0") as Entity | undefined;
|
||||
return entity ? isShown(entity) : false;
|
||||
}
|
||||
|
||||
case "TankComponent.GetAppliedSkinMaterialConfigName":
|
||||
return preset;
|
||||
|
||||
case "String.Compare":
|
||||
return input(id, "1") === input(id, "2");
|
||||
|
||||
case "String.Merge":
|
||||
// graphs use Separator as the prefix and Postfix as the number
|
||||
return ["Suffix", "Separator", "Postfix"]
|
||||
.map((pin) => input(id, pin) ?? "")
|
||||
.join("");
|
||||
|
||||
case "bool.Or":
|
||||
return Boolean(input(id, "a")) || Boolean(input(id, "b"));
|
||||
|
||||
case "Random.Random":
|
||||
case "Random.RandomFloat":
|
||||
case "TankComponent.GetSpeed":
|
||||
case "TankComponent.GetTurretAngle":
|
||||
case "TankComponent.GetGunAngle":
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (name === "==") return number("a") === number("b");
|
||||
if (name === ">") return number("a") > number("b");
|
||||
if (name === ">=") return number("a") >= number("b");
|
||||
if (name === "<=") return number("a") <= number("b");
|
||||
if (name.startsWith("Add")) return number("a") + number("b");
|
||||
if (name.startsWith("Subtract")) return number("a") - number("b");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function execute(id: string, depth: number) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
let pin = "exit";
|
||||
|
||||
if (type === "ScenarioEventNode") {
|
||||
pin = "event";
|
||||
} else if (type === "ScenarioBranchNode") {
|
||||
pin = input(id, "condition") ? "true" : "false";
|
||||
} else if (type === "ScenarioSetVarNode") {
|
||||
variables.set(
|
||||
node["scenarioSetVarNode.varPath"] as string,
|
||||
input(id, "set"),
|
||||
);
|
||||
} else if (type === "ScenarioSetAlarmClockNode") {
|
||||
const entity = (input(id, "entity") as Entity | undefined) ?? owner;
|
||||
|
||||
// alarms re-arm themselves
|
||||
if (animate && !fired.has(node)) {
|
||||
fired.add(node);
|
||||
pending.push(() => {
|
||||
for (const other of scenarios) {
|
||||
if (other.owner === entity) runScenario(other, "Alarm Clock");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const entity = input(id, "entity") as Entity | undefined;
|
||||
|
||||
switch (node["scenarioFunctionNode.functionName"]) {
|
||||
case "ActivateState":
|
||||
activateState(entity, Number(input(id, "state")));
|
||||
break;
|
||||
|
||||
case "StartAnimation":
|
||||
if (animate && entity) {
|
||||
startAnimation(entity, Number(input(id, "repeats") ?? 0));
|
||||
}
|
||||
break;
|
||||
|
||||
case "TriggerCustomEvent":
|
||||
if (animate) fireEvent(input(id, "event name") as string);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of links(id)) {
|
||||
if (link.pinIn === pin) execute(link.target, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id in nodes) {
|
||||
const { type, node } = nodes[id];
|
||||
const name =
|
||||
node["ScenarioEventNode.eventName"] === "Custom"
|
||||
? node.defaults?.["Event Name"]?.["any.value"]
|
||||
: node["ScenarioEventNode.eventName"];
|
||||
|
||||
if (
|
||||
type === "ScenarioEventNode" &&
|
||||
name === event &&
|
||||
(event !== "State Activated" ||
|
||||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
|
||||
) {
|
||||
execute(id, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireEvent(event: string) {
|
||||
if (fired.has(event)) return;
|
||||
|
||||
fired.add(event);
|
||||
|
||||
for (const { owner, action } of actions) {
|
||||
if (
|
||||
(animate || action["act.delay"] === 0) &&
|
||||
(event === LOAD_EVENTS[0]
|
||||
? action["act.event"] === SCENE_LOADED_EVENT
|
||||
: action["act.event"] === USER_EVENT &&
|
||||
action["act.userEventId"] === event)
|
||||
) {
|
||||
runAction(owner, action);
|
||||
}
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) runScenario(scenario, event);
|
||||
}
|
||||
|
||||
for (const event of LOAD_EVENTS) fireEvent(event);
|
||||
|
||||
for (let run = pending.shift(); run; run = pending.shift()) run();
|
||||
|
||||
return {
|
||||
states,
|
||||
motions: new Set(
|
||||
[...looping]
|
||||
.filter(isShown)
|
||||
.map((entity) => entity.hierarchy as Hierarchy),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* picks each state switcher's state; delayed actions are skipped so states stay
|
||||
* as they are when the tank appears
|
||||
*/
|
||||
export function switcherStates(
|
||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||
preset: string | undefined,
|
||||
attachments: Map<string, string[]>,
|
||||
) {
|
||||
return replay(sc2, preset, attachments, false).states;
|
||||
}
|
||||
|
||||
/** where a customization part ends up: its states and the motions left looping */
|
||||
export function partGarage(
|
||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||
preset: string | undefined,
|
||||
) {
|
||||
return replay(sc2, preset, new Map(), true);
|
||||
}
|
||||
@@ -7,12 +7,23 @@ import {
|
||||
import type { DuelMember } from "../../stores/duel";
|
||||
import { genericDefaultEquipmentMatrix } from "../../stores/duel/constants";
|
||||
|
||||
export function defaultTankWeapon(tank: TankDefinition) {
|
||||
for (const turret of [...tank.turrets].reverse()) {
|
||||
for (const gun of [...turret.guns].reverse()) {
|
||||
const shell = gun.shells[0];
|
||||
|
||||
if (shell) return { turret, gun, shell };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Tank ${tank.id} has no gun with shells`);
|
||||
}
|
||||
|
||||
export function tankToDuelMember(
|
||||
tank: TankDefinition,
|
||||
provisionDefinitions: ProvisionDefinitions,
|
||||
) {
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const { turret, gun, shell } = defaultTankWeapon(tank);
|
||||
|
||||
return {
|
||||
camouflage: true,
|
||||
@@ -22,7 +33,7 @@ export function tankToDuelMember(
|
||||
provisions: createDefaultProvisions(tank, gun, provisionDefinitions),
|
||||
engine: tank.engines.at(-1)!,
|
||||
gun,
|
||||
shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0],
|
||||
shell,
|
||||
tank,
|
||||
track: tank.tracks.at(-1)!,
|
||||
turret,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
AnimationMixer,
|
||||
Mesh,
|
||||
SkinnedMesh,
|
||||
type AnimationClip,
|
||||
type Group,
|
||||
type Material,
|
||||
type Object3D,
|
||||
} from "three";
|
||||
import { GLTFLoader } from "three-stdlib";
|
||||
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
|
||||
import { prepareMaterial } from "../core/blitzkit/jsxTree";
|
||||
import { nameOf } from "../core/blitzkit/skinTree";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
|
||||
export interface Skin {
|
||||
scene: Group;
|
||||
materials: Map<string, Material>;
|
||||
animations: AnimationClip[];
|
||||
/** customization parts by the tank node they attach to */
|
||||
parts: Map<string, Object3D[]>;
|
||||
mixer?: AnimationMixer;
|
||||
}
|
||||
|
||||
const skins = new Map<string, Promise<Skin>>();
|
||||
@@ -28,25 +30,24 @@ async function loadSkin(path: string) {
|
||||
const materials: Material[] = await gltf.parser.getDependencies("material");
|
||||
const skin = {
|
||||
scene: gltf.scene,
|
||||
materials: new Map(materials.map((material) => [material.name, material])),
|
||||
animations: gltf.animations,
|
||||
// captured now because rendering moves the parts out of the scene
|
||||
materials: new Map(
|
||||
materials
|
||||
.filter((material) => material.userData.camouflage)
|
||||
.map((material) => [material.name, material]),
|
||||
),
|
||||
// taken now because rendering moves the parts out of the scene
|
||||
parts: new Map(
|
||||
gltf.scene.children.map((target) => [target.name, [...target.children]]),
|
||||
),
|
||||
mixer:
|
||||
gltf.animations.length > 0 ? new AnimationMixer(gltf.scene) : undefined,
|
||||
};
|
||||
|
||||
for (const parts of skin.parts.values()) {
|
||||
for (const part of parts) {
|
||||
part.traverse((child) => {
|
||||
if (!(child instanceof Mesh)) return;
|
||||
// bound now for the same reason, every exported clip loops in the garage
|
||||
for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play();
|
||||
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
// bounds are from the bind pose
|
||||
if (child instanceof SkinnedMesh) child.frustumCulled = false;
|
||||
});
|
||||
}
|
||||
for (const parts of skin.parts.values()) {
|
||||
for (const part of parts) preparePart(part, gltf.scene.userData.states);
|
||||
}
|
||||
|
||||
loadedSkins.set(path, skin);
|
||||
@@ -54,48 +55,66 @@ async function loadSkin(path: string) {
|
||||
return skin;
|
||||
}
|
||||
|
||||
function skinPath(id: number, skin: number) {
|
||||
return alias("api", `/tanks/${id}/skins/${skin}.glb`);
|
||||
}
|
||||
// parts render as they are, so what jsxTree and skinTree would do happens here
|
||||
function preparePart(part: Object3D, states: Record<string, string | null>) {
|
||||
part.traverse((object) => {
|
||||
if (object.userData.state !== undefined) {
|
||||
const name = nameOf(object);
|
||||
const state = name in states ? states[name] : object.userData.state;
|
||||
|
||||
export function useSkin(id: number, skin?: number) {
|
||||
const [loaded, setLoaded] = useState<Skin>();
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const path = skin === undefined ? undefined : skinPath(id, skin);
|
||||
const cached = path === undefined ? undefined : loadedSkins.get(path);
|
||||
let settled = false;
|
||||
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
|
||||
if (path === undefined || cached) {
|
||||
setLoaded(cached);
|
||||
return;
|
||||
for (const child of object.children) {
|
||||
child.visible = nameOf(child) === state;
|
||||
}
|
||||
}
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = false;
|
||||
});
|
||||
if (!(object instanceof Mesh)) return;
|
||||
|
||||
if (path !== undefined && !skins.has(path)) {
|
||||
skins.set(path, loadSkin(path));
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
// bounds are from the bind pose
|
||||
if (object instanceof SkinnedMesh) object.frustumCulled = false;
|
||||
|
||||
prepareMaterial(object.material);
|
||||
});
|
||||
}
|
||||
|
||||
function skinPath(id: number, skin: number) {
|
||||
return alias("models_legendary", `/${id}/skins/${skin}.glb`);
|
||||
}
|
||||
|
||||
// attachments load the same way as camouflages and can be shown with one
|
||||
export function useSkin(id: number, loader: "skin" | "attachment" = "skin") {
|
||||
const requested = Tankopedia.use((state) => state.loaders[loader].requested);
|
||||
const loaded = Tankopedia.use((state) => state.loaders[loader].loaded);
|
||||
|
||||
useEffect(() => {
|
||||
if (requested === Tankopedia.state.loaders[loader].loaded) return;
|
||||
|
||||
const path = requested === undefined ? undefined : skinPath(id, requested);
|
||||
const cached = path === undefined || loadedSkins.has(path);
|
||||
let settled = false;
|
||||
|
||||
if (!cached) {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = false;
|
||||
});
|
||||
|
||||
if (!skins.has(path)) skins.set(path, loadSkin(path));
|
||||
}
|
||||
|
||||
// swaps halfway through the lighting transition
|
||||
Promise.all([
|
||||
path === undefined ? undefined : skins.get(path),
|
||||
path && skins.get(path),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3),
|
||||
),
|
||||
]).then(([loaded]) => {
|
||||
]).then(() => {
|
||||
if (settled) return;
|
||||
|
||||
settled = true;
|
||||
setLoaded(loaded);
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = true;
|
||||
draft.loaders[loader].loaded = requested;
|
||||
if (!cached) draft.revealed = true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,11 +122,16 @@ export function useSkin(id: number, skin?: number) {
|
||||
if (settled) return;
|
||||
|
||||
settled = true;
|
||||
|
||||
if (cached) return;
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.revealed = true;
|
||||
});
|
||||
};
|
||||
}, [id, skin]);
|
||||
}, [id, loader, requested]);
|
||||
|
||||
return loaded;
|
||||
return loaded === undefined
|
||||
? undefined
|
||||
: loadedSkins.get(skinPath(id, loaded));
|
||||
}
|
||||
@@ -1,85 +1,13 @@
|
||||
import { invalidate, useFrame } from "@react-three/fiber";
|
||||
import { Quicklime, type QuicklimeEvent } from "quicklime";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
createSkinRuntime,
|
||||
GARAGE_EVENT,
|
||||
type SkinRuntime,
|
||||
} from "../core/blitzkit/skinAnimations/runtime";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import type { Skin } from "./useSkin";
|
||||
|
||||
// demand rendering can leave long gaps between frames
|
||||
const MAX_DELTA = 0.1;
|
||||
export function useSkinAnimation(skins: Skin[]) {
|
||||
useFrame((_, delta) => {
|
||||
for (const { mixer } of skins) {
|
||||
if (!mixer) continue;
|
||||
|
||||
export const skinAnimationEvent = new Quicklime<string>();
|
||||
|
||||
export function useSkinAnimation(skin?: Skin) {
|
||||
const runtime = useRef<SkinRuntime>(undefined);
|
||||
const active = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skin) return;
|
||||
|
||||
let created: SkinRuntime;
|
||||
|
||||
try {
|
||||
created = createSkinRuntime(
|
||||
[...skin.parts.values()].flat(),
|
||||
skin.animations,
|
||||
);
|
||||
} catch (error) {
|
||||
// the camo still shows, just without animations
|
||||
console.warn("Skin animations failed to start", error);
|
||||
return;
|
||||
}
|
||||
|
||||
function handleSkinAnimation(event: QuicklimeEvent<string>) {
|
||||
if (event.data === GARAGE_EVENT) {
|
||||
created.replay();
|
||||
} else {
|
||||
created.trigger(event.data);
|
||||
}
|
||||
|
||||
active.current = true;
|
||||
mixer.update(delta);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
runtime.current = created;
|
||||
active.current = true;
|
||||
skinAnimationEvent.on(handleSkinAnimation);
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skinEvents = created.hasGarageAnimation
|
||||
? [GARAGE_EVENT, ...created.events]
|
||||
: created.events;
|
||||
});
|
||||
invalidate();
|
||||
|
||||
return () => {
|
||||
skinAnimationEvent.off(handleSkinAnimation);
|
||||
created.dispose();
|
||||
runtime.current = undefined;
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skinEvents = [];
|
||||
draft.skinAnimating = false;
|
||||
});
|
||||
};
|
||||
}, [skin]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!runtime.current || !active.current) return;
|
||||
|
||||
active.current = runtime.current.update(Math.min(delta, MAX_DELTA));
|
||||
|
||||
const busy = runtime.current.busy();
|
||||
|
||||
// only on change so the store doesn't update every frame
|
||||
if (busy !== Tankopedia.state.skinAnimating) {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skinAnimating = busy;
|
||||
});
|
||||
}
|
||||
|
||||
if (active.current) invalidate();
|
||||
});
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { APIContext } from "astro";
|
||||
import sharp from "sharp";
|
||||
import { extractPackedIcon } from "../../../../core/blitz/extractPackedIcon";
|
||||
import { parsePackedSpriteRect } from "../../../../core/blitz/parsePackedSpriteRect";
|
||||
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
|
||||
import { vfs } from "../../../../core/blitzkit/vfs";
|
||||
import { getStaticPaths as _getStaticPaths } from "../../_index";
|
||||
|
||||
interface Icon {
|
||||
path: string;
|
||||
/** sprite inside a texture0 atlas described by a .txt file */
|
||||
atlas: boolean;
|
||||
}
|
||||
|
||||
// white game icons for the camo animation triggers
|
||||
const ICONS: Record<string, Icon> = {
|
||||
garage: { path: "Lobby/icons/platoon/icon_hangar_m", atlas: false },
|
||||
shoot: { path: "UI/BattleScreenHUD/icon_aim-battle_m", atlas: true },
|
||||
kill: {
|
||||
path: "UI/BattleScreenHUD/icon_battle-chat_kill-type_default",
|
||||
atlas: true,
|
||||
},
|
||||
damage: { path: "UI/BattleScreenHUD/icon_damage_shadow_m", atlas: true },
|
||||
damage_enemy: {
|
||||
path: "UI/BattleScreenHUD/icon_attack-battle_m",
|
||||
atlas: true,
|
||||
},
|
||||
no_pierce: {
|
||||
path: "UI/BattleScreenHUD/icon_shooting-fb_shield_m",
|
||||
atlas: true,
|
||||
},
|
||||
move: { path: "Lobby/icons/icon_speed_s", atlas: false },
|
||||
consumable: { path: "Lobby/icons/icon_consumable_l", atlas: false },
|
||||
reload: { path: "Lobby/icons/icon_rate-of-fire_s", atlas: false },
|
||||
};
|
||||
|
||||
export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () =>
|
||||
Object.keys(ICONS).map((event) => ({ params: { event }, props: { event } })),
|
||||
);
|
||||
|
||||
export async function GET({ props }: APIContext<{ event: string }>) {
|
||||
const { path, atlas } = ICONS[props.event];
|
||||
let buffer: Buffer;
|
||||
|
||||
if (atlas) {
|
||||
const directory = path.split("/").slice(0, -1).join("/");
|
||||
const sizes = (await vfs.text(`Data/Gfx/${path}@2x.txt`))
|
||||
.split("\n")[4]
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map(Number);
|
||||
|
||||
buffer = await extractPackedIcon(
|
||||
sharp(await vfs.file(`Data/Gfx/${directory}/[email protected]`)),
|
||||
sizes.slice(0, 4),
|
||||
);
|
||||
} else {
|
||||
const packedBuffer = await vfs.file(`Data/Gfx/${path}@2x.packed.webp`);
|
||||
const sizes = parsePackedSpriteRect(packedBuffer);
|
||||
|
||||
buffer = sizes
|
||||
? await extractPackedIcon(sharp(packedBuffer), sizes)
|
||||
: packedBuffer;
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(await sharp(buffer).webp().toBuffer()));
|
||||
}
|
||||
@@ -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;
|
||||
---
|
||||
|
||||
<ToolLayout tool={tools.credits} {locale}>
|
||||
<CreditsPromo {locale} />
|
||||
</ToolLayout>
|
||||
@@ -5,6 +5,7 @@ import locales from "@blitzkit/i18n/locales.json";
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import { Box, Flex, Link } from "@radix-ui/themes";
|
||||
import { AesonPlug } from "../../../components/AesonPlug";
|
||||
import { Beg } from "../../../components/Beg";
|
||||
import HomeHero from "../../../components/HomeHero.astro";
|
||||
import { Plugs } from "../../../components/Plugs";
|
||||
import ToolCard from "../../../components/ToolCard.astro";
|
||||
@@ -65,6 +66,8 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, () => {
|
||||
<BaseLayout {locale}>
|
||||
<HomeHero />
|
||||
|
||||
<Beg />
|
||||
|
||||
{
|
||||
locale !== locales.default && (
|
||||
<Flex justify="center" pt="4" my="4">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { load } from "cheerio";
|
||||
import { api } from "../../../../../core/blitzkit/api";
|
||||
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
|
||||
import { tankCharacteristics } from "../../../../../core/blitzkit/tankCharacteristics";
|
||||
import { defaultTankWeapon } from "../../../../../core/blitzkit/tankToDuelMember";
|
||||
import { getStrings } from "../../../../../core/i18n/getStrings";
|
||||
import BaseLayout from "../../../../../layouts/BaseLayout.astro";
|
||||
import { genericDefaultEquipmentMatrix } from "../../../../../stores/duel/constants";
|
||||
@@ -53,9 +54,7 @@ const title = literals(strings.website.tools.tankopedia.tank_seo_title, {
|
||||
const url = import.meta.env.SITE + Astro.url.pathname;
|
||||
|
||||
const icon = alias("api", `/tanks/${id}/icons/big.webp`);
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const shell = gun.shells[0];
|
||||
const { turret, gun, shell } = defaultTankWeapon(tank);
|
||||
const characteristics = tankCharacteristics(
|
||||
{
|
||||
tank,
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type TankParameters,
|
||||
} from "@blitzkit/core";
|
||||
import {
|
||||
Animation,
|
||||
Document,
|
||||
Material,
|
||||
Node,
|
||||
NodeIO,
|
||||
PropertyType,
|
||||
@@ -17,13 +17,19 @@ 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 type { SkinMotion } from "../../../../../core/blitzkit/skinAnimations/skeleton";
|
||||
import { vfs } from "../../../../../core/blitzkit/vfs";
|
||||
import { getStaticPaths as _getStaticPaths } from "../../../_index";
|
||||
import { extractMaterials, extractModel } from "../model.glb";
|
||||
import { SKIN_NUMBER } from "../../../../constants/skinNumber";
|
||||
import { api } from "../../../../core/blitzkit/api";
|
||||
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
|
||||
import {
|
||||
partGarage,
|
||||
switcherStates,
|
||||
} from "../../../../core/blitzkit/switcherStates";
|
||||
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;
|
||||
@@ -66,8 +72,10 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
const [nation, tankKey] = (
|
||||
include as { vehicle: { name: string } }
|
||||
).vehicle.name.split(":");
|
||||
const { preset, customEntities } = camouflagesYaml[key];
|
||||
const skinNumber = Number(preset?.match(/(\d+)$/)?.[1] ?? 1);
|
||||
const { customEntities } = camouflagesYaml[key];
|
||||
const preset =
|
||||
camouflagesYaml[key].preset ??
|
||||
camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset;
|
||||
const parameters = await vfs.yaml<TankParameters>(
|
||||
`Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
|
||||
);
|
||||
@@ -87,7 +95,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
> = {};
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
|
||||
for (const component of components) {
|
||||
@@ -110,43 +117,50 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
scale: component["sc.attachmentScale"] as Vector3Tuple,
|
||||
};
|
||||
}
|
||||
|
||||
if (component["comp.typename"] !== "StateSwitcherComponent") continue;
|
||||
|
||||
const active = component[`ssc.state${component["ssc.activeState"]}`];
|
||||
// camos without their own variant keep the default piece, but groups
|
||||
// of elements to hide stay hidden
|
||||
let state: string | null =
|
||||
active && !/skin\d*/.test(active) && !active.includes("hide")
|
||||
? active
|
||||
: null;
|
||||
|
||||
for (let index = 0; index < component["ssc.statesCount"]; index++) {
|
||||
const name = component[`ssc.state${index}`];
|
||||
const match = name.match(/skin(\d*)/);
|
||||
|
||||
if (
|
||||
match &&
|
||||
!name.includes("hide") &&
|
||||
Number(match[1] || 1) === skinNumber
|
||||
) {
|
||||
state = name;
|
||||
}
|
||||
}
|
||||
|
||||
states[hierarchy.name] = state;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const attachments = new Map<string, string[]>();
|
||||
|
||||
for (const name in customEntities) {
|
||||
const slot = slots[name];
|
||||
|
||||
if (!slot) continue;
|
||||
|
||||
attachments.set(slot.target, [
|
||||
...(attachments.get(slot.target) ?? []),
|
||||
customEntities[name].item,
|
||||
]);
|
||||
}
|
||||
|
||||
const states = switcherStates(sc2, preset, attachments);
|
||||
|
||||
// attachments are layered over camouflages, so they only keep what they change
|
||||
if (camouflagesXml.root.camouflages[key].kind === "customization") {
|
||||
const baseline = switcherStates(sc2, preset, new Map());
|
||||
|
||||
for (const name in states) {
|
||||
if (states[name] === baseline[name]) delete states[name];
|
||||
}
|
||||
}
|
||||
const document = new Document();
|
||||
const scene = document.createScene().setExtras({ states });
|
||||
const scene = document.createScene();
|
||||
const targets = new Map<string, Node>();
|
||||
|
||||
document.createBuffer();
|
||||
|
||||
if (preset) await extractMaterials(document, sc2, basePath, preset);
|
||||
if (preset) {
|
||||
const materials = await extractMaterials(document, sc2, basePath, preset);
|
||||
|
||||
// parts may share these names, so mark the ones that replace the tank's
|
||||
for (const material of materials.values()) {
|
||||
if (!(material instanceof Material)) continue;
|
||||
|
||||
material.setExtras({ ...material.getExtras(), camouflage: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const name in customEntities) {
|
||||
const slot = slots[name];
|
||||
@@ -161,33 +175,20 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const part = await extractModel(vfs, path, true);
|
||||
const part = await extractModel(vfs, path, true, preset);
|
||||
const partSc2 = new Sc2ReadStream(
|
||||
(await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer,
|
||||
).sc2();
|
||||
|
||||
Object.assign(states, partGarage(partSc2, preset).states);
|
||||
|
||||
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)
|
||||
.setExtras(partScene.getExtras());
|
||||
|
||||
// clip names repeat across parts
|
||||
for (const animation of part.getRoot().listAnimations()) {
|
||||
(map.get(animation) as Animation).setName(
|
||||
`${name}:${animation.getName()}`,
|
||||
);
|
||||
}
|
||||
|
||||
partScene.traverse((child) => {
|
||||
const { motion } = child.getExtras() as { motion?: SkinMotion };
|
||||
|
||||
if (motion) {
|
||||
child.setExtras({
|
||||
...child.getExtras(),
|
||||
motion: { ...motion, clip: `${name}:${motion.clip}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
.setScale(slot.scale);
|
||||
let target = targets.get(slot.target);
|
||||
|
||||
if (!target) {
|
||||
@@ -204,6 +205,8 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
target.addChild(node);
|
||||
}
|
||||
|
||||
scene.setExtras({ states });
|
||||
|
||||
applySkinNumber(document, SKIN_NUMBER);
|
||||
|
||||
await document.transform(
|
||||
@@ -0,0 +1,3 @@
|
||||
import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths";
|
||||
|
||||
export const getStaticPaths = chunkedStaticPaths("models_legendary");
|
||||
@@ -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 },
|
||||
}));
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
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 { vfs } from "../../../../core/blitzkit/vfs";
|
||||
import { vfs } from "../../../core/blitzkit/vfs";
|
||||
|
||||
export { getStaticPaths } from "./_index";
|
||||
|
||||
@@ -28,19 +28,16 @@ import type { APIContext } from "astro";
|
||||
import { isEqual, times } from "lodash-es";
|
||||
import { dirname } from "path";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
addExtras,
|
||||
toExtras,
|
||||
} from "../../../../core/blitzkit/skinAnimations/extras";
|
||||
import type { Motion } from "../../../../core/blitzkit/skinAnimations/motion";
|
||||
import type { Motion } from "../../../core/blitzkit/skinAnimations/motion";
|
||||
import {
|
||||
bindSocket,
|
||||
createMotionClip,
|
||||
createSkeleton,
|
||||
loadMotion,
|
||||
skinPrimitive,
|
||||
} from "../../../../core/blitzkit/skinAnimations/skeleton";
|
||||
import { vfs } from "../../../../core/blitzkit/vfs";
|
||||
} from "../../../core/blitzkit/skinAnimations/skeleton";
|
||||
import { partGarage } from "../../../core/blitzkit/switcherStates";
|
||||
import { vfs } from "../../../core/blitzkit/vfs";
|
||||
import {
|
||||
vertexAttributeGLTFName,
|
||||
vertexAttributeGltfVectorSizes,
|
||||
@@ -85,7 +82,13 @@ export async function GET({ props }: APIContext<{ id: number }>) {
|
||||
const ERROR_ON_UNKNOWN_COMPONENT = false;
|
||||
|
||||
const omitMeshNames = {
|
||||
start: ["chassis_chassis_", "chassis_track_crash_", "HP_"],
|
||||
start: [
|
||||
"chassis_chassis_",
|
||||
"chassis_track_crash_",
|
||||
"HP_",
|
||||
// mastery marks a new tank doesn't have yet
|
||||
"custom_gun_marks_",
|
||||
],
|
||||
end: ["_POINT"],
|
||||
};
|
||||
|
||||
@@ -93,6 +96,7 @@ export async function extractModel(
|
||||
vfs: AbstractVFS,
|
||||
path: string,
|
||||
isPart = false,
|
||||
preset?: string,
|
||||
) {
|
||||
const sc2Path = `Data/3d/${path}.sc2`;
|
||||
const scgPath = `Data/3d/${path}.scg`;
|
||||
@@ -114,6 +118,7 @@ export async function extractModel(
|
||||
skin: Skin,
|
||||
][] = [];
|
||||
const motions = new Map<Hierarchy, Motion>();
|
||||
const garageMotions = isPart ? partGarage(sc2, preset).motions : new Set();
|
||||
|
||||
if (isPart) {
|
||||
const loading: Promise<void>[] = [];
|
||||
@@ -124,7 +129,7 @@ export async function extractModel(
|
||||
|
||||
loading.push(
|
||||
loadMotion(vfs, path, component).then((motion) => {
|
||||
if (motion) motions.set(hierarchy, motion);
|
||||
motions.set(hierarchy, motion);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -133,14 +138,6 @@ export async function extractModel(
|
||||
});
|
||||
|
||||
await Promise.all(loading);
|
||||
|
||||
for (const component of Object.values(
|
||||
(sc2["#sceneComponents"] ?? {}) as Record<string, Record<string, any>>,
|
||||
)) {
|
||||
if (component["comp.typename"] === "ScenarioSingletonComponent") {
|
||||
scene.setExtras({ scripts: toExtras(component["ssc.scripts"]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseHierarchies<Scene | Node>(
|
||||
@@ -162,7 +159,7 @@ export async function extractModel(
|
||||
const motionComponent = components.find(
|
||||
(component) => component["comp.typename"] === "MotionComponent",
|
||||
);
|
||||
const motion = motionComponent ? motions.get(hierarchy) : undefined;
|
||||
const motion = motions.get(hierarchy);
|
||||
|
||||
if (skeleton) {
|
||||
const created = createSkeleton(
|
||||
@@ -175,17 +172,16 @@ export async function extractModel(
|
||||
|
||||
skeletons.set(hierarchy, created);
|
||||
|
||||
if (motion && motionComponent) {
|
||||
addExtras(node, {
|
||||
motion: createMotionClip(
|
||||
document,
|
||||
buffer,
|
||||
`motion${document.getRoot().listAnimations().length}`,
|
||||
created.joints,
|
||||
motion,
|
||||
motionComponent,
|
||||
),
|
||||
});
|
||||
// only what loops in the garage is exported
|
||||
if (motion && motionComponent && garageMotions.has(hierarchy)) {
|
||||
createMotionClip(
|
||||
document,
|
||||
buffer,
|
||||
`motion${document.getRoot().listAnimations().length}`,
|
||||
created.joints,
|
||||
motion,
|
||||
motionComponent,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,37 +208,9 @@ export async function extractModel(
|
||||
}
|
||||
|
||||
case "StateSwitcherComponent":
|
||||
addExtras(node, {
|
||||
node.setExtras({
|
||||
state: component[`ssc.state${component["ssc.activeState"]}`],
|
||||
});
|
||||
|
||||
if (isPart) {
|
||||
addExtras(node, {
|
||||
stateNames: times(
|
||||
component["ssc.statesCount"],
|
||||
(index) => component[`ssc.state${index}`],
|
||||
),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "ActionComponent":
|
||||
if (!isPart) break;
|
||||
|
||||
addExtras(node, {
|
||||
actions: toExtras(
|
||||
times(
|
||||
component["ac.actionCount"],
|
||||
(index) => component[index.toString().padStart(4, "0")],
|
||||
),
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case "ScenarioComponent":
|
||||
if (!isPart) break;
|
||||
|
||||
addExtras(node, { scenario: component["scenario.scriptId"] });
|
||||
break;
|
||||
|
||||
case "SkeletonJointBindingComponent": {
|
||||
@@ -256,14 +224,14 @@ export async function extractModel(
|
||||
] as unknown as { entityUid: string; jointUid: string };
|
||||
const joint = skeleton.joints.get(binding.jointUid);
|
||||
|
||||
if (joint) {
|
||||
jointBindings.push([
|
||||
node,
|
||||
binding.entityUid,
|
||||
joint,
|
||||
skeleton.skin,
|
||||
]);
|
||||
}
|
||||
if (!joint) throw new Error(`Missing joint ${binding.jointUid}`);
|
||||
|
||||
jointBindings.push([
|
||||
node,
|
||||
binding.entityUid,
|
||||
joint,
|
||||
skeleton.skin,
|
||||
]);
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -379,7 +347,9 @@ export async function extractModel(
|
||||
for (const [owner, entity, joint, skin] of jointBindings) {
|
||||
const socket = findNode(owner, entity);
|
||||
|
||||
if (socket) bindSocket(socket, joint, skin);
|
||||
if (!socket) throw new Error(`Missing socket ${entity}`);
|
||||
|
||||
bindSocket(socket, joint, skin);
|
||||
}
|
||||
|
||||
scene.addChild(document.createNode("test"));
|
||||
@@ -518,6 +488,27 @@ export async function extractMaterials(
|
||||
}
|
||||
}
|
||||
|
||||
const enabledPresets =
|
||||
node.enabledPresets ?? defaultConfigArchive?.enabledPresets;
|
||||
const fxName = node.fxName ?? defaultConfigArchive?.fxName;
|
||||
|
||||
if (enabledPresets?.AlphaBlend || enabledPresets?.AlphaBlendAdditive) {
|
||||
material.setAlphaMode("BLEND");
|
||||
}
|
||||
|
||||
if (enabledPresets?.AlphaBlendAdditive) {
|
||||
// gltf has no additive blending so the viewer applies it
|
||||
material.setExtras({ blending: "additive" });
|
||||
}
|
||||
|
||||
if (fxName?.endsWith("/Textured.material")) {
|
||||
// unlit in game, emissive so the viewer can fade it with the lights
|
||||
material
|
||||
.setEmissiveTexture(material.getBaseColorTexture())
|
||||
.setEmissiveFactor([1, 1, 1])
|
||||
.setBaseColorFactor([0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
if (textures.miscMap) {
|
||||
material.setOcclusionTexture(
|
||||
document
|
||||
@@ -0,0 +1,3 @@
|
||||
import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths";
|
||||
|
||||
export const getStaticPaths = chunkedStaticPaths("models_tanks");
|
||||
@@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst {
|
||||
All,
|
||||
}
|
||||
|
||||
interface LoaderState<Type> {
|
||||
requested: Type;
|
||||
loaded: Type;
|
||||
}
|
||||
|
||||
interface Tankopedia {
|
||||
disturbed: boolean;
|
||||
revealed: boolean;
|
||||
modelRequested: boolean;
|
||||
loaders: {
|
||||
model: LoaderState<boolean>;
|
||||
skin: LoaderState<number | undefined>;
|
||||
attachment: LoaderState<number | undefined>;
|
||||
};
|
||||
shot?: Shot;
|
||||
skills: Record<string, number>;
|
||||
relativeAgainst: TankopediaRelativeAgainst;
|
||||
@@ -103,11 +112,6 @@ interface Tankopedia {
|
||||
);
|
||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||
customShell?: ShellDefinition;
|
||||
skin?: number;
|
||||
/** events the selected camo's animations can be triggered with */
|
||||
skinEvents: string[];
|
||||
/** a triggered camo animation is still playing */
|
||||
skinAnimating: boolean;
|
||||
requestedDisplay: TankopediaDisplay;
|
||||
display: TankopediaDisplay;
|
||||
statSearch?: string;
|
||||
@@ -118,14 +122,16 @@ const skillDefinitions = await api.skillDefinitions();
|
||||
export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({
|
||||
disturbed: false,
|
||||
revealed: false,
|
||||
modelRequested: App.state.autoLoadModels,
|
||||
loaders: {
|
||||
model: { requested: App.state.autoLoadModels, loaded: false },
|
||||
skin: { requested: undefined, loaded: undefined },
|
||||
attachment: { requested: undefined, loaded: undefined },
|
||||
},
|
||||
relativeAgainst: TankopediaRelativeAgainst.Class,
|
||||
editStatic: false,
|
||||
skills: createDefaultSkills(skillDefinitions),
|
||||
model,
|
||||
xpMultiplier: 1,
|
||||
skinEvents: [],
|
||||
skinAnimating: false,
|
||||
requestedDisplay: TankopediaDisplay.Model,
|
||||
display: TankopediaDisplay.Model,
|
||||
}));
|
||||