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
+179 -82

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());
const clippingPlaneInverse = useRef(new Plane());
const material = useRef(
new MeshStandardMaterial({
transparent: isTrack,
opacity: 2 ** -1,
side: DoubleSide, side: DoubleSide,
color: moduleColor[armorPlate.type], color: moduleColor[armorPlate.type],
}); clippingPlanes: isModule ? undefined : [clippingPlane.current],
const outlineMaterial = new LineBasicMaterial({ }),
);
const outlineMaterial = useRef(
new LineBasicMaterial({
transparent: !isModule,
opacity: 2 ** -3,
side: DoubleSide, side: DoubleSide,
color: moduleOutlineColor[armorPlate.type], 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({
}; };
}, []); }, []);
if (isModule) {
return ( return (
<> <mesh material={material.current} ref={baseWrapper}>
{/* <mesh {...props} material={material} /> */}
<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,13 +99,13 @@ 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();
@@ -204,11 +204,15 @@ export function Controls({ naked }: ControlsProps) {
// 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,15 +245,55 @@ 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]}>
<planeGeometry args={[GRID_SIZE, GRID_SIZE]} />
<meshStandardMaterial
clippingPlanes={[clippingPlane.current]}
map={gridTexture}
transparent
/>
</mesh>
<group ref={dissector}>
<mesh <mesh
rotation={[-Math.PI / 2, 0, 0]} position={[GRID_SIZE / 8, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI / 2]}
onPointerDown={(event) => { onPointerDown={(event) => {
if (display !== TankopediaDisplay.Dissector) return;
event.stopPropagation(); event.stopPropagation();
position.set(event.nativeEvent.clientX, event.nativeEvent.clientY); position.current.set(
event.nativeEvent.clientX,
event.nativeEvent.clientY,
);
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.controlsEnabled = false; draft.controlsEnabled = false;
}); });
@@ -262,9 +302,11 @@ export function SceneProps() {
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
}} }}
> >
<planeGeometry args={[10, 10]} /> <planeGeometry args={[GRID_SIZE / 2, GRID_SIZE / 4]} />
<meshStandardMaterial ref={material} map={texture} transparent /> <meshStandardMaterial map={dragTexture} transparent />
</mesh> </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));