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
+144 -290

No files matched your search

+2 -2
View File
@@ -900,7 +900,7 @@
"element": { "element": {
"primary": "Primary", "primary": "Primary",
"spaced": "Spaced", "spaced": "Spaced",
"external": "External", "modules": "Modules",
"gap": "Gap", "gap": "Gap",
"gun": "Gun", "gun": "Gun",
"track": "Tracks" "track": "Tracks"
@@ -922,7 +922,7 @@
"name": "Static armor", "name": "Static armor",
"primary": "Primary", "primary": "Primary",
"spaced": "Spaced", "spaced": "Spaced",
"external": "External", "modules": "Modules",
"edit": { "edit": {
"label": "Edit", "label": "Edit",
"reset": "Reset" "reset": "Reset"
@@ -1,10 +1,4 @@
import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei';
import { memo } from 'react'; 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'; import { StaticArmorSceneComponents } from './StaticArmorSceneComponents';
export interface ThicknessRange { export interface ThicknessRange {
@@ -55,11 +49,16 @@ export const StaticArmor = memo(() => {
// useTankTransform(track, turret, turretContainer, gunContainer); // useTankTransform(track, turret, turretContainer, gunContainer);
return ( return (
<group> <group>
<StaticArmorSceneComponents group="hull" /> <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> </group>
); );
}); });
@@ -1,39 +1,45 @@
import { asset } from '@blitzkit/core'; import { asset } from '@blitzkit/core';
import { useGLTF } from '@react-three/drei'; 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 { clamp } from 'three/src/math/MathUtils.js';
import { jsxTree } from '../../../core/blitzkit/jsxTree'; import { jsxTree } from '../../../core/blitzkit/jsxTree';
import { Duel } from '../../../stores/duel'; import { Duel } from '../../../stores/duel';
import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
type StaticArmorSceneComponentsProps = { interface StaticArmorSceneComponentsProps {
group: string; group: string;
}; }
const unselectedColor = new Color(0x404040); const moduleColor = new Color(192 / 255, 192 / 255, 192 / 255);
const externalModuleColor = new Color(192 / 255, 192 / 255, 192 / 255); const groupNameRegex = /([a-z]+)(_\d+)?/;
export function StaticArmorSceneComponents({ export function StaticArmorSceneComponents({
group, group,
}: StaticArmorSceneComponentsProps) { }: StaticArmorSceneComponentsProps) {
const tank = Duel.use((state) => state.protagonist.tank);
const showModules = TankopediaPersistent.use((state) => state.showModules); const showModules = TankopediaPersistent.use((state) => state.showModules);
const showArmorScreen = TankopediaPersistent.use( const showArmorScreen = TankopediaPersistent.use(
(state) => state.showArmorScreen, (state) => state.showArmorScreen,
); );
const showArmor = TankopediaPersistent.use((state) => state.showArmor); 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 gltf = useGLTF(asset(`tanks/${tank.id}/collision/${group}.glb`));
const armor = TankopediaEphemeral.use((state) => state.armor); 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( const thicknessRange = TankopediaEphemeral.use(
(state) => state.thicknessRange, (state) => state.thicknessRange,
); );
return jsxTree(gltf.scene, { return jsxTree(gltf.scene, {
mesh(mesh) { mesh(mesh, props) {
if (!(mesh.material instanceof Material)) { if (!(mesh.material instanceof Material)) {
return { visible: false }; return null;
} }
const armorName = mesh.material.name; const armorName = mesh.material.name;
@@ -44,216 +50,67 @@ export function StaticArmorSceneComponents({
switch (armorPlate.type) { switch (armorPlate.type) {
case 'Armor': case 'Armor':
if (!showArmor) return null;
if (x > 1) { if (x > 1) {
material.color = new Color(clamp(2 - x, 0.5, 1), 0, 0); material.color = new Color(clamp(2 - x, 0.5, 1), 0, 0);
} else { } else {
material.color = new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0); material.color = new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0);
} }
material.opacity = 1; material.opacity = 1;
material.visible = showArmor;
break; break;
case 'ArmorScreen': case 'ArmorScreen':
if (!showArmorScreen) return null;
material.color = new Color( material.color = new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1), clamp(1 - (7 / 8) * xClamped, 0, 1),
0, 0,
clamp(1 - (1 / 8) * xClamped, 0, 1), clamp(1 - (1 / 8) * xClamped, 0, 1),
); );
material.opacity = clamp(x + 1 / 2, 0, 1); material.opacity = clamp(x + 1 / 2, 0, 1);
material.visible = showArmorScreen;
break; break;
default: default:
material.color = externalModuleColor; if (!showModules) return null;
material.opacity = 7 / 8;
material.depthWrite = false; material.color = moduleColor;
material.visible = showModules; material.opacity = 1 / 2;
break; 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.opacity = clamp(material.opacity, 0, 1);
material.transparent = material.opacity < 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"> <Flex direction="column" align="end" justify="between">
<Text size="1"> <Text size="1">
{strings.website.tools.tanks.sandbox.static.external} {strings.website.tools.tanks.sandbox.static.modules}
</Text> </Text>
</Flex> </Flex>
@@ -1,13 +1,16 @@
import { OrbitControls } from '@react-three/drei'; import { OrbitControls } from '@react-three/drei';
import { useThree } from '@react-three/fiber'; import { useThree } from '@react-three/fiber';
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { PerspectiveCamera } from 'three'; import { PerspectiveCamera } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib'; import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { degToRad } from 'three/src/math/MathUtils.js'; import { degToRad } from 'three/src/math/MathUtils.js';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions'; import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { Pose } from '../../../../../../core/blitzkit/pose'; import { Pose } from '../../../../../../core/blitzkit/pose';
import { Duel } from '../../../../../../stores/duel'; 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'; import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
const poseDistances: Record<Pose, number> = { const poseDistances: Record<Pose, number> = {
@@ -80,11 +83,12 @@ export function Controls({ naked }: ControlsProps) {
// protagonistHullOrigin.y + // protagonistHullOrigin.y +
// protagonistTurretOrigin.y + // protagonistTurretOrigin.y +
// protagonistGunOrigin.y; // protagonistGunOrigin.y;
// const inspectModeInitialPosition = [ const gunHeight = 2;
// -8, const inspectModeInitialPosition = [
// gunHeight + (naked ? 10 : 4), -8,
// -13, gunHeight + (naked ? 10 : 4),
// ] as const; -13,
] as const;
// const protagonistGunOriginOnlyY = new Vector3( // const protagonistGunOriginOnlyY = new Vector3(
// 0, // 0,
// protagonistTurretModelDefinition.gun_origin.y, // protagonistTurretModelDefinition.gun_origin.y,
@@ -206,12 +210,12 @@ export function Controls({ naked }: ControlsProps) {
// }; // };
// }, [camera, protagonistTank.id, antagonistTank.id]); // }, [camera, protagonistTank.id, antagonistTank.id]);
// useEffect(() => { useEffect(() => {
// let isInitialIteration = true; let isInitialIteration = true;
// function handleDisturbance() { function handleDisturbance() {
// setAutoRotate(false); setAutoRotate(false);
// } }
// function handleKeyDown(event: KeyboardEvent) { // function handleKeyDown(event: KeyboardEvent) {
// if (display !== TankopediaDisplay.ShootingRange || event.repeat) return; // if (display !== TankopediaDisplay.ShootingRange || event.repeat) return;
@@ -247,25 +251,25 @@ export function Controls({ naked }: ControlsProps) {
// } // }
// poseEvent.on(handleDisturbance); // poseEvent.on(handleDisturbance);
// canvas.addEventListener('pointerdown', handleDisturbance); canvas.addEventListener('pointerdown', handleDisturbance);
// window.addEventListener('keydown', handleKeyDown); // window.addEventListener('keydown', handleKeyDown);
// canvas.addEventListener('wheel', handleWheel); // canvas.addEventListener('wheel', handleWheel);
// document.body.addEventListener('scroll', handleScroll); // document.body.addEventListener('scroll', handleScroll);
// function updateCamera() { function updateCamera() {
// if (!orbitControls.current) return; if (!orbitControls.current) return;
// orbitControls.current.rotateSpeed = 0.25; orbitControls.current.rotateSpeed = 0.25;
// if (display === TankopediaDisplay.ShootingRange) { if (display === TankopediaDisplay.ShootingRange) {
// const { shootingRangeZoom } = tankopediaEphemeralStore.getState(); const { shootingRangeZoom } = tankopediaEphemeralStore.getState();
// orbitControls.current.enablePan = false; orbitControls.current.enablePan = false;
// orbitControls.current.enableZoom = false; orbitControls.current.enableZoom = false;
// orbitControls.current.rotateSpeed *= orbitControls.current.rotateSpeed *=
// SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom]; SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
// camera.fov = camera.fov =
// ARCADE_MODE_FOV * SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom]; ARCADE_MODE_FOV * SHOOTING_RANGE_ZOOM_COEFFICIENTS[shootingRangeZoom];
// if (shootingRangeZoom === ShootingRangeZoom.Arcade) { // if (shootingRangeZoom === ShootingRangeZoom.Arcade) {
// orbitControls.current.target.set(0, gunHeight + 3, 0); // orbitControls.current.target.set(0, gunHeight + 3, 0);
@@ -295,42 +299,42 @@ export function Controls({ naked }: ControlsProps) {
// .multiplyScalar(-Number.EPSILON) // .multiplyScalar(-Number.EPSILON)
// .add(shellOrigin); // .add(shellOrigin);
// } // }
// } else { } else {
// camera.position.set(...inspectModeInitialPosition); camera.position.set(...inspectModeInitialPosition);
// orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0); orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
// orbitControls.current.enablePan = true; orbitControls.current.enablePan = true;
// orbitControls.current.enableZoom = true; orbitControls.current.enableZoom = true;
// camera.fov = naked ? 20 : 25; camera.fov = naked ? 20 : 25;
// } }
// camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
// } }
// updateCamera(); updateCamera();
// const unsubscribeDisplay = tankopediaEphemeralStore.subscribe( const unsubscribeDisplay = tankopediaEphemeralStore.subscribe(
// (state) => state.display, (state) => state.display,
// () => { () => {
// handleDisturbance(); handleDisturbance();
// updateCamera(); updateCamera();
// }, },
// ); );
// const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe( const unsubscribeShootingRangeZoom = tankopediaEphemeralStore.subscribe(
// (state) => state.shootingRangeZoom, (state) => state.shootingRangeZoom,
// updateCamera, updateCamera,
// ); );
// return () => { return () => {
// canvas.removeEventListener('pointerdown', handleDisturbance); canvas.removeEventListener('pointerdown', handleDisturbance);
// poseEvent.off(handleDisturbance); // poseEvent.off(handleDisturbance);
// unsubscribeDisplay(); unsubscribeDisplay();
// unsubscribeShootingRangeZoom(); unsubscribeShootingRangeZoom();
// window.removeEventListener('keydown', handleKeyDown); // window.removeEventListener('keydown', handleKeyDown);
// canvas.removeEventListener('wheel', handleWheel); // canvas.removeEventListener('wheel', handleWheel);
// document.body.removeEventListener('scroll', handleScroll); // document.body.removeEventListener('scroll', handleScroll);
// }; };
// }, [display === TankopediaDisplay.ShootingRange]); }, [display === TankopediaDisplay.ShootingRange]);
return ( return (
<OrbitControls <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'; import { Mesh, Object3D } from 'three';
export function jsxTree( export function jsxTree(
node: Object3D, node: Object3D,
mergers?: { mergers?: {
mesh?: MeshProps | ((mesh: Mesh) => MeshProps); mesh?: (mesh: Mesh, props: MeshProps) => ReactNode;
group?: GroupProps | ((object3d: Object3D) => GroupProps); group?: (object3d: Object3D, props: Object3DProps) => ReactNode;
}, },
key?: string, key?: string,
) { ): ReactNode {
if (node instanceof Mesh) { if (node instanceof Mesh) {
return ( const props = {
<mesh key,
key={key} geometry: node.geometry,
geometry={node.geometry} material: node.material,
material={node.material} position: node.position,
position={node.position} rotation: node.rotation,
rotation={node.rotation} scale: node.scale,
scale={node.scale} };
{...(mergers?.mesh &&
(typeof mergers.mesh === 'function' return mergers?.mesh?.(node, props) ?? <mesh {...props} />;
? mergers.mesh(node)
: mergers.mesh))}
/>
);
} else if (node instanceof Object3D) { } else if (node instanceof Object3D) {
return ( const props = {
<group key,
key={key} position: node.position,
position={node.position} rotation: node.rotation,
rotation={node.rotation} scale: node.scale,
scale={node.scale} children: node.children.map((child) =>
{...(mergers?.group && jsxTree(child, mergers, child.uuid),
(typeof mergers.group === 'function' ),
? mergers.group(node) };
: mergers.group))}
> return mergers?.group?.(node, props) ?? <group {...props} />;
{node.children.map((child) => jsxTree(child, mergers, child.uuid))}
</group>
);
} }
return null; return null;