From ceb759aba132ade9265e6c4b040703f73d7ac881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:24:18 -0500 Subject: [PATCH] feat: static armor --- .../[id]/components/Model/TankSandbox.tsx | 7 +- .../components/Model/components/TankModel.tsx | 30 ++- .../Armor/components/CoreArmorScene.tsx | 31 ++- .../SpacedArmorSceneComponent/index.tsx | 61 ++++- .../components/StaticArmorScene.tsx | 231 +++++++++++++++++- src/components/StaticArmor/index.tsx | 17 +- 6 files changed, 341 insertions(+), 36 deletions(-) diff --git a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx index f93aea420..7c52e9518 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/TankSandbox.tsx @@ -7,6 +7,7 @@ import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawL import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions'; import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform'; import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose'; +import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions'; import { useEquipment } from '../../../../../../hooks/useEquipment'; import * as Duel from '../../../../../../stores/duel'; import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral'; @@ -22,6 +23,7 @@ export function TankSandbox() { const canvas = useRef(null); const hasImprovedVerticalStabilizer = useEquipment(122); const awaitedModelDefinitions = use(modelDefinitions); + const awaitedTankDefinitions = use(tankDefinitions); const protagonist = Duel.use((state) => state.protagonist); const mutateDuel = Duel.useMutation(); const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id]; @@ -137,13 +139,14 @@ export function TankSandbox() { > - {/* idk why the shot display doesn't work without suspense here lol */} }> {armorMode === 'blitz' && } - {armorMode === 'static' && } + {armorMode === 'static' && ( + + )} diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx index e92eabb3d..4512ce535 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx @@ -1,5 +1,5 @@ import { ThreeEvent, useThree } from '@react-three/fiber'; -import { memo, useRef } from 'react'; +import { memo, useEffect, useRef } from 'react'; import { Group, Mesh, MeshStandardMaterial, Vector2 } from 'three'; import { ModelTankWrapper } from '../../../../../../../components/Armor/components/ModelTankWrapper'; import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits'; @@ -13,12 +13,14 @@ import { useModel } from '../../../../../../../hooks/useModel'; import { useTankTransform } from '../../../../../../../hooks/useTankTransform'; import * as Duel from '../../../../../../../stores/duel'; import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral'; +import * as TankopediaPersistent from '../../../../../../../stores/tankopediaPersistent'; export const TankModel = memo(() => { const mutateDuel = Duel.useMutation(); const duelStore = Duel.useStore(); const awaitedModelDefinitions = useAwait(modelDefinitions); const protagonist = Duel.use((draft) => draft.protagonist); + const tankopediaPersistentStore = TankopediaPersistent.useStore(); const canvas = useThree((state) => state.gl.domElement); const hullContainer = useRef(null); const turretContainer = useRef(null); @@ -36,6 +38,32 @@ export const TankModel = memo(() => { const { gltf } = useModel(protagonist.tank.id); const nodes = Object.values(gltf.nodes); + useEffect(() => { + function handleVisibility() { + if (!hullContainer.current) return; + + const state = tankopediaPersistentStore.getState(); + + hullContainer.current.visible = + state.mode === 'model' || state.armorMode === 'blitz'; + } + + const unsubscribes = [ + tankopediaPersistentStore.subscribe( + (state) => state.mode, + handleVisibility, + ), + tankopediaPersistentStore.subscribe( + (state) => state.armorMode, + handleVisibility, + ), + ]; + + handleVisibility(); + + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + }, []); + return ( {nodes.map((node) => { diff --git a/src/components/Armor/components/CoreArmorScene.tsx b/src/components/Armor/components/CoreArmorScene.tsx index d87f98cb9..5c1f626d4 100644 --- a/src/components/Armor/components/CoreArmorScene.tsx +++ b/src/components/Armor/components/CoreArmorScene.tsx @@ -16,23 +16,7 @@ export const CoreArmorScene = memo(() => { const modelDefinitions = useModelDefinitions(); const turretContainer = useRef(null); const gunContainer = useRef(null); - const tankopediaPersistentStore = TankopediaPersistent.useStore(); - const initialTankopediaState = tankopediaPersistentStore.getState(); const protagonist = Duel.use((draft) => draft.protagonist); - - useTankTransform(protagonist, turretContainer, gunContainer); - - useEffect(() => { - const unsubscribe = tankopediaPersistentStore.subscribe( - (state) => state.mode, - (mode) => { - if (wrapper.current) wrapper.current.visible = mode === 'armor'; - }, - ); - - 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); @@ -46,6 +30,21 @@ export const CoreArmorScene = memo(() => { const hullOrigin = correctZYTuple(trackModelDefinition.origin); const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin); const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin); + const tankopediaPersistentStore = TankopediaPersistent.useStore(); + const initialTankopediaState = tankopediaPersistentStore.getState(); + + useTankTransform(protagonist, turretContainer, gunContainer); + + useEffect(() => { + const unsubscribe = tankopediaPersistentStore.subscribe( + (state) => state.mode, + (mode) => { + if (wrapper.current) wrapper.current.visible = mode === 'armor'; + }, + ); + + return unsubscribe; + }); return ( ; @@ -68,6 +77,8 @@ export function SpacedArmorSceneComponent({ thickness, scene, clip, + static: staticMode, + thicknessRange, ...props }: SpacedArmorSceneComponentProps) { const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); @@ -354,6 +365,41 @@ export function SpacedArmorSceneComponent({ [camera], ); + const onClick = useCallback( + async (event: ThreeEvent) => { + event.stopPropagation(); + const shot = (await shoot(event.point, event.intersections, true))!; + tankopediaEphemeralStore.setState({ shot }); + }, + [camera], + ); + + if (staticMode) { + const x = clamp(thickness / thicknessRange.max, 0, 1); + const r = -((1 - x) ** 2) + 1; + const g = -(x ** 2) + 1; + + return jsxTree(node, { + material: new MeshStandardMaterial({ + color: + props.type === ArmorType.Core + ? `rgb(${to255(r)}, ${to255(g)}, 0)` + : props.type === ArmorType.Spaced + ? `rgb(${to255(1 - x)}, 0, ${to255(1 - x)})` + : `rgb(0, 255, 255)`, + opacity: props.type === ArmorType.External ? 0.25 : 1, + transparent: props.type === ArmorType.External, + }), + + userData: { + type: props.type, + variant: props.type === ArmorType.External ? props.variant : 'gun', + thickness, + } satisfies ArmorUserData, + onClick: props.type === ArmorType.Core ? onClick : () => {}, + }); + } + return ( <> {props.type === ArmorType.Core && @@ -366,18 +412,7 @@ export function SpacedArmorSceneComponent({ type: ArmorType.Core, thickness, } satisfies ArmorUserData, - - async onClick(event) { - event.stopPropagation(); - - const shot = (await shoot( - event.point, - event.intersections, - true, - ))!; - - tankopediaEphemeralStore.setState({ shot }); - }, + onClick, }, node.uuid, )} diff --git a/src/components/StaticArmor/components/StaticArmorScene.tsx b/src/components/StaticArmor/components/StaticArmorScene.tsx index 0c0217d28..a55125337 100644 --- a/src/components/StaticArmor/components/StaticArmorScene.tsx +++ b/src/components/StaticArmor/components/StaticArmorScene.tsx @@ -1,3 +1,230 @@ -export function StaticArmorScene() { - return null; +import { memo, useMemo, useRef } from 'react'; +import { Group, Plane, Scene, Vector3 } from 'three'; +import { correctZYTuple } from '../../../core/blitz/correctZYTuple'; +import { nameToArmorId } from '../../../core/blitzkit/nameToArmorId'; +import { resolveArmor } from '../../../core/blitzkit/resolveThickness'; +import { TankDefinitions } from '../../../core/blitzkit/tankDefinitions'; +import { useArmor } from '../../../hooks/useArmor'; +import { useModel } from '../../../hooks/useModel'; +import { useModelDefinitions } from '../../../hooks/useModelDefinitions'; +import { useTankTransform } from '../../../hooks/useTankTransform'; +import * as Duel from '../../../stores/duel'; +import { ModelTankWrapper } from '../../Armor/components/ModelTankWrapper'; +import { ArmorType } from '../../Armor/components/SpacedArmorScene'; +import { SpacedArmorSceneComponent } from '../../Armor/components/SpacedArmorSceneComponent'; + +interface StaticArmorSceneProps { + scene: Scene; + awaitedTankDefinitions: TankDefinitions; } + +export interface ThicknessRange { + max: number; +} + +export const StaticArmorScene = memo( + ({ scene, awaitedTankDefinitions }) => { + const wrapper = useRef(null); + const awaitedTankModelDefinitions = useModelDefinitions(); + const turretContainer = useRef(null); + const gunContainer = useRef(null); + 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 = awaitedTankModelDefinitions[tank.id]; + const trackModelDefinition = tankModelDefinition.tracks[track.id]; + const turretModelDefinition = tankModelDefinition.turrets[turret.id]; + const gunModelDefinition = turretModelDefinition.guns[gun.id]; + const hullOrigin = correctZYTuple(trackModelDefinition.origin); + const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin); + const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin); + const maskOrigin = + gunModelDefinition.mask === undefined + ? undefined + : gunModelDefinition.mask + hullOrigin.y + turretOrigin.y + gunOrigin.y; + const thicknessRange = useMemo(() => { + const thicknesses = Object.values(awaitedTankDefinitions) + .filter((thisTank) => thisTank.tier === tank.tier) + .map((tank) => awaitedTankModelDefinitions[tank.id]) + .filter(Boolean) + .map((model) => { + const tankThicknesses = Object.values(model.armor.thickness); + const trackThicknesses = Object.values(model.tracks) + .map((track) => track.thickness) + .flat(); + const turretThicknesses = Object.values(model.turrets) + .map((turret) => { + const turretThicknesses = Object.values(turret.armor.thickness); + const gunThicknesses = Object.values(turret.guns) + .map((gun) => { + return Object.values(gun.armor.thickness); + }) + .flat(); + + return [...turretThicknesses, ...gunThicknesses]; + }) + .flat(); + + return [ + ...tankThicknesses, + ...trackThicknesses, + ...turretThicknesses, + ]; + }) + .flat(); + + const max = Math.max(...thicknesses) * 0.75; + + return { max } satisfies ThicknessRange; + }, [tank.tier]); + + useTankTransform(protagonist, turretContainer, gunContainer); + + 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 gunString = `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`; + const isCurrentGun = gunModelDefinition.mask + ? node.name.startsWith(gunString) + : node.name === gunString; + const isVisible = isCurrentGun; + + if (!isVisible) return null; + + return ( + + ); + })} + + + + ); + }, +); diff --git a/src/components/StaticArmor/index.tsx b/src/components/StaticArmor/index.tsx index b4b0751c3..ebf5b65e8 100644 --- a/src/components/StaticArmor/index.tsx +++ b/src/components/StaticArmor/index.tsx @@ -1,5 +1,18 @@ +import { useThree } from '@react-three/fiber'; +import { TankDefinitions } from '../../core/blitzkit/tankDefinitions'; import { StaticArmorScene } from './components/StaticArmorScene'; -export function StaticArmor() { - return ; +export function StaticArmor({ + awaitedTankDefinitions, +}: { + awaitedTankDefinitions: TankDefinitions; +}) { + const scene = useThree((state) => state.scene); + + return ( + + ); }