diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/CanvasContent.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/CanvasContent.tsx index 5207a9395..f7941d698 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/CanvasContent.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/CanvasContent.tsx @@ -1,23 +1,34 @@ import type { QuicklimeEvent } from 'quicklime'; import { useEffect, useRef } from 'react'; import type { Group } from 'three'; +import { awaitableModelDefinitions } from '../../../../../../../../core/awaitables/modelDefinitions'; import { modelTransformEvent, type ModelTransformEventData, } from '../../../../../../../../core/blitzkit/modelTransform'; +import { Duel } from '../../../../../../../../stores/duel'; import { ModelChunk } from './ModelChunk'; +const modelDefinitions = await awaitableModelDefinitions; + export function CanvasContent() { - const hull = useRef(null); - const gun = useRef(null); + const hullWrapper = useRef(null); + const gunWrapper = useRef(null); + + const tank = Duel.use((state) => state.protagonist.tank); + const turret = Duel.use((state) => state.protagonist.turret); + + const tankModel = modelDefinitions.models[tank.id]; + const turretModel = tankModel.turrets[turret.id]; useEffect(() => { function handleModelTransformEvent( event: QuicklimeEvent, ) { - if (!gun.current) return; + if (!gunWrapper.current || !hullWrapper.current) return; - gun.current.rotation.x = event.data.pitch; + gunWrapper.current.rotation.x = event.data.pitch; + if (event.data.yaw) hullWrapper.current.rotation.y = event.data.yaw; } modelTransformEvent.on(handleModelTransformEvent); @@ -26,15 +37,18 @@ export function CanvasContent() { modelTransformEvent.off(handleModelTransformEvent); }; }, []); - return ( <> - - + + + + - - + + + + ); diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/FlexibilityCard.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/FlexibilityCard.tsx deleted file mode 100644 index 5baf1a6b1..000000000 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/FlexibilityCard.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { literals } from '@blitzkit/i18n'; -import { Box } from '@radix-ui/themes'; -import { useRef, useState } from 'react'; -import { clamp, degToRad, radToDeg } from 'three/src/math/MathUtils.js'; -import { awaitableModelDefinitions } from '../../../../../../../../core/awaitables/modelDefinitions'; -import { - DEFAULT_PITCH_TRANSITION, - applyPitchYawLimits, -} from '../../../../../../../../core/blitz/applyPitchYawLimits'; -import { hasEquipment } from '../../../../../../../../core/blitzkit/hasEquipment'; -import { modelTransformEvent } from '../../../../../../../../core/blitzkit/modelTransform'; -import { Var } from '../../../../../../../../core/radix/var'; -import { useLocale } from '../../../../../../../../hooks/useLocale'; -import { Duel } from '../../../../../../../../stores/duel'; -import { VisualizerCard } from '../../VisualizerCard'; -import { VisualizerCornerStat } from '../../VisualizerCornerStat'; - -const ANGLE_COEFFICIENT = 1 / 10; - -const modelDefinition = await awaitableModelDefinitions; - -function mag(x: number) { - return (1 / Math.PI) * Math.atan(ANGLE_COEFFICIENT * x) + 1 / 2; -} - -function magInverse(y: number): number { - return (1 / ANGLE_COEFFICIENT) * Math.tan(Math.PI * y - Math.PI / 2); -} - -function c(thetaDeg: number, m = 1) { - const theta = thetaDeg * (Math.PI / 180); - return `${m * Math.cos(theta)} ${-m * Math.sin(theta)}`; -} - -export function FlexibilityCard() { - const { strings } = useLocale(); - - const [yaw, setYaw] = useState(0); - const [pitch, setPitch] = useState(0); - const [minPitch, setMinPitch] = useState(0); - const [maxPitch, setMaxPitch] = useState(0); - - const container = useRef(null); - const highlighter = useRef(null); - const pointer = useRef(null); - - const duelStore = Duel.useStore(); - const tank = Duel.use((state) => state.protagonist.tank); - const turret = Duel.use((state) => state.protagonist.turret); - const gun = Duel.use((state) => state.protagonist.gun); - - const tankModel = modelDefinition.models[tank.id]; - const turretModel = tankModel.turrets[turret.id]; - const gunModel = turretModel.guns[gun.gun_type!.value.base.id]; - - const p = gunModel.pitch; - const y = turretModel.yaw; - const f = p.front; - const b = p.back; - const t = gunModel.pitch.transition ?? DEFAULT_PITCH_TRANSITION; - - let d = ''; - - // right - const mo = mag(p.max); - if (y) { - d += `M 0 0`; - d += `L ${c(90 - y.max, mo)}`; - } else if (b) { - d += `M ${c(-90 + b.range / 2 + t, mo)}`; - } else { - d += `M ${c(-90, mo)}`; - } - if (f) { - d += `A ${mo} ${mo} 0 0 0 ${c(90 - f.range / 2 - t, mo)}`; - } else { - d += `A ${mo} ${mo} 0 0 0 ${c(90, mo)}`; - } - - // front - if (f) { - const m = mag(f.max); - d += `L ${c(90 - f.range / 2, m)}`; - d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`; - } - - // left - if (f) { - d += `L ${c(90 + f.range / 2 + t, mo)}`; - } else { - d += `L ${c(90, mo)}`; - } - if (y) { - d += `A ${mo} ${mo} 0 0 0 ${c(90 - y.min, mo)}`; - } else if (b) { - d += `A ${mo} ${mo} 0 0 0 ${c(-90 - b.range / 2 - t, mo)}`; - } else { - d += `A ${mo} ${mo} 0 0 0 ${c(-90, mo)}`; - } - - // back - if (!y && b) { - const m = mag(b.max); - d += `L ${c(-90 - b.range / 2, m)}`; - d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`; - } - - d += `Z`; - - // right - const mi = mag(p.min); - if (y) { - d += `M 0 0`; - d += `L ${c(90 - y.max, mi)}`; - } else if (b) { - d += `M ${c(-90 + b.range / 2 + t, mi)}`; - } else { - d += `M ${c(-90, mi)}`; - } - if (f) { - d += `A ${mi} ${mi} 0 0 0 ${c(90 - f.range / 2 - t, mi)}`; - } else { - d += `A ${mi} ${mi} 0 0 0 ${c(90, mi)}`; - } - - // front - if (f) { - const m = mag(f.min); - d += `L ${c(90 - f.range / 2, m)}`; - d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`; - } - - // left - if (f) { - d += `L ${c(90 + f.range / 2 + t, mi)}`; - } else { - d += `L ${c(90, mi)}`; - } - if (y) { - d += `A ${mi} ${mi} 0 0 0 ${c(90 - y.min, mi)}`; - } else if (b) { - d += `A ${mi} ${mi} 0 0 0 ${c(-90 - b.range / 2 - t, mi)}`; - } else { - d += `A ${mi} ${mi} 0 0 0 ${c(-90, mi)}`; - } - - // back - if (!y && b) { - const m = mag(b.min); - d += `L ${c(-90 - b.range / 2, m)}`; - d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`; - } - - d += `Z`; - - const r = mag(radToDeg(-pitch)); - const dotX = r * Math.sin(yaw); - const dotY = r * Math.cos(yaw); - - return ( - { - if (!container.current || !highlighter.current || !pointer.current) - return; - - const rect = container.current.getBoundingClientRect(); - const u = event.clientX - rect.left - rect.width / 2; - const v = event.clientY - rect.top - rect.height / 2; - let yaw = Math.atan2(u, -v); - - const { - equipmentMatrix, - tank: { equipment_preset }, - } = duelStore.getState().protagonist; - const hasImprovedVerticalStabilizer = hasEquipment( - 122, - equipment_preset, - equipmentMatrix, - ); - const hasDownImprovedVerticalStabilizer = hasEquipment( - 124, - equipment_preset, - equipmentMatrix, - ); - - const min = applyPitchYawLimits( - -Math.PI, - yaw, - p, - y, - hasImprovedVerticalStabilizer, - hasDownImprovedVerticalStabilizer, - ); - const max = applyPitchYawLimits( - Math.PI, - yaw, - p, - y, - hasImprovedVerticalStabilizer, - hasDownImprovedVerticalStabilizer, - ); - - yaw = min[1]; - let t = 2 * Math.sqrt((u / rect.width) ** 2 + (v / rect.height) ** 2); - t = clamp(t, 0, 1); - let pitch = degToRad(magInverse(-t)); - pitch = clamp(pitch, min[0], max[0]); - - setPitch(pitch); - setYaw(yaw); - setMinPitch(min[0]); - setMaxPitch(max[0]); - - modelTransformEvent.dispatch({ pitch, yaw }); - }} - mb="0" - > - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/ModelChunk.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/ModelChunk.tsx index 92aadfec5..6e683bca3 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/ModelChunk.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/components/ModelChunk.tsx @@ -17,10 +17,10 @@ const outlineMaterial = new LineBasicMaterial({ }); interface Props { - onlyGun?: boolean; + only: 'gun' | 'turret' | 'body'; } -export function ModelChunk({ onlyGun = false }: Props) { +export function ModelChunk({ only }: Props) { const tank = Duel.use((state) => state.protagonist.tank); const turret = Duel.use((state) => state.protagonist.turret); const gun = Duel.use((state) => state.protagonist.gun); @@ -43,29 +43,38 @@ export function ModelChunk({ onlyGun = false }: Props) { `gun_${gunModel.model_id.toString().padStart(2, '0')}_mask`; const isCurrentGun = node.name === `gun_${gunModel.model_id.toString().padStart(2, '0')}`; - const isVisible = isCurrentGun || isCurrentMantlet; - const isHull = node.name === 'hull'; - const isWheel = node.name.startsWith('chassis_wheel_'); - const isTrack = node.name.startsWith('chassis_track_'); + const isGun = isCurrentGun || isCurrentMantlet; + const isCurrentTurret = node.name === `turret_${turretModel.model_id.toString().padStart(2, '0')}`; - const isAnythingElse = isHull || isWheel || isTrack || isCurrentTurret; + const isTurret = isCurrentTurret; - if ((onlyGun && isVisible) || (!onlyGun && isAnythingElse)) { - return jsxTree(node, { - mesh(mesh, props, key) { - return ( - <> - - - - - - ); - }, - }); + const isHull = node.name === 'hull'; + const isWheel = node.name.startsWith('chassis_wheel_'); + const isTrack = node.name.startsWith('chassis_track_'); + const isBody = isHull || isWheel || isTrack; + + if ( + (only === 'gun' && !isGun) || + (only === 'turret' && !isTurret) || + (only === 'body' && !isBody) + ) { + return null; } + + return jsxTree(node, { + mesh(mesh, props, key) { + return ( + <> + + + + + + ); + }, + }); })} ); diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx index 1aeb9d51a..06b02b794 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx @@ -1,12 +1,172 @@ +import { literals } from '@blitzkit/i18n'; import { Box, Card, Flex } from '@radix-ui/themes'; +import { clamp } from 'lodash-es'; +import { useRef, useState } from 'react'; +import { degToRad, radToDeg } from 'three/src/math/MathUtils.js'; +import { awaitableModelDefinitions } from '../../../../../../../core/awaitables/modelDefinitions'; +import { + DEFAULT_PITCH_TRANSITION, + applyPitchYawLimits, +} from '../../../../../../../core/blitz/applyPitchYawLimits'; +import { hasEquipment } from '../../../../../../../core/blitzkit/hasEquipment'; +import { modelTransformEvent } from '../../../../../../../core/blitzkit/modelTransform'; import { Var } from '../../../../../../../core/radix/var'; +import { useLocale } from '../../../../../../../hooks/useLocale'; +import { Duel } from '../../../../../../../stores/duel'; import type { MaybeSkeletonComponentProps } from '../../../../../../../types/maybeSkeletonComponentProps'; +import { VisualizerCard } from '../VisualizerCard'; +import { VisualizerCornerStat } from '../VisualizerCornerStat'; import { FlexibilityCanvas } from './components/FlexibilityCanvas'; -import { FlexibilityCard } from './components/FlexibilityCard'; + +const ANGLE_COEFFICIENT = 1 / 10; +const CONIC_TRANSITION = degToRad(0); + +const modelDefinition = await awaitableModelDefinitions; + +function mag(x: number) { + return (1 / Math.PI) * Math.atan(ANGLE_COEFFICIENT * x) + 1 / 2; +} + +function magInverse(y: number): number { + return (1 / ANGLE_COEFFICIENT) * Math.tan(Math.PI * y - Math.PI / 2); +} + +function c(thetaDeg: number, m = 1) { + const theta = thetaDeg * (Math.PI / 180); + return `${m * Math.cos(theta)} ${-m * Math.sin(theta)}`; +} export function GunFlexibilityVisualizer({ skeleton, }: MaybeSkeletonComponentProps) { + const { strings } = useLocale(); + + const duelStore = Duel.useStore(); + const tank = Duel.use((state) => state.protagonist.tank); + const turret = Duel.use((state) => state.protagonist.turret); + const gun = Duel.use((state) => state.protagonist.gun); + + const tankModel = modelDefinition.models[tank.id]; + const turretModel = tankModel.turrets[turret.id]; + const gunModel = turretModel.guns[gun.gun_type!.value.base.id]; + + const [yaw, setYaw] = useState(0); + const [pitch, setPitch] = useState(0); + const [minPitch, setMinPitch] = useState( + -degToRad(gunModel.pitch.front?.max ?? gunModel.pitch.max), + ); + const [maxPitch, setMaxPitch] = useState( + -degToRad(gunModel.pitch.front?.min ?? gunModel.pitch.min), + ); + + const container = useRef(null); + const highlighter = useRef(null); + const pointer = useRef(null); + + const p = gunModel.pitch; + const y = turretModel.yaw; + const f = p.front; + const b = p.back; + const t = gunModel.pitch.transition ?? DEFAULT_PITCH_TRANSITION; + + let d = ''; + + // right + const mo = mag(p.max); + if (y) { + d += `M 0 0`; + d += `L ${c(90 - y.max, mo)}`; + } else if (b) { + d += `M ${c(-90 + b.range / 2 + t, mo)}`; + } else { + d += `M ${c(-90, mo)}`; + } + if (f) { + d += `A ${mo} ${mo} 0 0 0 ${c(90 - f.range / 2 - t, mo)}`; + } else { + d += `A ${mo} ${mo} 0 0 0 ${c(90, mo)}`; + } + + // front + if (f) { + const m = mag(f.max); + d += `L ${c(90 - f.range / 2, m)}`; + d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`; + } + + // left + if (f) { + d += `L ${c(90 + f.range / 2 + t, mo)}`; + } else { + d += `L ${c(90, mo)}`; + } + if (y) { + d += `A ${mo} ${mo} 0 0 0 ${c(90 - y.min, mo)}`; + } else if (b) { + d += `A ${mo} ${mo} 0 0 0 ${c(-90 - b.range / 2 - t, mo)}`; + } else { + d += `A ${mo} ${mo} 0 0 0 ${c(-90, mo)}`; + } + + // back + if (!y && b) { + const m = mag(b.max); + d += `L ${c(-90 - b.range / 2, m)}`; + d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`; + } + + d += `Z`; + + // right + const mi = mag(p.min); + if (y) { + d += `M 0 0`; + d += `L ${c(90 - y.max, mi)}`; + } else if (b) { + d += `M ${c(-90 + b.range / 2 + t, mi)}`; + } else { + d += `M ${c(-90, mi)}`; + } + if (f) { + d += `A ${mi} ${mi} 0 0 0 ${c(90 - f.range / 2 - t, mi)}`; + } else { + d += `A ${mi} ${mi} 0 0 0 ${c(90, mi)}`; + } + + // front + if (f) { + const m = mag(f.min); + d += `L ${c(90 - f.range / 2, m)}`; + d += `A ${m} ${m} 0 0 0 ${c(90 + f.range / 2, m)}`; + } + + // left + if (f) { + d += `L ${c(90 + f.range / 2 + t, mi)}`; + } else { + d += `L ${c(90, mi)}`; + } + if (y) { + d += `A ${mi} ${mi} 0 0 0 ${c(90 - y.min, mi)}`; + } else if (b) { + d += `A ${mi} ${mi} 0 0 0 ${c(-90 - b.range / 2 - t, mi)}`; + } else { + d += `A ${mi} ${mi} 0 0 0 ${c(-90, mi)}`; + } + + // back + if (!y && b) { + const m = mag(b.min); + d += `L ${c(-90 - b.range / 2, m)}`; + d += `A ${m} ${m} 0 0 0 ${c(-90 + b.range / 2, m)}`; + } + + d += `Z`; + + const r = mag(radToDeg(-pitch)); + const dotX = r * Math.sin(yaw); + const dotY = r * Math.cos(yaw); + return ( @@ -20,11 +180,215 @@ export function GunFlexibilityVisualizer({ left="0" > {!skeleton && } + + - + { + if (!container.current || !highlighter.current || !pointer.current) + return; + + const rect = container.current.getBoundingClientRect(); + const u = event.clientX - rect.left - rect.width / 2; + const v = event.clientY - rect.top - rect.height / 2; + let yaw = Math.atan2(u, -v); + + const { + equipmentMatrix, + tank: { equipment_preset }, + } = duelStore.getState().protagonist; + const hasImprovedVerticalStabilizer = hasEquipment( + 122, + equipment_preset, + equipmentMatrix, + ); + const hasDownImprovedVerticalStabilizer = hasEquipment( + 124, + equipment_preset, + equipmentMatrix, + ); + + const min = applyPitchYawLimits( + -Math.PI, + yaw, + p, + y, + hasImprovedVerticalStabilizer, + hasDownImprovedVerticalStabilizer, + ); + const max = applyPitchYawLimits( + Math.PI, + yaw, + p, + y, + hasImprovedVerticalStabilizer, + hasDownImprovedVerticalStabilizer, + ); + + yaw = min[1]; + let t = 2 * Math.sqrt((u / rect.width) ** 2 + (v / rect.height) ** 2); + t = clamp(t, 0, 1); + let pitch = degToRad(magInverse(-t)); + pitch = clamp(pitch, min[0], max[0]); + + setPitch(pitch); + setYaw(yaw); + setMinPitch(min[0]); + setMaxPitch(max[0]); + + modelTransformEvent.dispatch({ pitch, yaw }); + }} + mb="0" + > + + + + + + + + + + + + + + + + + + + + + + + + ); }