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

318 lines
8.6 KiB
TypeScript

import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from "@radix-ui/themes";
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { clamp } from "lodash-es";
import { useEffect, useRef, type ComponentProps, type RefObject } from "react";
import {
BufferGeometry,
DoubleSide,
Euler,
Group,
Line,
LineDashedMaterial,
Mesh,
MeshBasicMaterial,
Path,
Quaternion,
} from "three";
import { isHalloween } from "../core/blitzkit/isHalloween";
import { LocaleProvider, useLocale } from "../hooks/useLocale";
import {
Tankopedia,
type ShotLayerNonExternal,
type ShotStatus,
} from "../stores/tankopedia";
import { ShotDisplayCard } from "./ShotDisplayCard";
export const shotStatusColors: Record<
ShotStatus,
ComponentProps<typeof Text>["color"]
> = {
blocked: "red",
penetration: "green",
ricochet: "yellow",
splash: "orange",
};
const SURFACE_POINT_SIZE = 12;
export const SHOT_DISPLAY_LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() {
const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
const { locale } = useLocale();
useEffect(() => {
if (shot?.splashRadius !== undefined) {
const outlineGeometry = new BufferGeometry().setFromPoints(
new Path().absarc(0, 0, 1, 0, Math.PI * 2).getSpacedPoints(50),
);
const outlineMaterial = new LineDashedMaterial({
color: "orange",
dashSize: 0.05,
gapSize: 0.025,
});
const outline = new Line(
outlineGeometry,
outlineMaterial,
).computeLineDistances();
splashRadiusWrapper.current?.add(outline);
return () => {
splashRadiusWrapper.current?.remove(outline);
};
}
});
useEffect(() => {
if (
isHalloween() &&
shot?.splashRadius !== undefined &&
shot.in.status === "penetration"
) {
const audio = new Audio("/assets/audio/lotta-damage.mp3");
audio.currentTime = 0.7;
audio.volume = 0.25;
audio.play();
}
}, [shot]);
if (!shot) return null;
const inLength =
shot.in.layers.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + SHOT_DISPLAY_LENGTH_INFINITY;
const outLength =
shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point,
)
: SHOT_DISPLAY_LENGTH_INFINITY;
const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null,
) as ShotLayerNonExternal;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
const tracerGoingUp =
shot.in.surfaceNormal.y + (shot.out?.surfaceNormal.y ?? 0) > 0;
return (
<group>
<Animator
inTracer={inTracer}
outTracer={outTracer}
inLength={inLength}
outLength={outLength}
splashRadiusWrapper={splashRadiusWrapper}
splashRadiusMaterial={splashRadiusMaterial}
/>
{shot.splashRadius && (
<group
position={inLast.point}
ref={splashRadiusWrapper}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(K_HAT, shot.in.surfaceNormal),
)}
>
<mesh>
<circleGeometry />
<meshBasicMaterial
side={DoubleSide}
color="orange"
transparent
ref={splashRadiusMaterial}
/>
</mesh>
</group>
)}
<Html position={inLast.point} center>
<Box
style={{
pointerEvents: "none",
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
}}
mb={tracerGoingUp ? "0" : "9"}
pb={tracerGoingUp ? "0" : "4"}
mt={tracerGoingUp ? "9" : "0"}
pt={tracerGoingUp ? "4" : "0"}
>
<LocaleProvider locale={locale}>
<ShotDisplayCard shot={shot} />
</LocaleProvider>
</Box>
</Html>
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
if (layer.type === null) return null;
const shotStatusColor =
shot.in.status === "splash"
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<Html
position={layer.point}
key={index}
style={{
pointerEvents: "none",
transform: "translateY(-50%)",
}}
>
<Flex
style={{
marginLeft: -SURFACE_POINT_SIZE / 2,
}}
>
<div
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
backgroundColor: shotStatusColor,
opacity: 0.75,
borderRadius: SURFACE_POINT_SIZE / 2,
}}
/>
</Flex>
</Html>
);
})}
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
)}
>
<mesh position={[0, inLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh ref={inTracer} position={[0, inLength / 2, 0]} renderOrder={3}>
<cylinderGeometry
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
/>
<meshBasicMaterial
color={inTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
{shot.out && (
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(
J_HAT,
shot.out.surfaceNormal.clone(),
),
)}
>
<mesh position={[0, outLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, outLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh
ref={outTracer}
position={[0, outLength / 2, 0]}
renderOrder={3}
>
<cylinderGeometry
args={[TRACER_THICK / 2, TRACER_THICK / 2, outLength]}
/>
<meshBasicMaterial
color={outTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
)}
</group>
);
}
interface AnimatorProps {
inTracer: RefObject<Mesh | null>;
outTracer: RefObject<Mesh | null>;
splashRadiusWrapper: RefObject<Group | null>;
splashRadiusMaterial: RefObject<MeshBasicMaterial | null>;
inLength: number;
outLength: number;
}
function Animator({
inTracer,
outTracer,
splashRadiusWrapper,
splashRadiusMaterial,
inLength,
outLength,
}: AnimatorProps) {
const shot = Tankopedia.use((state) => state.shot!);
let animationStartTime: number | null = null;
useFrame(({ clock, invalidate }) => {
const t = clock.elapsedTime;
const tracerT1 = t % 1;
const tracerT2 = (t + 0.5) % 1;
if (inTracer.current) {
inTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT1 - 0.5), 1);
inTracer.current.position.set(0, (1 - tracerT1) * inLength, 0);
if (outTracer.current) {
outTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT2 - 0.5), 1);
outTracer.current.position.set(0, tracerT2 * outLength, 0);
}
invalidate();
}
if (
shot.splashRadius !== undefined &&
splashRadiusWrapper.current &&
splashRadiusMaterial.current
) {
if (animationStartTime === null) animationStartTime = t;
const scale =
clamp(4 * (t - animationStartTime), 0, 1) * shot.splashRadius;
const opacity = 1 - clamp(2 * (t - animationStartTime), 0, 1);
splashRadiusWrapper.current.scale.set(scale, scale, scale);
splashRadiusMaterial.current.opacity = opacity;
invalidate();
}
});
return null;
}