Something went wrong. Try again.
Starship Simulator Controls thoriumsim.com
Something went wrong. Try again.
9.1 kB · 312 lines
TSX
at develop
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import { Edges, Line } from "@react-three/drei";import { useFrame } from "@react-three/fiber";import { useQueryClient } from "@tanstack/react-query";import { q } from "@thorium/context/AppContext";import { useCardContext } from "@thorium/context/CardContext";import useAnimationFrame from "@thorium/hooks/useAnimationFrame";import { useStation } from "@thorium/routes/station/useStation";import Button from "@thorium/ui/Button";import { Icon } from "@thorium/ui/Icon";import Slider from "@thorium/ui/Slider";import { useLiveQuery } from "@thorium/utils/live-query/client";import { isPointWithinCone } from "@thorium/utils/starmap/isPointWithinCone";import { degToRad } from "@thorium/utils/unitTypes";import { useEffect, useMemo, useRef } from "react";import { DoubleSide, Euler, type Group, Quaternion, Vector3 } from "three";import type { Line2 } from "three-stdlib";
export function PhaserArcs() { const { shipId } = useStation(); const [phasers] = q.targeting.phasers.list.useNetRequest({ shipId }); return ( <> {phasers.map((phaser) => ( <ConeVisualization key={phaser.id} {...phaser} /> ))} </> );}
const up = new Vector3(0, 1, 0);const cameraProjection = new Vector3();const planeVector = new Vector3(0, 1, 0);const directionVector = new Vector3();export function ConeVisualization({ arc, heading, pitch, maxArc, maxRange,}: { id: number; arc: number; heading: number; pitch: number; maxArc: number; maxRange: number;}) { const [height, radius, angle, rotation] = useMemo(() => { const range = maxRange - maxRange * (arc / (maxArc + 1));
const rotation = new Euler(pitch * (Math.PI / 180), heading * (Math.PI / 180), 0); const angle = arc * (Math.PI / 180);
const radius = range * Math.tan(angle / 2);
return [range, radius, angle, rotation]; }, [arc, maxArc, pitch, heading, maxRange]);
const lineLength = Math.sqrt(height ** 2 + radius ** 2); const coneRef = useRef<Line2>(null); const groupRef = useRef<Group>(null);
useFrame(({ camera }) => { cameraProjection .copy(camera.position) .projectOnPlane(planeVector.set(0, 1, 0)) .normalize();
const angle = Math.atan2(cameraProjection.x, cameraProjection.y) + rotation.y; if (coneRef.current) { coneRef.current.rotation.y = angle + Math.PI / 2; }
groupRef.current?.quaternion.setFromUnitVectors( up, directionVector.set(0, 0, -1).applyEuler(rotation).normalize().negate(), ); }); return ( <group ref={groupRef}> <mesh position={[0, -height / 2, 0]}> <coneGeometry args={[radius, height, 32, 1, true]} /> <meshStandardMaterial depthTest={false} opacity={0} transparent side={DoubleSide} /> <Edges threshold={30} scale={1} renderOrder={1000} color="orange"> <meshBasicMaterial transparent opacity={0.4} color="orange" depthTest={false} /> </Edges> </mesh> <Line ref={coneRef} rotation={[0, 0, -Math.PI / 2 - angle / 2]} points={[ [lineLength, 0, 0], [0, 0, 0], [lineLength * Math.cos(angle), lineLength * Math.sin(angle), 0], ]} color="orange" transparent opacity={0.4} depthTest={false} /> </group> );}
export function BeamVisualization() { const { shipId, ship: { currentSystem }, } = useStation(); const [firingPhasers] = q.targeting.phasers.firing.useNetRequest({ systemId: currentSystem, });
const { interpolate } = useLiveQuery(); const lineRef = useRef<Line2>(null);
useFrame(() => { if (!lineRef.current) return; const points: number[] = []; const player = interpolate(shipId); if (!player) return; firingPhasers.forEach((phaser) => { const ship = interpolate(phaser.shipId); const target = interpolate(phaser.targetId); if (!ship || !target) return; points.push(ship.x - player.x, ship.y - player.y, ship.z - player.z); points.push(target.x - player.x, target.y - player.y, target.z - player.z); }); if (points.length === 0) { lineRef.current.visible = false; } else { lineRef.current.visible = true; lineRef.current?.geometry.setPositions(points); lineRef.current.material.linewidth = 5 * Math.max(...firingPhasers.map((p) => p.firePercent)); } });
return ( <Line ref={lineRef} points={[0, 0, 0, 0, 0, 0]} segments color="orange" lineWidth={5} depthTest={false} /> );}
export function Phasers() { const { shipId } = useStation(); const [phasers] = q.targeting.phasers.list.useNetRequest({ shipId }); return ( <div className="phasers-container flex flex-col gap-4"> {phasers.map((phaser) => ( <PhaserControl key={phaser.id} {...phaser} /> ))} </div> );}
const targetVector = new Vector3();const playerVector = new Vector3();const rotationQuaternion = new Quaternion();const direction = new Vector3();function PhaserControl({ id, arc, maxArc, maxRange, nominalHeat, maxSafeHeat, heading, pitch,}: { id: number; arc: number; maxArc: number; maxRange: number; nominalHeat: number; maxSafeHeat: number; heading: number; pitch: number;}) { const { cardLoaded } = useCardContext(); const { shipId } = useStation(); const { interpolate } = useLiveQuery(); const chargeRef = useRef<HTMLProgressElement>(null); const heatRef = useRef<HTMLProgressElement>(null); const [targetedContact] = q.targeting.targetedContact.useNetRequest({ shipId, }); const buttonContainerRef = useRef<HTMLDivElement>(null); useAnimationFrame(() => { const values = interpolate(id); if (!values) return; const { z: heat, x: charge } = values; if (chargeRef.current) { chargeRef.current.value = charge; } if (heatRef.current) { // Scale the heat value heatRef.current.value = (heat - nominalHeat) / (maxSafeHeat - nominalHeat); } // Check if the target is in range let inCone = false; if (targetedContact) { const target = interpolate(targetedContact.id); const player = interpolate(shipId); if (target && player) { targetVector.set(target.x, target.y, target.z); playerVector.set(player.x, player.y, player.z);
const range = maxRange - maxRange * (arc / (maxArc + 1)); rotationQuaternion.set(player.r.x, player.r.y, player.r.z, player.r.w); // Turn the ship rotation quaternion into a vector direction.set(0, 0, 1).applyQuaternion(rotationQuaternion); // Add the Phaser rotation to the ship rotation direction.applyAxisAngle(new Vector3(0, 1, 0), degToRad(heading)); direction.applyAxisAngle(new Vector3(1, 0, 0), degToRad(pitch)); direction.multiplyScalar(range); inCone = isPointWithinCone(targetVector, { apex: playerVector, angle: degToRad(arc), direction, }); } } if (inCone) { buttonContainerRef.current?.childNodes.forEach((child) => { if (child instanceof HTMLButtonElement) { child.disabled = false; child.classList.remove("grayscale"); } }); } else { buttonContainerRef.current?.childNodes.forEach((child) => { if (child instanceof HTMLButtonElement) { child.disabled = true; child.classList.add("grayscale"); } }); } }, cardLoaded);
const cache = useQueryClient(); const getFirePhasers = (firePercent: number) => { return async function firePhasers() { await q.targeting.phasers.fire.netSend({ phaserId: id, firePercent }); document.addEventListener( "pointerup", () => { q.targeting.phasers.fire.netSend({ phaserId: id, firePercent: 0 }); }, { once: true }, ); }; };
useEffect(() => { return () => { q.targeting.phasers.fire.netSend({ phaserId: id, firePercent: 0 }); }; }, [id]);
return ( <div className="grid grid-cols-[auto_1fr]"> <Icon name="atomic-slashes" size="sm" /> <progress className="progress progress-warning" ref={chargeRef} /> <Icon name="flame" size="sm" /> <progress className="progress progress-error" ref={heatRef} /> <Slider aria-label="Arc" minValue={0} maxValue={maxArc} step={1} value={arc} className="slider col-span-2 w-full" onChange={(val: number | number[]) => { // Manually update the local cache with the arc value so it looks really smooth cache.setQueryData(q.targeting.phasers.list.getQueryKey({ shipId }), (data: any[]) => { if (!data) return data; return data.map((phaser) => { if (phaser.id === id) { return { ...phaser, arc: val as number }; } return phaser; }); }); q.targeting.phasers.setArc.netSend({ phaserId: id, arc: val as number, }); }} /> <div className="btn-container phaser-output col-span-2 flex gap-1" ref={buttonContainerRef}> <Button className="btn-xs btn-warning mt-2" onPointerDown={getFirePhasers(0.25)}> 25% </Button> <Button className="btn-xs btn-warning mt-2" onPointerDown={getFirePhasers(0.5)}> 50% </Button> <Button className="btn-xs btn-warning mt-2" onPointerDown={getFirePhasers(0.75)}> 75% </Button> <Button className="btn-xs btn-error mt-2 flex-1" onPointerDown={getFirePhasers(1)}> Full </Button> </div> </div> );}