diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ef122024..b395babe4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,7 @@ "glsl", "GLTF", "haha", + "highp", "iife", "immer", "Inclusivity", @@ -145,5 +146,6 @@ "zustand" ], "deno.enable": false, - "deno.unstable": false + "deno.unstable": false, + "cSpell.enableFiletypes": ["glsl"] } diff --git a/docs/changelogs/1.16.1.md b/docs/changelogs/1.16.1.md new file mode 100644 index 000000000..ed1de5eca --- /dev/null +++ b/docs/changelogs/1.16.1.md @@ -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 diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx index a8a087792..3e077f02b 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx @@ -4,7 +4,8 @@ import { PerspectiveCamera } from '@react-three/drei'; import { Canvas } from '@react-three/fiber'; import { Suspense, use, useEffect, useRef, useState } from 'react'; import { isMobile } from 'react-device-detect'; -import { ShotDisplay } from '../../../../../../components/ShotDisplay'; +import { Armor } from '../../../../../../components/Armor'; +import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay'; import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions'; import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform'; @@ -24,9 +25,6 @@ import { RotationInputs } from '../QuickInputs'; import { SceneProps } from '../SceneProps'; import { ModelLoader } from './components/ModelLoader'; import { Options } from './components/Options'; -import { TankArmor } from './components/TankArmor'; -import { ExternalModuleMask } from './components/TankArmor/components/ExternalModuleMask'; -import { SpacedArmorDepth } from './components/TankArmor/components/SpacedArmorDepth'; import { TankModel } from './components/TankModel'; export function TankSandbox() { @@ -198,12 +196,11 @@ export function TankSandbox() { }> - + {/* TODO: add rotation cube to the top right */} - - - + + diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx index 9896894d8..e3ac0d6c9 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/Options.tsx @@ -30,6 +30,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { const wireframe = useTankopediaPersistent( (state) => state.model.visual.wireframe, ); + const opaque = useTankopediaPersistent((state) => state.model.visual.opaque); const fullScreenAvailable = typeof document !== 'undefined' && document.fullscreenEnabled; const environment = useTankopediaPersistent( @@ -78,6 +79,8 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { + Armor + { @@ -89,6 +92,30 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { Green penetration + { + mutateTankopediaPersistent((draft) => { + draft.model.visual.wireframe = checked; + }); + }} + > + Wireframe + + + { + mutateTankopediaPersistent((draft) => { + draft.model.visual.opaque = checked; + }); + }} + > + Opaque + + + Environment + { @@ -132,17 +159,6 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { - - { - mutateTankopediaPersistent((draft) => { - draft.model.visual.wireframe = checked; - }); - }} - > - Wireframe - )} diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx deleted file mode 100644 index 66858e5bd..000000000 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx +++ /dev/null @@ -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( - ({ ornamental = false }) => { - const protagonist = useDuel((state) => state.protagonist!); - const wrapper = useRef(null); - const modelDefinitions = useModelDefinitions(); - const turretContainer = useRef(null); - const gunContainer = useRef(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 ( - - - {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 ( - - ); - })} - - - {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 ( - - ); - })} - - - - {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 ( - - - - ); - })} - - - - {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 ( - - - - ); - })} - - {modelNodes.map((node) => { - const isCurrentGun = - node.name === - `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`; - const isVisible = isCurrentGun; - - if (!isVisible) return null; - - return ( - - ); - })} - - - - ); - }, -); diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx deleted file mode 100644 index a856743ae..000000000 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/SpacedArmorDepth.tsx +++ /dev/null @@ -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( - ({ ornamental = false }) => { - const protagonist = useDuel((state) => state.protagonist!); - const wrapper = useRef(null); - const modelDefinitions = useModelDefinitions(); - const turretContainer = useRef(null); - const gunContainer = useRef(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 ( - - - {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 ( - - ); - })} - - {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 ( - - ); - })} - - - - {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 ( - - - - ); - })} - - - - {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 ( - - - - ); - })} - - - {modelNodes.map((node) => { - const isCurrentGun = - node.name === - `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`; - const isVisible = isCurrentGun; - - if (!isVisible) return null; - - return ( - - ); - })} - - - - ); - }, -); diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx index df4cc78d5..e69de29bb 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx @@ -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( - , - externalModuleMaskScene, - ); - const spacedArmorDepthPortal = createPortal( - , - spacedArmorDepthScene, - ); - const armorHighlightingPortal = createPortal( - , - 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} - - ); -}); diff --git a/src/app/tools/tankopedia/[id]/page.tsx b/src/app/tools/tankopedia/[id]/page.tsx index 0251a97c6..2a632f1ae 100644 --- a/src/app/tools/tankopedia/[id]/page.tsx +++ b/src/app/tools/tankopedia/[id]/page.tsx @@ -63,6 +63,8 @@ export default function Page({ params }: { params: { id: string } }) { }; }, [initialId, assigned]); + + return ( {assigned && ( diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx b/src/components/Armor/components/CoreArmorScene.tsx similarity index 54% rename from src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx rename to src/components/Armor/components/CoreArmorScene.tsx index e547a01fe..b5b133c3d 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx +++ b/src/components/Armor/components/CoreArmorScene.tsx @@ -1,28 +1,29 @@ -import { useEffect, useRef } from 'react'; -import { Euler, Group, Mesh, Vector3 } from 'three'; +import { memo, useEffect, useRef } from 'react'; +import { Euler, Group, Vector3 } from 'three'; import { degToRad } from 'three/src/math/MathUtils'; -import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh'; -import { I_HAT, J_HAT, K_HAT } from '../../../../../../../../../constants/axis'; +import { I_HAT, J_HAT, K_HAT } from '../../../constants/axis'; import { ModelTransformEventData, modelTransformEvent, -} from '../../../../../../../../../core/blitzkrieg/modelTransform'; -import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId'; -import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness'; -import { useArmor } from '../../../../../../../../../hooks/useArmor'; -import { useModelDefinitions } from '../../../../../../../../../hooks/useModelDefinitions'; -import { useDuel } from '../../../../../../../../../stores/duel'; -import { useTankopediaPersistent } from '../../../../../../../../../stores/tankopedia'; +} from '../../../core/blitzkrieg/modelTransform'; +import { nameToArmorId } from '../../../core/blitzkrieg/nameToArmorId'; +import { resolveArmor } from '../../../core/blitzkrieg/resolveThickness'; +import { useArmor } from '../../../hooks/useArmor'; +import { useModelDefinitions } from '../../../hooks/useModelDefinitions'; +import { useDuel } from '../../../stores/duel'; +import { useTankopediaPersistent } from '../../../stores/tankopedia'; +import { CoreArmorSceneComponent } from './CoreArmorSceneComponent'; -export function ArmorHighlighting() { - const protagonist = useDuel((state) => state.protagonist!); +export const CoreArmorScene = memo(() => { const wrapper = useRef(null); const modelDefinitions = useModelDefinitions(); - const gunContainer = useRef(null); const turretContainer = useRef(null); + const gunContainer = useRef(null); const initialTankopediaState = useTankopediaPersistent.getState(); useEffect(() => { + if (!modelDefinitions) return; + const hullOrigin = new Vector3( trackModelDefinition.origin[0], trackModelDefinition.origin[1], @@ -33,14 +34,17 @@ export function ArmorHighlighting() { tankModelDefinition.turretOrigin[1], -tankModelDefinition.turretOrigin[2], ).applyAxisAngle(I_HAT, Math.PI / 2); + const gunOrigin = new Vector3( + turretModelDefinition.gunOrigin[0], + turretModelDefinition.gunOrigin[1], + -turretModelDefinition.gunOrigin[2], + ).applyAxisAngle(I_HAT, Math.PI / 2); const turretPosition = new Vector3(); const turretRotation = new Euler(); const gunPosition = new Vector3(); const gunRotation = new Euler(); - function handleModelTransform({ pitch, yaw }: ModelTransformEventData) { - if (yaw === undefined) return; - + function handleModelTransform({ yaw, pitch }: ModelTransformEventData) { gunPosition .set(0, 0, 0) .sub(hullOrigin) @@ -53,6 +57,9 @@ export function ArmorHighlighting() { gunRotation.set(pitch, 0, 0); gunContainer.current?.position.copy(gunPosition); gunContainer.current?.rotation.copy(gunRotation); + + if (yaw === undefined) return; + turretPosition .set(0, 0, 0) .sub(hullOrigin) @@ -80,6 +87,16 @@ export function ArmorHighlighting() { turretContainer.current?.position.copy(turretPosition); turretContainer.current?.rotation.copy(turretRotation); } + + handleModelTransform(useDuel.getState().protagonist!); + modelTransformEvent.on(handleModelTransform); + + return () => { + modelTransformEvent.off(handleModelTransform); + }; + }); + + useEffect(() => { const unsubscribe = useTankopediaPersistent.subscribe( (state) => state.mode, (mode) => { @@ -87,27 +104,24 @@ export function ArmorHighlighting() { }, ); - handleModelTransform(protagonist); - modelTransformEvent.on(handleModelTransform); - - return () => { - modelTransformEvent.off(handleModelTransform); - unsubscribe(); - }; + return unsubscribe; }); - const armorGltf = useArmor(protagonist.tank.id); + const tank = useDuel((state) => state.protagonist!.tank); + const track = useDuel((state) => state.protagonist!.track); + const turret = useDuel((state) => state.protagonist!.turret); + const gun = useDuel((state) => state.protagonist!.gun); + const armorGltf = useArmor(tank.id); const armorNodes = Object.values(armorGltf.nodes); - const tankModelDefinition = modelDefinitions[protagonist.tank.id]; - const trackModelDefinition = tankModelDefinition.tracks[protagonist.track.id]; - const turretModelDefinition = - tankModelDefinition.turrets[protagonist.turret.id]; - const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; + const tankModelDefinition = modelDefinitions[tank.id]; + const trackModelDefinition = tankModelDefinition.tracks[track.id]; + const turretModelDefinition = tankModelDefinition.turrets[turret.id]; + const gunModelDefinition = turretModelDefinition.guns[gun.id]; const hullOrigin = new Vector3( trackModelDefinition.origin[0], trackModelDefinition.origin[1], -trackModelDefinition.origin[2], - ); + ).applyAxisAngle(I_HAT, Math.PI / 2); const turretOrigin = new Vector3( tankModelDefinition.turretOrigin[0], tankModelDefinition.turretOrigin[1], @@ -118,96 +132,88 @@ export function ArmorHighlighting() { turretModelDefinition.gunOrigin[1], -turretModelDefinition.gunOrigin[2], ).applyAxisAngle(I_HAT, Math.PI / 2); - const maxThickness = Math.max( - trackModelDefinition.thickness, - gunModelDefinition.thickness, - ...armorNodes - .map((node) => { - const armorId = nameToArmorId(node.name); - return resolveArmor(tankModelDefinition.armor, armorId).thickness; - }) - .filter(Boolean), - ); return ( - {armorNodes.map((node) => { - const isHull = node.name.startsWith('hull_'); - const isVisible = isHull; - const armorId = nameToArmorId(node.name); - const { spaced, thickness } = resolveArmor( - tankModelDefinition.armor, - armorId, - ); - - if (!isVisible || thickness === undefined || spaced) return null; - - return ( - - ); - })} - - + {armorNodes.map((node) => { - const isCurrentTurret = node.name.startsWith( - `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`, - ); - const isVisible = isCurrentTurret; + const isHull = node.name.startsWith('hull_'); + const isVisible = isHull; const armorId = nameToArmorId(node.name); const { spaced, thickness } = resolveArmor( - turretModelDefinition.armor, + tankModelDefinition.armor, armorId, ); - if (!isVisible || thickness === undefined || spaced) return null; + if (!isVisible || spaced || thickness === undefined) return null; return ( - ); })} + - + + {armorNodes.map((node) => { - const isCurrentGun = node.name.startsWith( - `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`, + const isCurrentTurret = node.name.startsWith( + `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`, ); - const isVisible = isCurrentGun; + const isVisible = isCurrentTurret; const armorId = nameToArmorId(node.name); const { spaced, thickness } = resolveArmor( - gunModelDefinition.armor, + turretModelDefinition.armor, armorId, ); - if (!isVisible || thickness === undefined || spaced) return null; + if (!isVisible || spaced || thickness === undefined) return null; return ( - + + + ); })} + + + {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 ( + + + + ); + })} + + ); -} +}); diff --git a/src/components/Armor/components/CoreArmorSceneComponent/index.tsx b/src/components/Armor/components/CoreArmorSceneComponent/index.tsx new file mode 100644 index 000000000..c646ee157 --- /dev/null +++ b/src/components/Armor/components/CoreArmorSceneComponent/index.tsx @@ -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, + })} + + ); +} diff --git a/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl b/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl new file mode 100644 index 000000000..e03696067 --- /dev/null +++ b/src/components/Armor/components/CoreArmorSceneComponent/shaders/fragment.glsl @@ -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); + } +} \ No newline at end of file diff --git a/src/components/ArmorMesh/components/Armor/shaders/vertex.glsl b/src/components/Armor/components/CoreArmorSceneComponent/shaders/vertex.glsl similarity index 100% rename from src/components/ArmorMesh/components/Armor/shaders/vertex.glsl rename to src/components/Armor/components/CoreArmorSceneComponent/shaders/vertex.glsl diff --git a/src/components/Armor/components/ShotDisplay.tsx b/src/components/Armor/components/ShotDisplay.tsx new file mode 100644 index 000000000..6b470feb4 --- /dev/null +++ b/src/components/Armor/components/ShotDisplay.tsx @@ -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.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(null); + const postMidPointTracer = useRef(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; + 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 ( + <> + + + + + + + + + + + + + {midPointLayer.status === 'ricochet' && ( + + + + + + + + + + + + )} + + {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 ( + <> + + + + + {hasMultipleLayers && ( + + {layer.index + 1} + + )} + + + + + + ); + })} + + + + + + {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`} + + + {shot.layers.map((layer, index) => { + if (layer.type === null && !explosive) return null; + + return ( + + + {hasMultipleLayers && ( + + {layer.type === null ? null : `${layer.index + 1}.`} + + )} + {layerTypeNames[`${layer.type}`]} + + + {layer.type !== null && ( + <> + + + {Math.round(layer.thickness)}mm + + + {layer.type !== ArmorType.External && ( + + {layer.status === 'ricochet' ? ( + + ) : layer.status === 'blocked' ? ( + + ) : ( + + )} + + {Math.round(layer.thicknessAngled)}mm ( + {Math.round(radToDeg(layer.angle))}°) + + + )} + + )} + + {layer.type === null && ( + + {Math.round(layer.distance * 1000)}mm (- + {Math.round(Math.min(100, layer.distance * 50))}% + penetration) + + )} + + ); + })} + + + + + ); +} diff --git a/src/components/Armor/components/SpacedArmorScene.tsx b/src/components/Armor/components/SpacedArmorScene.tsx new file mode 100644 index 000000000..56337dfe9 --- /dev/null +++ b/src/components/Armor/components/SpacedArmorScene.tsx @@ -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(({ scene }) => { + const wrapper = useRef(null); + const modelDefinitions = useModelDefinitions(); + const turretContainer = useRef(null); + const gunContainer = useRef(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 ( + + + {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 ( + + ); + })} + + {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 ( + + ); + })} + + + + {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 ( + + + + ); + })} + + + + {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 ( + + + + ); + })} + + + {modelNodes.map((node) => { + const isCurrentGun = + node.name === + `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`; + const isVisible = isCurrentGun; + + if (!isVisible) return null; + + return ( + + ); + })} + + + + ); +}); diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx new file mode 100644 index 000000000..053ca4dfa --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx @@ -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, + })} + + ); +} diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl new file mode 100644 index 000000000..0ab70c10d --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/fragment.glsl @@ -0,0 +1,6 @@ +uniform float thickness; +uniform float penetration; + +void main() { + gl_FragColor = vec4(thickness / penetration, 0.0, 0.0, 1.0); +} \ No newline at end of file diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl new file mode 100644 index 000000000..5f9df37ef --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/shaders/vertex.glsl @@ -0,0 +1,4 @@ +void main() { + vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * vViewPosition4; +} \ No newline at end of file diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx new file mode 100644 index 000000000..5a1b2933e --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx @@ -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, + })} + + ); +} diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl new file mode 100644 index 000000000..7d486d5e8 --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/fragment.glsl @@ -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); +} \ No newline at end of file diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl similarity index 73% rename from src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl rename to src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl index d57a82f32..82f94e255 100644 --- a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/vertex.glsl +++ b/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/shaders/vertex.glsl @@ -1,10 +1,11 @@ -varying vec3 vViewPosition; varying vec3 vNormal; +varying vec3 vViewPosition; void main() { vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0); + vViewPosition = vViewPosition4.xyz; vNormal = normalMatrix * normal; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * vViewPosition4; } \ No newline at end of file diff --git a/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx new file mode 100644 index 000000000..5bed3de2c --- /dev/null +++ b/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx @@ -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; + } + | { + type: ArmorType.External; + variant: ExternalModuleVariant; + } +); + +export type ExternalModuleVariant = 'gun' | 'track'; +export type ArmorUserData = { + thickness: number; +} & ( + | { + type: Exclude; + } + | { + 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 && ( + + )} + + {props.type === ArmorType.External && ( + + )} + + ); +} diff --git a/src/components/Armor/index.tsx b/src/components/Armor/index.tsx new file mode 100644 index 000000000..67ac23c84 --- /dev/null +++ b/src/components/Armor/index.tsx @@ -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, + ); + const coreArmorPortal = createPortal(, 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} + + ); +}); diff --git a/src/components/ArmorMesh/components/Armor/index.tsx b/src/components/ArmorMesh/components/Armor/index.tsx deleted file mode 100644 index 816799dfc..000000000 --- a/src/components/ArmorMesh/components/Armor/index.tsx +++ /dev/null @@ -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( - ({ thickness, maxThickness, ...props }) => { - const camera = useThree((state) => state.camera); - const initialShell = useDuel.getState().antagonist!.shell; - const initialTankopedia = useTankopediaPersistent.getState(); - const material = useRef(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(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 - ).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 ( - <> - - - - - { - // 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(); - 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, - }; - }); - }} - > - - - - ); - }, - () => true, -); diff --git a/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl b/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl deleted file mode 100644 index 1bb58ff26..000000000 --- a/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/components/ArmorMesh/components/ExternalModuleMask.tsx b/src/components/ArmorMesh/components/ExternalModuleMask.tsx deleted file mode 100644 index 1d5aed6bb..000000000 --- a/src/components/ArmorMesh/components/ExternalModuleMask.tsx +++ /dev/null @@ -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, - }), - })} - - ); -} diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx b/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx deleted file mode 100644 index 9d61e0796..000000000 --- a/src/components/ArmorMesh/components/SpacedArmorDepth/index.tsx +++ /dev/null @@ -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 }, - }, - }), - })} - - ); -} diff --git a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl b/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl deleted file mode 100644 index 6fe90ce36..000000000 --- a/src/components/ArmorMesh/components/SpacedArmorDepth/shaders/fragment.glsl +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/src/components/ArmorMesh/index.tsx b/src/components/ArmorMesh/index.tsx deleted file mode 100644 index b64eb57f2..000000000 --- a/src/components/ArmorMesh/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './components/Armor'; -export * from './components/ExternalModuleMask'; diff --git a/src/components/HeadsUpDisplay.tsx b/src/components/HeadsUpDisplay.tsx deleted file mode 100644 index b64759a1f..000000000 --- a/src/components/HeadsUpDisplay.tsx +++ /dev/null @@ -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; -} diff --git a/src/components/ShotDisplay.tsx b/src/components/ShotDisplay.tsx deleted file mode 100644 index 2e2c29a05..000000000 --- a/src/components/ShotDisplay.tsx +++ /dev/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 = { - ricochet: 'Ricochet', - penetration: 'Penetration', - block: 'Blocked', -}; - -export const SHOT_ICONS: Record = { - ricochet: , - block: , - penetration: , -}; - -const THICKNESS = 0.05; -const LENGTH = 4; - -export function ShotDisplay() { - const shot = useTankopediaTemporary((state) => state.shot); - const entryWrapper = useRef(null); - const entryLine = useRef(null); - const circle = useRef(null); - const ricochetCircle = useRef(null); - const exitWrapper = useRef(null); - const exitLine = useRef(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 ( - <> - - - - - - - - - - - {SHOT_ICONS[shot.type]} - - {SHOT_NAMES[shot.type]} at {Math.round(radToDeg(shot.angle))}° - - - - - {shot.thicknesses.map((layer, index) => ( - - {number && ( - - {index + 1}. {layer.type[0].toUpperCase()} - {layer.type.slice(1)} - - )} - - - {layer.type !== 'gap' && ( - - )} - - {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)` - : ''} - - - {layer.type !== 'external' && layer.type !== 'gap' && ( - - - {Math.round(layer.angled)}mm - - )} - - ))} - - {shot.ricochet && ( - <> - - {shot.thicknesses.length + 1}. Ricochet - - (-25% penetration) - - {shot.ricochet.penetration ? ( - - {shot.thicknesses.length + 2}. Penetration - - - ) : ( - - {shot.thicknesses.length + 2}. Blocked - - - )} - - )} - - - - - - - - - - - - - - - - {shot.type === 'ricochet' && ( - - - - - - - - - - - {shot.ricochet && ( - - - - - )} - - )} - - ); -} diff --git a/src/constants/axis.ts b/src/constants/axis.ts index 9d600d2be..784321114 100644 --- a/src/constants/axis.ts +++ b/src/constants/axis.ts @@ -1,5 +1,6 @@ import { Vector3 } from 'three'; +export const ZERO_VECTOR = new Vector3(); export const I_HAT = new Vector3(1, 0, 0); export const J_HAT = new Vector3(0, 1, 0); export const K_HAT = new Vector3(0, 0, 1); diff --git a/src/core/blitzkrieg/hasEquipment.ts b/src/core/blitzkrieg/hasEquipment.ts index 396153b31..d0ddf713f 100644 --- a/src/core/blitzkrieg/hasEquipment.ts +++ b/src/core/blitzkrieg/hasEquipment.ts @@ -1,15 +1,17 @@ import { useDuel } from '../../stores/duel'; import { equipmentDefinitions } from './equipmentDefinitions'; -export async function hasEquipment(id: number, antagonist = false) { +export async function hasEquipment( + id: number, + antagonist = false, + equipmentMatrix = useDuel.getState()[ + antagonist ? 'antagonist' : 'protagonist' + ]!.equipment, +) { const awaitedEquipmentDefinitions = await equipmentDefinitions; const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!; - const equipmentRows = - awaitedEquipmentDefinitions.presets[member.tank.equipment]; - const equipmentMatrix = - useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!.equipment; - - return equipmentRows.some((equipmentRow, rowIndex) => { + const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment]; + return presetRows.some((equipmentRow, rowIndex) => { return equipmentRow.some((equipment, columnIndex) => { const choice = equipmentMatrix[rowIndex][columnIndex]; if (choice === 0) return false; diff --git a/src/core/math/NaNFallback.ts b/src/core/math/NaNFallback.ts new file mode 100644 index 000000000..d1b93b662 --- /dev/null +++ b/src/core/math/NaNFallback.ts @@ -0,0 +1,3 @@ +export function NaNFallback(value: number, fallback: number) { + return isNaN(value) ? fallback : value; +} diff --git a/src/core/math/normalToEuler.ts b/src/core/math/normalToEuler.ts new file mode 100644 index 000000000..b3b005e96 --- /dev/null +++ b/src/core/math/normalToEuler.ts @@ -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), + ), + ); +} diff --git a/src/stores/app.ts b/src/stores/app.ts index 1c1366937..e615eb1f9 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,3 +1,4 @@ +import { merge } from 'lodash'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -10,6 +11,6 @@ export const useApp = create()( (set) => ({ devBuildAgreementTime: 0, }), - { name: 'app' }, + { name: 'app', merge: (a, b) => merge(b, a) }, ), ); diff --git a/src/stores/session.ts b/src/stores/session.ts index ffc8b46c9..3a2011e9f 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -1,4 +1,5 @@ import { produce } from 'immer'; +import { merge } from 'lodash'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn'; @@ -83,7 +84,7 @@ export const useSession = create()( ], color: 'slate', }), - { name: 'session' }, + { name: 'session', merge: (a, b) => merge(b, a) }, ), ); diff --git a/src/stores/tankopedia.ts b/src/stores/tankopedia.ts index f2f8b5d26..3cbaeb781 100644 --- a/src/stores/tankopedia.ts +++ b/src/stores/tankopedia.ts @@ -1,9 +1,10 @@ import { produce } from 'immer'; import { merge } from 'lodash'; -import { Vector3Tuple } from 'three'; +import { Vector3 } from 'three'; import { create } from 'zustand'; import { persist, subscribeWithSelector } from 'zustand/middleware'; import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting'; +import { ArmorType } from '../components/Armor/components/SpacedArmorScene'; import { TankType, TreeType } from '../components/Tanks'; import { Tier } from '../core/blitzkrieg/tankDefinitions'; @@ -12,10 +13,11 @@ export type TankopediaSortDirection = 'ascending' | 'descending'; export type TankopediaTestTankDisplay = 'include' | 'exclude' | 'only'; export type TankopediaMode = 'model' | 'armor'; -interface TankopediaPersistent { +export interface TankopediaPersistent { model: { visual: { wireframe: boolean; + opaque: boolean; environment: (typeof ENVIRONMENTS)[number]; controlsEnabled: boolean; showGrid: boolean; @@ -42,20 +44,38 @@ interface TankopediaTemporary { shot?: Shot; } -export interface Shot { - point: Vector3Tuple; - surfaceNormal: Vector3Tuple; - shellNormal: Vector3Tuple; - thicknesses: ArmorPiercingLayer[]; - type: 'ricochet' | 'penetration' | 'block'; - angle: number; - ricochet?: { - distance: number; - point: Vector3Tuple; - penetration: boolean; - }; +export interface ShotLayerBase { + index: number; + thickness: number; + point: Vector3; + shellNormal: Vector3; + surfaceNormal: Vector3; + status: 'blocked' | 'penetration' | 'ricochet'; } +interface ShotLayerExternal extends ShotLayerBase { + type: ArmorType.External; +} + +export interface ShotLayerNonExternal extends ShotLayerBase { + type: Exclude; + angle: number; + thicknessAngled: number; +} + +export interface ShotLayerGap { + type: null; + status: 'penetration' | 'blocked'; + distance: number; +} + +export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap; + +export type Shot = { + point: Vector3; + layers: ShotLayer[]; +}; + export type ArmorPiercingLayer = | { distance: number; @@ -104,6 +124,7 @@ export const useTankopediaPersistent = create()( model: { visual: { wireframe: false, + opaque: false, environment: ENVIRONMENTS[0], controlsEnabled: true, showGrid: true, @@ -125,7 +146,7 @@ export const useTankopediaPersistent = create()( }, mode: 'model', })), - { name: 'tankopedia', merge }, + { name: 'tankopedia', merge: (a, b) => merge(b, a) }, ), );