diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx index 7320bab3a..35c470f6f 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx @@ -1,4 +1,3 @@ -import { PerspectiveCamera } from '@react-three/drei'; import { Canvas } from '@react-three/fiber'; import { Suspense, use, useEffect, useRef } from 'react'; import { Armor } from '../../../../../../components/Armor'; @@ -121,10 +120,7 @@ export function TankSandbox() { return ( - diff --git a/src/components/Armor/components/SpacedArmorScene.tsx b/src/components/Armor/components/SpacedArmorScene.tsx index c14cb9be2..101b365ef 100644 --- a/src/components/Armor/components/SpacedArmorScene.tsx +++ b/src/components/Armor/components/SpacedArmorScene.tsx @@ -33,6 +33,37 @@ export const SpacedArmorScene = memo(({ scene }) => { const tankopediaPersistentStore = TankopediaPersistent.useStore(); const initialTankopediaState = tankopediaPersistentStore.getState(); const protagonist = Duel.use((draft) => draft.protagonist); + const tank = Duel.use((state) => state.protagonist.tank); + const track = Duel.use((state) => state.protagonist.track); + const turret = Duel.use((state) => state.protagonist.turret); + const gun = Duel.use((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); + const maskOrigin = + gunModelDefinition.mask === undefined + ? undefined + : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; useEffect(() => { if (!modelDefinitions) return; @@ -120,38 +151,6 @@ export const SpacedArmorScene = memo(({ scene }) => { return unsubscribe; }); - const tank = Duel.use((state) => state.protagonist.tank); - const track = Duel.use((state) => state.protagonist.track); - const turret = Duel.use((state) => state.protagonist.turret); - const gun = Duel.use((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); - const maskOrigin = - gunModelDefinition.mask === undefined - ? undefined - : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; - return ( state.camera); const duelStore = Duel.useStore(); - async function shoot( - point: Vector3, - intersections: Intersection[], - allowRicochet: boolean, - remainingPenetrationInput?: number, - ) { - const { antagonist, protagonist } = duelStore.getState(); - const shell = antagonist.shell; - const cameraNormal = camera.position.clone().sub(point).normalize(); - const shot: TankopediaEphemeral.Shot = { - damage: -1, - containsGaps: shell.type === ShellType.HEAT, - in: { - surfaceNormal: cameraNormal, - layers: [], - status: 'blocked', - }, - }; + const shoot = useCallback( + async ( + point: Vector3, + intersections: Intersection[], + allowRicochet: boolean, + remainingPenetrationInput?: number, + ) => { + const { antagonist, protagonist } = duelStore.getState(); + const shell = antagonist.shell; + const cameraNormal = camera.position.clone().sub(point).normalize(); + const shot: TankopediaEphemeral.Shot = { + damage: -1, + containsGaps: shell.type === ShellType.HEAT, + in: { + surfaceNormal: cameraNormal, + layers: [], + status: 'blocked', + }, + }; - const hasCalibratedShells = await hasEquipment( - 103, - antagonist.tank.equipment, - antagonist.equipmentMatrix, - ); - const hasEnhancedArmor = await hasEquipment( - 110, - protagonist.tank.equipment, - protagonist.equipmentMatrix, - ); - const penetration = - resolveNearPenetration(shell.penetration) * - resolvePenetrationCoefficient(hasCalibratedShells, shell.type); - const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; - const filteredIntersections = intersections.filter( - (intersection) => 'type' in intersection.object.userData, - ) as unknown as Intersection[]; - const encounteredExternalModuleVariants: ExternalModuleVariant[] = []; - const noDuplicateIntersections: typeof filteredIntersections = []; + const hasCalibratedShells = await hasEquipment( + 103, + antagonist.tank.equipment, + antagonist.equipmentMatrix, + ); + const hasEnhancedArmor = await hasEquipment( + 110, + protagonist.tank.equipment, + protagonist.equipmentMatrix, + ); + const penetration = + resolveNearPenetration(shell.penetration) * + resolvePenetrationCoefficient(hasCalibratedShells, shell.type); + const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1; + const filteredIntersections = intersections.filter( + (intersection) => 'type' in intersection.object.userData, + ) as unknown as Intersection[]; + const encounteredExternalModuleVariants: ExternalModuleVariant[] = []; + const noDuplicateIntersections: typeof filteredIntersections = []; - for (const intersection of filteredIntersections) { - if (intersection.object.userData.type === ArmorType.External) { - if ( - !encounteredExternalModuleVariants.includes( - intersection.object.userData.variant, - ) - ) { - noDuplicateIntersections.push(intersection); - encounteredExternalModuleVariants.push( - intersection.object.userData.variant, - ); - } - } else { - noDuplicateIntersections.push(intersection); - - if (intersection.object.userData.type === ArmorType.Core) { - break; - } - } - } - - let remainingPenetration = remainingPenetrationInput ?? penetration; - let index = 0; - for (const intersection of noDuplicateIntersections) { - const layer = intersection.object.userData; - const surfaceNormal = intersection - .normal!.clone() - .applyQuaternion( - intersection.object.getWorldQuaternion(new Quaternion()), - ); - - if (shell.type === ShellType.HEAT && index > 0) { - const lastLayer = shot.in.layers.at( - -1, - ) as TankopediaEphemeral.ShotLayerBase; - const distance = lastLayer.point.distanceTo(intersection.point); - remainingPenetration -= 0.5 * remainingPenetration * distance; - const blocked = remainingPenetration <= 0; - - shot.in.layers.push({ - type: null, - distance, - status: blocked ? 'blocked' : 'penetration', - }); - - if (blocked) break; - } - - if (layer.type === ArmorType.External) { - const thickness = layer.thickness * thicknessCoefficient; - remainingPenetration -= thickness; - const blocked = - remainingPenetration <= 0 || shell.type === ShellType.HE; - - shot.in.layers.push({ - type: ArmorType.External, - index, - thickness, - point: intersection.point, - surfaceNormal, - status: - shell.type === ShellType.HE - ? 'blocked' - : blocked - ? 'blocked' - : 'penetration', - variant: layer.variant, - }); - } else { - const thickness = layer.thickness; - const ricochet = degToRad( - isExplosive(shell.type) ? 90 : shell.ricochet!, - ); - const angle = surfaceNormal.angleTo(cameraNormal); - const threeCalibersRule = - shell.caliber > thickness * 3 || index > 0 || !allowRicochet; - const twoCalibersRule = shell.caliber > thickness * 2; - const normalization = degToRad(shell.normalization ?? 0); - const finalNormalization = twoCalibersRule - ? (1.4 * normalization * shell.caliber) / (2.0 * thickness) - : normalization; - const finalThickness = - thickness / Math.cos(Math.max(0, angle - finalNormalization)); - - if (!threeCalibersRule && angle >= ricochet) { - remainingPenetration *= 0.75; - - shot.in.layers.push({ - type: layer.type, - index, - thickness, - thicknessAngled: finalThickness, - point: intersection.point, - surfaceNormal, - status: 'ricochet', - angle, - }); - - break; + for (const intersection of filteredIntersections) { + if (intersection.object.userData.type === ArmorType.External) { + if ( + !encounteredExternalModuleVariants.includes( + intersection.object.userData.variant, + ) + ) { + noDuplicateIntersections.push(intersection); + encounteredExternalModuleVariants.push( + intersection.object.userData.variant, + ); + } } else { - remainingPenetration -= finalThickness; + noDuplicateIntersections.push(intersection); + + if (intersection.object.userData.type === ArmorType.Core) { + break; + } + } + } + + let remainingPenetration = remainingPenetrationInput ?? penetration; + let index = 0; + for (const intersection of noDuplicateIntersections) { + const layer = intersection.object.userData; + const surfaceNormal = intersection + .normal!.clone() + .applyQuaternion( + intersection.object.getWorldQuaternion(new Quaternion()), + ); + + if (shell.type === ShellType.HEAT && index > 0) { + const lastLayer = shot.in.layers.at( + -1, + ) as TankopediaEphemeral.ShotLayerBase; + const distance = lastLayer.point.distanceTo(intersection.point); + remainingPenetration -= 0.5 * remainingPenetration * distance; const blocked = remainingPenetration <= 0; shot.in.layers.push({ - type: layer.type, + type: null, + distance, + status: blocked ? 'blocked' : 'penetration', + }); + + if (blocked) break; + } + + if (layer.type === ArmorType.External) { + const thickness = layer.thickness * thicknessCoefficient; + remainingPenetration -= thickness; + const blocked = + remainingPenetration <= 0 || shell.type === ShellType.HE; + + shot.in.layers.push({ + type: ArmorType.External, index, thickness, - thicknessAngled: finalThickness, point: intersection.point, - surfaceNormal: intersection.face!.normal, + surfaceNormal, status: - shell.type === ShellType.HE && layer.type !== ArmorType.Core + shell.type === ShellType.HE ? 'blocked' : blocked ? 'blocked' : 'penetration', - angle, + variant: layer.variant, }); - } - } - - if (shell.type !== ShellType.HE && remainingPenetration <= 0) { - break; - } - - index++; - } - - const lastLayer = shot.in.layers.at(-1) as - | TankopediaEphemeral.ShotLayerNonExternal - | undefined; - const firstLayer = shot.in.layers[0] as - | TankopediaEphemeral.ShotLayerBase - | undefined; - - if (!lastLayer || !firstLayer) { - return null; - } else { - if (shell.type === ShellType.HE) { - const totalSpacedArmorThickness = shot.in.layers.reduce( - (acc, layer) => { - if (layer.type === null || layer.type === ArmorType.Core) { - return acc; - } - - if (layer.type === ArmorType.External) { - return acc + layer.thickness; - } - - return acc + layer.thicknessAngled; - }, - 0, - ); - const distanceFromSpacedArmor = firstLayer.point.distanceTo( - lastLayer.point, - ); - const finalDamage = Math.max( - 0, - 0.5 * - shell.damage.armor * - (1 - distanceFromSpacedArmor / shell.explosionRadius!) - - 1.1 * - (lastLayer.thicknessAngled + - Math.min(penetration, totalSpacedArmorThickness)), - ); - - /** - * Above is some peculiar math. We're multiplying 1.1 by the - * thickness of the core plate plus the total spaced armor - * thickness capped to the penetration itself. This is because - * the shader is normalized by the penetration value; hence, - * the total spaced armor thickness cannot ever be thicker - * than the penetration. This is a precision error on the - * shader-side and I do not intend on fixing it. Instead, - * for parity's sake, I am capping the value here. - */ - - if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') { - shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; - shot.damage = finalDamage; } else { - shot.in.status = 'penetration'; - shot.damage = shell.damage.armor; + const thickness = layer.thickness; + const ricochet = degToRad( + isExplosive(shell.type) ? 90 : shell.ricochet!, + ); + const angle = surfaceNormal.angleTo(cameraNormal); + + const threeCalibersRule = + shell.caliber > thickness * 3 || index > 0 || !allowRicochet; + const twoCalibersRule = shell.caliber > thickness * 2; + const normalization = degToRad(shell.normalization ?? 0); + const finalNormalization = twoCalibersRule + ? (1.4 * normalization * shell.caliber) / (2.0 * thickness) + : normalization; + const finalThickness = + thickness / Math.cos(Math.max(0, angle - finalNormalization)); + + if (!threeCalibersRule && angle >= ricochet) { + remainingPenetration *= 0.75; + + shot.in.layers.push({ + type: layer.type, + index, + thickness, + thicknessAngled: finalThickness, + point: intersection.point, + surfaceNormal, + status: 'ricochet', + angle, + }); + + break; + } else { + remainingPenetration -= finalThickness; + const blocked = remainingPenetration <= 0; + + shot.in.layers.push({ + type: layer.type, + index, + thickness, + thicknessAngled: finalThickness, + point: intersection.point, + surfaceNormal: intersection.face!.normal, + status: + shell.type === ShellType.HE && layer.type !== ArmorType.Core + ? 'blocked' + : blocked + ? 'blocked' + : 'penetration', + angle, + }); + } } + + if (shell.type !== ShellType.HE && remainingPenetration <= 0) { + break; + } + + index++; + } + + const lastLayer = shot.in.layers.at(-1) as + | TankopediaEphemeral.ShotLayerNonExternal + | undefined; + const firstLayer = shot.in.layers[0] as + | TankopediaEphemeral.ShotLayerBase + | undefined; + + if (!lastLayer || !firstLayer) { + return null; } else { - if (lastLayer.status === 'blocked') { - shot.in.status = 'blocked'; - shot.damage = 0; - } else if (lastLayer.status === 'penetration') { - shot.in.status = 'penetration'; - shot.damage = shell.damage.armor; + if (shell.type === ShellType.HE) { + const totalSpacedArmorThickness = shot.in.layers.reduce( + (acc, layer) => { + if (layer.type === null || layer.type === ArmorType.Core) { + return acc; + } + + if (layer.type === ArmorType.External) { + return acc + layer.thickness; + } + + return acc + layer.thicknessAngled; + }, + 0, + ); + const distanceFromSpacedArmor = firstLayer.point.distanceTo( + lastLayer.point, + ); + const finalDamage = Math.max( + 0, + 0.5 * + shell.damage.armor * + (1 - distanceFromSpacedArmor / shell.explosionRadius!) - + 1.1 * + (lastLayer.thicknessAngled + + Math.min(penetration, totalSpacedArmorThickness)), + ); + + /** + * Above is some peculiar math. We're multiplying 1.1 by the + * thickness of the core plate plus the total spaced armor + * thickness capped to the penetration itself. This is because + * the shader is normalized by the penetration value; hence, + * the total spaced armor thickness cannot ever be thicker + * than the penetration. This is a precision error on the + * shader-side and I do not intend on fixing it. Instead, + * for parity's sake, I am capping the value here. + */ + + if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') { + shot.in.status = finalDamage > 0 ? 'splash' : 'blocked'; + shot.damage = finalDamage; + } else { + shot.in.status = 'penetration'; + shot.damage = shell.damage.armor; + } } else { - const caster = new Raycaster(); - // https://math.stackexchange.com/a/13263/1222875 - const shellNormal = cameraNormal.clone().multiplyScalar(-1); - const ricochetNormal = shellNormal - .clone() - .sub( - lastLayer.surfaceNormal - .clone() - .multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)), + if (lastLayer.status === 'blocked') { + shot.in.status = 'blocked'; + shot.damage = 0; + } else if (lastLayer.status === 'penetration') { + shot.in.status = 'penetration'; + shot.damage = shell.damage.armor; + } else { + const caster = new Raycaster(); + // https://math.stackexchange.com/a/13263/1222875 + const shellNormal = cameraNormal.clone().multiplyScalar(-1); + const ricochetNormal = shellNormal + .clone() + .sub( + lastLayer.surfaceNormal + .clone() + .multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)), + ); + + caster.set(lastLayer.point, ricochetNormal); + + const ricochetIntersections = caster.intersectObjects( + scene.children, + true, + ); + const outShot = await shoot( + lastLayer.point, + ricochetIntersections, + false, + remainingPenetration, ); - caster.set(lastLayer.point, ricochetNormal); - - const ricochetIntersections = caster.intersectObjects( - scene.children, - true, - ); - const outShot = await shoot( - lastLayer.point, - ricochetIntersections, - false, - remainingPenetration, - ); - - shot.in.status = 'ricochet'; - if (outShot === null) { - shot.damage = 0; - shot.out = { - surfaceNormal: ricochetNormal, - layers: [], - status: 'ricochet', - }; - } else { - shot.damage = outShot.damage; - shot.out = outShot.in; - shot.out.surfaceNormal = ricochetNormal; + shot.in.status = 'ricochet'; + if (outShot === null) { + shot.damage = 0; + shot.out = { + surfaceNormal: ricochetNormal, + layers: [], + status: 'ricochet', + }; + } else { + shot.damage = outShot.damage; + shot.out = outShot.in; + shot.out.surfaceNormal = ricochetNormal; + } } } } - } - return shot; - } + return shot; + }, + [camera], + ); return ( <>