mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
Merge branch 'dev'
This commit is contained in:
commit
16ebea659a
81 files changed
+4379
-2507
No files matched your search
@@ -13,6 +13,7 @@ import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
|
||||
import { ShotDisplay } from './ShotDisplay';
|
||||
|
||||
export const CoreArmorScene = memo(() => {
|
||||
const wrapper = useRef<Group>(null);
|
||||
@@ -135,86 +136,94 @@ export const CoreArmorScene = memo(() => {
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
<>
|
||||
<ShotDisplay />
|
||||
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const isHull = node.name.startsWith('hull_');
|
||||
const isVisible = isHull;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
<group ref={gunContainer}>
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<CoreArmorSceneComponent thickness={thickness} node={node} />
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
<group ref={gunContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || spaced || thickness === undefined)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<CoreArmorSceneComponent
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
|
||||
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
|
||||
import { ShellDefinition } from '../../../../core/blitzkit/tankDefinitions';
|
||||
@@ -60,7 +61,9 @@ export function CoreArmorSceneComponent({
|
||||
useEffect(() => {
|
||||
async function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.ricochet.value = degToRad(
|
||||
isExplosive(shell.type) ? 90 : shell.ricochet!,
|
||||
);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
@@ -96,25 +99,17 @@ export function CoreArmorSceneComponent({
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const duel = useDuel.getState();
|
||||
const penetration = resolveNearPenetration(
|
||||
duel.antagonist!.shell.penetration,
|
||||
);
|
||||
const shell = duel.antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration *
|
||||
(duel.antagonist!.shell.type === 'ap'
|
||||
? 1.08
|
||||
: duel.antagonist!.shell.type === 'ap_cr'
|
||||
? 1.05
|
||||
: duel.antagonist!.shell.type === 'hc'
|
||||
? 1.13
|
||||
: 1.08)
|
||||
: penetration;
|
||||
material.uniforms.penetration.value =
|
||||
penetration *
|
||||
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import { Card, Flex, Text } from '@radix-ui/themes';
|
||||
import { Card, Flex, Inset, Separator, Text } from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Mesh } from 'three';
|
||||
import { ComponentProps, useRef } from 'react';
|
||||
import { Euler, Mesh, Quaternion } from 'three';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { canSplash } from '../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../core/blitz/isExplosive';
|
||||
import { NaNFallback } from '../../../core/math/NaNFallback';
|
||||
import { normalToEuler } from '../../../core/math/normalToEuler';
|
||||
import { AngledPenetration } from '../../../icons/AngledPenetration';
|
||||
import { Block } from '../../../icons/Block';
|
||||
import { NominalPenetration } from '../../../icons/NominalPenetration';
|
||||
import { Ricochet } from '../../../icons/Ricochet';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { J_HAT } from '../../../constants/axis';
|
||||
import {
|
||||
ShotLayer,
|
||||
ShotLayerBase,
|
||||
ShotLayerGap,
|
||||
ShotLayerNonExternal,
|
||||
mutateTankopediaTemporary,
|
||||
ShotStatus,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../stores/tankopedia';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
|
||||
|
||||
const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
[ArmorType.Core]: 'Core',
|
||||
@@ -30,297 +21,378 @@ const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
null: 'Gap',
|
||||
};
|
||||
|
||||
const externalLayerNames: Record<ExternalModuleVariant, string> = {
|
||||
gun: 'Gun',
|
||||
track: 'Tracks',
|
||||
};
|
||||
|
||||
const shotStatusNames: Record<ShotStatus, string> = {
|
||||
blocked: 'Blocked',
|
||||
ricochet: 'Ricochet',
|
||||
penetration: 'penetration',
|
||||
splash: 'splash',
|
||||
};
|
||||
|
||||
const shotStatusColors: Record<
|
||||
ShotStatus,
|
||||
ComponentProps<typeof Text>['color']
|
||||
> = {
|
||||
blocked: 'red',
|
||||
penetration: 'green',
|
||||
ricochet: 'yellow',
|
||||
splash: 'orange',
|
||||
};
|
||||
|
||||
const SURFACE_POINT_SIZE = 12;
|
||||
const LENGTH_INFINITY = 4;
|
||||
const TRACER_THICK = 1 / 64;
|
||||
const TRACER_THICK = 1 / 32;
|
||||
const TRACER_THIN = TRACER_THICK / 2;
|
||||
|
||||
function LayerEntry({
|
||||
layerIndex,
|
||||
shotStatusColor,
|
||||
layerName,
|
||||
layer,
|
||||
angle,
|
||||
}: {
|
||||
layerIndex?: string;
|
||||
shotStatusColor: ComponentProps<typeof Text>['color'];
|
||||
layerName: string;
|
||||
layer: ShotLayer;
|
||||
angle?: number;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center" gap="2">
|
||||
<Text
|
||||
size="1"
|
||||
color="gray"
|
||||
style={{
|
||||
width: 12,
|
||||
}}
|
||||
>
|
||||
{layerIndex}
|
||||
</Text>
|
||||
<Text size="2" color={shotStatusColor} style={{ width: 47 }}>
|
||||
{layerName}
|
||||
</Text>
|
||||
|
||||
{layer.type === null && (
|
||||
<>
|
||||
<Text
|
||||
size="2"
|
||||
color={shotStatusColor}
|
||||
style={{
|
||||
width: 64,
|
||||
}}
|
||||
>
|
||||
{Math.round(layer.distance * 1000).toLocaleString()}
|
||||
<Text size="1">mm</Text>
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">
|
||||
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer.type === ArmorType.External && (
|
||||
<Text
|
||||
size="2"
|
||||
color={shotStatusColor}
|
||||
style={{
|
||||
width: 64,
|
||||
}}
|
||||
>
|
||||
{Math.round(layer.thickness).toLocaleString()}
|
||||
<Text size="1">mm</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && (
|
||||
<>
|
||||
<Text
|
||||
size="2"
|
||||
color={shotStatusColor}
|
||||
style={{
|
||||
width: 80,
|
||||
}}
|
||||
>
|
||||
{Math.round(layer.thicknessAngled).toLocaleString()}
|
||||
<Text size="1">{`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`}</Text>
|
||||
</Text>
|
||||
|
||||
<Text size="2" color="gray">
|
||||
{Math.round(layer.thickness).toLocaleString()}
|
||||
<Text size="1">mm</Text> nominal
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShotDisplay() {
|
||||
const shot = useTankopediaTemporary((state) => state.shot);
|
||||
const preMidPointTracer = useRef<Mesh>(null);
|
||||
const postMidPointTracer = useRef<Mesh>(null);
|
||||
const hasMultipleLayers = shot?.layers.some(
|
||||
(layer, index) => index !== 0 && layer.type !== null,
|
||||
);
|
||||
const { shell } = useDuel.getState().antagonist!;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const splashing = canSplash(shell.type);
|
||||
const midPointLayer = (shot?.layers.findLast(
|
||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
||||
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
|
||||
const firstLayer = shot?.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
|
||||
const lastLayer = shot?.layers.at(-1)!;
|
||||
const distanceFromSpacedArmor = shot
|
||||
? firstLayer.point.distanceTo(shot.point)
|
||||
: undefined;
|
||||
const preMidPointLayers = shot?.layers.slice(
|
||||
0,
|
||||
shot.layers.indexOf(midPointLayer as ShotLayer),
|
||||
);
|
||||
const postMidPointLayers = shot?.layers.slice(
|
||||
shot.layers.indexOf(midPointLayer as ShotLayer),
|
||||
shot.layers.length,
|
||||
);
|
||||
const preMidPointGap = shot
|
||||
? preMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) + LENGTH_INFINITY
|
||||
: undefined;
|
||||
const preMidPointThickness = shot
|
||||
? preMidPointLayers!.reduce(
|
||||
(accumulator, layer) =>
|
||||
accumulator +
|
||||
(layer.type === null
|
||||
? 0
|
||||
: layer.type === ArmorType.External
|
||||
? layer.thickness
|
||||
: layer.thicknessAngled),
|
||||
0,
|
||||
)
|
||||
: undefined;
|
||||
const postMidPointGap = shot
|
||||
? postMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) || LENGTH_INFINITY
|
||||
: undefined;
|
||||
const inTracer = useRef<Mesh>(null);
|
||||
const outTracer = useRef<Mesh>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!shot || !preMidPointTracer.current) return;
|
||||
if (!shot || !inTracer.current) return;
|
||||
|
||||
const t1 = clock.elapsedTime % 1;
|
||||
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||
|
||||
preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
|
||||
preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
|
||||
inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
|
||||
inTracer.current.position.set(0, (1 - t1) * inLength, 0);
|
||||
|
||||
if (!postMidPointTracer.current) return;
|
||||
if (!outTracer.current) return;
|
||||
|
||||
postMidPointTracer.current.position.set(0, t2 * postMidPointGap!, 0);
|
||||
postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function resetShot() {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.protagonist?.tank, resetShot),
|
||||
useDuel.subscribe((state) => state.antagonist?.tank, resetShot),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
outTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
|
||||
outTracer.current.position.set(0, t2 * outLength, 0);
|
||||
});
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
const inLength =
|
||||
shot.in.layers.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) + LENGTH_INFINITY;
|
||||
const outLength =
|
||||
shot.out && shot.out.layers.length > 0
|
||||
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
|
||||
(shot.out.layers.at(-1) as ShotLayerNonExternal).point,
|
||||
)
|
||||
: LENGTH_INFINITY;
|
||||
const inLast = shot.in.layers.findLast(
|
||||
(layer) => layer.type !== null,
|
||||
) as ShotLayerNonExternal;
|
||||
const outTitleColor = shot.out
|
||||
? shotStatusColors[shot.out.status]
|
||||
: undefined;
|
||||
const inTitleColor = shotStatusColors[shot.in.status];
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(midPointLayer.shellNormal)}
|
||||
<group>
|
||||
<Html
|
||||
position={inLast.point}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
<Card ml="9" style={{ width: 300 }}>
|
||||
<Flex direction="column" gap="2">
|
||||
<Flex direction="column" gap="1">
|
||||
<Text color={inTitleColor} weight="bold">
|
||||
{shot.in.status !== 'blocked' &&
|
||||
shot.in.status !== 'ricochet' && (
|
||||
<>
|
||||
{Math.round(shot.damage).toLocaleString()}
|
||||
<Text size="1">hp</Text>
|
||||
</>
|
||||
)}
|
||||
{` ${shotStatusNames[shot.in.status]} `}
|
||||
</Text>
|
||||
|
||||
<Flex direction="column">
|
||||
{shot.in.layers.map((layer, index) => {
|
||||
const layerName =
|
||||
layer.type === ArmorType.External
|
||||
? externalLayerNames[layer.variant]
|
||||
: layerTypeNames[`${layer.type}`];
|
||||
const layerIndex =
|
||||
layer.type === null
|
||||
? undefined
|
||||
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
|
||||
const shotStatusColor =
|
||||
shot.in.status === 'splash'
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status];
|
||||
|
||||
return (
|
||||
<LayerEntry
|
||||
key={index}
|
||||
layer={layer}
|
||||
layerIndex={layerIndex}
|
||||
layerName={layerName}
|
||||
shotStatusColor={shotStatusColor}
|
||||
angle={
|
||||
layer.type === ArmorType.Core ||
|
||||
layer.type === ArmorType.Spaced
|
||||
? layer.angle
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{shot.out && shot.out.layers.length > 0 && (
|
||||
<>
|
||||
<Inset side="x">
|
||||
<Flex align="center" gap="2">
|
||||
<Separator
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
ricochet (-25% penetration)
|
||||
</Text>
|
||||
<Separator
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Inset>
|
||||
|
||||
<Text color={outTitleColor} weight="bold">
|
||||
{shot.out.status !== 'blocked' &&
|
||||
shot.out.status !== 'ricochet' && (
|
||||
<>
|
||||
{Math.round(shot.damage).toLocaleString()}
|
||||
<Text size="1">hp</Text>
|
||||
</>
|
||||
)}
|
||||
{` ${shotStatusNames[shot.out.status]} `}
|
||||
</Text>
|
||||
|
||||
<Flex direction="column">
|
||||
{shot.out.layers.map((layer, index) => {
|
||||
const layerName =
|
||||
layer.type === ArmorType.External
|
||||
? externalLayerNames[layer.variant]
|
||||
: layerTypeNames[`${layer.type}`];
|
||||
const shiftedIndex = index + shot.out!.layers.length;
|
||||
const layerIndex =
|
||||
layer.type === null
|
||||
? undefined
|
||||
: `${(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`;
|
||||
const shotStatusColor =
|
||||
shot.out!.status === 'splash'
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status];
|
||||
|
||||
return (
|
||||
<LayerEntry
|
||||
key={index}
|
||||
layer={layer}
|
||||
layerIndex={layerIndex}
|
||||
layerName={layerName}
|
||||
shotStatusColor={shotStatusColor}
|
||||
angle={
|
||||
layer.type === ArmorType.Core ||
|
||||
layer.type === ArmorType.Spaced
|
||||
? layer.angle
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
|
||||
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
|
||||
if (layer.type === null) return null;
|
||||
|
||||
const shotStatusColor =
|
||||
shot.in.status === 'splash'
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status];
|
||||
|
||||
return (
|
||||
<Html
|
||||
position={layer.point}
|
||||
key={index}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
style={{
|
||||
marginLeft: -SURFACE_POINT_SIZE / 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: SURFACE_POINT_SIZE,
|
||||
height: SURFACE_POINT_SIZE,
|
||||
backgroundColor: shotStatusColor,
|
||||
opacity: 0.75,
|
||||
borderRadius: SURFACE_POINT_SIZE / 2,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Html>
|
||||
);
|
||||
})}
|
||||
|
||||
<group
|
||||
position={inLast.point}
|
||||
rotation={new Euler().setFromQuaternion(
|
||||
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
|
||||
)}
|
||||
>
|
||||
<mesh position={[0, inLength / 2, 0]} renderOrder={2}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
|
||||
/>
|
||||
<meshBasicMaterial depthTest={false} transparent />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
|
||||
<mesh ref={inTracer} position={[0, inLength / 2, 0]} renderOrder={3}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
|
||||
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={
|
||||
lastLayer.status === 'blocked'
|
||||
? 0xff4040
|
||||
: lastLayer.status === 'penetration'
|
||||
? 0x00ff00
|
||||
: 0xffff00
|
||||
}
|
||||
color={inTitleColor}
|
||||
depthTest={false}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{midPointLayer.status === 'ricochet' && (
|
||||
{shot.out && (
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(
|
||||
midPointLayer.shellNormal
|
||||
.clone()
|
||||
.reflect(midPointLayer.surfaceNormal)
|
||||
.multiplyScalar(-1),
|
||||
position={inLast.point}
|
||||
rotation={new Euler().setFromQuaternion(
|
||||
new Quaternion().setFromUnitVectors(
|
||||
J_HAT,
|
||||
shot.out.surfaceNormal.clone(),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<mesh position={[0, postMidPointGap! / 2, 0]}>
|
||||
<mesh position={[0, outLength / 2, 0]} renderOrder={2}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
|
||||
args={[TRACER_THIN / 2, TRACER_THIN / 2, outLength]}
|
||||
/>
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
<meshBasicMaterial depthTest={false} transparent />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[0, postMidPointGap! / 2, 0]}
|
||||
ref={postMidPointTracer}
|
||||
ref={outTracer}
|
||||
position={[0, outLength / 2, 0]}
|
||||
renderOrder={3}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
|
||||
args={[TRACER_THICK / 2, TRACER_THICK / 2, outLength]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={outTitleColor}
|
||||
depthTest={false}
|
||||
transparent
|
||||
/>
|
||||
<meshBasicMaterial color={0xffff00} />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{shot.layers.map((layer, index) => {
|
||||
if (layer.type === null) return;
|
||||
|
||||
const shellRotation = normalToEuler(layer.shellNormal);
|
||||
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
|
||||
const trueStatus =
|
||||
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
key={`${index}-line`}
|
||||
position={layer.point}
|
||||
rotation={shellRotation}
|
||||
>
|
||||
<Html
|
||||
center
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
trueStatus === 'penetration'
|
||||
? '#00ff0080'
|
||||
: trueStatus === 'blocked'
|
||||
? '#ff000080'
|
||||
: '#ffff0080',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
padding: 4,
|
||||
width: hasMultipleLayers ? undefined : 8,
|
||||
height: hasMultipleLayers ? undefined : 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{hasMultipleLayers && (
|
||||
<Text
|
||||
size="2"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color: trueStatus === 'ricochet' ? 'black' : undefined,
|
||||
}}
|
||||
>
|
||||
<b>{layer.index + 1}</b>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
<Html
|
||||
position={midPointLayer.point}
|
||||
style={{
|
||||
transform: 'translateY(-50%)',
|
||||
marginLeft: 32,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 256 + 32 }}>
|
||||
<Flex direction="column">
|
||||
<Text>
|
||||
{lastLayer.status[0].toUpperCase()}
|
||||
{lastLayer.status.slice(1)}
|
||||
{lastLayer.status === 'penetration' &&
|
||||
`: ${Math.round(shell.damage.armor)}hp`}
|
||||
{lastLayer.status === 'blocked' &&
|
||||
shell.type === 'he' &&
|
||||
`: ${NaNFallback(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
0.5 *
|
||||
shell.damage.armor *
|
||||
(1 -
|
||||
distanceFromSpacedArmor! / shell.explosionRadius!) -
|
||||
1.1 *
|
||||
((midPointLayer as ShotLayerNonExternal)
|
||||
.thicknessAngled +
|
||||
preMidPointThickness!),
|
||||
),
|
||||
),
|
||||
0,
|
||||
)}hp`}
|
||||
</Text>
|
||||
|
||||
{shot.layers.map((layer, index) => {
|
||||
if (layer.type === null && (!explosive || splashing)) return null;
|
||||
|
||||
return (
|
||||
<Flex key={index} align="center" gap="2">
|
||||
<Flex>
|
||||
{hasMultipleLayers && (
|
||||
<Text size="2" style={{ width: 16 }}>
|
||||
{layer.type === null ? null : `${layer.index + 1}.`}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
|
||||
</Flex>
|
||||
|
||||
{layer.type !== null && (
|
||||
<>
|
||||
<Flex align="center" style={{ width: 64 }}>
|
||||
<NominalPenetration width={16} height={16} />
|
||||
<Text size="2">{Math.round(layer.thickness)}mm</Text>
|
||||
</Flex>
|
||||
|
||||
{layer.type !== ArmorType.External && (
|
||||
<Flex align="center">
|
||||
{layer.status === 'ricochet' ? (
|
||||
<Ricochet width={16} height={16} />
|
||||
) : layer.status === 'blocked' ? (
|
||||
<Block width={16} height={16} />
|
||||
) : (
|
||||
<AngledPenetration width={16} height={16} />
|
||||
)}
|
||||
<Text size="2">
|
||||
{Math.round(layer.thicknessAngled)}mm (
|
||||
{Math.round(radToDeg(layer.angle))}°)
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer.type === null && (
|
||||
<Text size="2">
|
||||
{Math.round(layer.distance * 1000)}mm (-
|
||||
{Math.round(Math.min(100, layer.distance * 50))}%
|
||||
penetration)
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
</>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -259,9 +259,10 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const gunString = `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isCurrentGun = gunModelDefinition.mask
|
||||
? node.name.startsWith(gunString)
|
||||
: node.name === gunString;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
+7
-13
@@ -8,6 +8,7 @@ import {
|
||||
} from 'three';
|
||||
import { ArmorUserData, ExternalModuleVariant } from '../..';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
|
||||
@@ -66,24 +67,17 @@ export function SpacedArmorSubExternal({
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const duel = useDuel.getState();
|
||||
const penetration = resolveNearPenetration(
|
||||
duel.antagonist!.shell.penetration,
|
||||
);
|
||||
const shell = duel.antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration *
|
||||
(duel.antagonist!.shell.type === 'ap'
|
||||
? 1.08
|
||||
: duel.antagonist!.shell.type === 'ap_cr'
|
||||
? 1.05
|
||||
: duel.antagonist!.shell.type === 'hc'
|
||||
? 1.13
|
||||
: 1.08)
|
||||
: penetration;
|
||||
|
||||
material.uniforms.penetration.value =
|
||||
penetration *
|
||||
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
|
||||
+9
-11
@@ -7,7 +7,9 @@ import {
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorUserData } from '../..';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
|
||||
@@ -53,7 +55,9 @@ export function SpacedArmorSubSpaced({
|
||||
shell.penetration,
|
||||
);
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.ricochet.value = degToRad(
|
||||
isExplosive(shell.type) ? 90 : shell.ricochet!,
|
||||
);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
@@ -80,16 +84,10 @@ export function SpacedArmorSubSpaced({
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration *
|
||||
(duel.antagonist!.shell.type === 'ap'
|
||||
? 1.08
|
||||
: duel.antagonist!.shell.type === 'ap_cr'
|
||||
? 1.05
|
||||
: duel.antagonist!.shell.type === 'hc'
|
||||
? 1.13
|
||||
: 1.08)
|
||||
: penetration;
|
||||
|
||||
material.uniforms.penetration.value =
|
||||
penetration *
|
||||
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
|
||||
@@ -10,16 +10,17 @@ import {
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
|
||||
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
|
||||
import { ShellType } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import { useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
Shot,
|
||||
ShotLayerBase,
|
||||
useTankopediaPersistent,
|
||||
ShotLayerNonExternal,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import { ArmorType } from '../SpacedArmorScene';
|
||||
@@ -78,6 +79,275 @@ export function SpacedArmorSceneComponent({
|
||||
}: SpacedArmorSceneComponentProps) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
async function shoot(
|
||||
point: Vector3,
|
||||
intersections: Intersection[],
|
||||
allowRicochet: boolean,
|
||||
remainingPenetrationInput?: number,
|
||||
) {
|
||||
const { antagonist, protagonist } = useDuel.getState();
|
||||
const shell = antagonist!.shell;
|
||||
const cameraNormal = camera.position.clone().sub(point).normalize();
|
||||
const shot: Shot = {
|
||||
damage: -1,
|
||||
containsGaps: shell.type === ShellType.HEAT,
|
||||
in: {
|
||||
surfaceNormal: cameraNormal,
|
||||
layers: [],
|
||||
status: 'blocked',
|
||||
},
|
||||
};
|
||||
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
antagonist!.tank.equipment,
|
||||
antagonist!.equipmentMatrix,
|
||||
);
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
protagonist!.tank.equipment,
|
||||
protagonist!.equipmentMatrix,
|
||||
);
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
|
||||
const filteredIntersections = intersections.filter(
|
||||
(intersection) => 'type' in intersection.object.userData,
|
||||
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
|
||||
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
|
||||
const noDuplicateIntersections: typeof filteredIntersections = [];
|
||||
|
||||
for (const intersection of filteredIntersections) {
|
||||
if (intersection.object.userData.type === ArmorType.External) {
|
||||
if (
|
||||
!encounteredExternalModuleVariants.includes(
|
||||
intersection.object.userData.variant,
|
||||
)
|
||||
) {
|
||||
noDuplicateIntersections.push(intersection);
|
||||
encounteredExternalModuleVariants.push(
|
||||
intersection.object.userData.variant,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
noDuplicateIntersections.push(intersection);
|
||||
|
||||
if (intersection.object.userData.type === ArmorType.Core) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remainingPenetration = remainingPenetrationInput ?? penetration;
|
||||
let index = 0;
|
||||
for (const intersection of noDuplicateIntersections) {
|
||||
const layer = intersection.object.userData;
|
||||
const surfaceNormal = intersection
|
||||
.normal!.clone()
|
||||
.applyQuaternion(
|
||||
intersection.object.getWorldQuaternion(new Quaternion()),
|
||||
);
|
||||
|
||||
if (shell.type === ShellType.HEAT && index > 0) {
|
||||
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
|
||||
const distance = lastLayer.point.distanceTo(intersection.point);
|
||||
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
||||
const blocked = remainingPenetration <= 0;
|
||||
|
||||
shot.in.layers.push({
|
||||
type: null,
|
||||
distance,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
});
|
||||
|
||||
if (blocked) break;
|
||||
}
|
||||
|
||||
if (layer.type === ArmorType.External) {
|
||||
const thickness = layer.thickness * thicknessCoefficient;
|
||||
remainingPenetration -= thickness;
|
||||
const blocked =
|
||||
remainingPenetration <= 0 || shell.type === ShellType.HE;
|
||||
|
||||
shot.in.layers.push({
|
||||
type: ArmorType.External,
|
||||
index,
|
||||
thickness,
|
||||
point: intersection.point,
|
||||
surfaceNormal,
|
||||
status:
|
||||
shell.type === ShellType.HE
|
||||
? 'blocked'
|
||||
: blocked
|
||||
? 'blocked'
|
||||
: 'penetration',
|
||||
variant: layer.variant,
|
||||
});
|
||||
} else {
|
||||
const thickness = layer.thickness;
|
||||
const ricochet = degToRad(
|
||||
isExplosive(shell.type) ? 90 : shell.ricochet!,
|
||||
);
|
||||
const angle = surfaceNormal.angleTo(cameraNormal);
|
||||
const threeCalibersRule =
|
||||
shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
|
||||
const twoCalibersRule = shell.caliber > thickness * 2;
|
||||
const normalization = degToRad(shell.normalization ?? 0);
|
||||
const finalNormalization = twoCalibersRule
|
||||
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
|
||||
: normalization;
|
||||
const finalThickness =
|
||||
thickness / Math.cos(Math.max(0, angle - finalNormalization));
|
||||
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
remainingPenetration *= 0.75;
|
||||
|
||||
shot.in.layers.push({
|
||||
type: layer.type,
|
||||
index,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
point: intersection.point,
|
||||
surfaceNormal,
|
||||
status: 'ricochet',
|
||||
angle,
|
||||
});
|
||||
|
||||
break;
|
||||
} else {
|
||||
remainingPenetration -= finalThickness;
|
||||
const blocked = remainingPenetration <= 0;
|
||||
|
||||
shot.in.layers.push({
|
||||
type: layer.type,
|
||||
index,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
point: intersection.point,
|
||||
surfaceNormal: intersection.face!.normal,
|
||||
status:
|
||||
shell.type === ShellType.HE && layer.type !== ArmorType.Core
|
||||
? 'blocked'
|
||||
: blocked
|
||||
? 'blocked'
|
||||
: 'penetration',
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal | undefined;
|
||||
const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
|
||||
|
||||
if (!lastLayer || !firstLayer) {
|
||||
return null;
|
||||
} else {
|
||||
if (shell.type === ShellType.HE) {
|
||||
const totalSpacedArmorThickness = shot.in.layers.reduce(
|
||||
(acc, layer) => {
|
||||
if (layer.type === null || layer.type === ArmorType.Core) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (layer.type === ArmorType.External) {
|
||||
return acc + layer.thickness;
|
||||
}
|
||||
|
||||
return acc + layer.thicknessAngled;
|
||||
},
|
||||
0,
|
||||
);
|
||||
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
|
||||
lastLayer.point,
|
||||
);
|
||||
const finalDamage = Math.max(
|
||||
0,
|
||||
0.5 *
|
||||
shell.damage.armor *
|
||||
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
|
||||
1.1 *
|
||||
(lastLayer.thicknessAngled +
|
||||
Math.min(penetration, totalSpacedArmorThickness)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Above is some peculiar math. We're multiplying 1.1 by the
|
||||
* thickness of the core plate plus the total spaced armor
|
||||
* thickness capped to the penetration itself. This is because
|
||||
* the shader is normalized by the penetration value; hence,
|
||||
* the total spaced armor thickness cannot ever be thicker
|
||||
* than the penetration. This is a precision error on the
|
||||
* shader-side and I do not intend on fixing it. Instead,
|
||||
* for parity's sake, I am capping the value here.
|
||||
*/
|
||||
|
||||
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
|
||||
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
|
||||
shot.damage = finalDamage;
|
||||
} else {
|
||||
shot.in.status = 'penetration';
|
||||
shot.damage = shell.damage.armor;
|
||||
}
|
||||
} else {
|
||||
if (lastLayer.status === 'blocked') {
|
||||
shot.in.status = 'blocked';
|
||||
shot.damage = 0;
|
||||
} else if (lastLayer.status === 'penetration') {
|
||||
shot.in.status = 'penetration';
|
||||
shot.damage = shell.damage.armor;
|
||||
} else {
|
||||
const caster = new Raycaster();
|
||||
// https://math.stackexchange.com/a/13263/1222875
|
||||
const shellNormal = cameraNormal.clone().multiplyScalar(-1);
|
||||
const ricochetNormal = shellNormal
|
||||
.clone()
|
||||
.sub(
|
||||
lastLayer.surfaceNormal
|
||||
.clone()
|
||||
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
|
||||
);
|
||||
|
||||
caster.set(lastLayer.point, ricochetNormal);
|
||||
|
||||
const ricochetIntersections = caster.intersectObjects(
|
||||
scene.children,
|
||||
true,
|
||||
);
|
||||
const outShot = await shoot(
|
||||
lastLayer.point,
|
||||
ricochetIntersections,
|
||||
false,
|
||||
remainingPenetration,
|
||||
);
|
||||
|
||||
shot.in.status = 'ricochet';
|
||||
if (outShot === null) {
|
||||
shot.damage = 0;
|
||||
shot.out = {
|
||||
surfaceNormal: ricochetNormal,
|
||||
layers: [],
|
||||
status: 'ricochet',
|
||||
};
|
||||
} else {
|
||||
shot.damage = outShot.damage;
|
||||
shot.out = outShot.in;
|
||||
shot.out.surfaceNormal = ricochetNormal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shot;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.type === ArmorType.Core &&
|
||||
@@ -94,250 +364,11 @@ export function SpacedArmorSceneComponent({
|
||||
async onClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const duel = useDuel.getState();
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
duel.antagonist!.equipmentMatrix,
|
||||
);
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
duel.protagonist!.tank.equipment,
|
||||
duel.protagonist!.equipmentMatrix,
|
||||
);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const splashing = canSplash(shell.type);
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
(hasCalibratedShells
|
||||
? duel.antagonist!.shell.type === 'ap'
|
||||
? 1.08
|
||||
: duel.antagonist!.shell.type === 'ap_cr'
|
||||
? 1.05
|
||||
: duel.antagonist!.shell.type === 'hc'
|
||||
? 1.13
|
||||
: 1.08
|
||||
: 1);
|
||||
const shot: Shot = {
|
||||
layers: [],
|
||||
point: event.point,
|
||||
}; // TODO: type this
|
||||
let remainingPenetration = penetration;
|
||||
|
||||
const shellNormal = camera.position
|
||||
.clone()
|
||||
.sub(event.point)
|
||||
.normalize();
|
||||
|
||||
pushLogs(
|
||||
shellNormal,
|
||||
filterIntersections(event.intersections),
|
||||
0,
|
||||
);
|
||||
|
||||
function filterIntersections(intersectionsRaw: Intersection[]) {
|
||||
const showSpacedArmor =
|
||||
useTankopediaPersistent.getState().model.visual
|
||||
.showSpacedArmor;
|
||||
const intersectionsTyped = intersectionsRaw.filter(
|
||||
({ object }) => object.userData.type !== undefined,
|
||||
) as ArmorMeshIntersection[];
|
||||
const intersectionsFiltered = intersectionsTyped.filter(
|
||||
({ object }) =>
|
||||
showSpacedArmor
|
||||
? true
|
||||
: object.userData.type === ArmorType.Core,
|
||||
);
|
||||
const intersectionsWithDuplicates = intersectionsFiltered.slice(
|
||||
0,
|
||||
intersectionsFiltered.findIndex(
|
||||
({ object }) => object.userData.type === ArmorType.Core,
|
||||
) + 1,
|
||||
);
|
||||
const firstGunIntersectionIndex =
|
||||
intersectionsWithDuplicates.findIndex(
|
||||
({ object }) =>
|
||||
object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'gun',
|
||||
);
|
||||
const firstTrackIntersectionIndex =
|
||||
intersectionsWithDuplicates.findIndex(
|
||||
({ object }) =>
|
||||
object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'track',
|
||||
);
|
||||
let intersections = intersectionsWithDuplicates;
|
||||
|
||||
if (firstGunIntersectionIndex !== -1) {
|
||||
intersections = intersections.filter(({ object }, index) => {
|
||||
return object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'gun'
|
||||
? index === firstGunIntersectionIndex
|
||||
: true;
|
||||
});
|
||||
}
|
||||
if (firstTrackIntersectionIndex !== -1) {
|
||||
intersections = intersections.filter(({ object }, index) => {
|
||||
return object.userData.type === ArmorType.External &&
|
||||
object.userData.variant === 'track'
|
||||
? index === firstTrackIntersectionIndex
|
||||
: true;
|
||||
});
|
||||
}
|
||||
|
||||
return intersections;
|
||||
}
|
||||
|
||||
function pushLogs(
|
||||
shellNormal: Vector3,
|
||||
intersections: ArmorMeshIntersection[],
|
||||
startingIndex: number,
|
||||
) {
|
||||
let layerIndex = startingIndex;
|
||||
let loopIndex = 0;
|
||||
|
||||
if (layerIndex !== 0 && intersections.length > 0) {
|
||||
const distance = (
|
||||
shot.layers.at(-1) as ShotLayerBase
|
||||
).point.distanceTo(intersections[0].point);
|
||||
if (explosive && !splashing)
|
||||
remainingPenetration -=
|
||||
0.5 * remainingPenetration * distance;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: null,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
distance,
|
||||
});
|
||||
|
||||
if (blocked) return;
|
||||
}
|
||||
|
||||
for (const intersection of intersections) {
|
||||
const surfaceNormal = intersection
|
||||
.normal!.clone()
|
||||
.applyQuaternion(
|
||||
event.object.getWorldQuaternion(new Quaternion()),
|
||||
);
|
||||
const thickness =
|
||||
thicknessCoefficient *
|
||||
intersection.object.userData.thickness;
|
||||
|
||||
if (layerIndex !== 0 && startingIndex === 0) {
|
||||
const previousIntersection = intersections[layerIndex - 1];
|
||||
const distance =
|
||||
intersection.distance - previousIntersection.distance;
|
||||
if (explosive && !splashing)
|
||||
remainingPenetration -=
|
||||
0.5 * remainingPenetration * distance;
|
||||
const wasted = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: null,
|
||||
status: wasted ? 'blocked' : 'penetration',
|
||||
distance,
|
||||
});
|
||||
|
||||
if (wasted && !splashing) return;
|
||||
}
|
||||
|
||||
if (
|
||||
intersection.object.userData.type === ArmorType.External
|
||||
) {
|
||||
remainingPenetration -= thickness;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type: ArmorType.External,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness: intersection.object.userData.thickness,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
});
|
||||
|
||||
if (splashing) {
|
||||
remainingPenetration = Math.max(0, remainingPenetration);
|
||||
} else if (blocked) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const type = intersection.object.userData.type;
|
||||
const angle = surfaceNormal.angleTo(shellNormal);
|
||||
const ricochet = degToRad(shell.ricochet ?? 90);
|
||||
const normalization = degToRad(shell.normalization ?? 0);
|
||||
const threeCalibersRule =
|
||||
shell.caliber > thickness * 3 || layerIndex > 0;
|
||||
const twoCalibersRule = shell.caliber > thickness * 2;
|
||||
const finalNormalization = twoCalibersRule
|
||||
? (1.4 * normalization * shell.caliber) /
|
||||
(2.0 * thickness)
|
||||
: normalization;
|
||||
const finalThickness =
|
||||
thickness === 0
|
||||
? 0
|
||||
: thickness /
|
||||
Math.cos(Math.max(0, angle - finalNormalization));
|
||||
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
shot.layers.push({
|
||||
type,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
angle,
|
||||
status: 'ricochet',
|
||||
});
|
||||
|
||||
remainingPenetration *= 0.75;
|
||||
|
||||
const raycaster = new Raycaster();
|
||||
const newShellNormal = shellNormal
|
||||
.clone()
|
||||
.reflect(surfaceNormal);
|
||||
const newCameraNormal = newShellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1);
|
||||
|
||||
raycaster.set(intersection.point, newCameraNormal);
|
||||
pushLogs(
|
||||
newShellNormal,
|
||||
filterIntersections(
|
||||
raycaster.intersectObjects(scene.children, true),
|
||||
),
|
||||
layerIndex + 1,
|
||||
);
|
||||
|
||||
break;
|
||||
} else {
|
||||
remainingPenetration -= finalThickness;
|
||||
const blocked = remainingPenetration < 0;
|
||||
|
||||
shot.layers.push({
|
||||
type,
|
||||
index: layerIndex,
|
||||
shellNormal,
|
||||
surfaceNormal,
|
||||
point: intersection.point,
|
||||
thickness,
|
||||
thicknessAngled: finalThickness,
|
||||
angle,
|
||||
status: blocked ? 'blocked' : 'penetration',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
layerIndex++;
|
||||
loopIndex++;
|
||||
}
|
||||
}
|
||||
const shot = (await shoot(
|
||||
event.point,
|
||||
event.intersections,
|
||||
true,
|
||||
))!;
|
||||
|
||||
useTankopediaTemporary.setState({ shot });
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ export function ConsumablesManager({
|
||||
wrap="wrap"
|
||||
style={{
|
||||
maxWidth: 288,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{consumables.map((consumable, index) => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const containerInner = style({
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
maxWidth: 1024,
|
||||
padding: '0 16px',
|
||||
|
||||
'@media': {
|
||||
'screen and (max-width: 640px)': {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,70 +1,103 @@
|
||||
import { Card, Code, Flex, Link, Spinner, Text, Theme } from '@radix-ui/themes';
|
||||
import { Suspense } from 'react';
|
||||
import packageJSON from '../../../package.json';
|
||||
import { useWideFormat } from '../../hooks/useWideFormat';
|
||||
import { WoTBVersion } from './components/WoTBVersion';
|
||||
'use client';
|
||||
|
||||
import { Flex, Heading, Text, Theme } from '@radix-ui/themes';
|
||||
import packageJson from '../../../package.json';
|
||||
import { Link } from '../Link';
|
||||
|
||||
export function Footer() {
|
||||
const wideFormat = useWideFormat(640);
|
||||
|
||||
return (
|
||||
<Theme radius="none">
|
||||
<Card>
|
||||
<Flex justify="center" align="center" gap="3">
|
||||
<Flex
|
||||
align="center"
|
||||
justify={wideFormat ? 'between' : 'center'}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
padding: '0 16px',
|
||||
}}
|
||||
>
|
||||
<Flex gap="1" justify="center" align="center">
|
||||
<Text color="gray" size="2">
|
||||
BlitzKit
|
||||
<Flex
|
||||
justify="center"
|
||||
gap="3"
|
||||
p="6"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
maxWidth="640px"
|
||||
flexGrow="1"
|
||||
gap="6"
|
||||
position="relative"
|
||||
direction={{
|
||||
initial: 'column',
|
||||
sm: 'row',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column">
|
||||
<Heading>
|
||||
BlitzKit{' '}
|
||||
<Text color="gray" size="1" weight="regular">
|
||||
{packageJson.version}
|
||||
</Text>
|
||||
</Heading>
|
||||
|
||||
<Code color="gray" size="2">
|
||||
{packageJSON.version}
|
||||
</Code>
|
||||
|
||||
<Text color="gray" size="2">
|
||||
for WoTB
|
||||
</Text>
|
||||
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<WoTBVersion />
|
||||
</Suspense>
|
||||
</Flex>
|
||||
<Text color="gray">Everything World of Tanks Blitz</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="6">
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="2"
|
||||
style={{
|
||||
maxWidth: 800,
|
||||
padding: '0 16px',
|
||||
direction="column"
|
||||
gap="1"
|
||||
align={{
|
||||
initial: 'end',
|
||||
sm: 'start',
|
||||
}}
|
||||
>
|
||||
<Link size="2" href="https://tresabhi.github.io/" target="_blank">
|
||||
Made by TrèsAbhi
|
||||
</Link>
|
||||
|
||||
<Text color="gray">•</Text>
|
||||
|
||||
<Link
|
||||
size="2"
|
||||
href="https://tresabhi.github.io/blitzkit/guide/credits.html"
|
||||
target="_blank"
|
||||
<Heading size="3">About</Heading>
|
||||
<Flex
|
||||
direction="column"
|
||||
align={{
|
||||
initial: 'end',
|
||||
sm: 'start',
|
||||
}}
|
||||
>
|
||||
Full credits
|
||||
</Link>
|
||||
<Link href="/docs/about/credits" color="gray">
|
||||
Credits
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
color="gray"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/tresabhi/blitzkit"
|
||||
target="_blank"
|
||||
color="gray"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
|
||||
{/* patreon supporters? */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="1">
|
||||
<Heading size="3">Legal</Heading>
|
||||
<Flex direction="column">
|
||||
<Link href="/docs/legal/privacy-policy" color="gray">
|
||||
Privacy policy
|
||||
</Link>
|
||||
<Link href="/docs/legal/terms-of-service" color="gray">
|
||||
Terms of service
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.google.com/settings/ads"
|
||||
target="_blank"
|
||||
color="gray"
|
||||
>
|
||||
Ad settings
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { LinkProps, Link as RadixLink } from '@radix-ui/themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function Link({ href, onClick, ...props }: LinkProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<RadixLink
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (href) router.push(href);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export function ConsumableButton({
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
icon={asset(`icons/consumables/${consumable}.webp`)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 'fit-content',
|
||||
borderRadius: 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{(cooldown || duration) && (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ModuleManager({
|
||||
const draft = { ...modules };
|
||||
draft.turret = turret;
|
||||
draft.gun = draft.turret.guns.at(-1)!;
|
||||
draft.shell = draft.gun.shells.at(-1)!;
|
||||
draft.shell = draft.gun.shells[0];
|
||||
|
||||
onChange?.(draft);
|
||||
setTurretMenuOpen(false);
|
||||
@@ -106,7 +106,7 @@ export function ModuleManager({
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.gun = gun;
|
||||
draft.shell = draft.gun.shells.at(-1)!;
|
||||
draft.shell = draft.gun.shells[0];
|
||||
|
||||
onChange?.(draft);
|
||||
setGunMenuOpen(false);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function EverythingSearch({
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
}}
|
||||
></Card>
|
||||
/>
|
||||
</TextField.Slot>
|
||||
)}
|
||||
</TextField.Root>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { style, styleVariants } from '@vanilla-extract/css';
|
||||
|
||||
export const hamburgerButton = style({
|
||||
'@media': {
|
||||
'screen and (min-width: 592px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const navbarBase = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 2,
|
||||
padding: '1rem',
|
||||
backgroundColor: 'var(--color-panel-translucent)',
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'max-height 0.2s ease-in-out',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const navbar = styleVariants({
|
||||
expanded: [
|
||||
navbarBase,
|
||||
{
|
||||
maxHeight: '100%',
|
||||
},
|
||||
],
|
||||
collapsed: [
|
||||
navbarBase,
|
||||
{
|
||||
maxHeight: 65,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const toolCard = style({
|
||||
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`,
|
||||
|
||||
':hover': {
|
||||
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
|
||||
},
|
||||
});
|
||||
|
||||
export const toolTexts = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 592px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
+180
-181
@@ -1,230 +1,229 @@
|
||||
import {
|
||||
Cross1Icon,
|
||||
DiscordLogoIcon,
|
||||
GearIcon,
|
||||
HamburgerMenuIcon,
|
||||
PersonIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Link as LinkRadix,
|
||||
Popover,
|
||||
Spinner,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions';
|
||||
import { TOOLS } from '../../constants/tools';
|
||||
import { authURL } from '../../core/blitz/authURL';
|
||||
import { extendAuth } from '../../core/blitz/extendAuth';
|
||||
import { logout } from '../../core/blitz/logout';
|
||||
import { useWideFormat } from '../../hooks/useWideFormat';
|
||||
import { BlitzkitWide } from '../../icons/BlitzkitWide';
|
||||
import { PatreonIcon } from '../../icons/Patreon';
|
||||
import { theme } from '../../stitches.config';
|
||||
import { useApp } from '../../stores/app';
|
||||
import { Link } from '../Link';
|
||||
import { LoggedIn } from './components/LoggedIn';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const NAVBAR_HEIGHT = 64;
|
||||
|
||||
export default function Navbar() {
|
||||
const wideFormat = useWideFormat(480);
|
||||
const login = useApp((state) => state.login);
|
||||
|
||||
useEffect(() => {
|
||||
if (!login) return;
|
||||
|
||||
const expiresIn = login.expiresAt - Date.now() / 1000;
|
||||
const expiresInDays = expiresIn / 60 / 60 / 24;
|
||||
|
||||
if (expiresInDays < 0) {
|
||||
logout();
|
||||
} else if (expiresInDays < 7) {
|
||||
extendAuth();
|
||||
}
|
||||
});
|
||||
|
||||
const plugs = (
|
||||
<>
|
||||
<Link
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DiscordLogoIcon width={16} height={16} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<PatreonIcon width={14} height={14} />
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const login = useApp((state) => state.login!);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 64 + 1, // extra pixel for frost bleeding
|
||||
zIndex: 2,
|
||||
padding: '1rem',
|
||||
background: theme.colors.appBackground1_alpha,
|
||||
borderBottom: theme.borderStyles.nonInteractive,
|
||||
backdropFilter: 'blur(4rem)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
<Box
|
||||
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
|
||||
>
|
||||
<Flex
|
||||
justify="center"
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" align="center" pt="2">
|
||||
<Flex
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{ maxWidth: 800, width: '100%', padding: '0 16px' }}
|
||||
gap="4"
|
||||
style={{ maxWidth: 1024, width: '100%', padding: '0 16px' }}
|
||||
gap="8"
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<HamburgerMenuIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Flex align="center" gap="4">
|
||||
<IconButton
|
||||
className={styles.hamburgerButton}
|
||||
size="4"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => setShowHamburgerMenu((state) => !state)}
|
||||
>
|
||||
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex direction="column" gap="4">
|
||||
{!wideFormat && <Flex gap="2">{plugs}</Flex>}
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="/"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<BlitzkitWide />
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="4">Tools</Heading>
|
||||
<Flex justify="center" gap="4" className={styles.toolTexts}>
|
||||
{TOOLS.filter((tool) => !tool.href).map((tool) => {
|
||||
const selected = pathname.startsWith(`/tools/${tool.id}`);
|
||||
|
||||
<Flex direction="column" gap="1" pl="4">
|
||||
{TOOLS.map((tool, index) => {
|
||||
if (tool.href || tool.disabled) return null;
|
||||
return (
|
||||
<Link
|
||||
key={tool.id}
|
||||
color="gray"
|
||||
size="2"
|
||||
highContrast={selected}
|
||||
underline={selected ? 'always' : 'hover'}
|
||||
href={`/tools/${tool.id}`}
|
||||
>
|
||||
{tool.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
return (
|
||||
<LinkRadix
|
||||
key={tool.id}
|
||||
tabIndex={-1}
|
||||
href={`/tools/${tool.id}`}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<Link
|
||||
href={`/tools/${tool.id}`}
|
||||
key={index}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tool.title}
|
||||
</Link>
|
||||
</LinkRadix>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Flex align="center" gap="4">
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DiscordLogoIcon width={16} height={16} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<BlitzkitWide />
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<PatreonIcon width={14} height={14} />
|
||||
</Link>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{wideFormat && plugs}
|
||||
|
||||
<Link href="/settings">
|
||||
<Flex style={{ width: '100%', height: '100%' }} justify="center">
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Link>
|
||||
|
||||
{login && (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<PersonIcon />
|
||||
<Link href="/settings" onClick={() => setShowHamburgerMenu(false)}>
|
||||
<Flex style={{ width: '100%', height: '100%' }} justify="center">
|
||||
<IconButton size="4" variant="ghost" color="gray">
|
||||
<GearIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
</Flex>
|
||||
</Link>
|
||||
|
||||
<Popover.Content align="center">
|
||||
<Flex direction="column" gap="2">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<LoggedIn />
|
||||
</Suspense>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
{login && (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton size="4" variant="ghost" color="gray">
|
||||
<PersonIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
{/* TODO: re-enable in the future */}
|
||||
{!login && false && (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">Log in</Button>
|
||||
</Dialog.Trigger>
|
||||
<Popover.Content align="center">
|
||||
<Flex direction="column" gap="2">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<LoggedIn />
|
||||
</Suspense>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
|
||||
<Dialog.Content
|
||||
{!login && false && (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">Log in</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content
|
||||
style={{
|
||||
maxWidth: 360,
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Heading size="3">Choose your region</Heading>
|
||||
|
||||
<Flex gap="4" align="center" justify="center" wrap="wrap">
|
||||
{REGIONS.map((region) => (
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
key={region}
|
||||
href={authURL(
|
||||
region,
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: location.href,
|
||||
)}
|
||||
>
|
||||
{UNLOCALIZED_REGION_NAMES[region]}
|
||||
</Link>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex mt="6" gap="3" justify="center" wrap="wrap">
|
||||
{TOOLS.filter((tool) => !tool.href).map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tools/${tool.id}`}
|
||||
className={styles.toolCard}
|
||||
style={{
|
||||
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
|
||||
cursor: tool.disabled ? 'default' : 'pointer',
|
||||
opacity: tool.disabled ? 0.25 : 1,
|
||||
height: 64,
|
||||
minWidth: 176,
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 8,
|
||||
transition: `box-shadow ${theme.durations.regular}`,
|
||||
}}
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: 360,
|
||||
color: theme.colors.textHighContrast,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="2" align="center">
|
||||
<Heading size="3">Choose your region</Heading>
|
||||
|
||||
<Flex gap="4" align="center" justify="center" wrap="wrap">
|
||||
{REGIONS.map((region) => (
|
||||
<LinkRadix
|
||||
key={region}
|
||||
href={authURL(
|
||||
region,
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: location.href,
|
||||
)}
|
||||
>
|
||||
{UNLOCALIZED_REGION_NAMES[region]}
|
||||
</LinkRadix>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Flex, FlexProps, Theme } from '@radix-ui/themes';
|
||||
import { ComponentProps } from 'react';
|
||||
import { theme } from '../stitches.config';
|
||||
|
||||
type PageWrapperProps = FlexProps & {
|
||||
color?: ComponentProps<typeof Theme>['accentColor'];
|
||||
size?: number | string;
|
||||
noPadding?: boolean;
|
||||
noMaxWidth?: boolean;
|
||||
highlight?: boolean;
|
||||
containerProps?: ComponentProps<typeof Theme>;
|
||||
noFlex1?: boolean;
|
||||
};
|
||||
@@ -20,23 +18,19 @@ export default function PageWrapper({
|
||||
noPadding = false,
|
||||
noMaxWidth = false,
|
||||
noFlex1 = false,
|
||||
highlight = false,
|
||||
containerProps,
|
||||
...props
|
||||
}: PageWrapperProps) {
|
||||
return (
|
||||
<Theme
|
||||
{...containerProps}
|
||||
accentColor={color}
|
||||
style={{
|
||||
flex: noFlex1 ? undefined : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: highlight
|
||||
? `linear-gradient(-90deg, ${theme.colors.appBackground1}, ${theme.colors.appBackground2})`
|
||||
: undefined,
|
||||
...containerProps?.style,
|
||||
}}
|
||||
{...containerProps}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function Party3() {
|
||||
|
||||
Reference in new issue
Block a user