This commit is contained in:
tresabhi committed 2026-09-23 17:25:51 -05:00
1 parent 960ca4b0f4
commit 4247004b13
20 files changed
+279 -251

No files matched your search

@@ -1,9 +0,0 @@
import { useFrame } from "@react-three/fiber";
export function AutoClear() {
useFrame(({ gl }) => {
gl.clear();
}, 0);
return null;
}
@@ -1,271 +0,0 @@
import { I_HAT, J_HAT } from "@blitzkit/core";
import { OrbitControls } from "@react-three/drei";
import { invalidate, useFrame, useThree } from "@react-three/fiber";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { PerspectiveCamera, Vector3 } from "three";
import { OrbitControls as OrbitControlsClass } from "three-stdlib";
import { api } from "../../../../../../api/dynamic";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { Pose, poseEvent } from "../../../../../../core/blitzkit/pose";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
const poseDistances: Record<Pose, number> = {
[Pose.HullDown]: 15,
[Pose.FaceHug]: 5,
[Pose.Default]: -1,
};
const inspectModeInitialPosition = new Vector3(-8, 2, -13);
const modelDefinitions = await api.models();
interface ControlsProps {
autoRotate?: boolean;
zoomable?: boolean;
enableRotate?: boolean;
}
export function Controls({
autoRotate = true,
zoomable = true,
enableRotate = true,
}: ControlsProps) {
const camera = useThree((state) => state.camera as PerspectiveCamera);
const canvas = useThree((state) => state.gl.domElement);
const orbitControls = useRef<OrbitControlsClass>(null);
const protagonistTurret = Duel.use((state) => state.protagonist.turret);
const antagonistTurret = Duel.use((state) => state.antagonist.turret);
const protagonistTrack = Duel.use((state) => state.protagonist.track);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const antagonistTank = Duel.use((state) => state.antagonist.tank);
const protagonistGun = Duel.use((state) => state.protagonist.gun);
const protagonistModelDefinition =
modelDefinitions.models[protagonistTank.id];
const protagonistTrackModelDefinition =
modelDefinitions.models[protagonistTank.id].tracks[protagonistTrack.id];
const antagonistModelDefinition = modelDefinitions.models[antagonistTank.id];
const protagonistTurretModelDefinition =
protagonistModelDefinition.turrets[protagonistTurret.id];
const antagonistTurretModelDefinition =
antagonistModelDefinition.turrets[antagonistTurret.id];
const protagonistGunModelDefinition =
protagonistTurretModelDefinition.guns[protagonistGun.id];
const protagonistHullOrigin = new Vector3(
protagonistTrackModelDefinition.origin!.x,
protagonistTrackModelDefinition.origin!.y,
-protagonistTrackModelDefinition.origin!.z,
);
const protagonistTurretOrigin = new Vector3(
protagonistModelDefinition.turret_origin!.x,
protagonistModelDefinition.turret_origin!.y,
-protagonistModelDefinition.turret_origin!.z,
);
const protagonistGunOrigin = new Vector3(
protagonistTurretModelDefinition.gun_origin!.x,
protagonistTurretModelDefinition.gun_origin!.y,
-protagonistTurretModelDefinition.gun_origin!.z,
);
const antagonistGunHeight =
protagonistTrackModelDefinition.origin!.y +
antagonistModelDefinition.turret_origin!.y +
antagonistTurretModelDefinition.gun_origin!.y;
const disturbed = Tankopedia.use((state) => state.disturbed);
const doAutoRotate = autoRotate && !disturbed;
const gunHeight =
protagonistHullOrigin.y +
protagonistTurretOrigin.y +
protagonistGunOrigin.y;
useEffect(() => {
function handleControlsEnabled(event: QuicklimeEvent<boolean>) {
if (!orbitControls.current) return;
orbitControls.current.enabled = event.data;
}
controlsEnabledEvent.on(handleControlsEnabled);
function handlePoseEvent(event: Pose) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
switch (event) {
case Pose.HullDown: {
const [pitch] = applyPitchYawLimits(
-Infinity,
0,
protagonistGunModelDefinition.pitch!,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
camera.position
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch),
),
);
camera.lookAt(
protagonistHullOrigin
.clone()
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin),
);
orbitControls.current?.target.set(0, antagonistGunHeight, 0);
break;
}
case Pose.FaceHug: {
const [pitch] = applyPitchYawLimits(
0,
0,
protagonistGunModelDefinition.pitch!,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
camera.position
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
poseDistances[event] * Math.sin(pitch),
poseDistances[event] * -Math.cos(pitch),
),
);
orbitControls.current?.target
.set(0, 0, 0)
.add(protagonistHullOrigin)
.add(protagonistTurretOrigin)
.add(protagonistGunOrigin)
.add(
new Vector3(
0,
0.5 * poseDistances[event] * Math.sin(pitch),
0.5 * poseDistances[event] * -Math.cos(pitch),
),
);
break;
}
case Pose.Default: {
camera.position.copy(inspectModeInitialPosition);
orbitControls.current?.target.set(0, 1.25, 0);
break;
}
}
invalidate();
}
poseEvent.on(handlePoseEvent);
return () => {
controlsEnabledEvent.off(handleControlsEnabled);
poseEvent.off(handlePoseEvent);
};
}, [camera, protagonistTank.id, antagonistTank.id]);
useEffect(() => {
function handleWheel(event: WheelEvent) {
event.preventDefault();
}
function handleScroll(event: Event) {
event.preventDefault();
}
function disturb() {
Tankopedia.mutate((draft) => {
draft.disturbed = true;
});
}
poseEvent.on(disturb);
canvas.addEventListener("pointerdown", disturb);
canvas.addEventListener("wheel", handleWheel);
document.body.addEventListener("scroll", handleScroll);
function updateCamera() {
if (!orbitControls.current) return;
orbitControls.current.rotateSpeed = 0.25;
camera.position.copy(inspectModeInitialPosition);
orbitControls.current.target.set(0, gunHeight * 0.6, 0);
orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true;
camera.fov = 25;
camera.updateProjectionMatrix();
}
updateCamera();
return () => {
canvas.removeEventListener("pointerdown", disturb);
poseEvent.off(disturb);
canvas.removeEventListener("wheel", handleWheel);
document.body.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<>
{doAutoRotate && <Animator />}
<OrbitControls
enableRotate={enableRotate}
maxDistance={40}
minDistance={5}
enableZoom={zoomable}
zoomSpeed={zoomable ? undefined : 0}
ref={orbitControls}
enabled={controlsEnabledEvent.last!}
enableDamping={false}
/>
</>
);
}
function Animator() {
const clock = useThree((state) => state.clock);
const t0 = useRef(clock.elapsedTime);
useFrame(({ camera }) => {
const t = clock.elapsedTime - t0.current;
camera.position
.copy(inspectModeInitialPosition)
.applyAxisAngle(I_HAT, (Math.PI / 32) * Math.sin(t / 9))
.applyAxisAngle(J_HAT, (-Math.PI / 16) * Math.sin(t / 7));
invalidate();
});
return null;
}
@@ -1,31 +0,0 @@
import { useEffect } from "react";
import { degToRad } from "three/src/math/MathUtils.js";
import { api } from "../../../../../../api/dynamic";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform";
import { Duel } from "../../../../../../stores/duel";
const modelDefinitions = await api.models();
export function InitialAligner() {
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
useEffect(() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(8),
degToRad(25),
gunModel.pitch!,
turretModel.yaw,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, []);
return null;
}
@@ -1,200 +0,0 @@
import { blueDark, orangeDark } from "@radix-ui/colors";
import { ContactShadows } from "@react-three/drei";
import { invalidate, useFrame } from "@react-three/fiber";
import { clamp, times } from "lodash-es";
import { Quicklime } from "quicklime";
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from "react";
import { HemisphereLight, SpotLight, type Group } from "three";
import { degToRad, lerp } from "three/src/math/MathUtils.js";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaPersistent } from "../../../../../../stores/tankopediaPersistent";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
const ANGLE = degToRad(15);
const REVEAL_ANIMATION_TIME = 3;
const TRANSITION_ANIMATION_TIME = 0.5;
const LIGHTS_COUNT = 3;
const THETA_OFFSET = degToRad(180 - 45);
const LIGHT_DISTANCE = 20;
const LIGHT_HEIGHT_0 = 7;
const LIGHT_HEIGHT_1 = 10;
const INTENSITY_0 = 2 ** 6;
const INTENSITY_1 = 2 ** 3;
const HEMISPHERE_INTENSITY = 2 ** 0.7;
const NON_PBR_FACTOR = 2;
const SHADOW_RESOLUTION = 2 ** 9;
const SHADOW_FOCUS = 2 ** 0;
export const transitionEvent = new Quicklime<number>(0);
interface Props {
hasPbr: boolean;
}
export function Lighting({ hasPbr }: Props) {
const wrapper = useRef<Group>(null);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
const isRevealing = useRef(true);
const [animate, setAnimate] = useState(true);
useEffect(() => {
Tankopedia.mutate((draft) => {
draft.revealed = true;
});
}, []);
useEffect(() => {
if (isRevealing.current) {
isRevealing.current = false;
} else {
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
}
setAnimate(true);
}, [requestedDisplay]);
return (
<>
<ContactShadows
width={1.5}
height={1.5}
near={0}
far={1}
opacity={2 ** 1}
blur={2 ** 1}
resolution={2 ** 9}
depthWrite={false}
/>
{animate && (
<Animator
hasPbr={hasPbr}
t0={t0}
animationTime={animationTime}
wrapper={wrapper}
stop={() => {
setAnimate(false);
}}
/>
)}
<group ref={wrapper}>
<hemisphereLight
intensity={0}
color={blueDark.blue12}
groundColor={orangeDark.orange8}
/>
{times(LIGHTS_COUNT, (index) => {
const x = index / (LIGHTS_COUNT - 1);
const theta = 2 * Math.PI * (index / LIGHTS_COUNT) + THETA_OFFSET;
const position = [
-LIGHT_DISTANCE * Math.sin(theta),
lerp(LIGHT_HEIGHT_0, LIGHT_HEIGHT_1, x),
LIGHT_DISTANCE * Math.cos(theta),
] as const;
const intensity = lerp(INTENSITY_0, INTENSITY_1, x);
const light = useRef<SpotLight>(null!);
// useHelper(light, SpotLightHelper);
return (
<spotLight
ref={light}
key={index}
position={position}
intensity={intensity}
penumbra={1}
castShadow={highGraphics}
decay={1}
color="#ffffff"
angle={0}
target-position={[0, 1.5, 0]}
shadow-mapSize={[SHADOW_RESOLUTION, SHADOW_RESOLUTION]}
shadow-focus={SHADOW_FOCUS}
/>
);
})}
</group>
</>
);
}
interface AnimatorProps {
stop: () => void;
t0: RefObject<number>;
animationTime: RefObject<number>;
wrapper: RefObject<Group | null>;
hasPbr: boolean;
}
function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) {
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const display = Tankopedia.use((state) => state.display);
const factor =
!hasPbr && display !== TankopediaDisplay.StaticArmor ? NON_PBR_FACTOR : 1;
useEffect(() => {
invalidate();
}, [requestedDisplay === display]);
const apply = useCallback((t: number) => {
if (!wrapper.current) return;
for (const child of wrapper.current.children) {
if (child instanceof SpotLight) {
child.angle = ANGLE * t * factor;
} else if (child instanceof HemisphereLight) {
child.intensity = HEMISPHERE_INTENSITY * t * factor;
}
}
transitionEvent.dispatch(t);
}, []);
useFrame(({ invalidate }) => {
const dt = performance.now() / 1e3 - t0.current;
const x = clamp(dt / animationTime.current, 0, 2);
if (x === 2) {
apply(1);
stop();
return;
}
// https://www.desmos.com/calculator/awsbahxjku
const t = (0.5 * Math.sin(Math.PI * (x + 0.5)) + 0.5) ** 2;
apply(t);
invalidate();
if (x >= 1 && Tankopedia.state.display !== requestedDisplay) {
Tankopedia.mutate((draft) => {
draft.display = requestedDisplay;
});
}
});
return null;
}
@@ -1,86 +0,0 @@
import { mauveDark } from "@radix-ui/colors";
import { invalidate } from "@react-three/fiber";
import { Quicklime, type QuicklimeEvent } from "quicklime";
import { useCallback, useEffect, useRef } from "react";
import { Mesh, type WebGLProgramParametersWithUniforms } from "three";
const SIZE = 2 ** 4;
export const screenshotReadyEvent = new Quicklime(false);
export function SceneProps() {
const mesh = useRef<Mesh>(null!);
useEffect(() => {
function handleScreenshotReady(event: QuicklimeEvent<boolean>) {
mesh.current.visible = !event.data;
invalidate();
}
screenshotReadyEvent.on(handleScreenshotReady);
return () => {
screenshotReadyEvent.off(handleScreenshotReady);
};
}, []);
const handleBeforeCompile = useCallback(
(shader: WebGLProgramParametersWithUniforms) => {
shader.uniforms.size = { value: SIZE / 2 };
shader.vertexShader = `
varying vec3 vWorldPosition;
${shader.vertexShader}
`.replace(
"#include <worldpos_vertex>",
`
#include <worldpos_vertex>
vWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
`,
);
shader.fragmentShader = `
varying vec3 vWorldPosition;
uniform float size;
${shader.fragmentShader}
`.replace(
"#include <color_fragment>",
`
#include <color_fragment>
vec3 scaled = vWorldPosition / size;
float radius = length(scaled.xz);
if (radius >= 1.0) {
discard;
}
diffuseColor.a *= (1.0 - radius);
`,
);
},
[],
);
return (
<mesh
position={[0, -(2 ** -8), 0]}
rotation={[-Math.PI / 2, 0, 0]}
visible={!screenshotReadyEvent.last!}
ref={mesh}
receiveShadow
>
<planeGeometry args={[SIZE, SIZE]} />
<meshStandardMaterial
color={mauveDark.mauve6}
roughness={1}
transparent
onBeforeCompile={handleBeforeCompile}
/>
</mesh>
);
}
@@ -1,265 +0,0 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { useModel } from "../../../../../../hooks/useModel";
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist);
const track = Duel.use((state) => state.protagonist.track);
const turret = Duel.use((state) => state.protagonist.turret);
const canvas = useThree((state) => state.gl.domElement);
const hullContainer = useRef<Group>(null);
const turretContainer = useRef<Group>(null!);
const gunContainer = useRef<Group>(null!);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
useTankTransform(track, turret, turretContainer, gunContainer);
return (
<ModelTankWrapper ref={hullContainer}>
{nodes.map((node) => {
const isHull = node.name === "hull";
const isWheel = node.name.startsWith("chassis_wheel_");
const isTrack = node.name.startsWith("chassis_track_");
const isVisible = isHull || isWheel || isTrack;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
function translateTexture(offset: number) {
const mesh = node.children[0] as Mesh;
const material = mesh?.material as MeshStandardMaterial;
if (!material) return;
invalidate();
if (material.map) material.map.offset.y += offset;
if (material.aoMap) material.aoMap.offset.y += offset;
if (material.normalMap) material.normalMap.offset.y += offset;
if (material.roughnessMap) material.roughnessMap.offset.y += offset;
if (material.metalnessMap) material.metalnessMap.offset.y += offset;
}
function onPointerDown(event: ThreeEvent<PointerEvent>) {
if (isTrack && Tankopedia.state.display === TankopediaDisplay.Model) {
position.set(event.clientX, event.clientY);
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
} else {
event.stopPropagation();
}
}
function handlePointerMove(event: PointerEvent) {
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
const deltaX = delta.x / window.innerWidth;
const deltaY = delta.y / window.innerHeight;
translateTexture(deltaX + deltaY);
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
<group ref={turretContainer}>
{nodes.map((node) => {
const isTurret = node.name.startsWith("turret_");
const isCurrentTurret =
node.name ===
`turret_${turretModelDefinition.model_id
.toString()
.padStart(2, "0")}`;
const isVisible = isCurrentTurret;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
if (!isTurret) return;
position.set(event.clientX, event.clientY);
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw +
delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
<group ref={gunContainer}>
{nodes.map((node) => {
const isCurrentMantlet =
node.name ===
`gun_${gunModelDefinition.model_id
.toString()
.padStart(2, "0")}_mask`;
const isCurrentGun =
node.name ===
`gun_${gunModelDefinition.model_id.toString().padStart(2, "0")}`;
const isVisible = isCurrentGun || isCurrentMantlet;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
let pitch = 0;
let yaw = 0;
function onPointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation();
controlsEnabledEvent.dispatch(false);
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
position.set(event.clientX, event.clientY);
pitch = modelTransformEvent.last!.pitch;
yaw = modelTransformEvent.last!.yaw;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = hasEquipment(
122,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const hasDownImprovedVerticalStabilizer = hasEquipment(
124,
Duel.state.protagonist.tank.equipment_preset,
Duel.state.protagonist.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}
function handlePointerUp() {
controlsEnabledEvent.dispatch(true);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
mesh(_, props, key) {
return (
<mesh
{...props}
key={key}
onPointerDown={onPointerDown}
castShadow
receiveShadow
/>
);
},
});
})}
</group>
</group>
</ModelTankWrapper>
);
}
@@ -1,200 +0,0 @@
import {
forwardRef,
Suspense,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { applyPitchYawLimits } from "../../../../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../../../../../core/blitzkit/modelTransform";
import { Pose, poseEvent } from "../../../../../core/blitzkit/pose";
import { useEquipment } from "../../../../../hooks/useEquipment";
import { useModel } from "../../../../../hooks/useModel";
import { useTankModelDefinition } from "../../../../../hooks/useTankModelDefinition";
import { Duel } from "../../../../../stores/duel";
import { Tankopedia } from "../../../../../stores/tankopedia";
import { TankopediaPersistent } from "../../../../../stores/tankopediaPersistent";
import { TankopediaDisplay } from "../../../../../stores/tankopediaPersistent/constants";
import { Armor } from "../../../../Armor";
import { ArmorPlateDisplay } from "../../../../Armor/components/ArmorPlateDisplay";
import { ShotDisplay } from "../../../../Armor/components/ShotDisplay";
import {
StaticArmor,
type ThicknessRange,
} from "../../../../Armor/components/StaticArmor";
import { SmartCanvas } from "../../../../SmartCanvas";
import { AutoClear } from "./components/AutoClear";
import { Controls } from "./components/Control";
import { InitialAligner } from "./components/InitialAligner";
import { Lighting } from "./components/Lighting";
import { SceneProps } from "./components/SceneProps";
import { TankModel } from "./components/TankModel";
interface TankSandboxProps {
thicknessRange: ThicknessRange;
}
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
({ thicknessRange }, ref) => {
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const protagonistGun = Duel.use((state) => state.protagonist.gun);
const protagonistTurret = Duel.use((state) => state.protagonist.turret);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonistTurret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonistGun.id];
const display = Tankopedia.use((state) => state.display);
const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor,
);
const id = Duel.use((state) => state.protagonist.tank.id);
const { hasPbr } = useModel(id);
useImperativeHandle(ref, () => canvas.current!, []);
function handlePointerDown() {
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
useEffect(() => {
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
case Pose.FaceHug: {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
case Pose.Default:
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
break;
}
}
poseEvent.on(handlePoseEvent);
return () => {
poseEvent.off(handlePoseEvent);
};
});
useEffect(() => {
const [pitch, yaw] = applyPitchYawLimits(
modelTransformEvent.last!.pitch,
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
}, [protagonistGun, protagonistTurret]);
useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) {
Tankopedia.mutate((draft) => {
draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
Tankopedia.mutate((draft) => {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<SmartCanvas
resize={{ debounce: 0 }}
ref={canvas}
gl={{
clippingPlanes: [],
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows="soft"
onPointerDown={handlePointerDown}
onPointerMissed={() => {
Tankopedia.mutate((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
}}
style={{
userSelect: "none",
width: "100%",
height: "100%",
outline: undefined,
}}
>
<Lighting hasPbr={hasPbr} />
<SceneProps />
{(display === TankopediaDisplay.Model ||
(display === TankopediaDisplay.DynamicArmor &&
!hideTankModelUnderArmor)) && <TankModel />}
<ShotDisplay />
<ArmorPlateDisplay />
<AutoClear />
<Suspense>
{/* Controls within Suspense to allow for frame-perfect start of camera auto-rotate */}
<Controls />
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} />
)}
</Suspense>
<InitialAligner />
</SmartCanvas>
);
},
);