feat: dragger

This commit is contained in:
tresabhi committed 2025-03-23 17:18:42 -05:00
1 parent dcd7ce61f7
commit 5982fdde8c
4 files changed
+283 -186

No files matched your search

@@ -1,4 +1,16 @@
import { asset } from '@blitzkit/core'; import { asset, J_HAT } from '@blitzkit/core';
import {
amberDark,
blueDark,
bronzeDark,
indigoDark,
mauveDark,
orangeDark,
plumDark,
redDark,
skyDark,
slateDark,
} from '@radix-ui/colors';
import { import {
Base, Base,
Geometry, Geometry,
@@ -15,6 +27,7 @@ import {
LineBasicMaterial, LineBasicMaterial,
Material, Material,
MeshStandardMaterial, MeshStandardMaterial,
Plane,
Vector3, Vector3,
} from 'three'; } from 'three';
import { import {
@@ -33,28 +46,28 @@ interface DissectorSceneComponentProps {
const NEGATIVE_SIZE = 20; const NEGATIVE_SIZE = 20;
const armorColor = new Color().multiplyScalar(2 ** -8); const armorColor = new Color().multiplyScalar(2 ** -4);
const armorOutlineColor = armorColor.clone().multiplyScalar(2 ** 4.5); const armorOutlineColor = armorColor.clone().multiplyScalar(2 ** 4.5);
const moduleColor: Record<string, Color> = { const moduleColor: Record<string, Color> = {
Armor: armorColor, Armor: armorColor,
ArmorScreen: armorColor, ArmorScreen: armorColor,
Commander: new Color('white'), Commander: new Color(mauveDark.mauve11),
Driver: new Color('white'), Driver: new Color(mauveDark.mauve11),
Gunner1: new Color('white'), Gunner1: new Color(mauveDark.mauve11),
Loader1: new Color('white'), Loader1: new Color(mauveDark.mauve11),
SurveyingDevice: new Color('purple'), SurveyingDevice: new Color(plumDark.plum8),
Radio: new Color('indigo'), Radio: new Color(slateDark.slate8),
Gun: new Color('pink'), Gun: new Color(blueDark.blue8),
FuelTank: new Color('yellow'), FuelTank: new Color(orangeDark.orange8),
Engine: new Color('orange'), Engine: new Color(redDark.red8),
AmmoBay: new Color('gold'), AmmoBay: new Color(amberDark.amber8),
LeftTrack: new Color('green'), LeftTrack: new Color(bronzeDark.bronze8),
RightTrack: new Color('green'), RightTrack: new Color(bronzeDark.bronze8),
TurretRotator: new Color('blue'), TurretRotator: new Color(indigoDark.indigo8),
Transmission: new Color('cyan'), Transmission: new Color(skyDark.sky8),
}; };
const moduleOutlineColor: Record<string, Color> = { const moduleOutlineColor: Record<string, Color> = {
Armor: armorOutlineColor, Armor: armorOutlineColor,
@@ -87,27 +100,56 @@ export function DissectorSceneComponent({
const armorName = mesh.material.name; const armorName = mesh.material.name;
const armorPlate = groupArmor.armors[armorName]; const armorPlate = groupArmor.armors[armorName];
const isModule =
if (armorPlate.type === 'Armor' || armorPlate.type === 'ArmorScreen') { armorPlate.type !== 'Armor' && armorPlate.type !== 'ArmorScreen';
return null; const isTrack =
} armorPlate.type === 'LeftTrack' || armorPlate.type === 'RightTrack';
if (!(armorPlate.type in moduleColor)) { if (!(armorPlate.type in moduleColor)) {
throw new TypeError(`Unhandled armor type: ${armorPlate.type}`); throw new TypeError(`Unhandled armor type: ${armorPlate.type}`);
} }
const material = new MeshStandardMaterial({ const clippingPlane = useRef(new Plane());
side: DoubleSide, const clippingPlaneInverse = useRef(new Plane());
color: moduleColor[armorPlate.type], const material = useRef(
}); new MeshStandardMaterial({
const outlineMaterial = new LineBasicMaterial({ transparent: isTrack,
side: DoubleSide, opacity: 2 ** -1,
color: moduleOutlineColor[armorPlate.type], side: DoubleSide,
}); color: moduleColor[armorPlate.type],
clippingPlanes: isModule ? undefined : [clippingPlane.current],
}),
);
const outlineMaterial = useRef(
new LineBasicMaterial({
transparent: !isModule,
opacity: 2 ** -3,
side: DoubleSide,
color: moduleOutlineColor[armorPlate.type],
clippingPlanes: isModule ? undefined : [clippingPlane.current],
}),
);
const outlineMaterialInverse = useRef(
new LineBasicMaterial({
transparent: !isModule,
opacity: 2 ** -6,
side: DoubleSide,
color: moduleOutlineColor[armorPlate.type],
clippingPlanes: isModule ? undefined : [clippingPlaneInverse.current],
}),
);
const worldPosition = new Vector3(); const worldPosition = new Vector3();
useEffect(() => { useEffect(() => {
const offset = new Vector3();
function handleDissectEvent(event: DissectEventData) { function handleDissectEvent(event: DissectEventData) {
clippingPlane.current.normal
.set(0, 0, 1)
.applyAxisAngle(J_HAT, event.rotation - Math.PI / 2);
clippingPlane.current.constant = event.offset;
clippingPlaneInverse.current.copy(clippingPlane.current).negate();
if ( if (
!subtractionWrapper.current || !subtractionWrapper.current ||
!csg.current || !csg.current ||
@@ -116,9 +158,10 @@ export function DissectorSceneComponent({
return; return;
baseWrapper.current.getWorldPosition(worldPosition); baseWrapper.current.getWorldPosition(worldPosition);
offset.set(-event.offset, 0, 0).applyAxisAngle(J_HAT, event.rotation);
subtractionWrapper.current.position subtractionWrapper.current.position
.copy(worldPosition) .copy(worldPosition)
.add(offset)
.multiplyScalar(-1); .multiplyScalar(-1);
subtractionWrapper.current.rotation.y = event.rotation; subtractionWrapper.current.rotation.y = event.rotation;
csg.current.update(); csg.current.update();
@@ -132,13 +175,11 @@ export function DissectorSceneComponent({
}; };
}, []); }, []);
return ( if (isModule) {
<> return (
{/* <mesh {...props} material={material} /> */} <mesh material={material.current} ref={baseWrapper}>
<mesh material={material} ref={baseWrapper}>
<Geometry ref={csg}> <Geometry ref={csg}>
<Base geometry={props.geometry}></Base> <Base geometry={props.geometry} />
<group rotation={[0, 0, 0]} ref={subtractionWrapper}> <group rotation={[0, 0, 0]} ref={subtractionWrapper}>
<Subtraction position={[NEGATIVE_SIZE / 2, 0, 0]}> <Subtraction position={[NEGATIVE_SIZE / 2, 0, 0]}>
@@ -149,10 +190,17 @@ export function DissectorSceneComponent({
</group> </group>
</Geometry> </Geometry>
</mesh> </mesh>
);
}
{/* <lineSegments material={outlineMaterial}> return (
<>
<lineSegments material={outlineMaterial.current}>
<edgesGeometry args={[props.geometry, 45]} /> <edgesGeometry args={[props.geometry, 45]} />
</lineSegments> */} </lineSegments>
<lineSegments material={outlineMaterialInverse.current}>
<edgesGeometry args={[props.geometry, 45]} />
</lineSegments>
</> </>
); );
}, },
@@ -85,9 +85,9 @@ export function Controls({ naked }: ControlsProps) {
// protagonistGunOrigin.y; // protagonistGunOrigin.y;
const gunHeight = 2; const gunHeight = 2;
const inspectModeInitialPosition = [ const inspectModeInitialPosition = [
-8, 13,
gunHeight + (naked ? 10 : 4), gunHeight + (naked ? 10 : 4),
-13, -8,
] as const; ] as const;
// const protagonistGunOriginOnlyY = new Vector3( // const protagonistGunOriginOnlyY = new Vector3(
// 0, // 0,
@@ -99,116 +99,120 @@ export function Controls({ naked }: ControlsProps) {
// .add(protagonistTurretOrigin) // .add(protagonistTurretOrigin)
// .add(protagonistGunOriginOnlyY); // .add(protagonistGunOriginOnlyY);
// useEffect(() => { useEffect(() => {
// const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe( const unsubscribeTankopediaEphemeral = tankopediaEphemeralStore.subscribe(
// (state) => state.controlsEnabled, (state) => state.controlsEnabled,
// (enabled) => { (enabled) => {
// if (orbitControls.current) orbitControls.current.enabled = enabled; if (orbitControls.current) orbitControls.current.enabled = enabled;
// }, },
// ); );
// async function handlePoseEvent(event: Pose) { // async function handlePoseEvent(event: Pose) {
// const duel = duelStore.getState(); // const duel = duelStore.getState();
// const hasImprovedVerticalStabilizer = await hasEquipment( // const hasImprovedVerticalStabilizer = await hasEquipment(
// 122, // 122,
// duel.protagonist.tank.equipment_preset, // duel.protagonist.tank.equipment_preset,
// duel.protagonist.equipmentMatrix, // duel.protagonist.equipmentMatrix,
// ); // );
// const hasDownImprovedVerticalStabilizer = await hasEquipment( // const hasDownImprovedVerticalStabilizer = await hasEquipment(
// 124, // 124,
// duel.protagonist.tank.equipment_preset, // duel.protagonist.tank.equipment_preset,
// duel.protagonist.equipmentMatrix, // duel.protagonist.equipmentMatrix,
// ); // );
// switch (event) { // switch (event) {
// case Pose.HullDown: { // case Pose.HullDown: {
// const [pitch] = applyPitchYawLimits( // const [pitch] = applyPitchYawLimits(
// -Infinity, // -Infinity,
// 0, // 0,
// protagonistGunModelDefinition.pitch, // protagonistGunModelDefinition.pitch,
// protagonistTurretModelDefinition.yaw, // protagonistTurretModelDefinition.yaw,
// hasImprovedVerticalStabilizer, // hasImprovedVerticalStabilizer,
// hasDownImprovedVerticalStabilizer, // hasDownImprovedVerticalStabilizer,
// ); // );
// camera.position // camera.position
// .set(0, 0, 0) // .set(0, 0, 0)
// .add(protagonistHullOrigin) // .add(protagonistHullOrigin)
// .add(protagonistTurretOrigin) // .add(protagonistTurretOrigin)
// .add(protagonistGunOrigin) // .add(protagonistGunOrigin)
// .add( // .add(
// new Vector3( // new Vector3(
// 0, // 0,
// poseDistances[event] * Math.sin(pitch), // poseDistances[event] * Math.sin(pitch),
// poseDistances[event] * -Math.cos(pitch), // poseDistances[event] * -Math.cos(pitch),
// ), // ),
// ); // );
// camera.lookAt( // camera.lookAt(
// protagonistHullOrigin // protagonistHullOrigin
// .clone() // .clone()
// .add(protagonistTurretOrigin) // .add(protagonistTurretOrigin)
// .add(protagonistGunOrigin), // .add(protagonistGunOrigin),
// ); // );
// orbitControls.current?.target.set(0, antagonistGunHeight, 0); // orbitControls.current?.target.set(0, antagonistGunHeight, 0);
// break; // break;
// } // }
// case Pose.FaceHug: { // case Pose.FaceHug: {
// const [pitch] = applyPitchYawLimits( // const [pitch] = applyPitchYawLimits(
// 0, // 0,
// 0, // 0,
// protagonistGunModelDefinition.pitch, // protagonistGunModelDefinition.pitch,
// protagonistTurretModelDefinition.yaw, // protagonistTurretModelDefinition.yaw,
// hasImprovedVerticalStabilizer, // hasImprovedVerticalStabilizer,
// hasDownImprovedVerticalStabilizer, // hasDownImprovedVerticalStabilizer,
// ); // );
// camera.position // camera.position
// .set(0, 0, 0) // .set(0, 0, 0)
// .add(protagonistHullOrigin) // .add(protagonistHullOrigin)
// .add(protagonistTurretOrigin) // .add(protagonistTurretOrigin)
// .add(protagonistGunOrigin) // .add(protagonistGunOrigin)
// .add( // .add(
// new Vector3( // new Vector3(
// 0, // 0,
// poseDistances[event] * Math.sin(pitch), // poseDistances[event] * Math.sin(pitch),
// poseDistances[event] * -Math.cos(pitch), // poseDistances[event] * -Math.cos(pitch),
// ), // ),
// ); // );
// orbitControls.current?.target // orbitControls.current?.target
// .set(0, 0, 0) // .set(0, 0, 0)
// .add(protagonistHullOrigin) // .add(protagonistHullOrigin)
// .add(protagonistTurretOrigin) // .add(protagonistTurretOrigin)
// .add(protagonistGunOrigin) // .add(protagonistGunOrigin)
// .add( // .add(
// new Vector3( // new Vector3(
// 0, // 0,
// 0.5 * poseDistances[event] * Math.sin(pitch), // 0.5 * poseDistances[event] * Math.sin(pitch),
// 0.5 * poseDistances[event] * -Math.cos(pitch), // 0.5 * poseDistances[event] * -Math.cos(pitch),
// ), // ),
// ); // );
// break; // break;
// } // }
// case Pose.Default: { // case Pose.Default: {
// camera.position.set(...inspectModeInitialPosition); // camera.position.set(...inspectModeInitialPosition);
// orbitControls.current?.target.set(0, 1.25, 0); // orbitControls.current?.target.set(0, 1.25, 0);
// break; // break;
// } // }
// } // }
// invalidate(); // invalidate();
// } // }
// poseEvent.on(handlePoseEvent); // poseEvent.on(handlePoseEvent);
// return () => { return () => {
// unsubscribeTankopediaEphemeral(); unsubscribeTankopediaEphemeral();
// poseEvent.off(handlePoseEvent); // poseEvent.off(handlePoseEvent);
// }; };
// }, [camera, protagonistTank.id, antagonistTank.id]); }, [
camera,
// protagonistTank.id,
// antagonistTank.id
]);
useEffect(() => { useEffect(() => {
let isInitialIteration = true; let isInitialIteration = true;
@@ -1,33 +1,32 @@
import { imgur } from '@blitzkit/core'; import { imgur, J_HAT } from '@blitzkit/core';
import { useFrame, useLoader } from '@react-three/fiber'; import { useLoader } from '@react-three/fiber';
import { clamp } from 'lodash-es'; import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useRef } from 'react'; import { Group, Plane, TextureLoader, Vector2 } from 'three';
import { MeshStandardMaterial, TextureLoader, Vector2 } from 'three'; import { clamp } from 'three/src/math/MathUtils.js';
import { awaitableEquipmentDefinitions } from '../../../../../../../core/awaitables/equipmentDefinitions';
import { awaitableModelDefinitions } from '../../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../../core/awaitables/provisionDefinitions';
import { import {
dissectEvent, dissectEvent,
lastDissection, lastDissection,
type DissectEventData,
} from '../../../../../../../core/blitzkit/dissect'; } from '../../../../../../../core/blitzkit/dissect';
import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../../../../stores/tankopediaEphemeral';
import { TankopediaDisplay } from '../../../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from '../../../../../../../stores/tankopediaPersistent/constants';
const [modelDefinitions, equipmentDefinitions, provisionDefinitions] = // const [modelDefinitions, equipmentDefinitions, provisionDefinitions] =
await Promise.all([ // await Promise.all([
awaitableModelDefinitions, // awaitableModelDefinitions,
awaitableEquipmentDefinitions, // awaitableEquipmentDefinitions,
awaitableProvisionDefinitions, // awaitableProvisionDefinitions,
]); // ]);
const emptyVector = new Vector2(); // const emptyVector = new Vector2();
const GRID_SIZE = 10;
export function SceneProps() { export function SceneProps() {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
// const targetCircleWrapper = useRef<Group>(null); // const targetCircleWrapper = useRef<Group>(null);
// const clientTargetCircle = useRef<HTMLDivElement>(null); // const clientTargetCircle = useRef<HTMLDivElement>(null);
// const serverTargetCircle = useRef<HTMLDivElement>(null); // const serverTargetCircle = useRef<HTMLDivElement>(null);
const material = useRef<MeshStandardMaterial>(null);
// const playground = useRef<Group>(null); // const playground = useRef<Group>(null);
// let lastPitch = Duel.use((state) => state.protagonist.pitch); // let lastPitch = Duel.use((state) => state.protagonist.pitch);
@@ -90,19 +89,12 @@ export function SceneProps() {
// let { dispersion } = characteristics; // let { dispersion } = characteristics;
// const gunRotationSpeed = degToRad(characteristics.gunTraverseSpeed); // const gunRotationSpeed = degToRad(characteristics.gunTraverseSpeed);
// const mockTank = useLoader(GLTFLoader, asset(`3d/tanks/models/6929.glb`)); // const mockTank = useLoader(GLTFLoader, asset(`3d/tanks/models/6929.glb`));
const texture = useLoader( const gridTexture = useLoader(TextureLoader, imgur('iq0iOCJ'));
TextureLoader, const dragTexture = useLoader(TextureLoader, imgur('Vi3xXsb'));
imgur(display === TankopediaDisplay.Dissector ? 'GgYPSVA' : 'C28Z8nU'), gridTexture.anisotropy = 2;
);
texture.anisotropy = 2;
// const path = new Vector3(); // const path = new Vector3();
// const direction = new Vector3(); // const direction = new Vector3();
useFrame(({ camera }) => {
if (!material.current) return;
material.current.opacity = clamp(0.5 * camera.position.y, 0, 1);
});
// let lastTime = 0; // let lastTime = 0;
// let DEBUG_disable = false; // let DEBUG_disable = false;
@@ -225,15 +217,23 @@ export function SceneProps() {
// serverTargetCircle.current.style.height = targetCircleDotsSize; // serverTargetCircle.current.style.height = targetCircleDotsSize;
// }); // });
const position = new Vector2(); const dissector = useRef<Group>(null);
const delta = new Vector2(); const position = useRef(new Vector2());
const delta = useRef(new Vector2());
const clippingPlane = useRef(new Plane());
const handlePointerMove = useCallback((event: PointerEvent) => { const handlePointerMove = useCallback((event: PointerEvent) => {
delta.set(event.clientX, event.clientY).sub(position); delta.current.set(event.clientX, event.clientY).sub(position.current);
position.set(event.clientX, event.clientY); position.current.set(event.clientX, event.clientY);
dissectEvent.emit({ dissectEvent.emit({
rotation: lastDissection.rotation + delta.x / window.innerWidth, rotation:
offset: lastDissection.offset, lastDissection.rotation + (4 * delta.current.x) / window.innerWidth,
// offset: 0,
offset: clamp(
lastDissection.offset + (4 * delta.current.y) / window.innerHeight,
-5,
5,
),
}); });
}, []); }, []);
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
@@ -245,26 +245,68 @@ export function SceneProps() {
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
}, []); }, []);
useEffect(() => {
function handleDissectEvent(event: DissectEventData) {
clippingPlane.current.normal
.set(0, 0, 1)
.applyAxisAngle(J_HAT, event.rotation - Math.PI / 2);
clippingPlane.current.constant = event.offset;
if (!dissector.current) return;
dissector.current.rotation.set(0, event.rotation, 0);
dissector.current.position
.set(event.offset, 0, 0)
.applyAxisAngle(J_HAT, event.rotation);
}
dissectEvent.on(handleDissectEvent);
handleDissectEvent(lastDissection);
return () => {
dissectEvent.off(handleDissectEvent);
};
}, []);
return ( return (
<> <>
{display !== TankopediaDisplay.ShootingRange && ( {display !== TankopediaDisplay.ShootingRange && (
<mesh <>
rotation={[-Math.PI / 2, 0, 0]} <mesh rotation={[-Math.PI / 2, 0, 0]}>
onPointerDown={(event) => { <planeGeometry args={[GRID_SIZE, GRID_SIZE]} />
event.stopPropagation(); <meshStandardMaterial
clippingPlanes={[clippingPlane.current]}
map={gridTexture}
transparent
/>
</mesh>
position.set(event.nativeEvent.clientX, event.nativeEvent.clientY); <group ref={dissector}>
mutateTankopediaEphemeral((draft) => { <mesh
draft.controlsEnabled = false; position={[GRID_SIZE / 8, 0, 0]}
}); rotation={[-Math.PI / 2, 0, Math.PI / 2]}
onPointerDown={(event) => {
if (display !== TankopediaDisplay.Dissector) return;
window.addEventListener('pointermove', handlePointerMove); event.stopPropagation();
window.addEventListener('pointerup', handlePointerUp);
}} position.current.set(
> event.nativeEvent.clientX,
<planeGeometry args={[10, 10]} /> event.nativeEvent.clientY,
<meshStandardMaterial ref={material} map={texture} transparent /> );
</mesh> mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false;
});
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}}
>
<planeGeometry args={[GRID_SIZE / 2, GRID_SIZE / 4]} />
<meshStandardMaterial map={dragTexture} transparent />
</mesh>
</group>
</>
)} )}
{/* {display === TankopediaDisplay.ShootingRange && ( {/* {display === TankopediaDisplay.ShootingRange && (
@@ -7,6 +7,9 @@ export interface DissectEventData {
export const dissectEvent = new EventManager<DissectEventData>(); export const dissectEvent = new EventManager<DissectEventData>();
export const lastDissection: DissectEventData = { rotation: 0, offset: 0 }; export const lastDissection: DissectEventData = {
rotation: Math.PI / 2,
offset: 0,
};
dissectEvent.on((data) => Object.assign(lastDissection, data)); dissectEvent.on((data) => Object.assign(lastDissection, data));