feat: search page

This commit is contained in:
TrèsAbhi committed 2024-09-21 20:26:58 -05:00
1 parent 408b561b75
commit ffa1395b50
70 files changed
+492 -468

No files matched your search

@@ -0,0 +1,52 @@
import { asset } from '@blitzkit/core';
import { IconButton } from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { useArmor } from '../../../../../../hooks/useArmor';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
export function DynamicArmorSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank);
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
const { hasDynamicArmor } = useArmor(tank.id);
if (!hasDynamicArmor) return null;
return (
<IconButton
color={isDynamicArmorActive ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
onClick={() => {
invalidate();
mutateDuel((draft) => {
if (draft.protagonist.consumables.includes(73)) {
draft.protagonist.consumables =
draft.protagonist.consumables.filter((id) => id !== 73);
} else {
if (draft.protagonist.consumables.length === tank.consumables) {
draft.protagonist.consumables[tank.consumables - 1] = 73;
} else {
draft.protagonist.consumables.push(73);
}
}
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Dynamic Armor"
src={asset('icons/consumables/73.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
);
}
@@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'none',
':focus-within': {
display: 'flex',
},
selectors: {
'.tank-sandbox-container:hover &': {
display: 'flex',
},
},
});
@@ -0,0 +1,167 @@
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent, ModelTransformEventData } from '../../../../../../core/blitzkit/modelTransform';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as styles from './index.css';
export function QuickInputs() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
useEffect(() => {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}, [protagonist.yaw]);
useEffect(() => {
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}, [protagonist.pitch]);
useEffect(() => {
function handleTransformEvent({ pitch, yaw }: ModelTransformEventData) {
if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = (
-radToDeg(pitch) + (tankModelDefinition.turretRotation?.pitch ?? 0)
).toFixed(1);
if (yaw === undefined) return;
yawInput.current.value = radToDeg(yaw).toFixed(1);
}
modelTransformEvent.on(handleTransformEvent);
return () => {
modelTransformEvent.off(handleTransformEvent);
};
}, []);
return (
<Flex
align="center"
gap="2"
width="10rem"
style={{
pointerEvents: 'auto',
position: 'absolute',
left: '50%',
top: 16,
transform: 'translateX(-50%)',
}}
className={styles.container}
>
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
yawInput.current?.blur();
}
}}
onFocus={() => yawInput.current?.focus()}
ref={yawInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
<WidthIcon />
</TextField.Slot>
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root>
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
pitchInput.current?.blur();
}
}}
onFocus={() => pitchInput.current?.focus()}
ref={pitchInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
<HeightIcon />
</TextField.Slot>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root>
</Flex>
);
}
@@ -0,0 +1,550 @@
import {
asset,
imgur,
resolveNearPenetration,
resolvePenetrationCoefficient,
TIER_ROMAN_NUMERALS,
uniqueGuns,
} from '@blitzkit/core';
import {
CameraIcon,
CopyIcon,
DownloadIcon,
EnterFullScreenIcon,
ExitFullScreenIcon,
EyeOpenIcon,
GearIcon,
} from '@radix-ui/react-icons';
import {
Button,
Dialog,
DropdownMenu,
Flex,
IconButton,
Popover,
SegmentedControl,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { type RefObject, useState } from 'react';
import { ENVIRONMENTS } from '../../../../../constants/lightingEnvironments';
import { Pose, poseEvent } from '../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../hooks/useFullScreen';
import { useFullscreenAvailability } from '../../../../../hooks/useFullscreenAvailability';
import { useStoreBoolean } from '../../../../../hooks/useStoreBoolean';
import { useStoreKey } from '../../../../../hooks/useStoreKey';
import { $app } from '../../../../../stores/app';
import { $tankopediaPersistent } from '../../../../../stores/tankopediaPersistent';
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
import type { ThicknessRange } from '../../../../Armor/components/StaticArmor';
import { ModuleButton } from '../../../../ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../SmallTankIcon';
import { TankSearch } from '../../../../TankSearch';
import { DynamicArmorSwitcher } from './components/DynamicArmorSwitcher';
interface OptionsProps {
thicknessRange: ThicknessRange;
canvas: RefObject<HTMLCanvasElement>;
}
export function Options({ thicknessRange, canvas }: OptionsProps) {
// const { display } = useStore($tankopediaPersistent, { keys: ['display'] });
const display = useStoreKey($tankopediaPersistent, 'display');
const isFullScreen = useFullScreen();
const showGrid = useStoreBoolean($tankopediaPersistent, 'showGrid');
const showEnvironment = useStoreBoolean(
$tankopediaPersistent,
'showEnvironment',
);
const greenPenetration = useStoreBoolean(
$tankopediaPersistent,
'greenPenetration',
);
const wireframe = useStoreBoolean($tankopediaPersistent, 'wireframe');
const opaque = useStoreBoolean($tankopediaPersistent, 'opaque');
const fullScreenAvailable = useFullscreenAvailability();
const environment = useStoreKey($tankopediaPersistent, 'environment');
const developerMode = useStoreBoolean($app, 'developerMode');
const protagonistTank = 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);
const antagonistTank = Duel.use((state) => state.antagonist.tank);
const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search');
const mutateDuel = Duel.useMutation();
const hasEnhancedArmor = useEquipment(110);
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
return (
<>
<QuickInputs />
{display === TankopediaDisplay.StaticArmor && (
<Thicknesses thicknessRange={thicknessRange} />
)}
{display === TankopediaDisplay.DynamicArmor && (
<Flex
gap="2"
direction="column"
top="50%"
right="3"
style={{ position: 'absolute', transform: 'translateY(-50%)' }}
align="end"
>
<Text color="gray" size={{ initial: '1', sm: '2' }}>
{(
resolvePenetrationCoefficient(
hasCalibratedShells,
antagonistShell.type,
) * resolveNearPenetration(antagonistShell.penetration)
).toFixed(0)}
mm
</Text>
{antagonistUniqueGuns.length > 1 && (
<Popover.Root>
<Popover.Trigger>
<IconButton
radius="full"
variant="soft"
color="gray"
size={{ initial: '2', sm: '3' }}
style={{ position: 'relative' }}
>
<Text
size="1"
color="gray"
style={{
position: 'absolute',
right: 0,
bottom: 0,
}}
mr="2"
mb="1"
>
{TIER_ROMAN_NUMERALS[antagonistGun.tier]}
</Text>
<img
alt="Antagonist Gun"
src={asset('icons/modules/gun.webp')}
style={{
width: '65%',
height: '65%',
objectFit: 'contain',
}}
/>
</IconButton>
</Popover.Trigger>
<Popover.Content side="left" align="center">
<Flex gap="2">
{[...antagonistUniqueGuns]
.reverse()
.map(({ gun, turret }, index) => (
<ModuleButton
module="gun"
key={gun.id}
onClick={() =>
mutateDuel((draft) => {
draft.antagonist.turret = turret;
draft.antagonist.gun = gun;
draft.antagonist.shell = gun.shells[0];
})
}
selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>
))}
</Flex>
</Popover.Content>
</Popover.Root>
)}
<Flex
direction="column"
style={{
borderRadius: 'var(--radius-full)',
}}
overflow="hidden"
>
{antagonistGun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === antagonistShell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
size={{ initial: '2', sm: '3' }}
radius="none"
style={{
marginTop: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.antagonist.shell = thisShell;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt={thisShell.name}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
))}
</Flex>
<Flex
direction="column"
style={{
pointerEvents: 'auto',
borderRadius: 'var(--radius-full)',
}}
overflow="hidden"
>
<IconButton
color={hasCalibratedShells ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
radius="none"
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.antagonist.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Calibrated Shells"
src={asset('icons/equipment/103.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
<IconButton
color={hasEnhancedArmor ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
style={{ marginTop: -1 }}
radius="none"
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.protagonist.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Enhanced Armor"
src={asset('icons/equipment/110.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
</Flex>
<DynamicArmorSwitcher />
</Flex>
)}
<Flex
direction="column"
align="center"
position="absolute"
bottom="4"
left="50%"
style={{ transform: 'translateX(-50%)' }}
>
{display === TankopediaDisplay.DynamicArmor && (
<Flex align="center" gap="2">
<Text color="gray" size="2">
Shooter:
</Text>
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorOpen}
>
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
<SmallTankIcon id={antagonistTank.id} size={16} />
{antagonistTank.name}
</Flex>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Select tank</Dialog.Title>
<Tabs.Root
value={tab}
onValueChange={setTab}
style={{ position: 'relative' }}
>
<TankSearch
compact
onSelect={(tank) => {
mutateDuel((draft) => {
draft.antagonist.tank = tank;
draft.antagonist.engine = tank.engines.at(-1)!;
draft.antagonist.track = tank.tracks.at(-1)!;
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
setAntagonistSelectorOpen(false);
}}
/>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
</Flex>
)}
<Flex gap="3" align="center" mt="2">
<SegmentedControl.Root
value={`${display}`}
onValueChange={(value) => {
mutateTankopediaPersistent((draft) => {
draft.display = Number(value);
});
}}
>
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
<Tooltip content="Model">
<Flex height="100%" align="center">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
<Tooltip content="Dynamic armor">
<Flex height="100%" align="center">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
<Tooltip content="Static armor">
<Flex height="100%" align="center">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
</SegmentedControl.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<EyeOpenIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{display === TankopediaDisplay.DynamicArmor && (
<>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
<b>DEV:</b> Wireframe
</DropdownMenu.CheckboxItem>
)}
</>
)}
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Popover.Root>
<Popover.Trigger>
<IconButton color="gray" variant="ghost">
<CameraIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Popover.Close>
<Button
onClick={() => {
if (!canvas.current) return;
const anchor = document.createElement('a');
anchor.setAttribute(
'download',
`${protagonistTank.name}.png`,
);
anchor.setAttribute(
'href',
canvas.current.toDataURL('image/png'),
);
anchor.click();
}}
>
<DownloadIcon />
Download
</Button>
</Popover.Close>
<Popover.Close>
<Button
variant="outline"
onClick={() => {
if (!canvas.current) return;
canvas.current.toBlob((blob) => {
if (!blob) return;
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
});
}}
>
<CopyIcon />
Copy
</Button>
</Popover.Close>
</Flex>
</Popover.Content>
</Popover.Root>
{fullScreenAvailable && (
<IconButton
color="gray"
variant="ghost"
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else document.body.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</IconButton>
)}
</Flex>
</Flex>
</>
);
}
@@ -0,0 +1,197 @@
import { Canvas } from '@react-three/fiber';
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';
import {
StaticArmor,
ThicknessRange,
} from '../../../../../../components/Armor/components/StaticArmor';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useOnScreen } from '../../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
import * as TankopediaPersistent from '../../../../../../stores/tankopediaPersistent';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { Controls } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Control';
import { Lighting } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Lighting';
import { SceneProps } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/SceneProps';
import { AutoClear } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/AutoClear';
import { ModelLoader } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/components/ModelLoader';
import { TankModel } from '../../../../../../website-legacy/src/app/tools/tankopedia/[id]/components/Model/components/TankModel';
interface TankSandboxProps {
thicknessRange: ThicknessRange;
}
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
({ thicknessRange }, ref) => {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(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);
const onScreen = useOnScreen(canvas);
useImperativeHandle(ref, () => canvas.current!, []);
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
useEffect(() => {
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
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.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);
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,
);
});
}, [protagonist.gun, protagonist.turret]);
useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<Canvas
frameloop={onScreen ? 'demand' : 'never'}
gl={{
clippingPlanes: [],
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 />}
<ShotDisplay />
<ArmorPlateDisplay />
<AutoClear />
{/* idk why the shot display doesn't work without suspense here lol */}
<Suspense fallback={<ModelLoader />}>
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} />
)}
<Lighting />
</Suspense>
</Canvas>
);
},
);
@@ -0,0 +1,22 @@
import { tankIcon } from '@blitzkit/core';
import { Flex, FlexProps, Progress } from '@radix-ui/themes';
import { useProgress } from '@react-three/drei';
type TankSandboxLoaderProps = Omit<FlexProps, 'id'> & {
id: number;
};
export function TankSandboxLoader({ id, ...props }: TankSandboxLoaderProps) {
const data = useProgress();
return (
<Flex width="100%" height="100%" align="center" justify="center" {...props}>
<img src={tankIcon(id)} style={{ height: '60%', filter: 'blur(4px)' }} />
<Progress
size="3"
value={data.progress}
style={{ position: 'absolute', width: 128 }}
/>
</Flex>
);
}
@@ -0,0 +1,138 @@
import { resolveNearPenetration, tankDefinitions } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { NAVBAR_HEIGHT } from '../../../constants/navbar';
import { Var } from '../../../core/radix/var';
import { useFullScreen } from '../../../hooks/useFullScreen';
import type { ThicknessRange } from '../../Armor/components/StaticArmor';
import { classIcons } from '../../ClassIcon';
import { Options } from './components/Options';
import { TankSandbox } from './components/TankSandbox';
import { TankSandboxLoader } from './components/TankSandboxLoader';
const awaitedTankDefinitions = await tankDefinitions;
interface HeroSectionProps {
id: number;
}
export function HeroSection({ id }: HeroSectionProps) {
const canvas = useRef<HTMLCanvasElement>(null);
const isFullScreen = useFullScreen();
const protagonist = awaitedTankDefinitions[id];
const Icon = classIcons[protagonist.class];
const [dummyLoader, setDummyLoader] = useState(true);
const treeColor =
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined;
const thicknessRange = useMemo(() => {
const entries = Object.values(awaitedTankDefinitions);
const filtered = entries.filter(
(thisTank) => thisTank.tier === protagonist.tier,
);
const value =
(filtered.reduce((accumulator, thisTank) => {
return (
accumulator +
resolveNearPenetration(
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
)
);
}, 0) /
filtered.length) *
(3 / 4);
return { value } satisfies ThicknessRange;
}, [protagonist]);
useEffect(() => {
setDummyLoader(false);
}, []);
return (
<Flex justify="center" style={{ backgroundColor: Var('color-surface') }}>
<Flex
direction={{ initial: 'column', md: 'row' }}
style={{
backgroundColor: isFullScreen
? Var('color-background')
: Var('color-surface'),
zIndex: isFullScreen ? 2 : undefined,
}}
height={
isFullScreen ? '100vh' : `calc(100vh - ${NAVBAR_HEIGHT}px - 8rem)`
}
maxHeight={isFullScreen ? undefined : '40rem'}
maxWidth={isFullScreen ? undefined : '120rem'}
flexGrow="1"
width={isFullScreen ? '100vw' : undefined}
position={isFullScreen ? 'fixed' : 'relative'}
top={isFullScreen ? '0' : undefined}
left={isFullScreen ? '0' : undefined}
>
<Flex
style={{ userSelect: 'none' }}
direction="column"
align={{ initial: 'center', md: 'start' }}
justify="center"
pl={{ initial: '0', md: '9' }}
pt={{ initial: '5', md: '0' }}
>
<Heading
weight="bold"
size={{ initial: '8', lg: '9' }}
wrap="nowrap"
color={treeColor}
>
<Flex align="center" gap="3">
<Icon width="0.8em" height="0.8em" />
{protagonist.name}
</Flex>
</Heading>
<Text
color="gray"
size="3"
weight="light"
ml={{ initial: '0', md: '9' }}
>
BlitzKit Tankopedia
</Text>
</Flex>
<Box
className="tank-sandbox-container"
flexGrow="1"
flexBasis="0"
flexShrink="0"
position="relative"
>
<Box position="absolute" width="100%" height="100%">
<Box width="100%" height="100%">
<TankSandboxLoader
id={protagonist.id}
display={dummyLoader ? 'flex' : 'none'}
/>
<Suspense
fallback={
<TankSandboxLoader
id={protagonist.id}
display={dummyLoader ? 'none' : 'flex'}
/>
}
>
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
</Suspense>
</Box>
<Options canvas={canvas} thicknessRange={thicknessRange} />
</Box>
</Box>
</Flex>
</Flex>
);
}