diff --git a/bun.lock b/bun.lock index e5d773f08..7facd4e96 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ "@react-three/fiber": "^9.3.0", "@types/react": "^19.1.14", "@types/react-dom": "^19.1.9", + "@types/three": "^0.180.0", "@vite-pwa/astro": "^1.1.0", "astro": "^5.14.1", "astro-favicons": "^3.1.5", @@ -130,6 +131,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-promise-suspense": "^0.3.4", + "three": "^0.180.0", + "three-stdlib": "^2.36.0", "typescript": "^5.9.2", "urlon": "^3.1.0", }, @@ -1540,7 +1543,7 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], "file-type": ["file-type@5.2.0", "", {}, "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ=="], @@ -2414,7 +2417,7 @@ "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], - "three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + "three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], @@ -2758,9 +2761,9 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2832,6 +2835,8 @@ "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "tar-fs/tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], diff --git a/packages/website/package.json b/packages/website/package.json index 61e773215..8e53effb3 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -2,9 +2,9 @@ "name": "@blitzkit/website", "type": "module", "scripts": { - "dev": "astro dev", - "build": "PUBLIC_TARGET=app astro build", - "preview": "PUBLIC_TARGET=app astro preview", + "dev": "PUBLIC_TARGET=web astro dev", + "build": "PUBLIC_TARGET=web astro build", + "preview": "PUBLIC_TARGET=web astro preview", "lint": "astro check", "astro": "astro" }, @@ -21,6 +21,7 @@ "@react-three/fiber": "^9.3.0", "@types/react": "^19.1.14", "@types/react-dom": "^19.1.9", + "@types/three": "^0.180.0", "@vite-pwa/astro": "^1.1.0", "astro": "^5.14.1", "astro-favicons": "^3.1.5", @@ -32,6 +33,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-promise-suspense": "^0.3.4", + "three": "^0.180.0", + "three-stdlib": "^2.36.0", "typescript": "^5.9.2", "urlon": "^3.1.0" } diff --git a/packages/website/src/components/MixerScene/components/Model.tsx b/packages/website/src/components/MixerScene/components/Model.tsx index 471883278..7ac4694e1 100644 --- a/packages/website/src/components/MixerScene/components/Model.tsx +++ b/packages/website/src/components/MixerScene/components/Model.tsx @@ -1,11 +1,25 @@ +import { useThree, type ThreeEvent } from "@react-three/fiber"; +import type { QuicklimeEvent } from "quicklime"; +import { useCallback, useEffect, useRef } from "react"; +import { Group, Vector2 } from "three"; import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions"; import { jsxTree } from "../../../core/blitzkit/jsxTree"; +import { + modelTransformEvent, + type ModelTransformEventData, +} from "../../../core/blitzkit/modelTransform"; +import { controlsEnabledEvent } from "../../../core/controlsEnabled"; import { useModel } from "../../../hooks/useModel"; import { Mixer } from "../../../stores/mixer"; const modelDefinitions = await awaitableModelDefinitions; export function Model() { + const canvas = useThree((state) => state.gl.domElement); + + const turretGroup = useRef(null!); + const gunGroup = useRef(null!); + const hull = Mixer.use((state) => state.hull); const turret = Mixer.use((state) => state.turret); const gun = Mixer.use((state) => state.gun); @@ -31,6 +45,54 @@ export function Model() { const gunTurretModel = gunTankModel.turrets[gun.turret.id]; const gunModel = gunTurretModel.guns[gun.gun.id]; + const pointer = useRef(new Vector2()); + const delta = useRef(new Vector2()); + + const handlePointerDown = useCallback((event: ThreeEvent) => { + event.stopPropagation(); + + controlsEnabledEvent.dispatch(false); + pointer.current.set(event.clientX, event.clientY); + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }, []); + const handlePointerMove = useCallback((event: PointerEvent) => { + const bounds = canvas.getBoundingClientRect(); + + delta.current.set(event.clientX, event.clientY).sub(pointer.current); + pointer.current.set(event.clientX, event.clientY); + + modelTransformEvent.dispatch({ + pitch: + modelTransformEvent.last!.pitch + + delta.current.y * (Math.PI / bounds.height), + yaw: + modelTransformEvent.last!.yaw + + delta.current.x * (Math.PI / bounds.width), + }); + }, []); + const handlePointerUp = useCallback(() => { + controlsEnabledEvent.dispatch(true); + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }, []); + + useEffect(() => { + function handleModelTransform( + event: QuicklimeEvent + ) { + turretGroup.current.rotation.z = event.data.yaw; + } + + modelTransformEvent.on(handleModelTransform); + + return () => { + modelTransformEvent.off(handleModelTransform); + }; + }, []); + return ( {hullNodes.map((node) => { @@ -49,6 +111,8 @@ export function Model() { })} ; + return ( + + ); }, }); })} diff --git a/packages/website/src/components/MixerScene/index.tsx b/packages/website/src/components/MixerScene/index.tsx index 372293b4f..c1b9291f5 100644 --- a/packages/website/src/components/MixerScene/index.tsx +++ b/packages/website/src/components/MixerScene/index.tsx @@ -1,12 +1,30 @@ import { OrbitControls } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; -import { forwardRef, Suspense } from "react"; +import type { QuicklimeEvent } from "quicklime"; +import { forwardRef, Suspense, useEffect, useRef } from "react"; +import type { OrbitControls as OrbitControlsClass } from "three-stdlib"; +import { controlsEnabledEvent } from "../../core/controlsEnabled"; import { Lighting } from "../Tankopedia/HeroSection/components/TankSandbox/components/Lighting"; import { SceneProps } from "../Tankopedia/HeroSection/components/TankSandbox/components/SceneProps"; import { MixerFallback } from "./components/MixerFallback"; import { Model } from "./components/Model"; export const MixerScene = forwardRef((_, ref) => { + const controls = useRef(null!); + + useEffect(() => { + function handleControlsEnabled(event: QuicklimeEvent) { + if (!controls.current) return; + controls.current.enabled = event.data; + } + + controlsEnabledEvent.on(handleControlsEnabled); + + return () => { + controlsEnabledEvent.off(handleControlsEnabled); + }; + }, []); + return ( ((_, ref) => { }} > { - const unsubscribeTankopediaEphemeral = Tankopedia.on( - (state) => state.controlsEnabled, - (enabled) => { - if (orbitControls.current) orbitControls.current.enabled = enabled; - } - ); + function handleControlsEnabled(event: QuicklimeEvent) { + if (!orbitControls.current) return; + orbitControls.current.enabled = event.data; + } + + controlsEnabledEvent.on(handleControlsEnabled); function handlePoseEvent(event: Pose) { const hasImprovedVerticalStabilizer = hasEquipment( @@ -203,7 +205,7 @@ export function Controls({ poseEvent.on(handlePoseEvent); return () => { - unsubscribeTankopediaEphemeral(); + controlsEnabledEvent.off(handleControlsEnabled); poseEvent.off(handlePoseEvent); }; }, [camera, protagonistTank.id, antagonistTank.id]); @@ -264,7 +266,7 @@ export function Controls({ enableZoom={zoomable} zoomSpeed={zoomable ? undefined : 0} ref={orbitControls} - enabled={Tankopedia.state.controlsEnabled} + enabled={controlsEnabledEvent.last!} enableDamping={false} /> ); diff --git a/packages/website/src/core/controlsEnabled.ts b/packages/website/src/core/controlsEnabled.ts new file mode 100644 index 000000000..46ca66e73 --- /dev/null +++ b/packages/website/src/core/controlsEnabled.ts @@ -0,0 +1,3 @@ +import { Quicklime } from "quicklime"; + +export const controlsEnabledEvent = new Quicklime(true); diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index b34502759..03af6edd7 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -82,7 +82,6 @@ interface Tankopedia { shot?: Shot; skills: Record; relativeAgainst: TankopediaRelativeAgainst; - controlsEnabled: boolean; model: ModelDefinition; editStatic: boolean; highlightArmor?: {