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
@@ -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,
|
||||
}));
|
||||