Files
blitzkit/packages/website/src/components/StaticArmorSceneComponent.tsx
T
2026-09-27 16:46:22 -05:00

330 lines
9.6 KiB
TypeScript

import { I_HAT, J_HAT } from "@blitzkit/core";
import { invalidate, useThree } from "@react-three/fiber";
import { clamp } from "lodash-es";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useMemo, type ComponentProps } from "react";
import {
Box3,
Color,
DoubleSide,
FrontSide,
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
Object3D,
Plane,
Quaternion,
Vector3,
} from "three";
import { unrotateDavaVector } from "../core/blitz/unrotateDavaVector";
import { jsxTree } from "../core/blitzkit/jsxTree";
import {
modelTransformEvent,
type ModelTransformEventData,
} from "../core/blitzkit/modelTransform";
import { defaultEqualizer } from "../core/blitzkit/tankToDuelMember";
import { discardClippingPlane } from "../core/three/discardClippingPlane";
import { useEquipment } from "../hooks/useEquipment";
import { Duel } from "../stores/duel";
import { Tankopedia } from "../stores/tankopedia";
import { transitionEvent } from "./Lighting";
import { ArmorType } from "./SpacedArmorScene";
import type {
ArmorUserData,
ExternalModuleVariant,
} from "./SpacedArmorSceneComponent";
import type { ThicknessRange } from "./StaticArmor";
type StaticArmorSceneComponentProps = {
name: string;
thickness: number;
thicknessRange: ThicknessRange;
node: Object3D;
onPointerDown?: ComponentProps<"mesh">["onPointerDown"];
} & (
| {
type: Exclude<ArmorType, ArmorType.External>;
}
| {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
) &
(
| {
clip?: undefined;
}
| {
clip: Plane;
hullOrigin: Vector3;
turretOrigin: Vector3;
gunOrigin: Vector3;
}
);
const unselectedColor = new Color(0x404040);
const externalModuleColor = new Color(192 / 255, 192 / 255, 192 / 255);
export function StaticArmorSceneComponent({
name,
thickness: thicknessRaw,
thicknessRange,
node,
onPointerDown,
...props
}: StaticArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
const hasEnhancedArmor = useEquipment("protagonist", 110);
const tank = Duel.use((state) => state.protagonist.tank);
const equalize = Duel.use((state) => state.equalize);
const equalizer = (equalize ? tank.equalizer : undefined) ?? defaultEqualizer;
const thicknessCoefficient = (hasEnhancedArmor ? 1.04 : 1) * equalizer.armor;
const thickness = thicknessRaw * thicknessCoefficient;
const x = thickness / thicknessRange.value;
const xClamped = clamp(x, 0, 1);
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);
}
}
case ArmorType.Spaced: {
return new Color(
clamp(1 - (7 / 8) * xClamped, 0, 1),
0,
clamp(1 - (1 / 8) * xClamped, 0, 1),
);
}
case ArmorType.External: {
return externalModuleColor;
}
}
}, [props.type, xClamped]);
const opacity = useMemo(() => {
switch (props.type) {
case ArmorType.Primary:
return 1;
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]);
const surfaceMaterial = useMemo(
() =>
new MeshBasicMaterial({
color,
transparent: true,
depthWrite,
...(props.clip ? { clippingPlanes: [props.clip] } : {}),
opacity: 0,
userData: { opacity0: opacity },
}),
[hasEnhancedArmor, equalize],
);
const outlineMaterial = useMemo(
() =>
new LineBasicMaterial({
color: color
.clone()
.multiplyScalar(props.type === ArmorType.Spaced ? 2 ** 2 : 2 ** -1),
opacity: 0,
transparent: true,
}),
[hasEnhancedArmor, equalize],
);
useEffect(() => {
function handleHighlightArmor(selectedName?: string) {
if (selectedName === undefined) {
// nothing selected, go back to defaults
surfaceMaterial.opacity = opacity;
surfaceMaterial.transparent = opacity < 1;
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = true;
} else if (
selectedName === name ||
(name.startsWith("chassis_") && selectedName.startsWith("chassis_")) ||
(name.startsWith("gun_") &&
selectedName.startsWith("gun_") &&
!name.includes("_armor_") &&
!selectedName.includes("_armor_"))
) {
// this selected, stand out!
surfaceMaterial.opacity = 1;
surfaceMaterial.transparent = false;
surfaceMaterial.color = color;
surfaceMaterial.depthWrite = true;
surfaceMaterial.side = DoubleSide;
outlineMaterial.visible = true;
} else {
// something else selected, become background
surfaceMaterial.opacity = 1 / 4;
surfaceMaterial.transparent = true;
surfaceMaterial.color = unselectedColor;
surfaceMaterial.depthWrite = props.type !== ArmorType.External;
surfaceMaterial.side = FrontSide;
outlineMaterial.visible = false;
}
surfaceMaterial.needsUpdate = true;
invalidate();
}
handleHighlightArmor();
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);
const unsubscribes = [
Tankopedia.on(
(state) => state.highlightArmor?.name,
handleHighlightArmor,
),
() => transitionEvent.off(handleTransitionEvent),
];
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(
unrotateDavaVector(props.turretOrigin.clone()),
);
const barrelOrigin = neckOrigin.clone().add(gunOrigin);
const distanceToBarrel = clip.distanceToPoint(barrelOrigin);
const point = new Vector3();
function handleModelTransform(
event: QuicklimeEvent<ModelTransformEventData>,
) {
clip.normal
.set(0, 0, -1)
.applyAxisAngle(I_HAT, event.data.pitch)
.applyAxisAngle(J_HAT, event.data.yaw);
clip.constant = 0;
point
.copy(gunOrigin)
.applyAxisAngle(J_HAT, event.data.yaw)
.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());
};
}, [hasEnhancedArmor, equalize]);
return (
<>
{jsxTree(node, {
mesh(_, meshProps, key) {
return (
<mesh
{...meshProps}
key={key}
castShadow
material={surfaceMaterial}
onPointerDown={onPointerDown}
userData={
{
type: props.type,
variant:
props.type === ArmorType.External ? props.variant : "gun",
thickness,
} satisfies ArmorUserData
}
onClick={(event) => {
if (discardClippingPlane(event.object, event.point)) return;
event.stopPropagation();
const { editStatic } = Tankopedia.state;
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(
event.object.getWorldQuaternion(new Quaternion()),
);
const angle = surfaceNormal.angleTo(cameraNormal);
const thicknessAngled =
thickness / Math.sin(Math.PI / 2 - angle);
Tankopedia.mutate((draft) => {
draft.highlightArmor = {
type: props.type,
name,
point,
thickness,
thicknessAngled,
angle,
color: `#${color.getHexString()}`,
editingPlate: editStatic,
};
});
}}
/>
);
},
})}
{node instanceof Mesh && (
<lineSegments material={outlineMaterial}>
<edgesGeometry args={[node.geometry, 45]} />
</lineSegments>
)}
</>
);
}