2026-09-23 17:25:51 -05:00
|
|
|
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";
|
2026-09-27 16:26:28 -05:00
|
|
|
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
2026-09-23 17:25:51 -05:00
|
|
|
import { useEquipment } from "../../hooks/useEquipment";
|
|
|
|
|
import { useModel } from "../../hooks/useModel";
|
|
|
|
|
import { Tankopedia } from "../../stores/tankopedia";
|
|
|
|
|
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
|
|
|
|
|
import { TankopediaDisplay } from "../../stores/tankopediaPersistent/constants";
|
2026-09-27 16:46:22 -05:00
|
|
|
import { ArmorPlateDisplay } from "../ArmorPlateDisplay";
|
2026-09-23 17:25:51 -05:00
|
|
|
import { CanvasAutoClear } from "../CanvasAutoClear";
|
|
|
|
|
import { CanvasControl } from "../CanvasControl";
|
2026-09-27 16:46:22 -05:00
|
|
|
import { DynamicArmor } from "../DynamicArmor";
|
2026-09-23 17:25:51 -05:00
|
|
|
import { InitialCameraAligner } from "../InitialCameraAligner";
|
|
|
|
|
import { Lighting } from "../Lighting";
|
|
|
|
|
import { SceneProps } from "../SceneProps";
|
2026-09-27 16:46:22 -05:00
|
|
|
import { ShotDisplay } from "../ShotDisplay";
|
2026-09-23 17:25:51 -05:00
|
|
|
import { SmartCanvas } from "../SmartCanvas";
|
2026-09-27 16:46:22 -05:00
|
|
|
import { StaticArmor, type ThicknessRange } from "../StaticArmor";
|
2026-09-23 17:25:51 -05:00
|
|
|
import { TankModel } from "../TankModel";
|
|
|
|
|
import styles from "./index.module.css";
|
|
|
|
|
|
|
|
|
|
interface TankSandboxProps {
|
|
|
|
|
thicknessRange: ThicknessRange;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const TankopediaSandbox = forwardRef<
|
|
|
|
|
HTMLCanvasElement,
|
|
|
|
|
TankSandboxProps
|
|
|
|
|
>(({ thicknessRange }, ref) => {
|
|
|
|
|
const canvas = useRef<HTMLCanvasElement>(null);
|
2026-09-27 16:26:28 -05:00
|
|
|
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
|
|
|
|
|
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
|
2026-09-23 17:25:51 -05:00
|
|
|
|
2026-09-27 13:49:02 -05:00
|
|
|
const tank = useDuel("protagonist", DuelModule.Tank);
|
|
|
|
|
const gun = useDuel("protagonist", DuelModule.Gun);
|
2026-09-27 16:26:28 -05:00
|
|
|
const turret = useDuel("protagonist", DuelModule.Turret);
|
2026-09-23 17:25:51 -05:00
|
|
|
|
|
|
|
|
const model = Tankopedia.use((state) => state.protagonist.model);
|
|
|
|
|
const turretModelDefinition = model.turrets[turret.id];
|
|
|
|
|
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
|
|
|
|
const display = Tankopedia.use((state) => state.display);
|
|
|
|
|
const hideTankModelUnderArmor = TankopediaPersistent.use(
|
|
|
|
|
(state) => state.hideTankModelUnderArmor,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { hasPbr } = useModel(tank.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 });
|
|
|
|
|
}, [gun, turret]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (display !== TankopediaDisplay.DynamicArmor) {
|
|
|
|
|
Tankopedia.mutate((draft) => {
|
|
|
|
|
draft.shot = undefined;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (display !== TankopediaDisplay.StaticArmor) {
|
|
|
|
|
Tankopedia.mutate((draft) => {
|
|
|
|
|
draft.highlightArmor = undefined;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [display]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.wrapper}>
|
|
|
|
|
<SmartCanvas
|
|
|
|
|
className={styles.canvas}
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Lighting hasPbr={hasPbr} />
|
|
|
|
|
|
|
|
|
|
<SceneProps />
|
|
|
|
|
{(display === TankopediaDisplay.Model ||
|
|
|
|
|
(display === TankopediaDisplay.DynamicArmor &&
|
|
|
|
|
!hideTankModelUnderArmor)) && <TankModel />}
|
|
|
|
|
|
|
|
|
|
<ShotDisplay />
|
|
|
|
|
<ArmorPlateDisplay />
|
|
|
|
|
<CanvasAutoClear />
|
|
|
|
|
|
|
|
|
|
<Suspense>
|
|
|
|
|
{/* Controls within Suspense to allow for frame-perfect start of camera auto-rotate */}
|
|
|
|
|
<CanvasControl />
|
|
|
|
|
|
2026-09-27 16:46:22 -05:00
|
|
|
{display === TankopediaDisplay.DynamicArmor && <DynamicArmor />}
|
2026-09-23 17:25:51 -05:00
|
|
|
{display === TankopediaDisplay.StaticArmor && (
|
|
|
|
|
<StaticArmor thicknessRange={thicknessRange} />
|
|
|
|
|
)}
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
|
|
|
|
<InitialCameraAligner />
|
|
|
|
|
</SmartCanvas>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|