diff --git a/src/app/tools/tankopedia/[id]/components/Model/HeroSection.tsx b/src/app/tools/tankopedia/[id]/components/Model/HeroSection.tsx index f415d4186..1be32e2f1 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/HeroSection.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/HeroSection.tsx @@ -1,7 +1,7 @@ import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons'; import { Box, Button, Flex, Heading, Text } from '@radix-ui/themes'; 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 { ScienceIcon } from '../../../../../../components/ScienceIcon'; import { ThicknessRange } from '../../../../../../components/StaticArmor'; @@ -20,6 +20,7 @@ interface HeroSectionProps { } export function HeroSection({ id }: HeroSectionProps) { + const canvas = useRef(null); const protagonist = Duel.use((state) => state.protagonist.tank); const antagonist = Duel.use((state) => state.antagonist.tank); const compareTanks = @@ -169,11 +170,11 @@ export function HeroSection({ id }: HeroSectionProps) { /> } > - + - + diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx index 2d6876969..773d18806 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx @@ -1,5 +1,11 @@ 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 { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay'; import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay'; @@ -26,154 +32,162 @@ interface TankSandboxProps { thicknessRange: ThicknessRange; } -export function TankSandbox({ thicknessRange }: TankSandboxProps) { - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); - const canvas = useRef(null); - const hasImprovedVerticalStabilizer = useEquipment(122); - const protagonist = Duel.use((state) => state.protagonist); - const mutateDuel = Duel.useMutation(); - const tankModelDefinition = useTankModelDefinition(); - const turretModelDefinition = - tankModelDefinition.turrets[protagonist.turret.id]; - const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; - const display = TankopediaPersistent.use((state) => state.display); +export const TankSandbox = forwardRef( + ({ thicknessRange }, ref) => { + const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); + const canvas = useRef(null); + const hasImprovedVerticalStabilizer = useEquipment(122); + const protagonist = Duel.use((state) => state.protagonist); + const mutateDuel = Duel.useMutation(); + const tankModelDefinition = useTankModelDefinition(); + const turretModelDefinition = + tankModelDefinition.turrets[protagonist.turret.id]; + const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; + const display = TankopediaPersistent.use((state) => state.display); - function handlePointerDown() { - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp); - } - function handlePointerMove(event: PointerEvent) { - event.preventDefault(); - } - function handlePointerUp(event: PointerEvent) { - event.preventDefault(); + useImperativeHandle(ref, () => canvas.current!, []); - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - } + function handlePointerDown() { + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + } + function handlePointerMove(event: PointerEvent) { + event.preventDefault(); + } + function handlePointerUp(event: PointerEvent) { + event.preventDefault(); - useEffect(() => { - function handlePoseEvent(pose: Pose) { - switch (pose) { - case Pose.HullDown: { - mutateDuel((draft) => { - const [pitch, yaw] = applyPitchYawLimits( - -Infinity, - 0, - gunModelDefinition.pitch, - turretModelDefinition.yaw, - hasImprovedVerticalStabilizer, - ); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + } - modelTransformEvent.emit({ pitch, yaw }); - draft.protagonist.pitch = pitch; - draft.protagonist.yaw = yaw; - }); + useEffect(() => { + function handlePoseEvent(pose: Pose) { + 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 () => { - poseEvent.off(handlePoseEvent); - }; - }); - - useEffect(() => { - mutateDuel((draft) => { - [draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits( - draft.protagonist.pitch, - draft.protagonist.yaw, - gunModelDefinition.pitch, - turretModelDefinition.yaw, - hasImprovedVerticalStabilizer, - ); + return () => { + poseEvent.off(handlePoseEvent); + }; }); - }, [protagonist.gun, protagonist.turret]); - useEffect(() => { - if (display !== TankopediaDisplay.DynamicArmor) { - mutateTankopediaEphemeral((draft) => { - draft.shot = undefined; + 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]); - if (display !== TankopediaDisplay.StaticArmor) { - mutateTankopediaEphemeral((draft) => { - draft.highlightArmor = undefined; - }); - } - }, [display]); - - return ( - { + useEffect(() => { + if (display !== TankopediaDisplay.DynamicArmor) { mutateTankopediaEphemeral((draft) => { draft.shot = undefined; + }); + } + + if (display !== TankopediaDisplay.StaticArmor) { + mutateTankopediaEphemeral((draft) => { draft.highlightArmor = undefined; }); - }} - style={{ userSelect: 'none' }} - camera={{ fov: 25, far: 32 }} - > - - - {display !== TankopediaDisplay.StaticArmor && } + } + }, [display]); - - + return ( + { + mutateTankopediaEphemeral((draft) => { + draft.shot = undefined; + draft.highlightArmor = undefined; + }); + }} + style={{ userSelect: 'none' }} + camera={{ fov: 25, far: 32 }} + > + + + {display !== TankopediaDisplay.StaticArmor && } - {/* idk why the shot display doesn't work without suspense here lol */} - }> - {display === TankopediaDisplay.DynamicArmor && } - {display === TankopediaDisplay.StaticArmor && ( - - )} - - - - ); -} + + + + {/* idk why the shot display doesn't work without suspense here lol */} + }> + {display === TankopediaDisplay.DynamicArmor && } + {display === TankopediaDisplay.StaticArmor && ( + + )} + + + + ); + }, +); diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx index 05ef3bd11..1f5c2c38a 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx @@ -1,4 +1,5 @@ import { + CameraIcon, EnterFullScreenIcon, ExitFullScreenIcon, EyeOpenIcon, @@ -19,7 +20,7 @@ import { Tooltip, } from '@radix-ui/themes'; import { invalidate } from '@react-three/fiber'; -import { useEffect, useState } from 'react'; +import { RefObject, useEffect, useState } from 'react'; import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon'; import { ThicknessRange } from '../../../../../../../components/StaticArmor'; @@ -45,9 +46,10 @@ import { Thicknesses } from '../../Thicknesses'; interface OptionsProps { thicknessRange: ThicknessRange; + canvas: RefObject; } -export function Options({ thicknessRange }: OptionsProps) { +export function Options({ thicknessRange, canvas }: OptionsProps) { const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const displayRaw = TankopediaPersistent.use((state) => state.display); @@ -73,6 +75,7 @@ export function Options({ thicknessRange }: OptionsProps) { (state) => state.model.visual.environment, ); 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 antagonistShell = Duel.use((state) => state.antagonist.shell); const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false); @@ -122,12 +125,9 @@ export function Options({ thicknessRange }: OptionsProps) { @@ -302,6 +302,24 @@ export function Options({ thicknessRange }: OptionsProps) { )} + + { + 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(); + }} + > + + + +