feat: start work on dynamic armor

This commit is contained in:
tresabhi committed 2025-04-06 10:44:26 -05:00
1 parent 1d301bf2fb
commit 1ee232d104
26 files changed
+296 -583

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

@@ -22,7 +22,6 @@ import { useThree } from '@react-three/fiber';
import { Fragment, useEffect, useRef } from 'react';
import type { Mesh } from 'three';
import {
Box3,
Color,
DoubleSide,
Group,
@@ -109,6 +108,12 @@ export function DissectorSceneComponent({
const armorName = mesh.material.name;
const armorPlate = groupArmor.armors[armorName];
if (armorPlate === undefined) {
console.warn(`Missing armor: ${armorName}`);
return null;
}
const isModule =
armorPlate.type !== 'Armor' && armorPlate.type !== 'ArmorScreen';
const isTrack =
@@ -149,8 +154,6 @@ export function DissectorSceneComponent({
}),
);
const worldPosition = new Vector3();
const boundsTop = new Vector3();
const bounds = new Box3();
useEffect(() => {
const offset = new Vector3();
@@ -1,15 +1,8 @@
import { memo, useRef } from 'react';
import { Group, Plane, Scene, Vector3 } from 'three';
import { correctZYTuple } from '../../../core/blitz/correctZYTuple';
import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId';
import { resolveArmor } from '../../../core/blitzkit/resolveThickness';
import { useArmor } from '../../../hooks/useArmor';
import { useModel } from '../../../hooks/useModel';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import { Scene } from 'three';
import { arrayfy } from '../../../core/three/arrayfy';
import { Duel } from '../../../stores/duel';
import { ModelTankWrapper } from './ModelTankWrapper';
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent/index';
export enum ArmorType {
Primary,
@@ -21,185 +14,25 @@ interface SpacedArmorSceneProps {
scene: Scene;
}
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const wrapper = useRef<Group>(null);
const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null);
const tank = Duel.use((state) => state.protagonist.tank);
const track = Duel.use((state) => state.protagonist.track);
export function SpacedArmorScene({ scene }: SpacedArmorSceneProps) {
const model = TankopediaEphemeral.use((state) => state.model);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const { gltf: armorGltf } = useArmor(tank.id);
const { gltf: modelGltf } = useModel(tank.id);
const armorNodes = Object.values(armorGltf.nodes);
const modelNodes = Object.values(modelGltf.nodes);
const tankModelDefinition = useTankModelDefinition();
const trackModelDefinition = tankModelDefinition.tracks[track.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin);
const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin);
const maskOrigin =
gunModelDefinition.mask === undefined
? undefined
: gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y;
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
useTankTransform(track, turret, turretContainer, gunContainer);
const turretTranslation = arrayfy(model.modules[turret.id].translation);
const gunTranslation = arrayfy(model.modules[gun.id].translation);
return (
<ModelTankWrapper ref={wrapper}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
<group>
<SpacedArmorSceneComponent group="hull" />
<SpacedArmorSceneComponent group="chassis" />
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
);
})}
<group position={turretTranslation}>
<SpacedArmorSceneComponent group={turret.id} />
</group>
{modelNodes.map((node) => {
const isWheel = node.name.startsWith('chassis_wheel_');
const isTrack = node.name.startsWith('chassis_track_');
const isVisible = isWheel || isTrack;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
/>
);
})}
<group ref={turretContainer}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model_id.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin} key={node.uuid}>
<group position={turretOrigin}>
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
<group ref={gunContainer}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model_id.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (
!isVisible ||
thickness === undefined ||
(isDynamicArmorActive && node.name.includes('state_00')) ||
(!isDynamicArmorActive && node.name.includes('state_01'))
)
return null;
return (
<group position={hullOrigin} key={node.uuid}>
<group position={turretOrigin.clone().add(gunOrigin)}>
<SpacedArmorSceneComponent
scene={scene}
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
/>
</group>
</group>
);
})}
{modelNodes.map((node) => {
const gunString = `gun_${gunModelDefinition.model_id
.toString()
.padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible) return null;
return (
<SpacedArmorSceneComponent
scene={scene}
key={node.uuid}
type={ArmorType.External}
thickness={gunModelDefinition.thickness}
variant="gun"
node={node}
clip={
maskOrigin === undefined
? undefined
: new Plane(new Vector3(0, 0, -1), -maskOrigin)
}
/>
);
})}
</group>
<group position={gunTranslation}>
<SpacedArmorSceneComponent group={gun.id} />
</group>
</ModelTankWrapper>
</group>
);
});
}
@@ -7,12 +7,12 @@ import {
Plane,
ShaderMaterial,
} from 'three';
import type { ArmorUserData, ExternalModuleVariant } from '../..';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../../../SpacedArmorScene';
import type { ArmorUserData, ExternalModuleVariant } from '../../_index';
import fragmentShader from './shaders/fragment.glsl?raw';
import vertexShader from './shaders/vertex.glsl?raw';
@@ -1,24 +1,14 @@
import { isExplosive, resolvePenetrationCoefficient } from '@blitzkit/core';
import { useEffect } from 'react';
import {
AdditiveBlending,
MeshBasicMaterial,
ShaderMaterial,
type Object3D,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import type { ArmorUserData } from '../..';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel';
import { ArmorPlate } from '@blitzkit/core';
import type { MeshProps } from '@react-three/fiber';
import { Fragment } from 'react';
import { AdditiveBlending, MeshBasicMaterial, ShaderMaterial } from 'three';
import { Duel } from '../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl?raw';
import vertexShader from './shaders/vertex.glsl?raw';
interface SpacedArmorSubSpacedProps {
node: Object3D;
thickness: number;
interface SpacedArmorSubSpacedProps extends MeshProps {
armorPlate: ArmorPlate;
}
const depthWriteMaterial = new MeshBasicMaterial({
@@ -27,8 +17,8 @@ const depthWriteMaterial = new MeshBasicMaterial({
});
export function SpacedArmorSubSpaced({
node,
thickness,
armorPlate,
...props
}: SpacedArmorSubSpacedProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
@@ -49,107 +39,89 @@ export function SpacedArmorSubSpaced({
},
});
useEffect(() => {
function handleShellChange() {
const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
// useEffect(() => {
// function handleShellChange() {
// const duel = duelStore.getState();
// const tankopediaEphemeral = tankopediaEphemeralStore.getState();
// const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near;
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
}
async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix,
) {
const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist.tank.equipment_preset,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist.tank.equipment_preset,
equipment,
);
// material.uniforms.penetration.value = shell.penetration.near;
// material.uniforms.caliber.value = shell.caliber;
// material.uniforms.ricochet.value = degToRad(
// isExplosive(shell.type) ? 90 : shell.ricochet!,
// );
// material.uniforms.normalization.value = degToRad(
// shell.normalization ?? 0,
// );
// }
// async function handleProtagonistEquipmentChange(
// equipment: EquipmentMatrix,
// ) {
// const duel = duelStore.getState();
// const hasEnhancedArmor = await hasEquipment(
// 110,
// duel.protagonist.tank.equipment_preset,
// equipment,
// );
// material.uniforms.thickness.value = hasEnhancedArmor
// ? thickness * 1.03
// : thickness;
// }
// async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
// const duel = duelStore.getState();
// const tankopediaEphemeral = tankopediaEphemeralStore.getState();
// const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
// const penetration = shell.penetration.near;
// const hasCalibratedShells = await hasEquipment(
// 103,
// duel.antagonist.tank.equipment_preset,
// equipment,
// );
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
// material.uniforms.penetration.value =
// penetration *
// resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
// }
handleShellChange();
handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix,
);
handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix,
);
// handleShellChange();
// handleProtagonistEquipmentChange(
// duelStore.getState().protagonist.equipmentMatrix,
// );
// handleAntagonistEquipmentChange(
// duelStore.getState().antagonist.equipmentMatrix,
// );
const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe(
(state) => state.customShell,
handleShellChange,
),
duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange,
),
duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange,
),
];
// const unsubscribes = [
// duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
// tankopediaEphemeralStore.subscribe(
// (state) => state.customShell,
// handleShellChange,
// ),
// duelStore.subscribe(
// (state) => state.protagonist.equipmentMatrix,
// handleProtagonistEquipmentChange,
// ),
// duelStore.subscribe(
// (state) => state.antagonist.equipmentMatrix,
// handleAntagonistEquipmentChange,
// ),
// ];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
// return () => {
// unsubscribes.forEach((unsubscribe) => unsubscribe());
// };
// });
return (
<>
{jsxTree(
node,
{
mesh: {
renderOrder: 2,
material,
onClick() {},
userData: {
type: ArmorType.Spaced,
thickness,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{
mesh: {
renderOrder: 5,
material: depthWriteMaterial,
},
},
`${node.uuid}-include`,
)}
</>
<Fragment>
<mesh
{...props}
renderOrder={2}
material={material}
onClick={() => {}}
userData={armorPlate}
/>
<mesh {...props} renderOrder={5} material={depthWriteMaterial} />
</Fragment>
);
}
@@ -1,83 +1,42 @@
import {
ShellType,
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { useCallback } from 'react';
import {
type Intersection,
MeshBasicMaterial,
Object3D,
Plane,
Quaternion,
Raycaster,
Scene,
Vector3,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils.js';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { Material, MeshBasicMaterial } from 'three';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane';
import { Duel } from '../../../../stores/duel';
import {
type Shot,
type ShotLayerBase,
type ShotLayerNonExternal,
TankopediaEphemeral,
} from '../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../SpacedArmorScene';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
import { groupNameRegex } from '../StaticArmorSceneComponent';
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
type SpacedArmorSceneComponentProps = {
node: Object3D;
thickness: number;
clip?: Plane;
scene: Scene;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
export type ExternalModuleVariant = 'gun' | 'track';
export type ArmorUserData = {
thickness: number;
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
);
interface StaticArmorSceneComponentsProp {
group: string;
}
const omitMaterial = new MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
});
/**
* Render orders allocations:
* - 0 : core omission
* - 1-2: spaced without depth buffer write but do test to allow additive blending
* - 3-4: external with depth buffer write in first pass and read in second to simplify geometry
* - 5 : spaced depth write
*/
export function SpacedArmorSceneComponent({
node,
thickness,
clip,
...props
}: SpacedArmorSceneComponentProps) {
group,
}: StaticArmorSceneComponentsProp) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore();
const tank = Duel.use((state) => state.protagonist.tank);
const showModules = TankopediaPersistent.use((state) => state.showModules);
const showArmorScreen = TankopediaPersistent.use(
(state) => state.showArmorScreen,
);
const showArmor = TankopediaPersistent.use((state) => state.showArmor);
const gltf = useGLTF(asset(`tanks/${tank.id}/collision/${group}.glb`));
const armor = TankopediaEphemeral.use((state) => state.armor);
const groupName = group.match(groupNameRegex)![1];
const groupArmor = armor.groups[groupName];
const thicknessRange = TankopediaEphemeral.use(
(state) => state.thicknessRange,
);
const camera = useThree((state) => state.camera);
const shoot = useCallback(
@@ -369,20 +328,30 @@ export function SpacedArmorSceneComponent({
[camera],
);
return (
<>
{props.type === ArmorType.Primary &&
jsxTree(
node,
{
mesh: {
renderOrder: 0,
material: omitMaterial,
userData: {
type: ArmorType.Primary,
thickness,
} satisfies ArmorUserData,
async onClick(event) {
return jsxTree(gltf.scene, {
mesh(mesh, props, key) {
if (!(mesh.material instanceof Material)) {
return null;
}
const armorName = mesh.material.name;
const armorPlate = groupArmor.armors[armorName];
if (armorPlate === undefined) {
console.warn(`Missing armor: ${armorName}`);
return null;
}
switch (armorPlate.type) {
case 'Armor':
return (
<mesh
{...props}
key={key}
renderOrder={0}
material={omitMaterial}
userData={armorPlate}
onClick={async (event) => {
event.stopPropagation();
const shot = (await shoot(
event.point,
@@ -390,24 +359,30 @@ export function SpacedArmorSceneComponent({
true,
))!;
tankopediaEphemeralStore.setState({ shot });
},
},
},
node.uuid,
)}
}}
/>
);
{props.type === ArmorType.Spaced && (
<SpacedArmorSubSpaced node={node} thickness={thickness} />
)}
case 'ArmorScreen':
return (
<SpacedArmorSubSpaced
{...props}
key={key}
armorPlate={armorPlate}
/>
);
{props.type === ArmorType.External && (
<SpacedArmorSubExternal
node={node}
thickness={thickness}
variant={props.variant}
clip={clip}
/>
)}
</>
);
default:
return null;
return (
<SpacedArmorSubExternal
node={node}
thickness={thickness}
variant={props.variant}
clip={clip}
/>
);
}
},
});
}
@@ -1,4 +1,3 @@
import { memo } from 'react';
import { arrayfy } from '../../../core/three/arrayfy';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
@@ -8,8 +7,7 @@ export interface ThicknessRange {
value: number;
}
export const StaticArmor = memo(() => {
const tank = Duel.use((state) => state.protagonist);
export function StaticArmor() {
const model = TankopediaEphemeral.use((state) => state.model);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
@@ -30,4 +28,4 @@ export const StaticArmor = memo(() => {
</group>
</group>
);
});
}
@@ -1,5 +1,6 @@
import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei';
import { Fragment } from 'react/jsx-runtime';
import {
Color,
LineBasicMaterial,
@@ -37,7 +38,7 @@ export function StaticArmorSceneComponent({
);
return jsxTree(gltf.scene, {
mesh(mesh, props) {
mesh(mesh, props, key) {
if (!(mesh.material instanceof Material)) {
return null;
}
@@ -109,13 +110,13 @@ export function StaticArmorSceneComponent({
material.transparent = material.opacity < 1;
return (
<>
<Fragment key={key}>
<mesh {...props} material={material} />
<lineSegments material={outlineMaterial}>
<edgesGeometry args={[props.geometry, 45]} />
</lineSegments>
</>
</Fragment>
);
},
});
@@ -6,6 +6,8 @@ import { spacedArmorRenderTarget } from './components/PrimaryArmorSceneComponent
import { SpacedArmorScene } from './components/SpacedArmorScene';
export const Armor = memo(() => {
return <SpacedArmorScene scene={new Scene()} />;
const [spacedArmorScene] = useState(() => new Scene());
const [primaryArmorScene] = useState(() => new Scene());
const spacedArmorPortal = createPortal(
@@ -403,18 +403,6 @@ export function Options({ canvas }: OptionsProps) {
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.Dissector}`}>
<Tooltip
content={strings.website.tools.tanks.sandbox.dissector.name}
>
<Flex height="100%" align="center">
<img
src="/assets/images/icons/tankopedia-static-armor.png"
style={{ height: '1.25em' }}
/>
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.Dissector}`}>
<Tooltip
content={strings.website.tools.tanks.sandbox.dissector.name}
@@ -1,3 +1,4 @@
import { asset } from '@blitzkit/core';
import { Box, Flex, type FlexProps, Progress } from '@radix-ui/themes';
import { useProgress } from '@react-three/drei';
@@ -15,7 +16,7 @@ export function TankSandboxLoader({ id, ...props }: TankSandboxLoaderProps) {
width="100%"
style={{
filter: 'blur(1rem)',
backgroundImage: `url(/api/tanks/${id}/icons/big.png)`,
backgroundImage: `url(${asset(`tanks/${id}/icons/big.webp`)})`,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
+1 -2
View File
@@ -124,8 +124,7 @@ export const apiTool: Tool = {
id: 'api',
button: {
color: 'indigo',
},
image: 'dIfvWnV',
},
};
export const TOOLS: Tool[] = [
@@ -10,14 +10,6 @@ export async function blobProxy(url: string) {
}
}
export function apiBlobProxy(path: string) {
return blobProxy(`http://localhost:5000${path}`);
}
export function apiBlobProxyCurry(path: string) {
return () => apiBlobProxy(path);
}
export async function jsonMirror<Type>(
path: string,
message: MessageFns<Type>,
+16 -6
View File
@@ -5,13 +5,16 @@ import { Mesh, Object3D } from 'three';
export function jsxTree(
node: Object3D,
mergers?: {
mesh?: (mesh: Mesh, props: MeshProps) => ReactNode;
group?: (object3d: Object3D, props: Object3DProps) => ReactNode;
mesh?: (mesh: Mesh, props: MeshProps, key: string) => ReactNode;
group?: (
object3d: Object3D,
props: Object3DProps,
key: string,
) => ReactNode;
},
): ReactNode {
if (node instanceof Mesh) {
const props = {
key: node.uuid,
geometry: node.geometry,
material: node.material,
position: node.position,
@@ -19,17 +22,24 @@ export function jsxTree(
scale: node.scale,
};
return mergers?.mesh ? mergers.mesh(node, props) : <mesh {...props} />;
return mergers?.mesh ? (
mergers.mesh(node, props, node.uuid)
) : (
<mesh key={node.uuid} {...props} />
);
} else if (node instanceof Object3D) {
const props = {
key: node.uuid,
position: node.position,
rotation: node.rotation,
scale: node.scale,
children: node.children.map((child) => jsxTree(child, mergers)),
};
return mergers?.group ? mergers.group(node, props) : <group {...props} />;
return mergers?.group ? (
mergers.group(node, props, node.uuid)
) : (
<group key={node.uuid} {...props} />
);
}
return null;
@@ -1,5 +1,5 @@
---
import { fetchTanksFull } from '@blitzkit/core';
import { asset, fetchTanksFull } from '@blitzkit/core';
import { Box, Flex, Text } from '@radix-ui/themes';
import { LinkI18n } from '../../../components/LinkI18n';
import { PageWrapper } from '../../../components/PageWrapper';
@@ -25,7 +25,7 @@ const tanksFull = await fetchTanksFull();
width="8rem"
height="6rem"
style={{
backgroundImage: `url(/api/tanks/${tank.id}/icons/big.png)`,
backgroundImage: `url(${asset(`tanks/${tank.id}/icons/big.webp`)})`,
backgroundSize: '150%',
backgroundPosition: 'center',
}}
@@ -2,9 +2,10 @@ import { TanksFull } from '@blitzkit/core';
import { jsonMirrorCurry } from '../../../core/blitzkit/blobMirror';
/**
* Identical to tanks/full.pb but in JSON.
* Returns meta data similar to tanks/[id].json but for all tanks as an array. There is no guarantee of order.
*
* @warning This is a large response. Consider using tanks/[id]/meta.json for individual tanks.
* @proto tanks_full
* @returns JSON.
*/
export const GET = jsonMirrorCurry('/tanks/full.pb', TanksFull);
@@ -1,8 +0,0 @@
import { apiBlobProxyCurry } from '../../../core/blitzkit/blobMirror';
/**
* Returns meta data similar to tanks/[id].json but for all tanks as an array. There is no guarantee of order.
*
* @proto tanks_full
*/
export const GET = apiBlobProxyCurry('/tanks/full.pb');
@@ -8,7 +8,7 @@ import type { Vector3 } from 'three';
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import type { ArmorType } from '../components/Armor/components/SpacedArmorScene';
import type { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
import type { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent/_index';
import type { XP_MULTIPLIERS } from '../components/Tankopedia/TechTreeSection';
import { awaitableSkillDefinitions } from '../core/awaitables/skillDefinitions';
import { createContextualStore } from '../core/zustand/createContextualStore';
@@ -148,7 +148,7 @@ export const TankopediaEphemeral = createContextualStore(
model,
controlsEnabled: true,
xpMultiplier: 1,
display: TankopediaDisplay.StaticArmor,
display: TankopediaDisplay.DynamicArmor,
})),
);
},
-51
View File
@@ -1,51 +0,0 @@
using System.Threading.Tasks;
using AssetRipper.TextureDecoder.Rgb.Channels;
using Blitzkit;
using Google.Protobuf;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace BlitzKit.CLI.Functions
{
public class Api(string[] args)
{
readonly WebApplication app = WebApplication.Create();
readonly Mangler mangler = new(args);
void Map<T>(string path, Func<Task<T>> func)
where T : IMessage<T> =>
app.MapGet(path, async context => await Octet(context, await func()));
void Map<T>(string path, Func<T> func)
where T : IMessage<T> => Map(path, () => Task.FromResult(func()));
static async Task Octet<T>(HttpContext context, T message)
where T : IMessage<T>
{
var bytes = message.ToByteArray();
context.Response.ContentType = "application/octet-stream";
await context.Response.Body.WriteAsync(bytes);
}
public async Task Run()
{
await mangler.Initialize();
Map("/tanks/full.pb", mangler.TanksFull);
Map("/tanks/list.pb", mangler.Tanks);
app.MapGet(
"/tanks/{id}.pb",
(HttpContext context, string id) => Octet(context, mangler.Tank(id))
);
app.MapGet(
"/tanks/{id}/icons/big.pb",
(HttpContext context, string id) => Octet(context, mangler.TankIconBig(id))
);
app.Run();
}
}
}
+74 -77
View File
@@ -49,81 +49,15 @@ namespace BlitzKit.CLI.Functions
readonly BlitzProvider provider = new(args.Contains("--depot"));
Locales? locales;
readonly Dictionary<string, Dictionary<string, string>> strings = [];
TanksFull tanksFull = null!;
readonly AssetUploader assetUploader = new("mangled ue assets") { enabled = true };
public async Task Initialize()
public async Task Mangle()
{
await FetchStrings();
tanksFull = TanksFull();
await MangleNations();
await assetUploader.Flush();
}
public TanksFull TanksFull()
{
if (tanksFull != null)
return tanksFull;
var dirs = provider.RootDirectory.GetDirectory("Blitz/Content/Tanks").Directories;
tanksFull = new();
foreach (var dir in dirs)
{
if (dir.Key == "TankStub")
continue;
foreach (var tankDir in dir.Value.Directories)
{
var tank = Tank(tankDir.Value);
tanksFull.Tanks.Add(tank);
}
}
DeduplicateSlugs(tanksFull);
return tanksFull;
}
public Tanks Tanks()
{
var tanksFull = TanksFull();
Tanks tanks = new();
foreach (var tank in tanksFull.Tanks)
{
tanks.Tanks_.Add(tank.Id);
}
return tanks;
}
public Tank Tank(string id)
{
return tanksFull.Tanks.First(tank => tank.Id == id);
}
Tank Tank(VFS tankDir)
{
var pdaName = $"PDA_{tankDir.Name}";
var pda = provider.LoadObject($"{tankDir.Path}/{pdaName}.{pdaName}");
var id = pda.Get<FName>("TankId").Text;
var name = GetString($"TankEntity__{id.ToLowerInvariant()}__Short_Name");
var slug = Diacritics.Remove(name.Locales["en"]).ToLower();
slug = NonAlphanumericRegex().Replace(slug, "-");
slug = MultipleDashesRegex().Replace(slug, "-");
slug = TrailingDashRegex().Replace(slug, "");
Tank tank = new()
{
Id = id,
Name = name,
Slug = slug,
};
return tank;
}
///////////////////////////////////////////////////////////////////////////
I18n GetString(string name)
{
I18n i18n = new();
@@ -175,6 +109,42 @@ namespace BlitzKit.CLI.Functions
}
}
async Task MangleNations()
{
var dirs = provider.RootDirectory.GetDirectory("Blitz/Content/Tanks").Directories;
TanksFull tanksFull = new();
foreach (var dir in dirs)
{
if (dir.Key == "TankStub")
continue;
foreach (var tankDir in dir.Value.Directories)
{
// if (tankDir.Value.Name != "R90_IS_4")
// continue;
// if (tankDir.Value.Name != "R81_IS_8")
// continue;
var tank = await MangleTank(tankDir.Value);
tanksFull.Tanks.Add(tank);
}
}
DeduplicateSlugs(tanksFull);
Tanks tanks = new();
foreach (var tank in tanksFull.Tanks)
{
tanks.Tanks_.Add(tank.Id);
await assetUploader.Add($"tanks/{tank.Id}/meta.pb", tank.ToByteArray());
}
await assetUploader.Add("definitions/tanks.pb", tanks.ToByteArray());
await assetUploader.Add("definitions/tanks-full.pb", tanksFull.ToByteArray());
}
void DeduplicateSlugs(TanksFull tanksFull)
{
List<string> checkedSlugs = [];
@@ -245,6 +215,33 @@ namespace BlitzKit.CLI.Functions
}
}
async Task<Tank> MangleTank(VFS tankDir)
{
var pdaName = $"PDA_{tankDir.Name}";
var pda = provider.LoadObject($"{tankDir.Path}/{pdaName}.{pdaName}");
var id = pda.Get<FName>("TankId").Text;
var name = GetString($"TankEntity__{id.ToLowerInvariant()}__Short_Name");
var slug = Diacritics.Remove(name.Locales["en"]).ToLower();
slug = NonAlphanumericRegex().Replace(slug, "-");
slug = MultipleDashesRegex().Replace(slug, "-");
slug = TrailingDashRegex().Replace(slug, "");
await MangleIcon(pda);
await MangleArmor(pda);
Tank tank = new()
{
Id = id,
Name = name,
Slug = slug,
};
await MangleModules(tank, pda);
return tank;
}
async Task MangleArmor(UObject pda)
{
var id = pda.Get<FName>("TankId").Text;
@@ -285,7 +282,7 @@ namespace BlitzKit.CLI.Functions
PrettyLog.Warn($"Penetration groups missing for {id}; creating empty list");
}
// await assetUploader.Add($"tanks/{id}/armor.pb", tankArmor.ToByteArray());
await assetUploader.Add($"tanks/{id}/armor.pb", tankArmor.ToByteArray());
}
async Task MangleIcon(UObject pda)
@@ -295,7 +292,7 @@ namespace BlitzKit.CLI.Functions
if (pda.TryGet<UTexture2D>("SmallIcon", out var smallIconTexture))
{
var smallIcon = BlitzKitExporter.Texture2D(smallIconTexture);
// await assetUploader.Add($"tanks/{id}/icons/small.png", smallIcon);
await assetUploader.Add($"tanks/{id}/icons/small.webp", smallIcon);
}
else
{
@@ -305,7 +302,7 @@ namespace BlitzKit.CLI.Functions
if (pda.TryGet<UTexture2D>("BigIcon", out var bigIconTexture))
{
var bigIcon = BlitzKitExporter.Texture2D(bigIconTexture);
// await assetUploader.Add($"tanks/{id}/icons/big.png", bigIcon);
await assetUploader.Add($"tanks/{id}/icons/big.webp", bigIcon);
}
else
{
@@ -357,9 +354,9 @@ namespace BlitzKit.CLI.Functions
{
MonoGltf gltf = new(collision);
// await assetUploader.Add(
// new($"tanks/{tank.Id}/collision/{moduleName}.glb", gltf.Write())
// );
await assetUploader.Add(
new($"tanks/{tank.Id}/collision/{moduleName}.glb", gltf.Write())
);
}
else
{
@@ -368,7 +365,7 @@ namespace BlitzKit.CLI.Functions
}
}
// await assetUploader.Add($"tanks/{tank.Id}/model.pb", tankModel.ToByteArray());
await assetUploader.Add($"tanks/{tank.Id}/model.pb", tankModel.ToByteArray());
}
}
}
+3 -3
View File
@@ -87,9 +87,9 @@ namespace BlitzKit.CLI.Models
}
else
{
PrettyLog.Background(
$"🔵 {blobPath} ({change.Content.Count.ToString("N0", Program.Culture)}B)"
);
// PrettyLog.Background(
// $"🔵 {blobPath} ({change.Content.Count.ToString("N0", Program.Culture)}B)"
// );
return;
}
}
+3 -3
View File
@@ -22,10 +22,10 @@ namespace BlitzKit.CLI
break;
}
case "api":
case "mangle":
{
Api api = new(args);
await api.Run();
Mangler mangler = new(args);
await mangler.Mangle();
break;
}
+1 -1
View File
@@ -14,7 +14,7 @@ namespace BlitzKit.CLI.Utils
{
return texture
.Decode(ETexturePlatform.DesktopMobile)!
.Encode(SKEncodedImageFormat.Webp, 100)
.Encode(SKEncodedImageFormat.Webp, 80)
.ToArray();
}