feat: outlines

This commit is contained in:
tresabhi committed 2025-03-21 18:55:24 -05:00
1 parent 1d928e8b72
commit 0c37550c05
6 files changed
+204 -350

No files matched your search

+2 -2
View File
@@ -900,7 +900,7 @@
"element": {
"primary": "Primary",
"spaced": "Spaced",
"external": "External",
"modules": "Modules",
"gap": "Gap",
"gun": "Gun",
"track": "Tracks"
@@ -922,7 +922,7 @@
"name": "Static armor",
"primary": "Primary",
"spaced": "Spaced",
"external": "External",
"modules": "Modules",
"edit": {
"label": "Edit",
"reset": "Reset"
@@ -1,10 +1,4 @@
import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei';
import { memo } from 'react';
import { Color, Material, MeshBasicMaterial } from 'three';
import { jsxTree } from '../../../core/blitzkit/jsxTree';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { StaticArmorSceneComponents } from './StaticArmorSceneComponents';
export interface ThicknessRange {
@@ -55,11 +49,16 @@ export const StaticArmor = memo(() => {
// useTankTransform(track, turret, turretContainer, gunContainer);
return (
<group>
<StaticArmorSceneComponents group="hull" />
<StaticArmorSceneComponents group="chassis" />
<group position={[0.7, 1.6, 0]}>
<StaticArmorSceneComponents group="turret_01" />
<group position={[1, 0.36, 0]}>
<StaticArmorSceneComponents group="gun_01" />
</group>
</group>
</group>
);
});
@@ -1,39 +1,45 @@
import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei';
import { Color, Material, MeshStandardMaterial } from 'three';
import {
Color,
LineBasicMaterial,
Material,
MeshStandardMaterial,
} from 'three';
import { clamp } from 'three/src/math/MathUtils.js';
import { jsxTree } from '../../../core/blitzkit/jsxTree';
import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
type StaticArmorSceneComponentsProps = {
interface StaticArmorSceneComponentsProps {
group: string;
};
}
const unselectedColor = new Color(0x404040);
const externalModuleColor = new Color(192 / 255, 192 / 255, 192 / 255);
const moduleColor = new Color(192 / 255, 192 / 255, 192 / 255);
const groupNameRegex = /([a-z]+)(_\d+)?/;
export function StaticArmorSceneComponents({
group,
}: StaticArmorSceneComponentsProps) {
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 tank = Duel.use((state) => state.protagonist.tank);
const gltf = useGLTF(asset(`tanks/${tank.id}/collision/${group}.glb`));
const armor = TankopediaEphemeral.use((state) => state.armor);
const groupArmor = armor.groups[group];
const groupName = group.match(groupNameRegex)![1];
const groupArmor = armor.groups[groupName];
const thicknessRange = TankopediaEphemeral.use(
(state) => state.thicknessRange,
);
return jsxTree(gltf.scene, {
mesh(mesh) {
mesh(mesh, props) {
if (!(mesh.material instanceof Material)) {
return { visible: false };
return null;
}
const armorName = mesh.material.name;
@@ -44,216 +50,67 @@ export function StaticArmorSceneComponents({
switch (armorPlate.type) {
case 'Armor':
if (!showArmor) return null;
if (x > 1) {
material.color = new Color(clamp(2 - x, 0.5, 1), 0, 0);
} else {
material.color = new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0);
}
material.opacity = 1;
material.visible = showArmor;
break;
case 'ArmorScreen':
if (!showArmorScreen) return null;
material.color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1),
0,
clamp(1 - (1 / 8) * xClamped, 0, 1),
);
material.opacity = clamp(x + 1 / 2, 0, 1);
material.visible = showArmorScreen;
break;
default:
material.color = externalModuleColor;
material.opacity = 7 / 8;
material.depthWrite = false;
material.visible = showModules;
if (!showModules) return null;
material.color = moduleColor;
material.opacity = 1 / 2;
break;
}
const outlineMaterial = new LineBasicMaterial({
color: material.color.clone(),
});
switch (armorPlate.type) {
case 'Armor':
outlineMaterial.color.multiplyScalar(0.5);
break;
case 'ArmorScreen':
outlineMaterial.color.multiplyScalar(4);
break;
default:
outlineMaterial.color.multiplyScalar(0.5);
break;
}
material.opacity = clamp(material.opacity, 0, 1);
material.transparent = material.opacity < 1;
return { material };
return (
<>
<mesh {...props} material={material} />
<lineSegments material={outlineMaterial}>
<edgesGeometry args={[props.geometry, 45]} />
</lineSegments>
</>
);
},
});
// const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation();
// const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
// const camera = useThree((state) => state.camera);
// const x = thickness / thicknessRange.value;
// const xClamped = clamp(x, 0, 1);
// let color: Color;
// let opacity: number;
// let depthWrite = true;
// switch (props.type) {
// case ArmorType.Primary:
// if (x > 1) {
// color = new Color(clamp(2 - x, 0.5, 1), 0, 0);
// } else {
// color = new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0);
// }
// opacity = 1;
// break;
// case ArmorType.Spaced:
// color = new Color(
// clamp(1 - (7 / 8) * xClamped, 0, 1),
// 0,
// clamp(1 - (1 / 8) * xClamped, 0, 1),
// );
// opacity = clamp(x + 1 / 2, 0, 1);
// break;
// case ArmorType.External:
// color = externalModuleColor;
// opacity = 1 / 8;
// depthWrite = false;
// break;
// }
// opacity = clamp(opacity, 0, 1);
// const surfaceMaterial = useMemo(
// () =>
// new MeshStandardMaterial({
// color,
// opacity,
// transparent: opacity < 1,
// depthWrite,
// ...(props.clip ? { clippingPlanes: [props.clip] } : {}),
// }),
// [thickness],
// );
// const outlineMaterial = useMemo(
// () =>
// new LineBasicMaterial({
// color: color
// .clone()
// .multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
// }),
// [thickness],
// );
// useEffect(() => {
// function handleHighlightArmor(selectedName?: string) {
// if (selectedName === undefined) {
// // nothing selected, go back to defaults
// surfaceMaterial.opacity = opacity;
// surfaceMaterial.transparent = opacity < 1;
// surfaceMaterial.color = color;
// surfaceMaterial.depthWrite = props.type !== ArmorType.External;
// surfaceMaterial.side = FrontSide;
// outlineMaterial.visible = true;
// } else if (
// selectedName === name ||
// (name.startsWith('chassis_') && selectedName.startsWith('chassis_')) ||
// (name.startsWith('gun_') &&
// selectedName.startsWith('gun_') &&
// !name.includes('_armor_') &&
// !selectedName.includes('_armor_'))
// ) {
// // this selected, stand out!
// surfaceMaterial.opacity = 1;
// surfaceMaterial.transparent = false;
// surfaceMaterial.color = color;
// surfaceMaterial.depthWrite = true;
// surfaceMaterial.side = DoubleSide;
// outlineMaterial.visible = true;
// } else {
// // something else selected, become background
// surfaceMaterial.opacity = 1 / 4;
// surfaceMaterial.transparent = true;
// surfaceMaterial.color = unselectedColor;
// surfaceMaterial.depthWrite = props.type !== ArmorType.External;
// surfaceMaterial.side = FrontSide;
// outlineMaterial.visible = false;
// }
// surfaceMaterial.needsUpdate = true;
// }
// const unsubscribes = [
// tankopediaEphemeralStore.subscribe(
// (state) => state.highlightArmor?.name,
// handleHighlightArmor,
// ),
// ];
// if (props.clip) {
// /**
// * hook inside an if statement?? don't panic! I assure you the clip prop
// * never mutates :)
// */
// const { clip } = props;
// const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
// const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
// unrotateDavaVector(props.turretOrigin.clone()),
// );
// const barrelOrigin = neckOrigin.clone().add(gunOrigin);
// const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
// const point = new Vector3();
// function handleModelTransform(data: ModelTransformEventData) {
// clip.normal
// .set(0, 0, -1)
// .applyAxisAngle(I_HAT, data.pitch)
// .applyAxisAngle(J_HAT, data.yaw ?? 0);
// clip.constant = 0;
// point
// .copy(gunOrigin)
// .applyAxisAngle(J_HAT, data.yaw ?? 0)
// .add(clip.normal.multiplyScalar(-distanceToBarrel))
// .add(neckOrigin);
// clip.normal.normalize();
// clip.constant -= clip.distanceToPoint(point);
// }
// modelTransformEvent.on(handleModelTransform);
// unsubscribes.push(() => modelTransformEvent.off(handleModelTransform));
// }
// return () => {
// unsubscribes.forEach((unsubscribe) => unsubscribe());
// };
// }, [thickness]);
// return (
// <>
// {jsxTree(node, {
// mesh: {
// material: surfaceMaterial,
// onPointerDown,
// userData: {
// type: props.type,
// variant: props.type === ArmorType.External ? props.variant : 'gun',
// thickness,
// } satisfies ArmorUserData,
// onClick(event) {
// if (discardClippingPlane(event.object, event.point)) return;
// event.stopPropagation();
// const { editStatic } = tankopediaEphemeralStore.getState();
// const bounds = new Box3().setFromObject(event.object);
// const point = bounds.min
// .clone()
// .add(bounds.max)
// .divideScalar(2)
// .setY(bounds.max.y);
// const cameraNormal = camera.position.clone().sub(point).normalize();
// const surfaceNormal = event
// .normal!.clone()
// .applyQuaternion(
// event.object.getWorldQuaternion(new Quaternion()),
// );
// const angle = surfaceNormal.angleTo(cameraNormal);
// const thicknessAngled = thickness / Math.sin(Math.PI / 2 - angle);
// mutateTankopediaEphemeralStore((draft) => {
// draft.highlightArmor = {
// type: props.type,
// name,
// point,
// thickness,
// thicknessAngled,
// angle,
// color: `#${color.getHexString()}`,
// editingPlate: editStatic,
// };
// });
// },
// },
// })}
// {node instanceof Mesh && (
// <lineSegments material={outlineMaterial}>
// <edgesGeometry args={[node.geometry, 45]} />
// </lineSegments>
// )}
// </>
// );
}
@@ -125,7 +125,7 @@ export function Thicknesses() {
>
<Flex direction="column" align="end" justify="between">
<Text size="1">
{strings.website.tools.tanks.sandbox.static.external}
{strings.website.tools.tanks.sandbox.static.modules}
</Text>
</Flex>
@@ -1,13 +1,16 @@
import { OrbitControls } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { PerspectiveCamera } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { degToRad } from 'three/src/math/MathUtils.js';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { Pose } from '../../../../../../core/blitzkit/pose';
import { Duel } from '../../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import {
SHOOTING_RANGE_ZOOM_COEFFICIENTS,
TankopediaEphemeral,
} from '../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
const poseDistances: Record<Pose, number> = {
@@ -80,11 +83,12 @@ export function Controls({ naked }: ControlsProps) {
// protagonistHullOrigin.y +
// protagonistTurretOrigin.y +
// protagonistGunOrigin.y;
// const inspectModeInitialPosition = [
// -8,
// gunHeight + (naked ? 10 : 4),
// -13,
// ] as const;
const gunHeight = 2;
const inspectModeInitialPosition = [
-8,
gunHeight + (naked ? 10 : 4),
-13,
] as const;
// const protagonistGunOriginOnlyY = new Vector3(
// 0,
// protagonistTurretModelDefinition.gun_origin.y,
@@ -206,131 +210,131 @@ export function Controls({ naked }: ControlsProps) {
// };
// }, [camera, protagonistTank.id, antagonistTank.id]);
// useEffect(() => {
// let isInitialIteration = true;
useEffect(() => {
let isInitialIteration = true;
// function handleDisturbance() {
// setAutoRotate(false);
// }
function handleDisturbance() {
setAutoRotate(false);
}
// function handleKeyDown(event: KeyboardEvent) {
// if (display !== TankopediaDisplay.ShootingRange || event.repeat) return;
// function handleKeyDown(event: KeyboardEvent) {
// if (display !== TankopediaDisplay.ShootingRange || event.repeat) return;
// if (event.key === 'Shift') {
// mutateTankopediaEphemeral((draft) => {
// draft.shootingRangeZoom =
// draft.shootingRangeZoom === ShootingRangeZoom.Arcade
// ? ShootingRangeZoom.Zoom0
// : ShootingRangeZoom.Arcade;
// });
// }
// }
// if (event.key === 'Shift') {
// mutateTankopediaEphemeral((draft) => {
// draft.shootingRangeZoom =
// draft.shootingRangeZoom === ShootingRangeZoom.Arcade
// ? ShootingRangeZoom.Zoom0
// : ShootingRangeZoom.Arcade;
// });
// }
// }
// function handleWheel(event: WheelEvent) {
// if (display !== TankopediaDisplay.ShootingRange) return;
// function handleWheel(event: WheelEvent) {
// if (display !== TankopediaDisplay.ShootingRange) return;
// event.preventDefault();
// event.preventDefault();
// mutateTankopediaEphemeral((draft) => {
// draft.shootingRangeZoom = clamp(
// draft.shootingRangeZoom - Math.sign(event.deltaY),
// ShootingRangeZoom.Arcade,
// ShootingRangeZoom.Zoom2,
// );
// });
// }
// mutateTankopediaEphemeral((draft) => {
// draft.shootingRangeZoom = clamp(
// draft.shootingRangeZoom - Math.sign(event.deltaY),
// ShootingRangeZoom.Arcade,
// ShootingRangeZoom.Zoom2,
// );
// });
// }
// function handleScroll(event: Event) {
// if (display !== TankopediaDisplay.ShootingRange) return;
// function handleScroll(event: Event) {
// if (display !== TankopediaDisplay.ShootingRange) return;
// event.preventDefault();
// }
// event.preventDefault();
// }
// poseEvent.on(handleDisturbance);
// canvas.addEventListener('pointerdown', handleDisturbance);
// window.addEventListener('keydown', handleKeyDown);
// canvas.addEventListener('wheel', handleWheel);
// document.body.addEventListener('scroll', handleScroll);
// poseEvent.on(handleDisturbance);
canvas.addEventListener('pointerdown', handleDisturbance);
// window.addEventListener('keydown', handleKeyDown);
// canvas.addEventListener('wheel', handleWheel);
// document.body.addEventListener('scroll', handleScroll);
// function updateCamera() {
// if (!orbitControls.current) return;
function updateCamera() {
if (!orbitControls.current) return;
// orbitControls.current.rotateSpeed = 0.25;
orbitControls.current.rotateSpeed = 0.25;
// if (display === TankopediaDisplay.ShootingRange) {
// const { shootingRangeZoom } = tankopediaEphemeralStore.getState();
if (display === TankopediaDisplay.ShootingRange) {
const { shootingRangeZoom } = tankopediaEphemeralStore.getState();
// orbitControls.current.enablePan = false;
// orbitControls.current.enableZoom = false;
// orbitControls.current.rotateSpeed *=
// SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
// camera.fov =
// ARCADE_MODE_FOV * SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
orbitControls.current.enablePan = false;
orbitControls.current.enableZoom = false;
orbitControls.current.rotateSpeed *=
SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
camera.fov =
ARCADE_MODE_FOV * SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
// if (shootingRangeZoom === ShootingRangeZoom.Arcade) {
// orbitControls.current.target.set(0, gunHeight + 3, 0);
// if (shootingRangeZoom === ShootingRangeZoom.Arcade) {
// orbitControls.current.target.set(0, gunHeight + 3, 0);
// if (isInitialIteration) {
// isInitialIteration = false;
// camera.position.set(
// 0,
// gunHeight + ARCADE_MODE_DISTANCE * Math.sin(ARCADE_MODE_ANGLE),
// ARCADE_MODE_DISTANCE * Math.cos(ARCADE_MODE_ANGLE),
// );
// } else {
// camera.position
// .copy(aimTarget)
// .sub(orbitControls.current.target)
// .normalize()
// .multiplyScalar(-ARCADE_MODE_DISTANCE)
// .add(orbitControls.current.target);
// }
// } else {
// orbitControls.current.target.copy(shellOrigin);
// if (isInitialIteration) {
// isInitialIteration = false;
// camera.position.set(
// 0,
// gunHeight + ARCADE_MODE_DISTANCE * Math.sin(ARCADE_MODE_ANGLE),
// ARCADE_MODE_DISTANCE * Math.cos(ARCADE_MODE_ANGLE),
// );
// } else {
// camera.position
// .copy(aimTarget)
// .sub(orbitControls.current.target)
// .normalize()
// .multiplyScalar(-ARCADE_MODE_DISTANCE)
// .add(orbitControls.current.target);
// }
// } else {
// orbitControls.current.target.copy(shellOrigin);
// camera.position
// .copy(aimTarget)
// .sub(shellOrigin)
// .normalize()
// .multiplyScalar(-Number.EPSILON)
// .add(shellOrigin);
// }
// } else {
// camera.position.set(...inspectModeInitialPosition);
// orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
// orbitControls.current.enablePan = true;
// orbitControls.current.enableZoom = true;
// camera.fov = naked ? 20 : 25;
// }
// camera.position
// .copy(aimTarget)
// .sub(shellOrigin)
// .normalize()
// .multiplyScalar(-Number.EPSILON)
// .add(shellOrigin);
// }
} else {
camera.position.set(...inspectModeInitialPosition);
orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true;
camera.fov = naked ? 20 : 25;
}
// camera.updateProjectionMatrix();
// }
camera.updateProjectionMatrix();
}
// updateCamera();
updateCamera();
// const unsubscribeDisplay = tankopediaEphemeralStore.subscribe(
// (state) => state.display,
// () => {
// handleDisturbance();
// updateCamera();
// },
// );
const unsubscribeDisplay = tankopediaEphemeralStore.subscribe(
(state) => state.display,
() => {
handleDisturbance();
updateCamera();
},
);
// const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
// (state) => state.shootingRangeZoom,
// updateCamera,
// );
const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
(state) => state.shootingRangeZoom,
updateCamera,
);
// return () => {
// canvas.removeEventListener('pointerdown', handleDisturbance);
// poseEvent.off(handleDisturbance);
// unsubscribeDisplay();
// unsubscribeShootingRangeZoom();
// window.removeEventListener('keydown', handleKeyDown);
// canvas.removeEventListener('wheel', handleWheel);
// document.body.removeEventListener('scroll', handleScroll);
// };
// }, [display === TankopediaDisplay.ShootingRange]);
return () => {
canvas.removeEventListener('pointerdown', handleDisturbance);
// poseEvent.off(handleDisturbance);
unsubscribeDisplay();
unsubscribeShootingRangeZoom();
// window.removeEventListener('keydown', handleKeyDown);
// canvas.removeEventListener('wheel', handleWheel);
// document.body.removeEventListener('scroll', handleScroll);
};
}, [display === TankopediaDisplay.ShootingRange]);
return (
<OrbitControls
+26 -32
View File
@@ -1,44 +1,38 @@
import type { GroupProps, MeshProps } from '@react-three/fiber';
import type { MeshProps, Object3DProps } from '@react-three/fiber';
import type { ReactNode } from 'react';
import { Mesh, Object3D } from 'three';
export function jsxTree(
node: Object3D,
mergers?: {
mesh?: MeshProps | ((mesh: Mesh) => MeshProps);
group?: GroupProps | ((object3d: Object3D) => GroupProps);
mesh?: (mesh: Mesh, props: MeshProps) => ReactNode;
group?: (object3d: Object3D, props: Object3DProps) => ReactNode;
},
key?: string,
) {
): ReactNode {
if (node instanceof Mesh) {
return (
<mesh
key={key}
geometry={node.geometry}
material={node.material}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(mergers?.mesh &&
(typeof mergers.mesh === 'function'
? mergers.mesh(node)
: mergers.mesh))}
/>
);
const props = {
key,
geometry: node.geometry,
material: node.material,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
return mergers?.mesh?.(node, props) ?? <mesh {...props} />;
} else if (node instanceof Object3D) {
return (
<group
key={key}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(mergers?.group &&
(typeof mergers.group === 'function'
? mergers.group(node)
: mergers.group))}
>
{node.children.map((child) => jsxTree(child, mergers, child.uuid))}
</group>
);
const props = {
key,
position: node.position,
rotation: node.rotation,
scale: node.scale,
children: node.children.map((child) =>
jsxTree(child, mergers, child.uuid),
),
};
return mergers?.group?.(node, props) ?? <group {...props} />;
}
return null;