mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fb64e690f | ||
|
|
54798c30b1 | ||
|
|
4c0f7b61e4 | ||
|
|
206ac373d2 | ||
|
|
c626e84f7d | ||
|
|
90ec5fa80b | ||
|
|
d496b46b6f | ||
|
|
7af1fdcf6b | ||
|
|
199e156e53 | ||
|
|
f5578ee788 | ||
|
|
c0a3628b3b | ||
|
|
fa8922e0a6 | ||
|
|
bf6991ce06 | ||
|
|
7f49245f15 | ||
|
|
f55313e0cd | ||
|
|
69c388f243 | ||
|
|
35788657a8 | ||
|
|
d531698867 | ||
|
|
cc4177bf14 | ||
|
|
d377067441 | ||
|
|
b2f026c212 | ||
|
|
07cdb0788c | ||
|
|
01f919ddd3 | ||
|
|
4b1fda9680 | ||
|
|
79d03233ae | ||
|
|
522b40aff1 | ||
|
|
06c878dd2c | ||
|
|
f5c589373d | ||
|
|
2689568650 | ||
|
|
fabf649608 | ||
|
|
080af86e1b | ||
|
|
e5b58cd5ff | ||
|
|
3500389628 | ||
|
|
1129f0d60b | ||
|
|
b3a6a47a6a | ||
|
|
bdb3844bcc | ||
|
|
f0bb25efc1 |
No files matched your search
Vendored
+3
-1
@@ -54,6 +54,7 @@
|
||||
"glsl",
|
||||
"GLTF",
|
||||
"haha",
|
||||
"highp",
|
||||
"iife",
|
||||
"immer",
|
||||
"Inclusivity",
|
||||
@@ -145,5 +146,6 @@
|
||||
"zustand"
|
||||
],
|
||||
"deno.enable": false,
|
||||
"deno.unstable": false
|
||||
"deno.unstable": false,
|
||||
"cSpell.enableFiletypes": ["glsl"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Blitzkrieg 1.16.1
|
||||
|
||||
Armor sandbox gains 25% rendering speed and 40% memory efficiency with added accuracy and features.
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple layers of spaced armors are now calculated properly by the visual armor highlighting
|
||||
- Added opaque mode to view armor highlighting without any transparency
|
||||
- Added breakdown on layers of armor penetrated
|
||||
- Penetration of each layer is shown represented by a color and an index
|
||||
- Green: penetration
|
||||
- Red: blocked
|
||||
- Yellow: ricochet
|
||||
- This also shows layers encountered after a ricochet
|
||||
|
||||
## Fixes
|
||||
|
||||
- Accuracy now doesn't drop the further the shot is placed from the origin
|
||||
- HE shells no longer loose penetration like how HEAT shells do when traveling between layers of armor
|
||||
- Persistent settings are now merged in the correct order making them truly persistent now
|
||||
- Enhanced armor and calibrated shells are now properly synced from the get go
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Spaced armor and external module scenes have been merged
|
||||
- They now use additive blending
|
||||
- Colors are now normalized by the penetration value instead of maximum armor plate thickness
|
||||
- Shell normalization now doesn't violate arccosine's domain
|
||||
+49
-46
@@ -57,6 +57,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
||||
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
<Analytics />
|
||||
@@ -100,59 +101,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
|
||||
<Flex direction="column">{children}</Flex>
|
||||
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
{!isEmbed && (
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
align="center"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
)}
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
||||
import { isMobile } from 'react-device-detect';
|
||||
import { ShotDisplay } from '../../../../../../components/ShotDisplay';
|
||||
import { Armor } from '../../../../../../components/Armor';
|
||||
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||
@@ -24,9 +25,6 @@ import { RotationInputs } from '../QuickInputs';
|
||||
import { SceneProps } from '../SceneProps';
|
||||
import { ModelLoader } from './components/ModelLoader';
|
||||
import { Options } from './components/Options';
|
||||
import { TankArmor } from './components/TankArmor';
|
||||
import { ExternalModuleMask } from './components/TankArmor/components/ExternalModuleMask';
|
||||
import { SpacedArmorDepth } from './components/TankArmor/components/SpacedArmorDepth';
|
||||
import { TankModel } from './components/TankModel';
|
||||
|
||||
export function TankSandbox() {
|
||||
@@ -198,12 +196,11 @@ export function TankSandbox() {
|
||||
<SceneProps />
|
||||
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
<ShotDisplay />
|
||||
{/* TODO: add rotation cube to the top right */}
|
||||
<Lighting />
|
||||
<TankModel />
|
||||
<TankArmor />
|
||||
<ExternalModuleMask ornamental />
|
||||
<SpacedArmorDepth ornamental />
|
||||
<Armor />
|
||||
<ShotDisplay />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
const wireframe = useTankopediaPersistent(
|
||||
(state) => state.model.visual.wireframe,
|
||||
);
|
||||
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
|
||||
const fullScreenAvailable =
|
||||
typeof document !== 'undefined' && document.fullscreenEnabled;
|
||||
const environment = useTankopediaPersistent(
|
||||
@@ -78,6 +79,8 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMeshExternalModuleMask } from '../../../../../../../../../components/ArmorMesh';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModel } from '../../../../../../../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
|
||||
interface ExternalModuleMaskProps {
|
||||
ornamental?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* When rendered, generates a mask and thickness buffer for external modules.
|
||||
* - R: 1 means external module
|
||||
* - G: normalized thickness; multiply with max thickness to get nominal thickness
|
||||
* - B: no data
|
||||
* - A: 1 means is armor; 0 is background
|
||||
*/
|
||||
export const ExternalModuleMask = memo<ExternalModuleMaskProps>(
|
||||
({ ornamental = false }) => {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(turretOrigin)
|
||||
.sub(hullOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(
|
||||
tankModelDefinition.turretRotation.roll,
|
||||
);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition =
|
||||
tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).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 { thickness, spaced } = resolveArmor(
|
||||
tankModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
maxThickness={maxThickness}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { thickness, spaced } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
<group ref={gunContainer}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentGun = node.name.startsWith(
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentGun;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { thickness, spaced } = resolveArmor(
|
||||
gunModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
exclude
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshExternalModuleMask
|
||||
ornamental={ornamental}
|
||||
maxThickness={maxThickness}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
},
|
||||
);
|
||||
-299
@@ -1,299 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMeshSpacedArmorDepth } from '../../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModel } from '../../../../../../../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
|
||||
interface SpacedArmorDepthProps {
|
||||
ornamental?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* When rendered, this component provides the depth buffer for the armor which
|
||||
* when compared to the depth of the core armor will give you the distance
|
||||
* between the first point of contact and the core armor. The color channels
|
||||
* hold information about the thickness of the spaced armor (external modules
|
||||
* not included).
|
||||
* - R: angle of the armor where 0 is 0 and 1 is 90
|
||||
* - G: normalized thickness; multiply with max thickness to get nominal thickness
|
||||
* - B: no data
|
||||
* - A: 1 means is armor; 0 is background
|
||||
*/
|
||||
export const SpacedArmorDepth = memo<SpacedArmorDepthProps>(
|
||||
({ ornamental = false }) => {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(
|
||||
tankModelDefinition.turretRotation.roll,
|
||||
);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const { gltf: modelGltf } = useModel(protagonist.tank.id);
|
||||
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition =
|
||||
tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).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,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
isExternalModule
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
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 || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
include={spaced}
|
||||
isExternalModule={false}
|
||||
thickness={thickness}
|
||||
maxThickness={maxThickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<ArmorMeshSpacedArmorDepth
|
||||
ornamental={ornamental}
|
||||
isExternalModule
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createPortal, useFrame } from '@react-three/fiber';
|
||||
import { memo } from 'react';
|
||||
import { DepthTexture, Scene, Vector2 } from 'three';
|
||||
import { externalModuleMaskRenderTarget } from '../../../../../../../../components/ArmorMesh';
|
||||
import { spacedArmorDepthRenderTarget } from '../../../../../../../../components/ArmorMesh/components/SpacedArmorDepth';
|
||||
import { ArmorHighlighting } from './components/ArmorHighlighting';
|
||||
import { ExternalModuleMask } from './components/ExternalModuleMask';
|
||||
import { SpacedArmorDepth } from './components/SpacedArmorDepth';
|
||||
|
||||
export const TankArmor = memo(() => {
|
||||
const externalModuleMaskScene = new Scene();
|
||||
const spacedArmorDepthScene = new Scene();
|
||||
const armorHighlightingScene = new Scene();
|
||||
const externalModuleMaskPortal = createPortal(
|
||||
<ExternalModuleMask />,
|
||||
externalModuleMaskScene,
|
||||
);
|
||||
const spacedArmorDepthPortal = createPortal(
|
||||
<SpacedArmorDepth />,
|
||||
spacedArmorDepthScene,
|
||||
);
|
||||
const armorHighlightingPortal = createPortal(
|
||||
<ArmorHighlighting />,
|
||||
armorHighlightingScene,
|
||||
);
|
||||
const renderSize = new Vector2();
|
||||
const newRenderSize = new Vector2();
|
||||
|
||||
useFrame(({ gl, scene, camera }) => {
|
||||
const pixelRatio = gl.getPixelRatio();
|
||||
gl.autoClear = true;
|
||||
|
||||
gl.getSize(newRenderSize).multiplyScalar(pixelRatio);
|
||||
if (!newRenderSize.equals(renderSize)) {
|
||||
renderSize.copy(newRenderSize);
|
||||
spacedArmorDepthRenderTarget.depthTexture = new DepthTexture(
|
||||
renderSize.x,
|
||||
renderSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
externalModuleMaskRenderTarget.setSize(renderSize.x, renderSize.y);
|
||||
gl.setRenderTarget(externalModuleMaskRenderTarget);
|
||||
gl.render(externalModuleMaskScene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
spacedArmorDepthRenderTarget.setSize(renderSize.x, renderSize.y * 0.5);
|
||||
gl.setRenderTarget(spacedArmorDepthRenderTarget);
|
||||
gl.render(spacedArmorDepthScene, camera);
|
||||
|
||||
gl.autoClear = false;
|
||||
|
||||
gl.setRenderTarget(null);
|
||||
gl.render(scene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
gl.render(armorHighlightingScene, camera);
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
{externalModuleMaskPortal}
|
||||
{spacedArmorDepthPortal}
|
||||
{armorHighlightingPortal}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -63,6 +63,8 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
};
|
||||
}, [initialId, assigned]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<PageWrapper color="purple" size="double">
|
||||
{assigned && (
|
||||
|
||||
+94
-88
@@ -1,28 +1,29 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Mesh, Vector3 } from 'three';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
||||
} from '../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
|
||||
|
||||
export function ArmorHighlighting() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
export const CoreArmorScene = memo(() => {
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
@@ -33,14 +34,17 @@ export function ArmorHighlighting() {
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ pitch, yaw }: ModelTransformEventData) {
|
||||
if (yaw === undefined) return;
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
@@ -53,6 +57,9 @@ export function ArmorHighlighting() {
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
@@ -80,6 +87,16 @@ export function ArmorHighlighting() {
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
@@ -87,27 +104,24 @@ export function ArmorHighlighting() {
|
||||
},
|
||||
);
|
||||
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
unsubscribe();
|
||||
};
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const armorGltf = useArmor(protagonist.tank.id);
|
||||
const tank = useDuel((state) => state.protagonist!.tank);
|
||||
const track = useDuel((state) => state.protagonist!.track);
|
||||
const turret = useDuel((state) => state.protagonist!.turret);
|
||||
const gun = useDuel((state) => state.protagonist!.gun);
|
||||
const armorGltf = useArmor(tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id];
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const tankModelDefinition = modelDefinitions[tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
);
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
@@ -118,96 +132,88 @@ export function ArmorHighlighting() {
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const maxThickness = Math.max(
|
||||
trackModelDefinition.thickness,
|
||||
gunModelDefinition.thickness,
|
||||
...armorNodes
|
||||
.map((node) => {
|
||||
const armorId = nameToArmorId(node.name);
|
||||
return resolveArmor(tankModelDefinition.armor, armorId).thickness;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={wrapper}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={initialTankopediaState.mode === 'armor'}
|
||||
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,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined || spaced) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
thickness={thickness}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<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 || thickness === undefined || spaced) return null;
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
<CoreArmorSceneComponent
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
position={turretOrigin}
|
||||
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 || thickness === undefined || spaced) return null;
|
||||
if (!isVisible || spaced || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<ArmorMesh
|
||||
maxThickness={maxThickness}
|
||||
key={node.uuid}
|
||||
geometry={(node as Mesh).geometry}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
thickness={thickness}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
WebGLRenderTarget,
|
||||
} 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 { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
TankopediaPersistent,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
export const spacedArmorRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
interface CoreArmorSceneComponentProps {
|
||||
thickness: number;
|
||||
node: Object3D;
|
||||
}
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function CoreArmorSceneComponent({
|
||||
node,
|
||||
thickness,
|
||||
}: CoreArmorSceneComponentProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
transparent: true,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
caliber: { value: null },
|
||||
ricochet: { value: null },
|
||||
normalization: { value: null },
|
||||
isExplosive: { value: null },
|
||||
canSplash: { value: null },
|
||||
damage: { value: null },
|
||||
explosionRadius: { value: null },
|
||||
greenPenetration: { value: null },
|
||||
opaque: { value: null },
|
||||
|
||||
resolution: { value: new Vector2() },
|
||||
spacedArmorBuffer: { value: null },
|
||||
spacedArmorDepth: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
material.uniforms.isExplosive.value = isExplosive(shell.type);
|
||||
material.uniforms.canSplash.value = canSplash(shell.type);
|
||||
material.uniforms.damage.value = shell.damage.armor;
|
||||
material.uniforms.explosionRadius.value = shell.explosionRadius;
|
||||
}
|
||||
function handleVisualChange(
|
||||
visual: TankopediaPersistent['model']['visual'],
|
||||
) {
|
||||
material.uniforms.greenPenetration.value = visual.greenPenetration;
|
||||
material.uniforms.opaque.value = visual.opaque || visual.wireframe;
|
||||
material.wireframe = visual.wireframe;
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleVisualChange(useTankopediaPersistent.getState().model.visual);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual,
|
||||
handleVisualChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ gl }) => {
|
||||
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
|
||||
gl.getPixelRatio(),
|
||||
);
|
||||
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
|
||||
material.uniforms.spacedArmorDepth.value =
|
||||
spacedArmorRenderTarget.depthTexture;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 0,
|
||||
material: excludeMaterial,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
material,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
varying mat4 vProjectionMatrix;
|
||||
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochet;
|
||||
uniform float normalization;
|
||||
uniform vec2 resolution;
|
||||
uniform bool isExplosive;
|
||||
uniform bool canSplash;
|
||||
uniform float damage;
|
||||
uniform float explosionRadius;
|
||||
uniform bool greenPenetration;
|
||||
uniform bool opaque;
|
||||
|
||||
uniform highp sampler2D spacedArmorBuffer;
|
||||
uniform highp sampler2D spacedArmorDepth;
|
||||
|
||||
float depthToDistance(float depth) {
|
||||
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition;
|
||||
return length(eyePosition.xyz / eyePosition.w);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float penetrationChance = -1.0;
|
||||
float splashChance = -1.0;
|
||||
|
||||
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
|
||||
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
|
||||
|
||||
vec4 spacedArmorBufferFragment = texture2D(spacedArmorBuffer, screenCoordinates);
|
||||
bool isUnderSpacedArmor = spacedArmorBufferFragment.a != 0.0;
|
||||
|
||||
bool threeCalibersRule = caliber > thickness * 3.0 || isUnderSpacedArmor;
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
penetrationChance = 0.0;
|
||||
} else {
|
||||
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
||||
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
||||
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
||||
float remainingPenetration = penetration;
|
||||
|
||||
// world space is in meters
|
||||
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
|
||||
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
|
||||
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
||||
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
|
||||
|
||||
if (isUnderSpacedArmor) {
|
||||
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
|
||||
remainingPenetration -= spacedArmorThickness;
|
||||
|
||||
if (isExplosive && !canSplash && remainingPenetration > 0.0) {
|
||||
// there is a 50% penetration loss per meter for HEAT shells
|
||||
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
|
||||
}
|
||||
}
|
||||
|
||||
remainingPenetration = max(0.0, remainingPenetration);
|
||||
float delta = finalThickness - remainingPenetration;
|
||||
float randomization = remainingPenetration * 0.05;
|
||||
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
|
||||
|
||||
if (canSplash) {
|
||||
// only allow splashing if the damage equation deals more than 0 damage
|
||||
float nominalArmorThickness = finalThickness - remainingPenetration;
|
||||
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
|
||||
splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
|
||||
} else {
|
||||
splashChance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
float alpha = opaque ? 1.0 : 0.5;
|
||||
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
|
||||
if (greenPenetration) {
|
||||
// non-trigonometric red-green mixing: https://www.desmos.com/calculator/ceo1oq7l67
|
||||
float falling = -pow(penetrationChance, 2.0) + 1.0;
|
||||
float gaining = -pow(penetrationChance - 1.0, 2.0) + 1.0;
|
||||
gl_FragColor = vec4(falling * color + gaining * vec3(0.0, 1.0, 0.0), alpha);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, (1.0 - penetrationChance) * alpha);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,306 @@
|
||||
import { Card, Flex, Inset, 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 { radToDeg } from 'three/src/math/MathUtils';
|
||||
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 {
|
||||
ShotLayer,
|
||||
ShotLayerBase,
|
||||
ShotLayerGap,
|
||||
ShotLayerNonExternal,
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../stores/tankopedia';
|
||||
import { ArmorType } from './SpacedArmorScene';
|
||||
|
||||
const layerTypeNames: Record<ArmorType | 'null', string> = {
|
||||
[ArmorType.Core]: 'Core',
|
||||
[ArmorType.Spaced]: 'Spaced',
|
||||
[ArmorType.External]: 'External',
|
||||
null: 'Gap',
|
||||
};
|
||||
|
||||
const LENGTH_INFINITY = 4;
|
||||
const TRACER_THICK = 1 / 64;
|
||||
const TRACER_THIN = TRACER_THICK / 2;
|
||||
|
||||
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 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 postMidPointGap = shot
|
||||
? postMidPointLayers!.reduce((accumulator, layer) => {
|
||||
if (layer.type === null) return accumulator + layer.distance;
|
||||
return accumulator;
|
||||
}, 0) || LENGTH_INFINITY
|
||||
: undefined;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!shot || !preMidPointTracer.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);
|
||||
|
||||
if (!postMidPointTracer.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());
|
||||
};
|
||||
});
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(midPointLayer.shellNormal)}
|
||||
>
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={
|
||||
lastLayer.status === 'blocked'
|
||||
? 0xff4040
|
||||
: lastLayer.status === 'penetration'
|
||||
? 0x00ff00
|
||||
: 0xffff00
|
||||
}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{midPointLayer.status === 'ricochet' && (
|
||||
<group
|
||||
position={midPointLayer.point}
|
||||
rotation={normalToEuler(
|
||||
midPointLayer.shellNormal
|
||||
.clone()
|
||||
.reflect(midPointLayer.surfaceNormal)
|
||||
.multiplyScalar(-1),
|
||||
)}
|
||||
>
|
||||
<mesh position={[0, postMidPointGap! / 2, 0]}>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
|
||||
/>
|
||||
<meshBasicMaterial depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[0, postMidPointGap! / 2, 0]}
|
||||
ref={postMidPointTracer}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
|
||||
/>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor:
|
||||
trueStatus === 'penetration'
|
||||
? '#00ff00c0'
|
||||
: trueStatus === 'blocked'
|
||||
? '#ff0000c0'
|
||||
: '#ffff00c0',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Inset>
|
||||
{hasMultipleLayers && (
|
||||
<Text
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color:
|
||||
trueStatus === 'ricochet' ? 'black' : undefined,
|
||||
}}
|
||||
>
|
||||
<b>{layer.index + 1}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Inset>
|
||||
</Card>
|
||||
</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,
|
||||
),
|
||||
),
|
||||
0,
|
||||
)}hp`}
|
||||
</Text>
|
||||
|
||||
{shot.layers.map((layer, index) => {
|
||||
if (layer.type === null && !explosive) 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Euler, Group, Scene, Vector3 } from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis';
|
||||
import {
|
||||
ModelTransformEventData,
|
||||
modelTransformEvent,
|
||||
} from '../../../core/blitzkrieg/modelTransform';
|
||||
import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
|
||||
import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
|
||||
import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useModel } from '../../../hooks/useModel';
|
||||
import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||
import { useDuel } from '../../../stores/duel';
|
||||
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||
import { SpacedArmorSceneComponent } from './SpacedArmorSceneComponent';
|
||||
|
||||
export enum ArmorType {
|
||||
Core,
|
||||
Spaced,
|
||||
External,
|
||||
}
|
||||
|
||||
interface SpacedArmorSceneProps {
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
const wrapper = useRef<Group>(null);
|
||||
const modelDefinitions = useModelDefinitions();
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretPosition = new Vector3();
|
||||
const turretRotation = new Euler();
|
||||
const gunPosition = new Vector3();
|
||||
const gunRotation = new Euler();
|
||||
|
||||
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||
gunPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.sub(gunOrigin)
|
||||
.applyAxisAngle(I_HAT, pitch)
|
||||
.add(gunOrigin)
|
||||
.add(turretOrigin)
|
||||
.add(hullOrigin);
|
||||
gunRotation.set(pitch, 0, 0);
|
||||
gunContainer.current?.position.copy(gunPosition);
|
||||
gunContainer.current?.rotation.copy(gunRotation);
|
||||
|
||||
if (yaw === undefined) return;
|
||||
|
||||
turretPosition
|
||||
.set(0, 0, 0)
|
||||
.sub(hullOrigin)
|
||||
.sub(turretOrigin)
|
||||
.applyAxisAngle(new Vector3(0, 0, 1), yaw);
|
||||
turretRotation.set(0, 0, yaw);
|
||||
|
||||
if (tankModelDefinition.turretRotation) {
|
||||
const initialPitch = -degToRad(
|
||||
tankModelDefinition.turretRotation.pitch,
|
||||
);
|
||||
const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
|
||||
const initialRoll = -degToRad(tankModelDefinition.turretRotation.roll);
|
||||
|
||||
turretPosition
|
||||
.applyAxisAngle(I_HAT, initialPitch)
|
||||
.applyAxisAngle(J_HAT, initialRoll)
|
||||
.applyAxisAngle(K_HAT, initialYaw);
|
||||
turretRotation.x += initialPitch;
|
||||
turretRotation.y += initialRoll;
|
||||
turretRotation.z += initialYaw;
|
||||
}
|
||||
|
||||
turretPosition.add(turretOrigin).add(hullOrigin);
|
||||
turretContainer.current?.position.copy(turretPosition);
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
modelTransformEvent.off(handleModelTransform);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||
(state) => state.mode,
|
||||
(mode) => {
|
||||
if (wrapper.current) wrapper.current.visible = mode === 'armor';
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const tank = useDuel((state) => state.protagonist!.tank);
|
||||
const track = useDuel((state) => state.protagonist!.track);
|
||||
const turret = useDuel((state) => state.protagonist!.turret);
|
||||
const gun = useDuel((state) => state.protagonist!.gun);
|
||||
const armorGltf = useArmor(tank.id);
|
||||
const { gltf: modelGltf } = useModel(tank.id);
|
||||
const armorNodes = Object.values(armorGltf.nodes);
|
||||
const modelNodes = Object.values(modelGltf.nodes);
|
||||
const tankModelDefinition = modelDefinitions[tank.id];
|
||||
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const hullOrigin = new Vector3(
|
||||
trackModelDefinition.origin[0],
|
||||
trackModelDefinition.origin[1],
|
||||
-trackModelDefinition.origin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const turretOrigin = new Vector3(
|
||||
tankModelDefinition.turretOrigin[0],
|
||||
tankModelDefinition.turretOrigin[1],
|
||||
-tankModelDefinition.turretOrigin[2],
|
||||
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||
const gunOrigin = new Vector3(
|
||||
turretModelDefinition.gunOrigin[0],
|
||||
turretModelDefinition.gunOrigin[1],
|
||||
-turretModelDefinition.gunOrigin[2],
|
||||
).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,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
{modelNodes.map((node) => {
|
||||
const isWheel = node.name.startsWith('chassis_wheel_');
|
||||
const isTrack = node.name.startsWith('chassis_track_');
|
||||
const isVisible = isWheel || isTrack;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={trackModelDefinition.thickness}
|
||||
variant="track"
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<group ref={turretContainer}>
|
||||
<group position={hullOrigin}>
|
||||
{armorNodes.map((node) => {
|
||||
const isCurrentTurret = node.name.startsWith(
|
||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||
);
|
||||
const isVisible = isCurrentTurret;
|
||||
const armorId = nameToArmorId(node.name);
|
||||
const { spaced, thickness } = resolveArmor(
|
||||
turretModelDefinition.armor,
|
||||
armorId,
|
||||
);
|
||||
|
||||
if (!isVisible || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group key={node.uuid} position={turretOrigin}>
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
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 || thickness === undefined) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
key={node.uuid}
|
||||
position={turretOrigin.clone().add(gunOrigin)}
|
||||
>
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Core}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
|
||||
{modelNodes.map((node) => {
|
||||
const isCurrentGun =
|
||||
node.name ===
|
||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
|
||||
const isVisible = isCurrentGun;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<SpacedArmorSceneComponent
|
||||
scene={scene}
|
||||
key={node.uuid}
|
||||
type={ArmorType.External}
|
||||
thickness={gunModelDefinition.thickness}
|
||||
variant="gun"
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
AdditiveBlending,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
} from 'three';
|
||||
import { ArmorUserData, ExternalModuleVariant } from '../..';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
|
||||
import { ArmorType } from '../../../SpacedArmorScene';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface SpacedArmorSubExternalProps {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
|
||||
export function SpacedArmorSubExternal({
|
||||
node,
|
||||
thickness,
|
||||
variant,
|
||||
}: SpacedArmorSubExternalProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 3,
|
||||
material: new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthTest: true,
|
||||
depthWrite: true,
|
||||
}),
|
||||
onClick() {},
|
||||
userData: {
|
||||
type: ArmorType.External,
|
||||
thickness,
|
||||
variant,
|
||||
} satisfies ArmorUserData,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 4,
|
||||
material,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
void main() {
|
||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * vViewPosition4;
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
AdditiveBlending,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
} 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 { hasEquipment } from '../../../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../../../core/blitzkrieg/jsxTree';
|
||||
import { ShellDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { EquipmentMatrix, useDuel } from '../../../../../../stores/duel';
|
||||
import { ArmorType } from '../../../SpacedArmorScene';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface SpacedArmorSubSpacedProps {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
const depthWriteMaterial = new MeshBasicMaterial({
|
||||
depthWrite: true,
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function SpacedArmorSubSpaced({
|
||||
node,
|
||||
thickness,
|
||||
}: SpacedArmorSubSpacedProps) {
|
||||
const material = new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
|
||||
uniforms: {
|
||||
thickness: { value: null },
|
||||
penetration: { value: null },
|
||||
caliber: { value: null },
|
||||
ricochet: { value: null },
|
||||
normalization: { value: null },
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleShellChange(shell: ShellDefinition) {
|
||||
material.uniforms.penetration.value = resolveNearPenetration(
|
||||
shell.penetration,
|
||||
);
|
||||
material.uniforms.caliber.value = shell.caliber;
|
||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||
material.uniforms.normalization.value = degToRad(
|
||||
shell.normalization ?? 0,
|
||||
);
|
||||
}
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsxTree(node, {
|
||||
renderOrder: 2,
|
||||
material,
|
||||
onClick() {},
|
||||
userData: {
|
||||
type: ArmorType.Spaced,
|
||||
thickness,
|
||||
} satisfies ArmorUserData,
|
||||
})}
|
||||
{jsxTree(node, {
|
||||
renderOrder: 5,
|
||||
material: depthWriteMaterial,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochet;
|
||||
uniform float normalization;
|
||||
|
||||
void main() {
|
||||
float angle = acos(dot(vNormal, -vViewPosition) / length(vViewPosition));
|
||||
|
||||
bool threeCalibersRule = caliber > thickness * 3.0;
|
||||
if (!threeCalibersRule && angle >= ricochet) {
|
||||
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
||||
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
||||
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
||||
|
||||
gl_FragColor = vec4(finalThickness / penetration, 0.0, 0.0, 1.0);
|
||||
}
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewPosition;
|
||||
|
||||
void main() {
|
||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||
|
||||
vViewPosition = vViewPosition4.xyz;
|
||||
vNormal = normalMatrix * normal;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * vViewPosition4;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useThree } from '@react-three/fiber';
|
||||
import {
|
||||
Intersection,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Scene,
|
||||
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 { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
Shot,
|
||||
ShotLayerBase,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import { ArmorType } from '../SpacedArmorScene';
|
||||
import { SpacedArmorSubExternal } from './components/SpacedArmorSubExternal';
|
||||
import { SpacedArmorSubSpaced } from './components/SpacedArmorSubSpaced';
|
||||
|
||||
type SpacedArmorSceneComponentProps = {
|
||||
node: Object3D;
|
||||
thickness: number;
|
||||
scene: Scene;
|
||||
} & (
|
||||
| {
|
||||
type: Exclude<ArmorType, ArmorType.External>;
|
||||
}
|
||||
| {
|
||||
type: ArmorType.External;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
);
|
||||
|
||||
export type ExternalModuleVariant = 'gun' | 'track';
|
||||
export type ArmorUserData = {
|
||||
thickness: number;
|
||||
} & (
|
||||
| {
|
||||
type: Exclude<ArmorType, ArmorType.External>;
|
||||
}
|
||||
| {
|
||||
type: ArmorType.External;
|
||||
variant: ExternalModuleVariant;
|
||||
}
|
||||
);
|
||||
type ArmorMeshIntersection = Intersection & {
|
||||
object: Object3D & { userData: ArmorUserData };
|
||||
};
|
||||
|
||||
const omitMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Render orders allocations:
|
||||
* - 0 : core omission
|
||||
* - 1-2: spaced without depth buffer write but do test to allow additive blending
|
||||
* - 3-4: external with depth buffer write in first pass and read in second to simplify geometry
|
||||
* - 5 : spaced depth write
|
||||
*/
|
||||
export function SpacedArmorSceneComponent({
|
||||
node,
|
||||
thickness,
|
||||
scene,
|
||||
...props
|
||||
}: SpacedArmorSceneComponentProps) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.type === ArmorType.Core &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 0,
|
||||
material: omitMaterial,
|
||||
userData: {
|
||||
type: ArmorType.Core,
|
||||
thickness,
|
||||
} satisfies ArmorUserData,
|
||||
|
||||
async onClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const explosive = isExplosive(shell.type);
|
||||
const splashing = canSplash(shell.type);
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
(hasCalibratedShells
|
||||
? isExplosive(shell.type)
|
||||
? 1.1
|
||||
: 1.05
|
||||
: 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 intersectionsAll = intersectionsRaw.filter(
|
||||
({ object }) => object.userData.type !== undefined,
|
||||
) as ArmorMeshIntersection[];
|
||||
const intersectionsWithDuplicates = intersectionsAll.slice(
|
||||
0,
|
||||
intersectionsAll.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) 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 (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++;
|
||||
}
|
||||
}
|
||||
|
||||
useTankopediaTemporary.setState({ shot });
|
||||
},
|
||||
})}
|
||||
|
||||
{props.type === ArmorType.Spaced && (
|
||||
<SpacedArmorSubSpaced node={node} thickness={thickness} />
|
||||
)}
|
||||
|
||||
{props.type === ArmorType.External && (
|
||||
<SpacedArmorSubExternal
|
||||
node={node}
|
||||
thickness={thickness}
|
||||
variant={props.variant}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createPortal, useFrame } from '@react-three/fiber';
|
||||
import { memo, useState } from 'react';
|
||||
import { DepthTexture, Scene, Vector2 } from 'three';
|
||||
import { CoreArmorScene } from './components/CoreArmorScene';
|
||||
import { spacedArmorRenderTarget } from './components/CoreArmorSceneComponent';
|
||||
import { SpacedArmorScene } from './components/SpacedArmorScene';
|
||||
|
||||
export const Armor = memo(() => {
|
||||
const [spacedArmorScene] = useState(() => new Scene());
|
||||
const [coreArmorScene] = useState(() => new Scene());
|
||||
const spacedArmorPortal = createPortal(
|
||||
<SpacedArmorScene scene={spacedArmorScene} />,
|
||||
spacedArmorScene,
|
||||
);
|
||||
const coreArmorPortal = createPortal(<CoreArmorScene />, coreArmorScene);
|
||||
|
||||
const renderSize = new Vector2();
|
||||
const newRenderSize = new Vector2();
|
||||
|
||||
useFrame(({ gl, camera, scene }) => {
|
||||
gl.autoClear = true;
|
||||
|
||||
gl.getSize(newRenderSize).multiplyScalar(gl.getPixelRatio());
|
||||
|
||||
if (!newRenderSize.equals(renderSize)) {
|
||||
renderSize.copy(newRenderSize);
|
||||
spacedArmorRenderTarget.depthTexture = new DepthTexture(
|
||||
renderSize.x,
|
||||
renderSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
spacedArmorRenderTarget.setSize(renderSize.x, renderSize.y);
|
||||
gl.setRenderTarget(spacedArmorRenderTarget);
|
||||
gl.render(spacedArmorScene, camera);
|
||||
|
||||
gl.autoClear = false;
|
||||
|
||||
gl.setRenderTarget(null);
|
||||
gl.render(scene, camera);
|
||||
|
||||
gl.clearDepth();
|
||||
gl.render(coreArmorScene, camera);
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
{spacedArmorPortal}
|
||||
{coreArmorPortal}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
import { MeshProps, useFrame, useThree } from '@react-three/fiber';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IUniform,
|
||||
Mesh,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { canRicochet } from '../../../../core/blitz/canRicochet';
|
||||
import { canSplash } from '../../../../core/blitz/canSplash';
|
||||
import { isExplosive } from '../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
|
||||
import { hasEquipment } from '../../../../core/blitzkrieg/hasEquipment';
|
||||
import { useAwait } from '../../../../hooks/useAwait';
|
||||
import { useDuel } from '../../../../stores/duel';
|
||||
import {
|
||||
ArmorPiercingLayer,
|
||||
Shot,
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaPersistent,
|
||||
} from '../../../../stores/tankopedia';
|
||||
import { externalModuleMaskRenderTarget } from '../ExternalModuleMask';
|
||||
import { spacedArmorDepthRenderTarget } from '../SpacedArmorDepth';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
interface ArmorMeshProps extends MeshProps {
|
||||
thickness: number;
|
||||
maxThickness: number;
|
||||
}
|
||||
|
||||
export type ArmorMeshUserData = {
|
||||
type: 'coreArmor' | 'spacedArmor' | 'externalModule';
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
export const ArmorMesh = memo<ArmorMeshProps>(
|
||||
({ thickness, maxThickness, ...props }) => {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const initialShell = useDuel.getState().antagonist!.shell;
|
||||
const initialTankopedia = useTankopediaPersistent.getState();
|
||||
const material = useRef<ShaderMaterial>(null);
|
||||
const explosionCapable = isExplosive(initialShell.type);
|
||||
const ricochetCapable = canRicochet(initialShell.type);
|
||||
const cameraNormal = new Vector3();
|
||||
const shellNormal = new Vector3();
|
||||
const surfaceNormal = new Vector3();
|
||||
const mesh = useRef<Mesh>(null);
|
||||
const scene = useThree((state) => state.scene);
|
||||
const hasCalibratedShells = useAwait(hasEquipment(103, true));
|
||||
const hasEnhancedArmor = useAwait(hasEquipment(110));
|
||||
const wireframe = initialTankopedia.model.visual.wireframe;
|
||||
|
||||
useEffect(() => {
|
||||
async function updateQuickEquipments() {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const nearPenetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
|
||||
if (material.current) {
|
||||
material.current.uniforms.thickness.value =
|
||||
thickness * (hasEnhancedArmor ? 1.04 : 1);
|
||||
material.current.uniforms.penetration.value =
|
||||
nearPenetration *
|
||||
(hasCalibratedShells ? (isExplosive(shell.type) ? 1.1 : 1.05) : 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updateShellProperties() {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
|
||||
if (material.current) {
|
||||
material.current.uniforms.canRicochet.value = canRicochet(shell.type);
|
||||
material.current.uniforms.isExplosive.value = isExplosive(shell.type);
|
||||
material.current.uniforms.canSplash.value = canSplash(shell.type);
|
||||
material.current.uniforms.caliber.value = shell.caliber;
|
||||
material.current.uniforms.ricochetAngle.value = degToRad(
|
||||
shell.ricochet ?? 90,
|
||||
);
|
||||
material.current.uniforms.damage.value = shell.damage.armor;
|
||||
material.current.uniforms.explosionRadius.value =
|
||||
shell.explosionRadius ?? 0;
|
||||
}
|
||||
|
||||
updateQuickEquipments();
|
||||
}
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
updateQuickEquipments,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
updateQuickEquipments,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.shell,
|
||||
updateShellProperties,
|
||||
),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual.greenPenetration,
|
||||
(greenPenetration) => {
|
||||
if (material.current) {
|
||||
material.current.uniforms.greenPenetration.value =
|
||||
greenPenetration;
|
||||
}
|
||||
},
|
||||
),
|
||||
useTankopediaPersistent.subscribe(
|
||||
(state) => state.model.visual.wireframe,
|
||||
(wireframe) => {
|
||||
if (material.current) {
|
||||
material.current.wireframe = wireframe;
|
||||
material.current.uniforms.wireframe.value = wireframe;
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ gl, camera }) => {
|
||||
if (material.current) {
|
||||
gl.getSize(material.current.uniforms.resolution.value);
|
||||
|
||||
(
|
||||
material.current.uniforms.resolution as IUniform<Vector2>
|
||||
).value.multiplyScalar(gl.getPixelRatio());
|
||||
|
||||
material.current.uniforms.externalModuleMask.value =
|
||||
externalModuleMaskRenderTarget.texture;
|
||||
material.current.uniforms.spacedArmorDepth.value =
|
||||
spacedArmorDepthRenderTarget.depthTexture;
|
||||
material.current.uniforms.spacedArmorMask.value =
|
||||
spacedArmorDepthRenderTarget.texture;
|
||||
material.current.uniforms.projectionMatrixInverse.value =
|
||||
camera.projectionMatrixInverse;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh {...props} renderOrder={0}>
|
||||
<meshBasicMaterial colorWrite={false} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
{...props}
|
||||
renderOrder={1}
|
||||
userData={
|
||||
{
|
||||
type: 'coreArmor',
|
||||
thickness,
|
||||
} satisfies ArmorMeshUserData
|
||||
}
|
||||
ref={mesh}
|
||||
onClick={async (event) => {
|
||||
// if (useTankopediaTemporary.getState().mode !== 'armor') return;
|
||||
|
||||
shellNormal.copy(
|
||||
cameraNormal.copy(camera.position).sub(event.point).normalize(),
|
||||
);
|
||||
surfaceNormal
|
||||
.copy(event.normal!)
|
||||
.applyQuaternion(
|
||||
event.object.getWorldQuaternion(new Quaternion()),
|
||||
);
|
||||
const angle = Math.acos(surfaceNormal.dot(shellNormal));
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const resolvedPenetration =
|
||||
resolveNearPenetration(shell.penetration) *
|
||||
(hasCalibratedShells
|
||||
? isExplosive(shell.type)
|
||||
? 1.1
|
||||
: 1.05
|
||||
: 1);
|
||||
const indexedObjects = new Set<ArmorMeshUserData['type']>();
|
||||
const intersections = event.intersections
|
||||
.filter(({ object }) => Boolean(object.userData.type))
|
||||
.map((event) => {
|
||||
const typedEvent = event as typeof event & {
|
||||
object: (typeof event)['object'] & {
|
||||
userData: ArmorMeshUserData;
|
||||
};
|
||||
};
|
||||
|
||||
return typedEvent;
|
||||
})
|
||||
.filter(({ object }) => {
|
||||
if (indexedObjects.has(object.userData.type)) return false;
|
||||
indexedObjects.add(object.userData.type);
|
||||
return true;
|
||||
});
|
||||
const intersectionsTillFirstCoreArmor = intersections.slice(
|
||||
0,
|
||||
intersections.findIndex(
|
||||
({ object }) => object.userData.type === 'coreArmor',
|
||||
) + 1,
|
||||
);
|
||||
const coreArmorMesh =
|
||||
intersectionsTillFirstCoreArmor.at(-1)!.object;
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||
|
||||
if (mesh.current !== (coreArmorMesh as unknown as Mesh)) return;
|
||||
|
||||
let hasRicochet = false;
|
||||
const armorPiercingLayersRaw = intersectionsTillFirstCoreArmor
|
||||
.map((event, index) => {
|
||||
if (hasRicochet) return null;
|
||||
|
||||
switch (event.object.userData.type) {
|
||||
case 'externalModule': {
|
||||
return {
|
||||
nominal:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
angled:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
type: 'external',
|
||||
distance: event.distance,
|
||||
ricochet: false,
|
||||
} satisfies ArmorPiercingLayer;
|
||||
}
|
||||
|
||||
case 'spacedArmor':
|
||||
case 'coreArmor': {
|
||||
const threeCalibersRule =
|
||||
shell.caliber >
|
||||
3 *
|
||||
(event.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
|
||||
if (
|
||||
index === 0 &&
|
||||
canRicochet(shell.type) &&
|
||||
!threeCalibersRule &&
|
||||
angle >= degToRad(shell.ricochet!)
|
||||
) {
|
||||
hasRicochet = true;
|
||||
}
|
||||
|
||||
const twoCalibersRule =
|
||||
shell.caliber >
|
||||
2 *
|
||||
(event.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
const normalization =
|
||||
twoCalibersRule && event.object.userData.thickness > 0
|
||||
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
|
||||
(2 *
|
||||
event.object.userData.thickness *
|
||||
thicknessCoefficient)
|
||||
: shell.normalization ?? 0;
|
||||
const angled =
|
||||
(event.object.userData.thickness * thicknessCoefficient) /
|
||||
Math.cos(angle - degToRad(normalization));
|
||||
|
||||
return {
|
||||
ricochet: hasRicochet,
|
||||
nominal:
|
||||
event.object.userData.thickness * thicknessCoefficient,
|
||||
angled,
|
||||
type:
|
||||
event.object.userData.type === 'coreArmor'
|
||||
? 'core'
|
||||
: 'spaced',
|
||||
distance: event.distance,
|
||||
} satisfies ArmorPiercingLayer;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const gaps = armorPiercingLayersRaw.map((layerA, aIndex) => {
|
||||
if (aIndex === armorPiercingLayersRaw.length - 1) return null;
|
||||
const layerB = armorPiercingLayersRaw[aIndex + 1];
|
||||
|
||||
return {
|
||||
type: 'gap',
|
||||
gap: Math.abs(layerB.distance - layerA.distance),
|
||||
} satisfies ArmorPiercingLayer;
|
||||
});
|
||||
const armorPiercingLayers: ArmorPiercingLayer[] = [];
|
||||
|
||||
armorPiercingLayersRaw.forEach((layer, index) => {
|
||||
const gap = gaps[index];
|
||||
|
||||
armorPiercingLayers.push(layer);
|
||||
if (gap) armorPiercingLayers.push(gap);
|
||||
});
|
||||
|
||||
let remainingPenetration = resolvedPenetration;
|
||||
const explosiveCapable = isExplosive(shell.type);
|
||||
|
||||
armorPiercingLayers.forEach((layer) => {
|
||||
if (layer.type !== 'gap') {
|
||||
if (layer.ricochet) {
|
||||
remainingPenetration *= 0.75;
|
||||
} else {
|
||||
remainingPenetration -= layer.angled;
|
||||
}
|
||||
} else if (explosiveCapable) {
|
||||
// there is a 50% penetration loss per meter for HE based shells
|
||||
remainingPenetration -= 0.5 * remainingPenetration * layer.gap;
|
||||
}
|
||||
|
||||
remainingPenetration = Math.max(0, remainingPenetration);
|
||||
});
|
||||
|
||||
const hasPenetrated = remainingPenetration > 0;
|
||||
let ricochet: Shot['ricochet'] = undefined;
|
||||
|
||||
if (hasRicochet) {
|
||||
const raycaster = new Raycaster();
|
||||
const bounceNormal = shellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1)
|
||||
.sub(
|
||||
surfaceNormal
|
||||
.clone()
|
||||
.multiplyScalar(
|
||||
2 *
|
||||
surfaceNormal.dot(
|
||||
shellNormal.clone().multiplyScalar(-1),
|
||||
),
|
||||
),
|
||||
);
|
||||
raycaster.set(event.point, bounceNormal);
|
||||
const intersections = raycaster
|
||||
.intersectObjects(scene.children, true)
|
||||
.filter(
|
||||
(intersection) =>
|
||||
typeof intersection.object.userData?.type === 'string',
|
||||
);
|
||||
const indexOfCoreArmor = intersections.findIndex(
|
||||
({ object }) => object.userData.type === 'coreArmor',
|
||||
);
|
||||
const intersectionsTillFirstCoreArmor = intersections.slice(
|
||||
0,
|
||||
indexOfCoreArmor + 1,
|
||||
);
|
||||
|
||||
if (intersectionsTillFirstCoreArmor.length > 0) {
|
||||
intersectionsTillFirstCoreArmor.forEach((intersection) => {
|
||||
switch (intersection.object.userData.type) {
|
||||
case 'externalModule': {
|
||||
remainingPenetration -=
|
||||
intersection.object.userData.thickness;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'spacedArmor':
|
||||
case 'coreArmor': {
|
||||
const angle = bounceNormal.angleTo(
|
||||
intersection.object.position.clone().sub(event.point),
|
||||
);
|
||||
const twoCalibersRule =
|
||||
shell.caliber >
|
||||
2 *
|
||||
(intersection.object.userData.thickness *
|
||||
thicknessCoefficient);
|
||||
const normalization = twoCalibersRule
|
||||
? ((shell.normalization ?? 0) * 1.4 * shell.caliber) /
|
||||
(2 *
|
||||
intersection.object.userData.thickness *
|
||||
thicknessCoefficient)
|
||||
: shell.normalization ?? 0;
|
||||
const angled =
|
||||
(intersection.object.userData.thickness *
|
||||
thicknessCoefficient) /
|
||||
Math.cos(angle - degToRad(normalization));
|
||||
|
||||
remainingPenetration -= angled;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ricochet = {
|
||||
point: intersectionsTillFirstCoreArmor
|
||||
.at(-1)!
|
||||
.point.toArray(),
|
||||
distance: intersectionsTillFirstCoreArmor.at(-1)!.distance,
|
||||
penetration: remainingPenetration > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = {
|
||||
type: hasRicochet
|
||||
? 'ricochet'
|
||||
: hasPenetrated
|
||||
? 'penetration'
|
||||
: 'block',
|
||||
point: event.point.toArray(),
|
||||
thicknesses: armorPiercingLayers,
|
||||
shellNormal: shellNormal.toArray(),
|
||||
surfaceNormal: surfaceNormal.toArray(),
|
||||
angle,
|
||||
ricochet,
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<shaderMaterial
|
||||
wireframe={wireframe}
|
||||
ref={material}
|
||||
fragmentShader={fragmentShader}
|
||||
vertexShader={vertexShader}
|
||||
transparent
|
||||
depthWrite={false}
|
||||
uniforms={{
|
||||
resolution: { value: new Vector2() },
|
||||
externalModuleMask: { value: null },
|
||||
spacedArmorDepth: { value: null },
|
||||
spacedArmorMask: { value: null },
|
||||
projectionMatrixInverse: { value: null },
|
||||
zNear: { value: camera.near },
|
||||
zFar: { value: camera.far },
|
||||
greenPenetration: {
|
||||
value: initialTankopedia.model.visual.greenPenetration,
|
||||
},
|
||||
maxThickness: { value: maxThickness },
|
||||
isExplosive: { value: explosionCapable },
|
||||
canRicochet: { value: ricochetCapable },
|
||||
canSplash: { value: canSplash(initialShell.type) },
|
||||
thickness: {
|
||||
value: thickness * (hasEnhancedArmor ? 1.04 : 1),
|
||||
},
|
||||
penetration: {
|
||||
value:
|
||||
resolveNearPenetration(initialShell.penetration) *
|
||||
(hasCalibratedShells ? (explosionCapable ? 1.1 : 1.05) : 1),
|
||||
},
|
||||
caliber: { value: initialShell.caliber },
|
||||
ricochetAngle: { value: degToRad(initialShell.ricochet ?? 90) },
|
||||
normalization: {
|
||||
value: degToRad(initialShell.normalization ?? 0),
|
||||
},
|
||||
damage: { value: initialShell.damage.armor },
|
||||
explosionRadius: { value: initialShell.explosionRadius ?? 0 },
|
||||
wireframe: { value: wireframe },
|
||||
}}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1,120 +0,0 @@
|
||||
#define PI 3.1415926535897932384626433832795
|
||||
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
uniform bool isExplosive;
|
||||
uniform bool canRicochet;
|
||||
uniform bool canSplash;
|
||||
uniform float thickness;
|
||||
uniform float penetration;
|
||||
uniform float caliber;
|
||||
uniform float ricochetAngle;
|
||||
uniform float normalization;
|
||||
uniform sampler2D externalModuleMask;
|
||||
uniform highp sampler2D spacedArmorDepth;
|
||||
uniform sampler2D spacedArmorMask;
|
||||
uniform vec2 resolution;
|
||||
varying mat4 vProjectionMatrix;
|
||||
uniform float maxThickness;
|
||||
uniform bool greenPenetration;
|
||||
uniform float damage;
|
||||
uniform float explosionRadius;
|
||||
uniform bool wireframe;
|
||||
|
||||
float depthToDistance(float depth) {
|
||||
mat4 projectionMatrixInverse = inverse(vProjectionMatrix);
|
||||
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||
vec4 eyePosition = projectionMatrixInverse * clipPosition;
|
||||
vec3 NDCPosition = eyePosition.xyz / eyePosition.w;
|
||||
float worldDistance = length(NDCPosition);
|
||||
|
||||
return worldDistance;
|
||||
}
|
||||
|
||||
float angleTo(vec3 a, vec3 b) {
|
||||
return acos(dot(a, b) / (length(a) * length(b)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float penetrationChance = -1.0;
|
||||
float splashChance = -1.0;
|
||||
|
||||
vec2 screenCoordinates = gl_FragCoord.xy / resolution;
|
||||
vec4 externalModuleMaskColor = texture2D(externalModuleMask, screenCoordinates);
|
||||
vec4 spacedArmorMaskColor = texture2D(spacedArmorMask, screenCoordinates);
|
||||
bool isUnderExternalModule = externalModuleMaskColor.r == 1.0;
|
||||
bool isUnderSpacedArmor = spacedArmorMaskColor.a == 1.0;
|
||||
float spacedArmorAngle = spacedArmorMaskColor.r * (PI / 2.0);
|
||||
|
||||
float coreArmorAngle = angleTo(vNormal, -vViewPosition);
|
||||
bool coreArmorThreeCalibersRule = caliber > 3.0 * thickness;
|
||||
float spacedArmorNominalThickness = spacedArmorMaskColor.g * maxThickness;
|
||||
bool spacedArmorThreeCalibersRule = isUnderSpacedArmor && caliber > 3.0 * spacedArmorNominalThickness;
|
||||
bool hasRicochetedSpacedArmor = isUnderSpacedArmor && !spacedArmorThreeCalibersRule && spacedArmorAngle >= ricochetAngle;
|
||||
bool hasRicochetedCoreArmor = !hasRicochetedSpacedArmor && !isUnderExternalModule && canRicochet && !coreArmorThreeCalibersRule && coreArmorAngle >= ricochetAngle;
|
||||
|
||||
if (hasRicochetedCoreArmor || hasRicochetedSpacedArmor) {
|
||||
penetrationChance = 0.0;
|
||||
splashChance = 0.0;
|
||||
} else {
|
||||
// time to chip away at the penetration, one case at a time
|
||||
float remainingPenetration = penetration;
|
||||
|
||||
if (isUnderExternalModule) {
|
||||
// external modules don't care about angle, they'll reduce penetration by their thickness
|
||||
float externalModuleThickness = externalModuleMaskColor.g * maxThickness;
|
||||
remainingPenetration -= externalModuleThickness;
|
||||
}
|
||||
|
||||
if (isUnderSpacedArmor) {
|
||||
// spaced armor on the other hand, does care about angle
|
||||
bool spacedArmorTwoCalibersRule = caliber > 2.0 * spacedArmorNominalThickness && spacedArmorNominalThickness > 0.0;
|
||||
float finalSpacedArmorNormalization = spacedArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * spacedArmorNominalThickness) : normalization;
|
||||
float finalSpacedArmorThickness = spacedArmorNominalThickness / cos(spacedArmorAngle - finalSpacedArmorNormalization);
|
||||
remainingPenetration -= finalSpacedArmorThickness;
|
||||
}
|
||||
remainingPenetration = max(remainingPenetration, 0.0);
|
||||
|
||||
float spacedArmorDepth = texture2D(spacedArmorDepth, screenCoordinates).r;
|
||||
float spacedArmorDistance = depthToDistance(spacedArmorDepth);
|
||||
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
||||
float distanceFromSpacedArmor = abs(coreArmorDistance - spacedArmorDistance);
|
||||
if (isExplosive) {
|
||||
// there is a 50% penetration loss per meter for HE based shells
|
||||
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
|
||||
}
|
||||
|
||||
bool coreArmorTwoCalibersRule = caliber > 2.0 * thickness && thickness > 0.0;
|
||||
float finalNormalization = coreArmorTwoCalibersRule ? (normalization * 1.4 * caliber) / (2.0 * thickness) : normalization;
|
||||
float finalCoreArmorThickness = thickness / cos(coreArmorAngle - finalNormalization);
|
||||
float deltaPenetration = finalCoreArmorThickness - remainingPenetration;
|
||||
float randomRadius = remainingPenetration * 0.05;
|
||||
|
||||
if (deltaPenetration > randomRadius) {
|
||||
// even with a 5% buff to the penetration, can't penetrate
|
||||
penetrationChance = 0.0;
|
||||
} else if (deltaPenetration < -randomRadius) {
|
||||
// even with a 5% nerf, can penetrate
|
||||
penetrationChance = 1.0;
|
||||
} else {
|
||||
// transition area
|
||||
penetrationChance = max(1.0 - (deltaPenetration + randomRadius) / (2.0 * randomRadius), 0.0);
|
||||
}
|
||||
|
||||
if (canSplash) {
|
||||
// only allow splasing if the damage equation deals more than 0 damage
|
||||
float nominalArmorThickness = finalCoreArmorThickness - remainingPenetration;
|
||||
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
|
||||
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
|
||||
} else {
|
||||
splashChance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
vec3 color = vec3(1.0, splashChance * 0.392, 0.0);
|
||||
if (greenPenetration || wireframe) {
|
||||
gl_FragColor = vec4(mix(color, vec3(0.0, 1.0, 0.0), penetrationChance), wireframe ? 1.0 : 0.5);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, (1.0 - penetrationChance) * 0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Color, MeshBasicMaterial, Object3D, WebGLRenderTarget } from 'three';
|
||||
import { jsxTree } from '../../../core/blitzkrieg/jsxTree';
|
||||
import { ArmorMeshUserData } from './Armor';
|
||||
|
||||
export const externalModuleMaskRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
type ArmorMeshExternalModuleMaskProps = {
|
||||
ornamental?: boolean;
|
||||
node: Object3D;
|
||||
} & (
|
||||
| {
|
||||
exclude?: false;
|
||||
maxThickness: number;
|
||||
thickness: number;
|
||||
}
|
||||
| {
|
||||
exclude: true;
|
||||
}
|
||||
);
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
|
||||
export function ArmorMeshExternalModuleMask({
|
||||
ornamental = false,
|
||||
node,
|
||||
...props
|
||||
}: ArmorMeshExternalModuleMaskProps) {
|
||||
return (
|
||||
<>
|
||||
{props.exclude &&
|
||||
!ornamental &&
|
||||
jsxTree(node, { renderOrder: 0, material: excludeMaterial })}
|
||||
|
||||
{!props.exclude &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
onClick: ornamental ? () => {} : undefined,
|
||||
userData: {
|
||||
type: 'externalModule',
|
||||
thickness: props.thickness,
|
||||
} satisfies ArmorMeshUserData,
|
||||
material: new MeshBasicMaterial({
|
||||
color: new Color(1, props.thickness / props.maxThickness, 0),
|
||||
depthWrite: false,
|
||||
colorWrite: !ornamental,
|
||||
}),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
ShaderMaterial,
|
||||
WebGLRenderTarget,
|
||||
} from 'three';
|
||||
import { jsxTree } from '../../../../core/blitzkrieg/jsxTree';
|
||||
import { ArmorMeshUserData } from '../Armor';
|
||||
import fragmentShader from './shaders/fragment.glsl';
|
||||
import vertexShader from './shaders/vertex.glsl';
|
||||
|
||||
export const spacedArmorDepthRenderTarget = new WebGLRenderTarget();
|
||||
|
||||
type ArmorMeshSpacedArmorDepthProps = {
|
||||
ornamental?: boolean;
|
||||
node: Object3D;
|
||||
} & (
|
||||
| {
|
||||
include?: false;
|
||||
isExternalModule: boolean;
|
||||
}
|
||||
| {
|
||||
include: true;
|
||||
thickness: number;
|
||||
maxThickness: number;
|
||||
}
|
||||
);
|
||||
|
||||
const excludeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
});
|
||||
const includeMaterial = new MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
export function ArmorMeshSpacedArmorDepth({
|
||||
ornamental = false,
|
||||
node,
|
||||
...props
|
||||
}: ArmorMeshSpacedArmorDepthProps) {
|
||||
return (
|
||||
<>
|
||||
{!props.include &&
|
||||
!ornamental &&
|
||||
jsxTree(node, {
|
||||
renderOrder: props.isExternalModule ? 2 : 0,
|
||||
material: excludeMaterial,
|
||||
})}
|
||||
|
||||
{props.include &&
|
||||
jsxTree(node, {
|
||||
renderOrder: 1,
|
||||
onClick: ornamental ? () => {} : undefined,
|
||||
userData: {
|
||||
type: 'spacedArmor',
|
||||
thickness: props.thickness,
|
||||
} satisfies ArmorMeshUserData,
|
||||
material: ornamental
|
||||
? includeMaterial
|
||||
: new ShaderMaterial({
|
||||
fragmentShader,
|
||||
vertexShader,
|
||||
uniforms: {
|
||||
thickness: { value: props.thickness },
|
||||
maxThickness: { value: props.maxThickness },
|
||||
},
|
||||
}),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#define PI 3.1415926535897932384626433832795
|
||||
|
||||
varying vec3 vViewPosition;
|
||||
varying vec3 vNormal;
|
||||
uniform float thickness;
|
||||
uniform float maxThickness;
|
||||
|
||||
void main() {
|
||||
float angle = acos(dot(vNormal, -normalize(vViewPosition)));
|
||||
gl_FragColor = vec4(angle / (PI / 2.0), thickness / maxThickness, 0.0, 1.0);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './components/Armor';
|
||||
export * from './components/ExternalModuleMask';
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber';
|
||||
import { isInteger } from 'lodash';
|
||||
import { ReactNode } from 'react';
|
||||
import { Scene } from 'three';
|
||||
|
||||
interface HeadsUpDisplayProps {
|
||||
priority?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
/**
|
||||
* Renders `children` above the default scene, even if the priority is a
|
||||
* negative number.
|
||||
*/
|
||||
export function HeadsUpDisplay({
|
||||
priority = 1,
|
||||
children,
|
||||
}: HeadsUpDisplayProps) {
|
||||
const { gl: renderer, scene: defaultScene, camera } = useThree();
|
||||
const scene = new Scene();
|
||||
const portal = createPortal(children, scene, {
|
||||
events: { priority: priority + 1 },
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (priority === 1) {
|
||||
renderer.clear();
|
||||
renderer.render(defaultScene, camera);
|
||||
}
|
||||
|
||||
renderer.clearDepth();
|
||||
renderer.render(scene, camera);
|
||||
}, priority);
|
||||
|
||||
renderer.autoClear = false;
|
||||
if (!isInteger(priority)) {
|
||||
throw new TypeError('Priority property must be an integer');
|
||||
}
|
||||
|
||||
return portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this component if the default scene disappears on usage of the
|
||||
* HeadsUpDisplay; caused because there are not HeadsUpDisplay components
|
||||
* with `1` priority.
|
||||
*/
|
||||
export function RenderDefault() {
|
||||
const { gl: renderer, scene, camera } = useThree();
|
||||
|
||||
useFrame(() => renderer.render(scene, camera));
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { Card, Flex, Text } from '@radix-ui/themes';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
import { Group, Mesh, Vector3 } from 'three';
|
||||
import { radToDeg } from 'three/src/math/MathUtils';
|
||||
import { isExplosive } from '../core/blitz/isExplosive';
|
||||
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 { Shot, useTankopediaTemporary } from '../stores/tankopedia';
|
||||
|
||||
export const SHOT_NAMES: Record<Shot['type'], string> = {
|
||||
ricochet: 'Ricochet',
|
||||
penetration: 'Penetration',
|
||||
block: 'Blocked',
|
||||
};
|
||||
|
||||
export const SHOT_ICONS: Record<Shot['type'], ReactNode> = {
|
||||
ricochet: <Ricochet width={24} height={24} />,
|
||||
block: <Block width={24} height={24} />,
|
||||
penetration: <NominalPenetration width={24} height={24} />,
|
||||
};
|
||||
|
||||
const THICKNESS = 0.05;
|
||||
const LENGTH = 4;
|
||||
|
||||
export function ShotDisplay() {
|
||||
const shot = useTankopediaTemporary((state) => state.shot);
|
||||
const entryWrapper = useRef<Group>(null);
|
||||
const entryLine = useRef<Mesh>(null);
|
||||
const circle = useRef<Mesh>(null);
|
||||
const ricochetCircle = useRef<Mesh>(null);
|
||||
const exitWrapper = useRef<Group>(null);
|
||||
const exitLine = useRef<Mesh>(null);
|
||||
const secondLineLength = shot?.ricochet ? shot?.ricochet.distance : LENGTH;
|
||||
const number = shot && (shot.thicknesses.length > 1 || shot.ricochet);
|
||||
const shell = useDuel((state) => state.antagonist!.shell);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const t1 = clock.elapsedTime % 1;
|
||||
const t2 = (clock.elapsedTime + 0.5) % 1;
|
||||
|
||||
entryLine.current?.position.set(0, 0, LENGTH - t1 * LENGTH);
|
||||
entryLine.current?.scale.set(1, -2 * Math.abs(t1 - 0.5) + 1, 1);
|
||||
exitLine.current?.position.set(0, 0, t2 * secondLineLength);
|
||||
exitLine.current?.scale.set(1, -2 * Math.abs(t2 - 0.5) + 1, 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shot) return;
|
||||
|
||||
const point = new Vector3(...shot.point);
|
||||
const shellNormal = new Vector3(...shot.shellNormal);
|
||||
const surfaceNormal = new Vector3(...shot.surfaceNormal);
|
||||
const bounceNormal = shellNormal
|
||||
.clone()
|
||||
.multiplyScalar(-1)
|
||||
.sub(
|
||||
surfaceNormal
|
||||
.clone()
|
||||
.multiplyScalar(
|
||||
2 * surfaceNormal.dot(shellNormal.clone().multiplyScalar(-1)),
|
||||
),
|
||||
);
|
||||
|
||||
entryWrapper.current?.position.set(...shot.point);
|
||||
entryWrapper.current?.lookAt(point.clone().add(shellNormal));
|
||||
exitWrapper.current?.position.set(...shot.point);
|
||||
exitWrapper.current?.lookAt(point.clone().add(bounceNormal));
|
||||
circle.current?.position.set(...shot.point);
|
||||
circle.current?.lookAt(point.clone().add(surfaceNormal));
|
||||
}, [shot]);
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh ref={circle}>
|
||||
<torusGeometry args={[0.05, 0.0125, 4, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={shot.type === 'penetration' ? 'green' : 'red'}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<group ref={entryWrapper}>
|
||||
<Html
|
||||
position={[0, 0, 0]}
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: number ? 300 : 200,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="1">
|
||||
<Flex align="center" gap="2">
|
||||
{SHOT_ICONS[shot.type]}
|
||||
<Text>
|
||||
{SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}°
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
paddingLeft: number ? 8 : undefined,
|
||||
}}
|
||||
>
|
||||
{shot.thicknesses.map((layer, index) => (
|
||||
<Flex gap="2" key={index}>
|
||||
{number && (
|
||||
<Text>
|
||||
{index + 1}. {layer.type[0].toUpperCase()}
|
||||
{layer.type.slice(1)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Flex gap="1">
|
||||
{layer.type !== 'gap' && (
|
||||
<NominalPenetration width={24} height={24} />
|
||||
)}
|
||||
<Text>
|
||||
{Math.round(
|
||||
layer.type === 'gap'
|
||||
? layer.gap * 1000
|
||||
: layer.nominal,
|
||||
)}
|
||||
mm
|
||||
{layer.type === 'gap' && isExplosive(shell.type)
|
||||
? ` (-${Math.round(Math.min(100, 0.5 * layer.gap * 100))}% penetration)`
|
||||
: ''}
|
||||
</Text>
|
||||
</Flex>
|
||||
{layer.type !== 'external' && layer.type !== 'gap' && (
|
||||
<Flex gap="1">
|
||||
<AngledPenetration width={24} height={24} />
|
||||
<Text>{Math.round(layer.angled)}mm</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{shot.ricochet && (
|
||||
<>
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 1}. Ricochet</Text>
|
||||
<Ricochet width={24} height={24} />
|
||||
<Text>(-25% penetration)</Text>
|
||||
</Flex>
|
||||
{shot.ricochet.penetration ? (
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 2}. Penetration</Text>
|
||||
<NominalPenetration width={24} height={24} />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex gap="1">
|
||||
<Text>{shot.thicknesses.length + 2}. Blocked</Text>
|
||||
<Block width={24} height={24} />
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Html>
|
||||
|
||||
<mesh
|
||||
renderOrder={1}
|
||||
position={[0, 0, LENGTH / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
ref={entryLine}
|
||||
>
|
||||
<cylinderGeometry args={[THICKNESS / 2, THICKNESS / 2, LENGTH, 8]} />
|
||||
<meshBasicMaterial color="#ffff80" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, LENGTH / 2]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[THICKNESS / 4, THICKNESS / 4, LENGTH, 8]} />
|
||||
<meshBasicMaterial color="#ffffff" depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{shot.type === 'ricochet' && (
|
||||
<group ref={exitWrapper}>
|
||||
<mesh
|
||||
renderOrder={1}
|
||||
position={[0, 0, secondLineLength / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
ref={exitLine}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[THICKNESS / 2, THICKNESS / 2, secondLineLength, 8]}
|
||||
/>
|
||||
<meshBasicMaterial color="#ff8080" />
|
||||
</mesh>
|
||||
<mesh
|
||||
position={[0, 0, secondLineLength / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[THICKNESS / 4, THICKNESS / 4, secondLineLength, 8]}
|
||||
/>
|
||||
<meshBasicMaterial color="#ffffff" depthTest={false} />
|
||||
</mesh>
|
||||
|
||||
{shot.ricochet && (
|
||||
<mesh ref={ricochetCircle} position={[0, 0, secondLineLength]}>
|
||||
<icosahedronGeometry args={[0.05, 1]} />
|
||||
<meshBasicMaterial
|
||||
color={shot.ricochet.penetration ? 'green' : 'red'}
|
||||
depthTest={false}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Vector3 } from 'three';
|
||||
|
||||
export const ZERO_VECTOR = new Vector3();
|
||||
export const I_HAT = new Vector3(1, 0, 0);
|
||||
export const J_HAT = new Vector3(0, 1, 0);
|
||||
export const K_HAT = new Vector3(0, 0, 1);
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useDuel } from '../../stores/duel';
|
||||
import { equipmentDefinitions } from './equipmentDefinitions';
|
||||
|
||||
export async function hasEquipment(id: number, antagonist = false) {
|
||||
export async function hasEquipment(
|
||||
id: number,
|
||||
antagonist = false,
|
||||
equipmentMatrix = useDuel.getState()[
|
||||
antagonist ? 'antagonist' : 'protagonist'
|
||||
]!.equipment,
|
||||
) {
|
||||
const awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
|
||||
const equipmentRows =
|
||||
awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||
const equipmentMatrix =
|
||||
useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!.equipment;
|
||||
|
||||
return equipmentRows.some((equipmentRow, rowIndex) => {
|
||||
const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||
return presetRows.some((equipmentRow, rowIndex) => {
|
||||
return equipmentRow.some((equipment, columnIndex) => {
|
||||
const choice = equipmentMatrix[rowIndex][columnIndex];
|
||||
if (choice === 0) return false;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function NaNFallback(value: number, fallback: number) {
|
||||
return isNaN(value) ? fallback : value;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Euler, Quaternion, Vector3 } from 'three';
|
||||
import { J_HAT } from '../../constants/axis';
|
||||
|
||||
const quaternion = new Quaternion();
|
||||
const axis = new Vector3();
|
||||
|
||||
export function normalToEuler(normal: Vector3, up = J_HAT) {
|
||||
return new Euler().setFromQuaternion(
|
||||
quaternion.setFromAxisAngle(
|
||||
axis.crossVectors(up, normal).normalize(),
|
||||
up.angleTo(normal),
|
||||
),
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { merge } from 'lodash';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
@@ -10,6 +11,6 @@ export const useApp = create<App>()(
|
||||
(set) => ({
|
||||
devBuildAgreementTime: 0,
|
||||
}),
|
||||
{ name: 'app' },
|
||||
{ name: 'app', merge: (a, b) => merge(b, a) },
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { produce } from 'immer';
|
||||
import { merge } from 'lodash';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
|
||||
@@ -83,7 +84,7 @@ export const useSession = create<Session>()(
|
||||
],
|
||||
color: 'slate',
|
||||
}),
|
||||
{ name: 'session' },
|
||||
{ name: 'session', merge: (a, b) => merge(b, a) },
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+36
-15
@@ -1,9 +1,10 @@
|
||||
import { produce } from 'immer';
|
||||
import { merge } from 'lodash';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { Vector3 } from 'three';
|
||||
import { create } from 'zustand';
|
||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
|
||||
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
|
||||
import { TankType, TreeType } from '../components/Tanks';
|
||||
import { Tier } from '../core/blitzkrieg/tankDefinitions';
|
||||
|
||||
@@ -12,10 +13,11 @@ export type TankopediaSortDirection = 'ascending' | 'descending';
|
||||
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
|
||||
export type TankopediaMode = 'model' | 'armor';
|
||||
|
||||
interface TankopediaPersistent {
|
||||
export interface TankopediaPersistent {
|
||||
model: {
|
||||
visual: {
|
||||
wireframe: boolean;
|
||||
opaque: boolean;
|
||||
environment: (typeof ENVIRONMENTS)[number];
|
||||
controlsEnabled: boolean;
|
||||
showGrid: boolean;
|
||||
@@ -42,20 +44,38 @@ interface TankopediaTemporary {
|
||||
shot?: Shot;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
point: Vector3Tuple;
|
||||
surfaceNormal: Vector3Tuple;
|
||||
shellNormal: Vector3Tuple;
|
||||
thicknesses: ArmorPiercingLayer[];
|
||||
type: 'ricochet' | 'penetration' | 'block';
|
||||
angle: number;
|
||||
ricochet?: {
|
||||
distance: number;
|
||||
point: Vector3Tuple;
|
||||
penetration: boolean;
|
||||
};
|
||||
export interface ShotLayerBase {
|
||||
index: number;
|
||||
thickness: number;
|
||||
point: Vector3;
|
||||
shellNormal: Vector3;
|
||||
surfaceNormal: Vector3;
|
||||
status: 'blocked' | 'penetration' | 'ricochet';
|
||||
}
|
||||
|
||||
interface ShotLayerExternal extends ShotLayerBase {
|
||||
type: ArmorType.External;
|
||||
}
|
||||
|
||||
export interface ShotLayerNonExternal extends ShotLayerBase {
|
||||
type: Exclude<ArmorType, ArmorType.External>;
|
||||
angle: number;
|
||||
thicknessAngled: number;
|
||||
}
|
||||
|
||||
export interface ShotLayerGap {
|
||||
type: null;
|
||||
status: 'penetration' | 'blocked';
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
|
||||
|
||||
export type Shot = {
|
||||
point: Vector3;
|
||||
layers: ShotLayer[];
|
||||
};
|
||||
|
||||
export type ArmorPiercingLayer =
|
||||
| {
|
||||
distance: number;
|
||||
@@ -104,6 +124,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
|
||||
model: {
|
||||
visual: {
|
||||
wireframe: false,
|
||||
opaque: false,
|
||||
environment: ENVIRONMENTS[0],
|
||||
controlsEnabled: true,
|
||||
showGrid: true,
|
||||
@@ -125,7 +146,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
|
||||
},
|
||||
mode: 'model',
|
||||
})),
|
||||
{ name: 'tankopedia', merge },
|
||||
{ name: 'tankopedia', merge: (a, b) => merge(b, a) },
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user