mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 11:39:50 +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",
|
"glsl",
|
||||||
"GLTF",
|
"GLTF",
|
||||||
"haha",
|
"haha",
|
||||||
|
"highp",
|
||||||
"iife",
|
"iife",
|
||||||
"immer",
|
"immer",
|
||||||
"Inclusivity",
|
"Inclusivity",
|
||||||
@@ -145,5 +146,6 @@
|
|||||||
"zustand"
|
"zustand"
|
||||||
],
|
],
|
||||||
"deno.enable": false,
|
"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={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
paddingTop: isEmbed ? 0 : '3.25rem',
|
||||||
|
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
@@ -100,59 +101,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||||||
|
|
||||||
<Flex direction="column">{children}</Flex>
|
<Flex direction="column">{children}</Flex>
|
||||||
|
|
||||||
<PageWrapper>
|
{!isEmbed && (
|
||||||
<a
|
<PageWrapper>
|
||||||
target="_blank"
|
<a
|
||||||
href="https://ko-fi.com/tresabhi"
|
target="_blank"
|
||||||
style={{ textDecoration: 'none' }}
|
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
direction="column"
|
align="center"
|
||||||
gap="1"
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
padding: 16,
|
||||||
zIndex: 1,
|
borderRadius: 8,
|
||||||
|
backgroundColor: mintTheme.colors.componentInteractive,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="4" color="mint">
|
<Flex
|
||||||
Blitzkrieg. Free forever. For everyone.
|
direction="column"
|
||||||
</Text>
|
gap="1"
|
||||||
<Text color="mint">
|
style={{
|
||||||
<b>Consider supporting today 🍀</b>
|
flex: 1,
|
||||||
</Text>
|
zIndex: 1,
|
||||||
</Flex>
|
}}
|
||||||
|
>
|
||||||
<Text
|
<Text size="4" color="mint">
|
||||||
color="mint"
|
Blitzkrieg. Free forever. For everyone.
|
||||||
style={{
|
</Text>
|
||||||
zIndex: 1,
|
<Text color="mint">
|
||||||
}}
|
<b>Consider supporting today 🍀</b>
|
||||||
>
|
</Text>
|
||||||
<Flex>
|
|
||||||
<CaretRightIcon width={32} height={32} />
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SkyLine
|
<Text
|
||||||
style={{
|
color="mint"
|
||||||
position: 'absolute',
|
style={{
|
||||||
right: 'min(8vw, 15%)',
|
zIndex: 1,
|
||||||
top: '5%',
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
<Flex>
|
||||||
</Flex>
|
<CaretRightIcon width={32} height={32} />
|
||||||
</a>
|
</Flex>
|
||||||
</PageWrapper>
|
</Text>
|
||||||
|
|
||||||
|
<SkyLine
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 'min(8vw, 15%)',
|
||||||
|
top: '5%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</a>
|
||||||
|
</PageWrapper>
|
||||||
|
)}
|
||||||
</Theme>
|
</Theme>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { PerspectiveCamera } from '@react-three/drei';
|
|||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
||||||
import { isMobile } from 'react-device-detect';
|
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 { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||||
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
|
||||||
@@ -24,9 +25,6 @@ import { RotationInputs } from '../QuickInputs';
|
|||||||
import { SceneProps } from '../SceneProps';
|
import { SceneProps } from '../SceneProps';
|
||||||
import { ModelLoader } from './components/ModelLoader';
|
import { ModelLoader } from './components/ModelLoader';
|
||||||
import { Options } from './components/Options';
|
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';
|
import { TankModel } from './components/TankModel';
|
||||||
|
|
||||||
export function TankSandbox() {
|
export function TankSandbox() {
|
||||||
@@ -198,12 +196,11 @@ export function TankSandbox() {
|
|||||||
<SceneProps />
|
<SceneProps />
|
||||||
|
|
||||||
<Suspense fallback={<ModelLoader />}>
|
<Suspense fallback={<ModelLoader />}>
|
||||||
<ShotDisplay />
|
{/* TODO: add rotation cube to the top right */}
|
||||||
<Lighting />
|
<Lighting />
|
||||||
<TankModel />
|
<TankModel />
|
||||||
<TankArmor />
|
<Armor />
|
||||||
<ExternalModuleMask ornamental />
|
<ShotDisplay />
|
||||||
<SpacedArmorDepth ornamental />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
const wireframe = useTankopediaPersistent(
|
const wireframe = useTankopediaPersistent(
|
||||||
(state) => state.model.visual.wireframe,
|
(state) => state.model.visual.wireframe,
|
||||||
);
|
);
|
||||||
|
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
|
||||||
const fullScreenAvailable =
|
const fullScreenAvailable =
|
||||||
typeof document !== 'undefined' && document.fullscreenEnabled;
|
typeof document !== 'undefined' && document.fullscreenEnabled;
|
||||||
const environment = useTankopediaPersistent(
|
const environment = useTankopediaPersistent(
|
||||||
@@ -78,6 +79,8 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={greenPenetration}
|
checked={greenPenetration}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
@@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
Green penetration
|
Green penetration
|
||||||
</DropdownMenu.CheckboxItem>
|
</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
|
<DropdownMenu.CheckboxItem
|
||||||
checked={showGrid}
|
checked={showGrid}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
@@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
</DropdownMenu.RadioGroup>
|
</DropdownMenu.RadioGroup>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
checked={wireframe}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
mutateTankopediaPersistent((draft) => {
|
|
||||||
draft.model.visual.wireframe = checked;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Wireframe
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</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]);
|
}, [initialId, assigned]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper color="purple" size="double">
|
<PageWrapper color="purple" size="double">
|
||||||
{assigned && (
|
{assigned && (
|
||||||
|
|||||||
+94
-88
@@ -1,28 +1,29 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { memo, useEffect, useRef } from 'react';
|
||||||
import { Euler, Group, Mesh, Vector3 } from 'three';
|
import { Euler, Group, Vector3 } from 'three';
|
||||||
import { degToRad } from 'three/src/math/MathUtils';
|
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 {
|
import {
|
||||||
ModelTransformEventData,
|
ModelTransformEventData,
|
||||||
modelTransformEvent,
|
modelTransformEvent,
|
||||||
} from '../../../../../../../../../core/blitzkrieg/modelTransform';
|
} from '../../../core/blitzkrieg/modelTransform';
|
||||||
import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
|
import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId';
|
||||||
import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
|
import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness';
|
||||||
import { useArmor } from '../../../../../../../../../hooks/useArmor';
|
import { useArmor } from '../../../hooks/useArmor';
|
||||||
import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions';
|
import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
|
||||||
import { useDuel } from '../../../../../../../../../stores/duel';
|
import { useDuel } from '../../../stores/duel';
|
||||||
import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia';
|
import { useTankopediaPersistent } from '../../../stores/tankopedia';
|
||||||
|
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
|
||||||
|
|
||||||
export function ArmorHighlighting() {
|
export const CoreArmorScene = memo(() => {
|
||||||
const protagonist = useDuel((state) => state.protagonist!);
|
|
||||||
const wrapper = useRef<Group>(null);
|
const wrapper = useRef<Group>(null);
|
||||||
const modelDefinitions = useModelDefinitions();
|
const modelDefinitions = useModelDefinitions();
|
||||||
const gunContainer = useRef<Group>(null);
|
|
||||||
const turretContainer = useRef<Group>(null);
|
const turretContainer = useRef<Group>(null);
|
||||||
|
const gunContainer = useRef<Group>(null);
|
||||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!modelDefinitions) return;
|
||||||
|
|
||||||
const hullOrigin = new Vector3(
|
const hullOrigin = new Vector3(
|
||||||
trackModelDefinition.origin[0],
|
trackModelDefinition.origin[0],
|
||||||
trackModelDefinition.origin[1],
|
trackModelDefinition.origin[1],
|
||||||
@@ -33,14 +34,17 @@ export function ArmorHighlighting() {
|
|||||||
tankModelDefinition.turretOrigin[1],
|
tankModelDefinition.turretOrigin[1],
|
||||||
-tankModelDefinition.turretOrigin[2],
|
-tankModelDefinition.turretOrigin[2],
|
||||||
).applyAxisAngle(I_HAT, Math.PI / 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 turretPosition = new Vector3();
|
||||||
const turretRotation = new Euler();
|
const turretRotation = new Euler();
|
||||||
const gunPosition = new Vector3();
|
const gunPosition = new Vector3();
|
||||||
const gunRotation = new Euler();
|
const gunRotation = new Euler();
|
||||||
|
|
||||||
function handleModelTransform({ pitch, yaw }: ModelTransformEventData) {
|
function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
|
||||||
if (yaw === undefined) return;
|
|
||||||
|
|
||||||
gunPosition
|
gunPosition
|
||||||
.set(0, 0, 0)
|
.set(0, 0, 0)
|
||||||
.sub(hullOrigin)
|
.sub(hullOrigin)
|
||||||
@@ -53,6 +57,9 @@ export function ArmorHighlighting() {
|
|||||||
gunRotation.set(pitch, 0, 0);
|
gunRotation.set(pitch, 0, 0);
|
||||||
gunContainer.current?.position.copy(gunPosition);
|
gunContainer.current?.position.copy(gunPosition);
|
||||||
gunContainer.current?.rotation.copy(gunRotation);
|
gunContainer.current?.rotation.copy(gunRotation);
|
||||||
|
|
||||||
|
if (yaw === undefined) return;
|
||||||
|
|
||||||
turretPosition
|
turretPosition
|
||||||
.set(0, 0, 0)
|
.set(0, 0, 0)
|
||||||
.sub(hullOrigin)
|
.sub(hullOrigin)
|
||||||
@@ -80,6 +87,16 @@ export function ArmorHighlighting() {
|
|||||||
turretContainer.current?.position.copy(turretPosition);
|
turretContainer.current?.position.copy(turretPosition);
|
||||||
turretContainer.current?.rotation.copy(turretRotation);
|
turretContainer.current?.rotation.copy(turretRotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleModelTransform(useDuel.getState().protagonist!);
|
||||||
|
modelTransformEvent.on(handleModelTransform);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
modelTransformEvent.off(handleModelTransform);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const unsubscribe = useTankopediaPersistent.subscribe(
|
const unsubscribe = useTankopediaPersistent.subscribe(
|
||||||
(state) => state.mode,
|
(state) => state.mode,
|
||||||
(mode) => {
|
(mode) => {
|
||||||
@@ -87,27 +104,24 @@ export function ArmorHighlighting() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
handleModelTransform(protagonist);
|
return unsubscribe;
|
||||||
modelTransformEvent.on(handleModelTransform);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
modelTransformEvent.off(handleModelTransform);
|
|
||||||
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 armorNodes = Object.values(armorGltf.nodes);
|
||||||
const tankModelDefinition = modelDefinitions[protagonist.tank.id];
|
const tankModelDefinition = modelDefinitions[tank.id];
|
||||||
const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id];
|
const trackModelDefinition = tankModelDefinition.tracks[track.id];
|
||||||
const turretModelDefinition =
|
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||||
tankModelDefinition.turrets[protagonist.turret.id];
|
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
|
||||||
const hullOrigin = new Vector3(
|
const hullOrigin = new Vector3(
|
||||||
trackModelDefinition.origin[0],
|
trackModelDefinition.origin[0],
|
||||||
trackModelDefinition.origin[1],
|
trackModelDefinition.origin[1],
|
||||||
-trackModelDefinition.origin[2],
|
-trackModelDefinition.origin[2],
|
||||||
);
|
).applyAxisAngle(I_HAT, Math.PI / 2);
|
||||||
const turretOrigin = new Vector3(
|
const turretOrigin = new Vector3(
|
||||||
tankModelDefinition.turretOrigin[0],
|
tankModelDefinition.turretOrigin[0],
|
||||||
tankModelDefinition.turretOrigin[1],
|
tankModelDefinition.turretOrigin[1],
|
||||||
@@ -118,96 +132,88 @@ export function ArmorHighlighting() {
|
|||||||
turretModelDefinition.gunOrigin[1],
|
turretModelDefinition.gunOrigin[1],
|
||||||
-turretModelDefinition.gunOrigin[2],
|
-turretModelDefinition.gunOrigin[2],
|
||||||
).applyAxisAngle(I_HAT, Math.PI / 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 (
|
return (
|
||||||
<group
|
<group
|
||||||
ref={wrapper}
|
ref={wrapper}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
visible={initialTankopediaState.mode === 'armor'}
|
visible={initialTankopediaState.mode === 'armor'}
|
||||||
position={hullOrigin}
|
|
||||||
>
|
>
|
||||||
{armorNodes.map((node) => {
|
<group position={hullOrigin}>
|
||||||
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}>
|
|
||||||
{armorNodes.map((node) => {
|
{armorNodes.map((node) => {
|
||||||
const isCurrentTurret = node.name.startsWith(
|
const isHull = node.name.startsWith('hull_');
|
||||||
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
const isVisible = isHull;
|
||||||
);
|
|
||||||
const isVisible = isCurrentTurret;
|
|
||||||
const armorId = nameToArmorId(node.name);
|
const armorId = nameToArmorId(node.name);
|
||||||
const { spaced, thickness } = resolveArmor(
|
const { spaced, thickness } = resolveArmor(
|
||||||
turretModelDefinition.armor,
|
tankModelDefinition.armor,
|
||||||
armorId,
|
armorId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isVisible || thickness === undefined || spaced) return null;
|
if (!isVisible || spaced || thickness === undefined) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArmorMesh
|
<CoreArmorSceneComponent
|
||||||
maxThickness={maxThickness}
|
|
||||||
key={node.uuid}
|
key={node.uuid}
|
||||||
geometry={(node as Mesh).geometry}
|
|
||||||
position={turretOrigin}
|
|
||||||
thickness={thickness}
|
thickness={thickness}
|
||||||
|
node={node}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</group>
|
||||||
|
|
||||||
<group ref={gunContainer}>
|
<group ref={turretContainer}>
|
||||||
|
<group position={hullOrigin}>
|
||||||
{armorNodes.map((node) => {
|
{armorNodes.map((node) => {
|
||||||
const isCurrentGun = node.name.startsWith(
|
const isCurrentTurret = node.name.startsWith(
|
||||||
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
|
||||||
);
|
);
|
||||||
const isVisible = isCurrentGun;
|
const isVisible = isCurrentTurret;
|
||||||
const armorId = nameToArmorId(node.name);
|
const armorId = nameToArmorId(node.name);
|
||||||
const { spaced, thickness } = resolveArmor(
|
const { spaced, thickness } = resolveArmor(
|
||||||
gunModelDefinition.armor,
|
turretModelDefinition.armor,
|
||||||
armorId,
|
armorId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isVisible || thickness === undefined || spaced) return null;
|
if (!isVisible || spaced || thickness === undefined) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArmorMesh
|
<group key={node.uuid} position={turretOrigin}>
|
||||||
maxThickness={maxThickness}
|
<CoreArmorSceneComponent
|
||||||
key={node.uuid}
|
key={node.uuid}
|
||||||
geometry={(node as Mesh).geometry}
|
thickness={thickness}
|
||||||
position={turretOrigin.clone().add(gunOrigin)}
|
node={node}
|
||||||
thickness={thickness}
|
/>
|
||||||
/>
|
</group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</group>
|
</group>
|
||||||
|
<group ref={gunContainer}>
|
||||||
|
<group position={hullOrigin}>
|
||||||
|
{armorNodes.map((node) => {
|
||||||
|
const isCurrentGun = node.name.startsWith(
|
||||||
|
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
|
||||||
|
);
|
||||||
|
const isVisible = isCurrentGun;
|
||||||
|
const armorId = nameToArmorId(node.name);
|
||||||
|
const { spaced, thickness } = resolveArmor(
|
||||||
|
gunModelDefinition.armor,
|
||||||
|
armorId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isVisible || spaced || thickness === undefined) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
key={node.uuid}
|
||||||
|
position={turretOrigin.clone().add(gunOrigin)}
|
||||||
|
>
|
||||||
|
<CoreArmorSceneComponent thickness={thickness} node={node} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</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 vNormal;
|
||||||
|
varying vec3 vViewPosition;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
|
||||||
vViewPosition = vViewPosition4.xyz;
|
vViewPosition = vViewPosition4.xyz;
|
||||||
vNormal = normalMatrix * normal;
|
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';
|
import { Vector3 } from 'three';
|
||||||
|
|
||||||
|
export const ZERO_VECTOR = new Vector3();
|
||||||
export const I_HAT = new Vector3(1, 0, 0);
|
export const I_HAT = new Vector3(1, 0, 0);
|
||||||
export const J_HAT = new Vector3(0, 1, 0);
|
export const J_HAT = new Vector3(0, 1, 0);
|
||||||
export const K_HAT = new Vector3(0, 0, 1);
|
export const K_HAT = new Vector3(0, 0, 1);
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useDuel } from '../../stores/duel';
|
import { useDuel } from '../../stores/duel';
|
||||||
import { equipmentDefinitions } from './equipmentDefinitions';
|
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 awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||||
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
|
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
|
||||||
const equipmentRows =
|
const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||||
awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
return presetRows.some((equipmentRow, rowIndex) => {
|
||||||
const equipmentMatrix =
|
|
||||||
useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!.equipment;
|
|
||||||
|
|
||||||
return equipmentRows.some((equipmentRow, rowIndex) => {
|
|
||||||
return equipmentRow.some((equipment, columnIndex) => {
|
return equipmentRow.some((equipment, columnIndex) => {
|
||||||
const choice = equipmentMatrix[rowIndex][columnIndex];
|
const choice = equipmentMatrix[rowIndex][columnIndex];
|
||||||
if (choice === 0) return false;
|
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 { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
@@ -10,6 +11,6 @@ export const useApp = create<App>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
devBuildAgreementTime: 0,
|
devBuildAgreementTime: 0,
|
||||||
}),
|
}),
|
||||||
{ name: 'app' },
|
{ name: 'app', merge: (a, b) => merge(b, a) },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
|
import { merge } from 'lodash';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
|
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
|
||||||
@@ -83,7 +84,7 @@ export const useSession = create<Session>()(
|
|||||||
],
|
],
|
||||||
color: 'slate',
|
color: 'slate',
|
||||||
}),
|
}),
|
||||||
{ name: 'session' },
|
{ name: 'session', merge: (a, b) => merge(b, a) },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+36
-15
@@ -1,9 +1,10 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import { Vector3Tuple } from 'three';
|
import { Vector3 } from 'three';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
|
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
|
||||||
|
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
|
||||||
import { TankType, TreeType } from '../components/Tanks';
|
import { TankType, TreeType } from '../components/Tanks';
|
||||||
import { Tier } from '../core/blitzkrieg/tankDefinitions';
|
import { Tier } from '../core/blitzkrieg/tankDefinitions';
|
||||||
|
|
||||||
@@ -12,10 +13,11 @@ export type TankopediaSortDirection = 'ascending' | 'descending';
|
|||||||
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
|
export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only';
|
||||||
export type TankopediaMode = 'model' | 'armor';
|
export type TankopediaMode = 'model' | 'armor';
|
||||||
|
|
||||||
interface TankopediaPersistent {
|
export interface TankopediaPersistent {
|
||||||
model: {
|
model: {
|
||||||
visual: {
|
visual: {
|
||||||
wireframe: boolean;
|
wireframe: boolean;
|
||||||
|
opaque: boolean;
|
||||||
environment: (typeof ENVIRONMENTS)[number];
|
environment: (typeof ENVIRONMENTS)[number];
|
||||||
controlsEnabled: boolean;
|
controlsEnabled: boolean;
|
||||||
showGrid: boolean;
|
showGrid: boolean;
|
||||||
@@ -42,20 +44,38 @@ interface TankopediaTemporary {
|
|||||||
shot?: Shot;
|
shot?: Shot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shot {
|
export interface ShotLayerBase {
|
||||||
point: Vector3Tuple;
|
index: number;
|
||||||
surfaceNormal: Vector3Tuple;
|
thickness: number;
|
||||||
shellNormal: Vector3Tuple;
|
point: Vector3;
|
||||||
thicknesses: ArmorPiercingLayer[];
|
shellNormal: Vector3;
|
||||||
type: 'ricochet' | 'penetration' | 'block';
|
surfaceNormal: Vector3;
|
||||||
angle: number;
|
status: 'blocked' | 'penetration' | 'ricochet';
|
||||||
ricochet?: {
|
|
||||||
distance: number;
|
|
||||||
point: Vector3Tuple;
|
|
||||||
penetration: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
export type ArmorPiercingLayer =
|
||||||
| {
|
| {
|
||||||
distance: number;
|
distance: number;
|
||||||
@@ -104,6 +124,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
|
|||||||
model: {
|
model: {
|
||||||
visual: {
|
visual: {
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
|
opaque: false,
|
||||||
environment: ENVIRONMENTS[0],
|
environment: ENVIRONMENTS[0],
|
||||||
controlsEnabled: true,
|
controlsEnabled: true,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
@@ -125,7 +146,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
|
|||||||
},
|
},
|
||||||
mode: 'model',
|
mode: 'model',
|
||||||
})),
|
})),
|
||||||
{ name: 'tankopedia', merge },
|
{ name: 'tankopedia', merge: (a, b) => merge(b, a) },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user