Files
blitzkit/packages/website/src/components/StaticArmorSceneComponent.tsx
T

329 lines
9.6 KiB
TypeScript
Raw Normal View History

2025-09-01 22:56:39 -05:00
import { I_HAT, J_HAT } from "@blitzkit/core";
2026-02-18 18:45:39 -06:00
import { invalidate, useThree } from "@react-three/fiber";
2025-09-01 22:56:39 -05:00
import { clamp } from "lodash-es";
import type { QuicklimeEvent } from "quicklime";
2025-09-27 16:07:45 -05:00
import { useEffect, useMemo, type ComponentProps } from "react";
2024-07-31 13:36:56 -05:00
import {
Box3,
Color,
DoubleSide,
FrontSide,
LineBasicMaterial,
Mesh,
2025-08-28 17:59:31 -05:00
MeshBasicMaterial,
2024-07-31 13:36:56 -05:00
Object3D,
Plane,
Quaternion,
2024-08-02 13:48:24 -05:00
Vector3,
2025-09-01 22:56:39 -05:00
} from "three";
2026-09-30 17:56:31 -05:00
import { defaultEqualizer } from "../config/equalizer";
2026-09-27 16:46:22 -05:00
import { unrotateDavaVector } from "../core/blitz/unrotateDavaVector";
import { jsxTree } from "../core/blitzkit/jsxTree";
2024-09-07 21:45:00 -05:00
import {
modelTransformEvent,
2025-09-27 16:07:45 -05:00
type ModelTransformEventData,
2026-09-27 16:46:22 -05:00
} from "../core/blitzkit/modelTransform";
import { discardClippingPlane } from "../core/three/discardClippingPlane";
2026-09-30 17:56:31 -05:00
import { DuelModule, useDuel } from "../hooks/useDuel";
2026-09-27 16:46:22 -05:00
import { useEquipment } from "../hooks/useEquipment";
import { Tankopedia } from "../stores/tankopedia";
import { transitionEvent } from "./Lighting";
2025-09-01 22:56:39 -05:00
import { ArmorType } from "./SpacedArmorScene";
2024-09-22 21:12:18 -05:00
import type {
2024-07-31 13:36:56 -05:00
ArmorUserData,
ExternalModuleVariant,
2025-09-01 22:56:39 -05:00
} from "./SpacedArmorSceneComponent";
import type { ThicknessRange } from "./StaticArmor";
2024-07-31 13:36:56 -05:00
type StaticArmorSceneComponentProps = {
name: string;
thickness: number;
thicknessRange: ThicknessRange;
node: Object3D;
2025-09-27 16:07:45 -05:00
onPointerDown?: ComponentProps<"mesh">["onPointerDown"];
2024-07-31 13:36:56 -05:00
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
2024-08-02 13:48:24 -05:00
) &
(
| {
clip?: undefined;
}
| {
clip: Plane;
hullOrigin: Vector3;
turretOrigin: Vector3;
gunOrigin: Vector3;
}
);
2024-07-31 13:36:56 -05:00
const unselectedColor = new Color(0x404040);
const externalModuleColor = new Color(192 / 255, 192 / 255, 192 / 255);
2024-07-31 13:36:56 -05:00
export function StaticArmorSceneComponent({
name,
2026-04-03 14:53:14 -05:00
thickness: thicknessRaw,
2024-07-31 13:36:56 -05:00
thicknessRange,
node,
2024-08-02 13:48:24 -05:00
onPointerDown,
2024-07-31 13:36:56 -05:00
...props
}: StaticArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
2026-09-27 16:26:28 -05:00
const hasEnhancedArmor = useEquipment("protagonist", 110);
2026-09-30 17:56:31 -05:00
const tank = useDuel("protagonist", DuelModule.Tank);
const equalize = Tankopedia.use((state) => state.equalize);
2026-04-03 14:53:14 -05:00
const equalizer = (equalize ? tank.equalizer : undefined) ?? defaultEqualizer;
2026-07-31 19:48:03 -05:00
const thicknessCoefficient = (hasEnhancedArmor ? 1.04 : 1) * equalizer.armor;
2026-04-03 14:53:14 -05:00
const thickness = thicknessRaw * thicknessCoefficient;
2024-07-31 13:36:56 -05:00
const x = thickness / thicknessRange.value;
2024-07-31 13:48:14 -05:00
const xClamped = clamp(x, 0, 1);
2026-04-03 14:53:14 -05:00
const color = useMemo(() => {
switch (props.type) {
case ArmorType.Primary: {
if (x > 1) {
return new Color(clamp(2 - x, 0.5, 1), 0, 0);
} else {
return new Color(-((1 - x) ** 2) + 1, -(x ** 2) + 1, 0);
}
2024-07-31 13:36:56 -05:00
}
2026-04-03 14:53:14 -05:00
case ArmorType.Spaced: {
return new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1),
0,
clamp(1 - (1 / 8) * xClamped, 0, 1),
);
}
2024-07-31 13:36:56 -05:00
2026-04-03 14:53:14 -05:00
case ArmorType.External: {
return externalModuleColor;
}
}
}, [props.type, xClamped]);
const opacity = useMemo(() => {
switch (props.type) {
case ArmorType.Primary:
return 1;
2024-07-31 13:36:56 -05:00
2026-04-03 14:53:14 -05:00
case ArmorType.Spaced:
return clamp(x + 1 / 2, 0, 1);
case ArmorType.External:
return 1 / 8;
}
}, [props.type, x]);
const depthWrite = useMemo(() => {
switch (props.type) {
case ArmorType.Primary:
return true;
case ArmorType.Spaced:
return true;
case ArmorType.External:
return false;
}
}, [props.type]);
2024-07-31 13:36:56 -05:00
const surfaceMaterial = useMemo(
() =>
2025-08-28 17:59:31 -05:00
new MeshBasicMaterial({
2024-07-31 13:36:56 -05:00
color,
2025-11-27 13:42:37 -06:00
transparent: true,
2024-07-31 13:36:56 -05:00
depthWrite,
2024-08-02 13:48:24 -05:00
...(props.clip ? { clippingPlanes: [props.clip] } : {}),
2025-11-27 13:42:37 -06:00
opacity: 0,
userData: { opacity0: opacity },
2024-07-31 13:36:56 -05:00
}),
2026-04-03 14:53:14 -05:00
[hasEnhancedArmor, equalize],
2024-07-31 13:36:56 -05:00
);
const outlineMaterial = useMemo(
() =>
new LineBasicMaterial({
2024-07-31 13:48:14 -05:00
color: color
.clone()
2024-08-01 14:21:39 -05:00
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
2025-11-27 13:42:37 -06:00
opacity: 0,
transparent: true,
2024-07-31 13:36:56 -05:00
}),
2026-04-03 14:53:14 -05:00
[hasEnhancedArmor, equalize],
2024-07-31 13:36:56 -05:00
);
useEffect(() => {
function handleHighlightArmor(selectedName?: string) {
if (selectedName === undefined) {
// nothing selected, go back to defaults
surfaceMaterial.opacity = opacity;
2024-08-01 14:15:05 -05:00
surfaceMaterial.transparent = opacity < 1;
2024-07-31 13:36:56 -05:00
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = true;
} else if (
selectedName === name ||
2025-09-01 22:56:39 -05:00
(name.startsWith("chassis_") && selectedName.startsWith("chassis_")) ||
(name.startsWith("gun_") &&
selectedName.startsWith("gun_") &&
!name.includes("_armor_") &&
!selectedName.includes("_armor_"))
2024-07-31 13:36:56 -05:00
) {
// this selected, stand out!
surfaceMaterial.opacity = 1;
2024-08-01 14:15:05 -05:00
surfaceMaterial.transparent = false;
2024-07-31 13:36:56 -05:00
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = true;
surfaceMaterial.side = DoubleSide;
outlineMaterial.visible = true;
} else {
// something else selected, become background
surfaceMaterial.opacity = 1 / 4;
2024-08-01 14:15:05 -05:00
surfaceMaterial.transparent = true;
2024-07-31 13:36:56 -05:00
surfaceMaterial.color = unselectedColor;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = false;
}
2024-08-01 14:15:05 -05:00
surfaceMaterial.needsUpdate = true;
2026-02-18 18:45:39 -06:00
invalidate();
2024-07-31 13:36:56 -05:00
}
handleHighlightArmor();
2025-11-27 13:42:37 -06:00
function handleTransitionEvent(event: QuicklimeEvent<number>) {
surfaceMaterial.opacity = surfaceMaterial.userData.opacity0 * event.data;
outlineMaterial.opacity = event.data;
surfaceMaterial.transparent = surfaceMaterial.opacity < 1;
outlineMaterial.transparent = outlineMaterial.opacity < 1;
}
transitionEvent.on(handleTransitionEvent);
2024-08-02 13:48:24 -05:00
const unsubscribes = [
2025-09-01 22:56:39 -05:00
Tankopedia.on(
2024-08-02 13:48:24 -05:00
(state) => state.highlightArmor?.name,
2026-02-18 18:45:39 -06:00
handleHighlightArmor,
2024-08-02 13:48:24 -05:00
),
2025-11-27 13:42:37 -06:00
() => transitionEvent.off(handleTransitionEvent),
2024-08-02 13:48:24 -05:00
];
2024-07-31 13:36:56 -05:00
2024-08-02 13:48:24 -05:00
if (props.clip) {
/**
* hook inside an if statement?? don't panic! I assure you the clip prop
* never mutates :)
*/
const { clip } = props;
const gunOrigin = unrotateDavaVector(props.gunOrigin.clone());
const neckOrigin = unrotateDavaVector(props.hullOrigin.clone()).add(
2026-02-18 18:45:39 -06:00
unrotateDavaVector(props.turretOrigin.clone()),
2024-08-02 13:48:24 -05:00
);
const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3();
2025-08-12 13:43:11 -05:00
function handleModelTransform(
2026-02-18 18:45:39 -06:00
event: QuicklimeEvent<ModelTransformEventData>,
2025-08-12 13:43:11 -05:00
) {
2024-08-02 13:48:24 -05:00
clip.normal
.set(0, 0, -1)
2025-08-12 13:43:11 -05:00
.applyAxisAngle(I_HAT, event.data.pitch)
.applyAxisAngle(J_HAT, event.data.yaw);
2024-08-02 13:48:24 -05:00
clip.constant = 0;
point
.copy(gunOrigin)
2025-08-12 13:43:11 -05:00
.applyAxisAngle(J_HAT, event.data.yaw)
2024-08-02 13:48:24 -05:00
.add(clip.normal.multiplyScalar(-distanceToBarrel))
.add(neckOrigin);
clip.normal.normalize();
clip.constant -= clip.distanceToPoint(point);
}
modelTransformEvent.on(handleModelTransform);
unsubscribes.push(() => modelTransformEvent.off(handleModelTransform));
}
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
2026-04-03 14:53:14 -05:00
}, [hasEnhancedArmor, equalize]);
2024-07-31 13:36:56 -05:00
return (
<>
{jsxTree(node, {
2025-04-15 00:11:49 -05:00
mesh(_, meshProps, key) {
return (
<mesh
{...meshProps}
key={key}
2026-07-05 14:04:09 -05:00
castShadow
2025-04-15 00:11:49 -05:00
material={surfaceMaterial}
2025-04-20 21:34:57 -05:00
onPointerDown={onPointerDown}
2025-04-15 00:11:49 -05:00
userData={
{
type: props.type,
variant:
2025-09-01 22:56:39 -05:00
props.type === ArmorType.External ? props.variant : "gun",
2025-04-15 00:11:49 -05:00
thickness,
} satisfies ArmorUserData
}
onClick={(event) => {
if (discardClippingPlane(event.object, event.point)) return;
2024-07-31 13:36:56 -05:00
2025-04-15 00:11:49 -05:00
event.stopPropagation();
2024-07-31 14:18:58 -05:00
2025-09-01 22:56:39 -05:00
const { editStatic } = Tankopedia.state;
2024-07-31 13:36:56 -05:00
2025-04-15 00:11:49 -05:00
const bounds = new Box3().setFromObject(event.object);
const point = bounds.min
.clone()
.add(bounds.max)
.divideScalar(2)
.setY(bounds.max.y);
const cameraNormal = camera.position
.clone()
.sub(point)
.normalize();
const surfaceNormal = event
.normal!.clone()
.applyQuaternion(
2026-02-18 18:45:39 -06:00
event.object.getWorldQuaternion(new Quaternion()),
2025-04-15 00:11:49 -05:00
);
const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled =
thickness / Math.sin(Math.PI / 2 - angle);
2024-07-31 13:36:56 -05:00
2025-09-01 22:56:39 -05:00
Tankopedia.mutate((draft) => {
2025-04-15 00:11:49 -05:00
draft.highlightArmor = {
type: props.type,
name,
point,
thickness,
thicknessAngled,
angle,
color: `#${color.getHexString()}`,
editingPlate: editStatic,
};
});
}}
/>
);
2024-07-31 13:36:56 -05:00
},
})}
2024-08-01 14:19:52 -05:00
{node instanceof Mesh && (
<lineSegments material={outlineMaterial}>
2024-08-01 14:21:39 -05:00
<edgesGeometry args={[node.geometry, 45]} />
2024-08-01 14:19:52 -05:00
</lineSegments>
)}
2024-07-31 13:36:56 -05:00
</>
);
}