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 { Fragment, useEffect, useRef } from 'react';
import type { Mesh } from 'three'; import type { Mesh } from 'three';
import { import {
Box3,
Color, Color,
DoubleSide, DoubleSide,
Group, Group,
@@ -109,6 +108,12 @@ export function DissectorSceneComponent({
const armorName = mesh.material.name; const armorName = mesh.material.name;
const armorPlate = groupArmor.armors[armorName]; const armorPlate = groupArmor.armors[armorName];
if (armorPlate === undefined) {
console.warn(`Missing armor: ${armorName}`);
return null;
}
const isModule = const isModule =
armorPlate.type !== 'Armor' && armorPlate.type !== 'ArmorScreen'; armorPlate.type !== 'Armor' && armorPlate.type !== 'ArmorScreen';
const isTrack = const isTrack =
@@ -149,8 +154,6 @@ export function DissectorSceneComponent({
}), }),
); );
const worldPosition = new Vector3(); const worldPosition = new Vector3();
const boundsTop = new Vector3();
const bounds = new Box3();
useEffect(() => { useEffect(() => {
const offset = new Vector3(); const offset = new Vector3();
@@ -1,15 +1,8 @@
import { memo, useRef } from 'react'; import { Scene } from 'three';
import { Group, Plane, Scene, Vector3 } from 'three'; import { arrayfy } from '../../../core/three/arrayfy';
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 { Duel } from '../../../stores/duel'; import { Duel } from '../../../stores/duel';
import { ModelTankWrapper } from './ModelTankWrapper'; import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent'; import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent/index';
export enum ArmorType { export enum ArmorType {
Primary, Primary,
@@ -21,185 +14,25 @@ interface SpacedArmorSceneProps {
scene: Scene; scene: Scene;
} }
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => { export function SpacedArmorScene({ scene }: SpacedArmorSceneProps) {
const wrapper = useRef<Group>(null); const model = TankopediaEphemeral.use((state) => state.model);
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);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun); const gun = Duel.use((state) => state.protagonist.gun);
const { gltf: armorGltf } = useArmor(tank.id); const turretTranslation = arrayfy(model.modules[turret.id].translation);
const { gltf: modelGltf } = useModel(tank.id); const gunTranslation = arrayfy(model.modules[gun.id].translation);
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);
return ( return (
<ModelTankWrapper ref={wrapper}> <group>
<group position={hullOrigin}> <SpacedArmorSceneComponent group="hull" />
{armorNodes.map((node) => { <SpacedArmorSceneComponent group="chassis" />
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
if ( <group position={turretTranslation}>
!isVisible || <SpacedArmorSceneComponent group={turret.id} />
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> </group>
{modelNodes.map((node) => { <group position={gunTranslation}>
const isWheel = node.name.startsWith('chassis_wheel_'); <SpacedArmorSceneComponent group={gun.id} />
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> </group>
</ModelTankWrapper> </group>
); );
}); }
@@ -7,12 +7,12 @@ import {
Plane, Plane,
ShaderMaterial, ShaderMaterial,
} from 'three'; } from 'three';
import type { ArmorUserData, ExternalModuleVariant } from '../..';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment'; import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree'; import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel'; import { Duel, type EquipmentMatrix } from '../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../../../SpacedArmorScene'; import { ArmorType } from '../../../SpacedArmorScene';
import type { ArmorUserData, ExternalModuleVariant } from '../../_index';
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from './shaders/fragment.glsl?raw';
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from './shaders/vertex.glsl?raw';
@@ -1,24 +1,14 @@
import { isExplosive, resolvePenetrationCoefficient } from '@blitzkit/core'; import { ArmorPlate } from '@blitzkit/core';
import { useEffect } from 'react'; import type { MeshProps } from '@react-three/fiber';
import { import { Fragment } from 'react';
AdditiveBlending, import { AdditiveBlending, MeshBasicMaterial, ShaderMaterial } from 'three';
MeshBasicMaterial, import { Duel } from '../../../../../../stores/duel';
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 { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../../../SpacedArmorScene';
import fragmentShader from './shaders/fragment.glsl?raw'; import fragmentShader from './shaders/fragment.glsl?raw';
import vertexShader from './shaders/vertex.glsl?raw'; import vertexShader from './shaders/vertex.glsl?raw';
interface SpacedArmorSubSpacedProps { interface SpacedArmorSubSpacedProps extends MeshProps {
node: Object3D; armorPlate: ArmorPlate;
thickness: number;
} }
const depthWriteMaterial = new MeshBasicMaterial({ const depthWriteMaterial = new MeshBasicMaterial({
@@ -27,8 +17,8 @@ const depthWriteMaterial = new MeshBasicMaterial({
}); });
export function SpacedArmorSubSpaced({ export function SpacedArmorSubSpaced({
node, armorPlate,
thickness, ...props
}: SpacedArmorSubSpacedProps) { }: SpacedArmorSubSpacedProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
const duelStore = Duel.useStore(); const duelStore = Duel.useStore();
@@ -49,107 +39,89 @@ export function SpacedArmorSubSpaced({
}, },
}); });
useEffect(() => { // useEffect(() => {
function handleShellChange() { // function handleShellChange() {
const duel = duelStore.getState(); // const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); // const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; // const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
material.uniforms.penetration.value = shell.penetration.near; // material.uniforms.penetration.value = shell.penetration.near;
material.uniforms.caliber.value = shell.caliber; // material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad( // material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!, // isExplosive(shell.type) ? 90 : shell.ricochet!,
); // );
material.uniforms.normalization.value = degToRad( // material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0, // shell.normalization ?? 0,
); // );
} // }
async function handleProtagonistEquipmentChange( // async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, // equipment: EquipmentMatrix,
) { // ) {
const duel = duelStore.getState(); // const duel = duelStore.getState();
const hasEnhancedArmor = await hasEquipment( // const hasEnhancedArmor = await hasEquipment(
110, // 110,
duel.protagonist.tank.equipment_preset, // duel.protagonist.tank.equipment_preset,
equipment, // equipment,
); // );
material.uniforms.thickness.value = hasEnhancedArmor // material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.03 // ? thickness * 1.03
: thickness; // : thickness;
} // }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { // async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = duelStore.getState(); // const duel = duelStore.getState();
const tankopediaEphemeral = tankopediaEphemeralStore.getState(); // const tankopediaEphemeral = tankopediaEphemeralStore.getState();
const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; // const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell;
const penetration = shell.penetration.near; // const penetration = shell.penetration.near;
const hasCalibratedShells = await hasEquipment( // const hasCalibratedShells = await hasEquipment(
103, // 103,
duel.antagonist.tank.equipment_preset, // duel.antagonist.tank.equipment_preset,
equipment, // equipment,
); // );
material.uniforms.penetration.value = // material.uniforms.penetration.value =
penetration * // penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type); // resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
} // }
handleShellChange(); // handleShellChange();
handleProtagonistEquipmentChange( // handleProtagonistEquipmentChange(
duelStore.getState().protagonist.equipmentMatrix, // duelStore.getState().protagonist.equipmentMatrix,
); // );
handleAntagonistEquipmentChange( // handleAntagonistEquipmentChange(
duelStore.getState().antagonist.equipmentMatrix, // duelStore.getState().antagonist.equipmentMatrix,
); // );
const unsubscribes = [ // const unsubscribes = [
duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), // duelStore.subscribe((state) => state.antagonist.shell, handleShellChange),
tankopediaEphemeralStore.subscribe( // tankopediaEphemeralStore.subscribe(
(state) => state.customShell, // (state) => state.customShell,
handleShellChange, // handleShellChange,
), // ),
duelStore.subscribe( // duelStore.subscribe(
(state) => state.protagonist.equipmentMatrix, // (state) => state.protagonist.equipmentMatrix,
handleProtagonistEquipmentChange, // handleProtagonistEquipmentChange,
), // ),
duelStore.subscribe( // duelStore.subscribe(
(state) => state.antagonist.equipmentMatrix, // (state) => state.antagonist.equipmentMatrix,
handleAntagonistEquipmentChange, // handleAntagonistEquipmentChange,
), // ),
]; // ];
return () => { // return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); // unsubscribes.forEach((unsubscribe) => unsubscribe());
}; // };
}); // });
return ( return (
<> <Fragment>
{jsxTree( <mesh
node, {...props}
{ renderOrder={2}
mesh: { material={material}
renderOrder: 2, onClick={() => {}}
material, userData={armorPlate}
onClick() {}, />
userData: { <mesh {...props} renderOrder={5} material={depthWriteMaterial} />
type: ArmorType.Spaced, </Fragment>
thickness,
} satisfies ArmorUserData,
},
},
`${node.uuid}-exclude`,
)}
{jsxTree(
node,
{
mesh: {
renderOrder: 5,
material: depthWriteMaterial,
},
},
`${node.uuid}-include`,
)}
</>
); );
} }
@@ -1,83 +1,42 @@
import { import { asset } from '@blitzkit/core';
ShellType, import { useGLTF } from '@react-three/drei';
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { useThree } from '@react-three/fiber'; import { useThree } from '@react-three/fiber';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import { Material, MeshBasicMaterial } from 'three';
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 { jsxTree } from '../../../../core/blitzkit/jsxTree'; import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { discardClippingPlane } from '../../../../core/three/discardClippingPlane';
import { Duel } from '../../../../stores/duel'; import { Duel } from '../../../../stores/duel';
import { import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
type Shot, import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
type ShotLayerBase, import { groupNameRegex } from '../StaticArmorSceneComponent';
type ShotLayerNonExternal,
TankopediaEphemeral,
} from '../../../../stores/tankopediaEphemeral';
import { ArmorType } from '../SpacedArmorScene';
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal'; import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced'; import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
type SpacedArmorSceneComponentProps = { interface StaticArmorSceneComponentsProp {
node: Object3D; group: string;
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;
}
);
const omitMaterial = new MeshBasicMaterial({ const omitMaterial = new MeshBasicMaterial({
colorWrite: false, colorWrite: false,
depthWrite: true, 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({ export function SpacedArmorSceneComponent({
node, group,
thickness, }: StaticArmorSceneComponentsProp) {
clip,
...props
}: SpacedArmorSceneComponentProps) {
const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); 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 camera = useThree((state) => state.camera);
const shoot = useCallback( const shoot = useCallback(
@@ -369,20 +328,30 @@ export function SpacedArmorSceneComponent({
[camera], [camera],
); );
return ( return jsxTree(gltf.scene, {
<> mesh(mesh, props, key) {
{props.type === ArmorType.Primary && if (!(mesh.material instanceof Material)) {
jsxTree( return null;
node, }
{
mesh: { const armorName = mesh.material.name;
renderOrder: 0, const armorPlate = groupArmor.armors[armorName];
material: omitMaterial,
userData: { if (armorPlate === undefined) {
type: ArmorType.Primary, console.warn(`Missing armor: ${armorName}`);
thickness, return null;
} satisfies ArmorUserData, }
async onClick(event) {
switch (armorPlate.type) {
case 'Armor':
return (
<mesh
{...props}
key={key}
renderOrder={0}
material={omitMaterial}
userData={armorPlate}
onClick={async (event) => {
event.stopPropagation(); event.stopPropagation();
const shot = (await shoot( const shot = (await shoot(
event.point, event.point,
@@ -390,24 +359,30 @@ export function SpacedArmorSceneComponent({
true, true,
))!; ))!;
tankopediaEphemeralStore.setState({ shot }); tankopediaEphemeralStore.setState({ shot });
}, }}
}, />
}, );
node.uuid,
)}
{props.type === ArmorType.Spaced && ( case 'ArmorScreen':
<SpacedArmorSubSpaced node={node} thickness={thickness} /> return (
)} <SpacedArmorSubSpaced
{...props}
key={key}
armorPlate={armorPlate}
/>
);
{props.type === ArmorType.External && ( default:
<SpacedArmorSubExternal return null;
node={node} return (
thickness={thickness} <SpacedArmorSubExternal
variant={props.variant} node={node}
clip={clip} thickness={thickness}
/> variant={props.variant}
)} clip={clip}
</> />
); );
}
},
});
} }
@@ -1,4 +1,3 @@
import { memo } from 'react';
import { arrayfy } from '../../../core/three/arrayfy'; import { arrayfy } from '../../../core/three/arrayfy';
import { Duel } from '../../../stores/duel'; import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
@@ -8,8 +7,7 @@ export interface ThicknessRange {
value: number; value: number;
} }
export const StaticArmor = memo(() => { export function StaticArmor() {
const tank = Duel.use((state) => state.protagonist);
const model = TankopediaEphemeral.use((state) => state.model); const model = TankopediaEphemeral.use((state) => state.model);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun); const gun = Duel.use((state) => state.protagonist.gun);
@@ -30,4 +28,4 @@ export const StaticArmor = memo(() => {
</group> </group>
</group> </group>
); );
}); }
@@ -1,5 +1,6 @@
import { asset } from '@blitzkit/core'; import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei'; import { useGLTF } from '@react-three/drei';
import { Fragment } from 'react/jsx-runtime';
import { import {
Color, Color,
LineBasicMaterial, LineBasicMaterial,
@@ -37,7 +38,7 @@ export function StaticArmorSceneComponent({
); );
return jsxTree(gltf.scene, { return jsxTree(gltf.scene, {
mesh(mesh, props) { mesh(mesh, props, key) {
if (!(mesh.material instanceof Material)) { if (!(mesh.material instanceof Material)) {
return null; return null;
} }
@@ -109,13 +110,13 @@ export function StaticArmorSceneComponent({
material.transparent = material.opacity < 1; material.transparent = material.opacity < 1;
return ( return (
<> <Fragment key={key}>
<mesh {...props} material={material} /> <mesh {...props} material={material} />
<lineSegments material={outlineMaterial}> <lineSegments material={outlineMaterial}>
<edgesGeometry args={[props.geometry, 45]} /> <edgesGeometry args={[props.geometry, 45]} />
</lineSegments> </lineSegments>
</> </Fragment>
); );
}, },
}); });
@@ -6,6 +6,8 @@ import { spacedArmorRenderTarget } from './components/PrimaryArmorSceneComponent
import { SpacedArmorScene } from './components/SpacedArmorScene'; import { SpacedArmorScene } from './components/SpacedArmorScene';
export const Armor = memo(() => { export const Armor = memo(() => {
return <SpacedArmorScene scene={new Scene()} />;
const [spacedArmorScene] = useState(() => new Scene()); const [spacedArmorScene] = useState(() => new Scene());
const [primaryArmorScene] = useState(() => new Scene()); const [primaryArmorScene] = useState(() => new Scene());
const spacedArmorPortal = createPortal( const spacedArmorPortal = createPortal(
@@ -403,18 +403,6 @@ export function Options({ canvas }: OptionsProps) {
</Flex> </Flex>
</Tooltip> </Tooltip>
</SegmentedControl.Item> </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}`}> <SegmentedControl.Item value={`${TankopediaDisplay.Dissector}`}>
<Tooltip <Tooltip
content={strings.website.tools.tanks.sandbox.dissector.name} 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 { Box, Flex, type FlexProps, Progress } from '@radix-ui/themes';
import { useProgress } from '@react-three/drei'; import { useProgress } from '@react-three/drei';
@@ -15,7 +16,7 @@ export function TankSandboxLoader({ id, ...props }: TankSandboxLoaderProps) {
width="100%" width="100%"
style={{ style={{
filter: 'blur(1rem)', filter: 'blur(1rem)',
backgroundImage: `url(/api/tanks/${id}/icons/big.png)`, backgroundImage: `url(${asset(`tanks/${id}/icons/big.webp`)})`,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
}} }}
+1 -2
View File
@@ -124,8 +124,7 @@ export const apiTool: Tool = {
id: 'api', id: 'api',
button: { button: {
color: 'indigo', color: 'indigo',
}, },
image: 'dIfvWnV',
}; };
export const TOOLS: Tool[] = [ 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>( export async function jsonMirror<Type>(
path: string, path: string,
message: MessageFns<Type>, message: MessageFns<Type>,
+16 -6
View File
@@ -5,13 +5,16 @@ import { Mesh, Object3D } from 'three';
export function jsxTree( export function jsxTree(
node: Object3D, node: Object3D,
mergers?: { mergers?: {
mesh?: (mesh: Mesh, props: MeshProps) => ReactNode; mesh?: (mesh: Mesh, props: MeshProps, key: string) => ReactNode;
group?: (object3d: Object3D, props: Object3DProps) => ReactNode; group?: (
object3d: Object3D,
props: Object3DProps,
key: string,
) => ReactNode;
}, },
): ReactNode { ): ReactNode {
if (node instanceof Mesh) { if (node instanceof Mesh) {
const props = { const props = {
key: node.uuid,
geometry: node.geometry, geometry: node.geometry,
material: node.material, material: node.material,
position: node.position, position: node.position,
@@ -19,17 +22,24 @@ export function jsxTree(
scale: node.scale, 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) { } else if (node instanceof Object3D) {
const props = { const props = {
key: node.uuid,
position: node.position, position: node.position,
rotation: node.rotation, rotation: node.rotation,
scale: node.scale, scale: node.scale,
children: node.children.map((child) => jsxTree(child, mergers)), 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; 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 { Box, Flex, Text } from '@radix-ui/themes';
import { LinkI18n } from '../../../components/LinkI18n'; import { LinkI18n } from '../../../components/LinkI18n';
import { PageWrapper } from '../../../components/PageWrapper'; import { PageWrapper } from '../../../components/PageWrapper';
@@ -25,7 +25,7 @@ const tanksFull = await fetchTanksFull();
width="8rem" width="8rem"
height="6rem" height="6rem"
style={{ style={{
backgroundImage: `url(/api/tanks/${tank.id}/icons/big.png)`, backgroundImage: `url(${asset(`tanks/${tank.id}/icons/big.webp`)})`,
backgroundSize: '150%', backgroundSize: '150%',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
@@ -2,9 +2,10 @@ import { TanksFull } from '@blitzkit/core';
import { jsonMirrorCurry } from '../../../core/blitzkit/blobMirror'; 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. * @warning This is a large response. Consider using tanks/[id]/meta.json for individual tanks.
* @proto tanks_full
* @returns JSON. * @returns JSON.
*/ */
export const GET = jsonMirrorCurry('/tanks/full.pb', TanksFull); 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 { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware'; import { subscribeWithSelector } from 'zustand/middleware';
import type { ArmorType } from '../components/Armor/components/SpacedArmorScene'; 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 type { XP_MULTIPLIERS } from '../components/Tankopedia/TechTreeSection';
import { awaitableSkillDefinitions } from '../core/awaitables/skillDefinitions'; import { awaitableSkillDefinitions } from '../core/awaitables/skillDefinitions';
import { createContextualStore } from '../core/zustand/createContextualStore'; import { createContextualStore } from '../core/zustand/createContextualStore';
@@ -148,7 +148,7 @@ export const TankopediaEphemeral = createContextualStore(
model, model,
controlsEnabled: true, controlsEnabled: true,
xpMultiplier: 1, 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")); readonly BlitzProvider provider = new(args.Contains("--depot"));
Locales? locales; Locales? locales;
readonly Dictionary<string, Dictionary<string, string>> strings = []; 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(); 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 GetString(string name)
{ {
I18n i18n = new(); 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) void DeduplicateSlugs(TanksFull tanksFull)
{ {
List<string> checkedSlugs = []; 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) async Task MangleArmor(UObject pda)
{ {
var id = pda.Get<FName>("TankId").Text; 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"); 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) async Task MangleIcon(UObject pda)
@@ -295,7 +292,7 @@ namespace BlitzKit.CLI.Functions
if (pda.TryGet<UTexture2D>("SmallIcon", out var smallIconTexture)) if (pda.TryGet<UTexture2D>("SmallIcon", out var smallIconTexture))
{ {
var smallIcon = BlitzKitExporter.Texture2D(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 else
{ {
@@ -305,7 +302,7 @@ namespace BlitzKit.CLI.Functions
if (pda.TryGet<UTexture2D>("BigIcon", out var bigIconTexture)) if (pda.TryGet<UTexture2D>("BigIcon", out var bigIconTexture))
{ {
var bigIcon = BlitzKitExporter.Texture2D(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 else
{ {
@@ -357,9 +354,9 @@ namespace BlitzKit.CLI.Functions
{ {
MonoGltf gltf = new(collision); MonoGltf gltf = new(collision);
// await assetUploader.Add( await assetUploader.Add(
// new($"tanks/{tank.Id}/collision/{moduleName}.glb", gltf.Write()) new($"tanks/{tank.Id}/collision/{moduleName}.glb", gltf.Write())
// ); );
} }
else 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 else
{ {
PrettyLog.Background( // PrettyLog.Background(
$"🔵 {blobPath} ({change.Content.Count.ToString("N0", Program.Culture)}B)" // $"🔵 {blobPath} ({change.Content.Count.ToString("N0", Program.Culture)}B)"
); // );
return; return;
} }
} }
+3 -3
View File
@@ -22,10 +22,10 @@ namespace BlitzKit.CLI
break; break;
} }
case "api": case "mangle":
{ {
Api api = new(args); Mangler mangler = new(args);
await api.Run(); await mangler.Mangle();
break; break;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ namespace BlitzKit.CLI.Utils
{ {
return texture return texture
.Decode(ETexturePlatform.DesktopMobile)! .Decode(ETexturePlatform.DesktopMobile)!
.Encode(SKEncodedImageFormat.Webp, 100) .Encode(SKEncodedImageFormat.Webp, 80)
.ToArray(); .ToArray();
} }