feat: screenshot button

This commit is contained in:
TrèsAbhi committed 2024-08-25 19:11:30 -05:00
1 parent 8bc4eab2c0
commit ece0791a08
3 files changed
+177 -144

No files matched your search

@@ -1,7 +1,7 @@
import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons'; import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons';
import { Box, Button, Flex, Heading, Text } from '@radix-ui/themes'; import { Box, Button, Flex, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense, use, useEffect, useMemo, useState } from 'react'; import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { classIcons } from '../../../../../../components/ClassIcon'; import { classIcons } from '../../../../../../components/ClassIcon';
import { ScienceIcon } from '../../../../../../components/ScienceIcon'; import { ScienceIcon } from '../../../../../../components/ScienceIcon';
import { ThicknessRange } from '../../../../../../components/StaticArmor'; import { ThicknessRange } from '../../../../../../components/StaticArmor';
@@ -20,6 +20,7 @@ interface HeroSectionProps {
} }
export function HeroSection({ id }: HeroSectionProps) { export function HeroSection({ id }: HeroSectionProps) {
const canvas = useRef<HTMLCanvasElement>(null);
const protagonist = Duel.use((state) => state.protagonist.tank); const protagonist = Duel.use((state) => state.protagonist.tank);
const antagonist = Duel.use((state) => state.antagonist.tank); const antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks = const compareTanks =
@@ -169,11 +170,11 @@ export function HeroSection({ id }: HeroSectionProps) {
/> />
} }
> >
<TankSandbox thicknessRange={thicknessRange} /> <TankSandbox ref={canvas} thicknessRange={thicknessRange} />
</Suspense> </Suspense>
</Box> </Box>
<Options thicknessRange={thicknessRange} /> <Options canvas={canvas} thicknessRange={thicknessRange} />
</Box> </Box>
</Box> </Box>
</Flex> </Flex>
@@ -1,5 +1,11 @@
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Suspense, useEffect, useRef } from 'react'; import {
forwardRef,
Suspense,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { Armor } from '../../../../../../components/Armor'; import { Armor } from '../../../../../../components/Armor';
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay'; import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay'; import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
@@ -26,154 +32,162 @@ interface TankSandboxProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
} }
export function TankSandbox({ thicknessRange }: TankSandboxProps) { export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); ({ thicknessRange }, ref) => {
const canvas = useRef<HTMLCanvasElement>(null); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const hasImprovedVerticalStabilizer = useEquipment(122); const canvas = useRef<HTMLCanvasElement>(null);
const protagonist = Duel.use((state) => state.protagonist); const hasImprovedVerticalStabilizer = useEquipment(122);
const mutateDuel = Duel.useMutation(); const protagonist = Duel.use((state) => state.protagonist);
const tankModelDefinition = useTankModelDefinition(); const mutateDuel = Duel.useMutation();
const turretModelDefinition = const tankModelDefinition = useTankModelDefinition();
tankModelDefinition.turrets[protagonist.turret.id]; const turretModelDefinition =
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; tankModelDefinition.turrets[protagonist.turret.id];
const display = TankopediaPersistent.use((state) => state.display); const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const display = TankopediaPersistent.use((state) => state.display);
function handlePointerDown() { useImperativeHandle(ref, () => canvas.current!, []);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
window.removeEventListener('pointermove', handlePointerMove); function handlePointerDown() {
window.removeEventListener('pointerup', handlePointerUp); window.addEventListener('pointermove', handlePointerMove);
} window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
useEffect(() => { window.removeEventListener('pointermove', handlePointerMove);
function handlePoseEvent(pose: Pose) { window.removeEventListener('pointerup', handlePointerUp);
switch (pose) { }
case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw }); useEffect(() => {
draft.protagonist.pitch = pitch; function handlePoseEvent(pose: Pose) {
draft.protagonist.yaw = yaw; switch (pose) {
}); case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
break; modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
} }
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
} }
}
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
return () => { return () => {
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
});
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
}); });
}, [protagonist.gun, protagonist.turret]);
useEffect(() => { useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) { mutateDuel((draft) => {
mutateTankopediaEphemeral((draft) => { [draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.shot = undefined; draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
}); });
} }, [protagonist.gun, protagonist.turret]);
if (display !== TankopediaDisplay.StaticArmor) { useEffect(() => {
mutateTankopediaEphemeral((draft) => { if (display !== TankopediaDisplay.DynamicArmor) {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<Canvas
frameloop="demand"
gl={{ clippingPlanes: Object.freeze([]), localClippingEnabled: true }}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => { mutateTankopediaEphemeral((draft) => {
draft.shot = undefined; draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined; draft.highlightArmor = undefined;
}); });
}} }
style={{ userSelect: 'none' }} }, [display]);
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
<ShotDisplay /> return (
<ArmorPlateDisplay /> <Canvas
frameloop="demand"
gl={{
clippingPlanes: Object.freeze([]),
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
}}
style={{ userSelect: 'none' }}
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
{/* idk why the shot display doesn't work without suspense here lol */} <ShotDisplay />
<Suspense fallback={<ModelLoader />}> <ArmorPlateDisplay />
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && ( {/* idk why the shot display doesn't work without suspense here lol */}
<StaticArmor thicknessRange={thicknessRange} /> <Suspense fallback={<ModelLoader />}>
)} {display === TankopediaDisplay.DynamicArmor && <Armor />}
<Lighting /> {display === TankopediaDisplay.StaticArmor && (
</Suspense> <StaticArmor thicknessRange={thicknessRange} />
</Canvas> )}
); <Lighting />
} </Suspense>
</Canvas>
);
},
);
@@ -1,4 +1,5 @@
import { import {
CameraIcon,
EnterFullScreenIcon, EnterFullScreenIcon,
ExitFullScreenIcon, ExitFullScreenIcon,
EyeOpenIcon, EyeOpenIcon,
@@ -19,7 +20,7 @@ import {
Tooltip, Tooltip,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber'; import { invalidate } from '@react-three/fiber';
import { useEffect, useState } from 'react'; import { RefObject, useEffect, useState } from 'react';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon'; import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
import { ThicknessRange } from '../../../../../../../components/StaticArmor'; import { ThicknessRange } from '../../../../../../../components/StaticArmor';
@@ -45,9 +46,10 @@ import { Thicknesses } from '../../Thicknesses';
interface OptionsProps { interface OptionsProps {
thicknessRange: ThicknessRange; thicknessRange: ThicknessRange;
canvas: RefObject<HTMLCanvasElement>;
} }
export function Options({ thicknessRange }: OptionsProps) { export function Options({ thicknessRange, canvas }: OptionsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const displayRaw = TankopediaPersistent.use((state) => state.display); const displayRaw = TankopediaPersistent.use((state) => state.display);
@@ -73,6 +75,7 @@ export function Options({ thicknessRange }: OptionsProps) {
(state) => state.model.visual.environment, (state) => state.model.visual.environment,
); );
const developerMode = App.useDeferred(false, (state) => state.developerMode); const developerMode = App.useDeferred(false, (state) => state.developerMode);
const protagonist = Duel.use((state) => state.protagonist.tank);
const antagonistGun = Duel.use((state) => state.antagonist.gun); const antagonistGun = Duel.use((state) => state.antagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell); const antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false); const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
@@ -122,12 +125,9 @@ export function Options({ thicknessRange }: OptionsProps) {
<Flex <Flex
gap="2" gap="2"
direction="column" direction="column"
style={{ top="50%"
position: 'absolute', right="3"
right: 16, style={{ position: 'absolute', transform: 'translateY(-50%)' }}
top: '50%',
transform: 'translateY(-50%)',
}}
align="end" align="end"
> >
<Text color="gray" size={{ initial: '1', sm: '2' }}> <Text color="gray" size={{ initial: '1', sm: '2' }}>
@@ -302,6 +302,24 @@ export function Options({ thicknessRange }: OptionsProps) {
</Flex> </Flex>
)} )}
<Box position="absolute" bottom="3" right="3">
<IconButton
size="3"
variant="soft"
onClick={() => {
if (!canvas.current) return;
const anchor = document.createElement('a');
anchor.setAttribute('download', `${protagonist.name}.png`);
anchor.setAttribute('href', canvas.current.toDataURL('image/png'));
anchor.click();
}}
>
<CameraIcon />
</IconButton>
</Box>
<Flex <Flex
style={{ style={{
position: 'absolute', position: 'absolute',