mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: screenshot button
This commit is contained in:
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',
|
||||||
|
|||||||
Reference in new issue
Block a user