diff --git a/src/components/hud/HUD.tsx b/src/components/hud/HUD.tsx index d5ee15e..a0a4181 100644 --- a/src/components/hud/HUD.tsx +++ b/src/components/hud/HUD.tsx @@ -13,9 +13,17 @@ export default function HUD() { const [showTimeMenu, setShowTimeMenu] = useState(false); useEffect(() => { - const update = () => setTimeConfig(getTimeConfig(timeMode)); - update(); - const interval = setInterval(update, 5000); + setTimeConfig(getTimeConfig(timeMode)); + const interval = setInterval(() => { + setTimeConfig((prev) => { + const next = getTimeConfig(timeMode); + // Only trigger a React re-render if the time string or phase actually changed + if (prev.formattedTime !== next.formattedTime || prev.resolvedPhase !== next.resolvedPhase) { + return next; + } + return prev; + }); + }, 5000); return () => clearInterval(interval); }, [timeMode]); diff --git a/src/components/scene/GuavaTree.tsx b/src/components/scene/GuavaTree.tsx index f7b3c4f..8b61be0 100644 --- a/src/components/scene/GuavaTree.tsx +++ b/src/components/scene/GuavaTree.tsx @@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; +import { Instances, Instance } from '@react-three/drei'; import { seededRandom, getBarkTexture, @@ -17,54 +18,57 @@ import { // Guava Tree — multi-stemmed, smooth bark, dense oval canopy // ───────────────────────────────────────────────────── -/** Leaf cluster that sits AT a branch tip */ -function GuavaLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) { +/** Aggregated Leaf clusters */ +function GuavaLeafClusters({ positions }: { positions: Vec3[] }) { const leafTex = getLeafTexture('#1a6e28', 'rgba(100, 210, 100, 0.25)', '#2d8838', 'oval'); - const leaves = useMemo(() => { - const rng = seededRandom( - Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10), - ); - const count = 6 + Math.floor(rng() * 5); - const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; - for (let i = 0; i < count; i++) { - const angle = rng() * Math.PI * 2; - const tilt = rng() * 0.5 - 0.25; - // Leaves radiate outward from this tip in a tight cluster - const spread = 0.08 + rng() * 0.18 * size; - arr.push({ - pos: [ - Math.cos(angle) * spread, - (rng() - 0.4) * 0.15 * size, - Math.sin(angle) * spread, - ], - rot: [tilt, angle + rng() * 0.6, rng() * 0.3], - scale: (0.22 + rng() * 0.18) * size, - }); - } - return arr; - }, [position, size]); + + const allLeaves = useMemo(() => { + const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; + positions.forEach((pos, index) => { + const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10)); + const size = 0.7 + (index % 4) * 0.12; + const count = 6 + Math.floor(rng() * 5); + + for (let i = 0; i < count; i++) { + const angle = rng() * Math.PI * 2; + const tilt = rng() * 0.5 - 0.25; + const spread = 0.08 + rng() * 0.18 * size; + leaves.push({ + pos: [ + pos[0] + Math.cos(angle) * spread, + pos[1] + (rng() - 0.4) * 0.15 * size, + pos[2] + Math.sin(angle) * spread, + ], + rot: [tilt, angle + rng() * 0.6, rng() * 0.3], + scale: (0.22 + rng() * 0.18) * size, + }); + } + }); + return leaves; + }, [positions]); + + if (allLeaves.length === 0) return null; return ( - - {leaves.map((leaf, i) => ( - - - - + + + + {allLeaves.map((leaf, i) => ( + ))} - + ); } -/** Renders all connected branch segments */ +/** Renders all connected branch segments using Instances */ function BranchRenderer({ branches, barkColor = '#5a5045' }: { branches: BranchSegment[]; barkColor?: string; @@ -72,33 +76,28 @@ function BranchRenderer({ branches, barkColor = '#5a5045' }: { const barkTex = getBarkTexture(barkColor); const barkNorm = getBarkNormal(); - // Pre-compute transforms for each segment - const transforms = useMemo(() => { - return branches.map((b) => computeCylinderTransform(b.start, b.end)); + const instancedData = useMemo(() => { + return branches.map((b) => { + const t = computeCylinderTransform(b.start, b.end); + const avgRadius = (b.radiusStart + b.radiusEnd) / 2; + return { + pos: t.position, + rot: t.quaternion, + scale: [avgRadius, t.length, avgRadius] as [number, number, number], + }; + }); }, [branches]); + if (instancedData.length === 0) return null; + return ( - - {branches.map((b, i) => { - const t = transforms[i]; - return ( - - - - - ); - })} - + + + + {instancedData.map((d, i) => ( + + ))} + ); } @@ -115,45 +114,61 @@ function GuavaFruits({ fruitPositions, seed = 0 }: { } }); - const fruits = useMemo(() => { + const { fruits, calyxes, stems } = useMemo(() => { const rng = seededRandom(seed + 400); - return fruitPositions.map((pos) => ({ - pos: [pos[0], pos[1] - 0.1 - rng() * 0.12, pos[2]] as Vec3, - scale: 0.07 + rng() * 0.035, - ripeness: rng(), - })); + const fArr: any[] = []; + const cArr: any[] = []; + const stArr: any[] = []; + + fruitPositions.forEach((pos) => { + const p: Vec3 = [pos[0], pos[1] - 0.1 - rng() * 0.12, pos[2]]; + const scale = 0.07 + rng() * 0.035; + const r = rng(); + + const color = new THREE.Color(); + if (r < 0.5) { + color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2); + } else { + color.lerpColors(new THREE.Color('#8ac44a'), new THREE.Color('#d4c82a'), (r - 0.5) * 2); + } + + fArr.push({ pos: p, scale, color }); + cArr.push({ pos: [p[0], p[1] + scale * 0.85, p[2]], scale }); + stArr.push({ pos: [p[0], p[1] + scale * 1.0, p[2]] }); + }); + + return { fruits: fArr, calyxes: cArr, stems: stArr }; }, [fruitPositions, seed]); return ( - {fruits.map((fruit, i) => { - const r = fruit.ripeness; - const color = new THREE.Color(); - if (r < 0.5) { - color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2); - } else { - color.lerpColors(new THREE.Color('#8ac44a'), new THREE.Color('#d4c82a'), (r - 0.5) * 2); - } - - return ( - - - - - - {/* Calyx crown */} - - - - - {/* Short stem connecting to branch */} - - - - - - ); - })} + {fruits.length > 0 && ( + + + + {fruits.map((f, i) => ( + + ))} + + )} + {calyxes.length > 0 && ( + + + + {calyxes.map((c, i) => ( + + ))} + + )} + {stems.length > 0 && ( + + + + {stems.map((st, i) => ( + + ))} + + )} ); } @@ -162,45 +177,41 @@ function GuavaFruits({ fruitPositions, seed = 0 }: { function GuavaRoots({ seed = 0 }: { seed?: number }) { const barkTex = getBarkTexture('#5a5045'); - const roots = useMemo(() => { + const rootTransforms = useMemo(() => { const rng = seededRandom(seed + 600); - const arr: { angle: number; length: number; thickness: number }[] = []; + const arr = []; const count = 3 + Math.floor(rng() * 3); for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2 + rng() * 0.6; + const length = 0.3 + rng() * 0.25; + const thickness = 0.04 + rng() * 0.03; + + const start: Vec3 = [0, 0.05, 0]; + const end: Vec3 = [ + Math.cos(angle) * length, + -0.05, + Math.sin(angle) * length, + ]; + const t = computeCylinderTransform(start, end); arr.push({ - angle: (i / count) * Math.PI * 2 + rng() * 0.6, - length: 0.3 + rng() * 0.25, - thickness: 0.04 + rng() * 0.03, + pos: t.position, + rot: t.quaternion, + scale: [thickness * 0.3, t.length, thickness] as [number, number, number] }); } return arr; }, [seed]); - const rootTransforms = useMemo(() => { - return roots.map((root) => { - const start: Vec3 = [0, 0.05, 0]; - const end: Vec3 = [ - Math.cos(root.angle) * root.length, - -0.05, - Math.sin(root.angle) * root.length, - ]; - return { ...computeCylinderTransform(start, end), root }; - }); - }, [roots]); + if (rootTransforms.length === 0) return null; return ( - + + + {rootTransforms.map((rt, i) => ( - - - - + ))} - + ); } @@ -298,12 +309,11 @@ export default function GuavaTree({ x, z, groundY = 0, isSpecial = false }: Guav {/* ── Connected branches ── */} + {/* Branch Segments */} - {/* ── Leaf clusters AT branch tips ── */} - {treeData.leafTipPositions.map((pos, i) => ( - - ))} + {/* Aggregated Leaf Clusters */} + {/* ── Fruits hanging from branch junctions ── */} diff --git a/src/components/scene/JujubeTree.tsx b/src/components/scene/JujubeTree.tsx index 140f18d..c1a17cc 100644 --- a/src/components/scene/JujubeTree.tsx +++ b/src/components/scene/JujubeTree.tsx @@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; +import { Instances, Instance } from '@react-three/drei'; import { seededRandom, getBarkTexture, @@ -20,79 +21,83 @@ import { const BARK_COLOR = '#4a3520'; -/** Small oval leaf cluster at a branch tip */ -function JujubeLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) { - const leafTex = getLeafTexture('#1a5520', 'rgba(90, 180, 90, 0.2)', '#2a7030', 'oval'); - const leaves = useMemo(() => { - const rng = seededRandom( - Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10), - ); - const count = 7 + Math.floor(rng() * 5); - const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; - for (let i = 0; i < count; i++) { - const angle = rng() * Math.PI * 2; - const tilt = rng() * 0.6 - 0.3; - const spread = 0.06 + rng() * 0.14 * size; - arr.push({ - pos: [ - Math.cos(angle) * spread, - (rng() - 0.35) * 0.12 * size, - Math.sin(angle) * spread, - ], - rot: [tilt, angle + rng() * 0.5, rng() * 0.4], - scale: (0.14 + rng() * 0.1) * size, - }); - } - return arr; - }, [position, size]); +/** Aggregated Leaf clusters */ +function JujubeLeafClusters({ positions }: { positions: Vec3[] }) { + const leafTex = getLeafTexture('#2a5c1a', 'rgba(150, 220, 150, 0.25)', '#3a7a2a', 'oval'); + + const allLeaves = useMemo(() => { + const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; + positions.forEach((pos, index) => { + const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10)); + const size = 0.7 + (index % 4) * 0.12; + const count = 5 + Math.floor(rng() * 4); + + for (let i = 0; i < count; i++) { + const angle = rng() * Math.PI * 2; + const tilt = rng() * 0.5 - 0.1; + const spread = 0.05 + rng() * 0.12 * size; + leaves.push({ + pos: [ + pos[0] + Math.cos(angle) * spread, + pos[1] + (rng() - 0.4) * 0.12 * size, + pos[2] + Math.sin(angle) * spread, + ], + rot: [tilt, angle + rng() * 0.6, rng() * 0.2], + scale: (0.18 + rng() * 0.12) * size, + }); + } + }); + return leaves; + }, [positions]); + + if (allLeaves.length === 0) return null; return ( - - {leaves.map((leaf, i) => ( - - - - + + + + {allLeaves.map((leaf, i) => ( + ))} - + ); } -/** Renders connected branch segments */ +/** Renders connected branch segments using Instances */ function BranchRenderer({ branches }: { branches: BranchSegment[] }) { const barkTex = getBarkTexture(BARK_COLOR); const barkNorm = getBarkNormal(); - const transforms = useMemo(() => { - return branches.map((b) => computeCylinderTransform(b.start, b.end)); + const instancedData = useMemo(() => { + return branches.map((b) => { + const t = computeCylinderTransform(b.start, b.end); + const avgRadius = (b.radiusStart + b.radiusEnd) / 2; + return { + pos: t.position, + rot: t.quaternion, + scale: [avgRadius, t.length, avgRadius] as [number, number, number], + }; + }); }, [branches]); + if (instancedData.length === 0) return null; + return ( - - {branches.map((b, i) => { - const t = transforms[i]; - return ( - - - - - ); - })} - + + + + {instancedData.map((d, i) => ( + + ))} + ); } @@ -101,19 +106,17 @@ function JujubeThorns({ branches, seed }: { branches: BranchSegment[]; seed: num const thorns = useMemo(() => { const rng = seededRandom(seed + 900); const arr: { pos: Vec3; rot: [number, number, number] }[] = []; - // Place thorns along branch segments for (const b of branches) { const thornCount = 1 + Math.floor(rng() * 2); for (let t = 0; t < thornCount; t++) { if (rng() > 0.55) continue; const frac = 0.2 + rng() * 0.6; - const pos: Vec3 = [ - b.start[0] + (b.end[0] - b.start[0]) * frac + (rng() - 0.5) * 0.03, - b.start[1] + (b.end[1] - b.start[1]) * frac + (rng() - 0.5) * 0.03, - b.start[2] + (b.end[2] - b.start[2]) * frac + (rng() - 0.5) * 0.03, - ]; arr.push({ - pos, + pos: [ + b.start[0] + (b.end[0] - b.start[0]) * frac + (rng() - 0.5) * 0.03, + b.start[1] + (b.end[1] - b.start[1]) * frac + (rng() - 0.5) * 0.03, + b.start[2] + (b.end[2] - b.start[2]) * frac + (rng() - 0.5) * 0.03, + ], rot: [rng() * Math.PI * 0.5, rng() * Math.PI * 2, rng() * Math.PI * 0.3], }); } @@ -121,15 +124,16 @@ function JujubeThorns({ branches, seed }: { branches: BranchSegment[]; seed: num return arr; }, [branches, seed]); + if (thorns.length === 0) return null; + return ( - + + + {thorns.map((thorn, i) => ( - - - - + ))} - + ); } @@ -146,42 +150,51 @@ function JujubeFruits({ fruitPositions, seed = 0 }: { } }); - const fruits = useMemo(() => { + const { fruits, stems } = useMemo(() => { const rng = seededRandom(seed + 700); - return fruitPositions.map((pos) => ({ - pos: [pos[0], pos[1] - 0.06 - rng() * 0.08, pos[2]] as Vec3, - scale: 0.035 + rng() * 0.02, - ripeness: rng(), - })); + const fArr: any[] = []; + const stArr: any[] = []; + + fruitPositions.forEach((pos) => { + const scale = 0.035 + rng() * 0.02; + const r = rng(); + + const color = new THREE.Color(); + if (r < 0.3) { + color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#a4b044'), r * 3.3); + } else if (r < 0.7) { + color.lerpColors(new THREE.Color('#a4b044'), new THREE.Color('#c44a28'), (r - 0.3) * 2.5); + } else { + color.lerpColors(new THREE.Color('#c44a28'), new THREE.Color('#8a2020'), (r - 0.7) * 3.3); + } + + const p: Vec3 = [pos[0], pos[1] - 0.06 - rng() * 0.08, pos[2]]; + fArr.push({ pos: p, scale, color }); + stArr.push({ pos: [p[0], p[1] + scale * 0.9, p[2]] }); + }); + return { fruits: fArr, stems: stArr }; }, [fruitPositions, seed]); return ( - {fruits.map((fruit, i) => { - const r = fruit.ripeness; - const color = new THREE.Color(); - if (r < 0.3) { - color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#a4b044'), r * 3.3); - } else if (r < 0.7) { - color.lerpColors(new THREE.Color('#a4b044'), new THREE.Color('#c44a28'), (r - 0.3) * 2.5); - } else { - color.lerpColors(new THREE.Color('#c44a28'), new THREE.Color('#8a2020'), (r - 0.7) * 3.3); - } - - return ( - - - - - - {/* Tiny stem connecting to branch */} - - - - - - ); - })} + {fruits.length > 0 && ( + + + + {fruits.map((f, i) => ( + + ))} + + )} + {stems.length > 0 && ( + + + + {stems.map((st, i) => ( + + ))} + + )} ); } @@ -204,21 +217,26 @@ function JujubeRoots({ seed = 0 }: { seed?: number }) { -0.04, Math.sin(angle) * length, ]; - const transform = computeCylinderTransform(start, end); - arr.push({ ...transform, thickness }); + const t = computeCylinderTransform(start, end); + arr.push({ + pos: t.position, + rot: t.quaternion, + scale: [thickness * 0.3, t.length, thickness] as [number, number, number] + }); } return arr; }, [seed]); + if (rootTransforms.length === 0) return null; + return ( - + + + {rootTransforms.map((rt, i) => ( - - - - + ))} - + ); } @@ -304,14 +322,13 @@ export default function JujubeTree({ x, z, groundY = 0 }: JujubeTreeProps) { {/* ── Connected branches + thorns + leaves + fruits ── */} + {/* Connected branch segments */} - {/* Leaf clusters at branch tips */} - {treeData.leafTipPositions.map((pos, i) => ( - - ))} - + {/* Aggregated Leaf Clusters */} + + {/* Fruits from branch junctions */} diff --git a/src/components/scene/LemonTree.tsx b/src/components/scene/LemonTree.tsx index 18a327c..3f51249 100644 --- a/src/components/scene/LemonTree.tsx +++ b/src/components/scene/LemonTree.tsx @@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; +import { Instances, Instance } from '@react-three/drei'; import { seededRandom, getBarkTexture, @@ -20,127 +21,135 @@ import { const BARK_COLOR = '#5a4a2a'; -/** Glossy narrow citrus leaf cluster at branch tip */ -function LemonLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) { - const leafTex = getLeafTexture('#1a5a18', 'rgba(140, 220, 100, 0.3)', '#2a7a28', 'narrow'); - const leaves = useMemo(() => { - const rng = seededRandom( - Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10), - ); - const count = 7 + Math.floor(rng() * 5); - const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; - for (let i = 0; i < count; i++) { - const angle = rng() * Math.PI * 2; - const tilt = rng() * 0.4 - 0.2; - const spread = 0.06 + rng() * 0.16 * size; - arr.push({ - pos: [ - Math.cos(angle) * spread, - (rng() - 0.35) * 0.14 * size, - Math.sin(angle) * spread, - ], - rot: [tilt, angle + rng() * 0.5, rng() * 0.25], - scale: (0.18 + rng() * 0.14) * size, - }); - } - return arr; - }, [position, size]); +/** Aggregated Leaf clusters */ +function LemonLeafClusters({ positions }: { positions: Vec3[] }) { + const leafTex = getLeafTexture('#2a5c1a', 'rgba(150, 220, 150, 0.25)', '#3a7a2a', 'oval'); + + const allLeaves = useMemo(() => { + const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; + positions.forEach((pos, index) => { + const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10)); + const size = 0.7 + (index % 4) * 0.12; + const count = 5 + Math.floor(rng() * 4); + + for (let i = 0; i < count; i++) { + const angle = rng() * Math.PI * 2; + const tilt = rng() * 0.5 - 0.1; + const spread = 0.05 + rng() * 0.12 * size; + leaves.push({ + pos: [ + pos[0] + Math.cos(angle) * spread, + pos[1] + (rng() - 0.4) * 0.12 * size, + pos[2] + Math.sin(angle) * spread, + ], + rot: [tilt, angle + rng() * 0.6, rng() * 0.2], + scale: (0.18 + rng() * 0.12) * size, + }); + } + }); + return leaves; + }, [positions]); + + if (allLeaves.length === 0) return null; return ( - - {leaves.map((leaf, i) => ( - - - - + + + + {allLeaves.map((leaf, i) => ( + ))} - + ); } -/** Renders connected branch segments */ +/** Renders connected branch segments using Instances */ function BranchRenderer({ branches }: { branches: BranchSegment[] }) { const barkTex = getBarkTexture(BARK_COLOR); const barkNorm = getBarkNormal(); - const transforms = useMemo(() => { - return branches.map((b) => computeCylinderTransform(b.start, b.end)); + const instancedData = useMemo(() => { + return branches.map((b) => { + const t = computeCylinderTransform(b.start, b.end); + const avgRadius = (b.radiusStart + b.radiusEnd) / 2; + return { + pos: t.position, + rot: t.quaternion, + scale: [avgRadius, t.length, avgRadius] as [number, number, number], + }; + }); }, [branches]); + if (instancedData.length === 0) return null; + return ( - - {branches.map((b, i) => { - const t = transforms[i]; - return ( - - - - - ); - })} - + + + + {instancedData.map((d, i) => ( + + ))} + ); } /** White 5-petaled citrus flowers at some branch tips */ function LemonFlowers({ positions, seed }: { positions: Vec3[]; seed: number }) { - const flowers = useMemo(() => { + const { petals, centers } = useMemo(() => { + const p: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; + const c: { pos: Vec3; scale: number }[] = []; const rng = seededRandom(seed + 250); - // Only some positions get flowers - return positions.filter(() => rng() > 0.55).map((pos) => ({ - pos, - scale: 0.022 + rng() * 0.012, - })); + + positions.forEach((pos) => { + if (rng() > 0.55) { + const scale = 0.022 + rng() * 0.012; + c.push({ pos, scale: scale * 0.4 }); + + for (let pi = 0; pi < 5; pi++) { + const pAngle = (pi / 5) * Math.PI * 2; + p.push({ + pos: [ + pos[0] + Math.cos(pAngle) * scale * 0.7, + pos[1], + pos[2] + Math.sin(pAngle) * scale * 0.7, + ], + rot: [0.3, pAngle, 0], + scale, + }); + } + } + }); + return { petals: p, centers: c }; }, [positions, seed]); return ( - {flowers.map((flower, i) => ( - - {Array.from({ length: 5 }, (_, pi) => { - const pAngle = (pi / 5) * Math.PI * 2; - return ( - - - - - ); - })} - - - - - - ))} + {petals.length > 0 && ( + + + + {petals.map((p, i) => ( + + ))} + + )} + {centers.length > 0 && ( + + + + {centers.map((c, i) => ( + + ))} + + )} ); } @@ -158,51 +167,47 @@ function LemonFruits({ fruitPositions, seed = 0 }: { } }); - const fruits = useMemo(() => { + const { fruits } = useMemo(() => { const rng = seededRandom(seed + 450); - return fruitPositions.map((pos) => ({ - pos: [pos[0], pos[1] - 0.08 - rng() * 0.1, pos[2]] as Vec3, - rot: [rng() * 0.3 - 0.15, rng() * Math.PI * 2, rng() * 0.3] as [number, number, number], - scaleX: 0.055 + rng() * 0.02, - scaleY: 0.075 + rng() * 0.025, - ripeness: rng(), - })); + const fArr: any[] = []; + + fruitPositions.forEach((pos) => { + const r = rng(); + const color = new THREE.Color(); + if (r < 0.5) { + color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#b4c830'), r * 2); + } else { + color.lerpColors(new THREE.Color('#b4c830'), new THREE.Color('#fde047'), (r - 0.5) * 2); + } + + const p: Vec3 = [pos[0], pos[1] - 0.08 - rng() * 0.1, pos[2]]; + const rot: [number, number, number] = [rng() * 0.3 - 0.15, rng() * Math.PI * 2, rng() * 0.3]; + const scaleX = 0.055 + rng() * 0.02; + const scaleY = 0.075 + rng() * 0.025; + + fArr.push({ pos: p, rot, scale: [scaleX, scaleY, scaleX], color }); + }); + return { fruits: fArr }; }, [fruitPositions, seed]); return ( - {fruits.map((fruit, i) => { - const r = fruit.ripeness; - const color = new THREE.Color(); - if (r < 0.5) { - color.lerpColors(new THREE.Color('#4a8a2a'), new THREE.Color('#b4c830'), r * 2); - } else { - color.lerpColors(new THREE.Color('#b4c830'), new THREE.Color('#fde047'), (r - 0.5) * 2); - } - - return ( - - {/* Lemon body */} - - - - - {/* Nipple tip */} - - - - - {/* Stem connecting to branch */} - - - - - - ); - })} + {fruits.map((fruit, i) => ( + + + + + + + + + + + + + + + ))} ); } @@ -221,21 +226,26 @@ function LemonRoots({ seed = 0 }: { seed?: number }) { const thickness = 0.04 + rng() * 0.025; const start: Vec3 = [0, 0.04, 0]; const end: Vec3 = [Math.cos(angle) * length, -0.03, Math.sin(angle) * length]; - const transform = computeCylinderTransform(start, end); - arr.push({ ...transform, thickness }); + const t = computeCylinderTransform(start, end); + arr.push({ + pos: t.position, + rot: t.quaternion, + scale: [thickness * 0.3, t.length, thickness] as [number, number, number] + }); } return arr; }, [seed]); + if (rootTransforms.length === 0) return null; + return ( - + + + {rootTransforms.map((rt, i) => ( - - - - + ))} - + ); } @@ -321,13 +331,12 @@ export default function LemonTree({ x, z, groundY = 0 }: LemonTreeProps) { {/* ── Connected branches + leaves + flowers + fruits ── */} + {/* Connected branch segments */} - {/* Leaf clusters at branch tips */} - {treeData.leafTipPositions.map((pos, i) => ( - - ))} - + {/* Aggregated Leaf Clusters */} + + {/* Flowers at some leaf positions */} diff --git a/src/components/scene/MangoTree.tsx b/src/components/scene/MangoTree.tsx index e44a86f..ac413e4 100644 --- a/src/components/scene/MangoTree.tsx +++ b/src/components/scene/MangoTree.tsx @@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; +import { Instances, Instance } from '@react-three/drei'; import { seededRandom, getBarkTexture, @@ -19,49 +20,53 @@ import { // panicles, and hanging mango fruits at various ripeness. // ───────────────────────────────────────────────────── -/** Lanceolate leaf cluster at a branch tip */ -function MangoLeafCluster({ position, size = 1.0 }: { position: Vec3; size?: number }) { +/** Aggregated Lanceolate leaf clusters for the entire tree */ +function MangoLeafClusters({ positions }: { positions: Vec3[] }) { const leafTex = getLeafTexture('#1a5c1a', 'rgba(120, 200, 120, 0.25)', '#2a7a2a', 'lanceolate'); - const leaves = useMemo(() => { - const rng = seededRandom( - Math.floor(position[0] * 1000 + position[1] * 100 + position[2] * 10), - ); - const count = 6 + Math.floor(rng() * 5); - const arr: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; - for (let i = 0; i < count; i++) { - const angle = rng() * Math.PI * 2; - const tilt = rng() * 0.6 - 0.3; - const spread = 0.08 + rng() * 0.2 * size; - arr.push({ - pos: [ - Math.cos(angle) * spread, - (rng() - 0.3) * 0.18 * size, - Math.sin(angle) * spread, - ], - rot: [tilt, angle + rng() * 0.5, rng() * 0.3], - scale: (0.28 + rng() * 0.22) * size, - }); - } - return arr; - }, [position, size]); + + const allLeaves = useMemo(() => { + const leaves: { pos: Vec3; rot: [number, number, number]; scale: number }[] = []; + positions.forEach((pos, index) => { + const rng = seededRandom(Math.floor(pos[0] * 1000 + pos[1] * 100 + pos[2] * 10)); + const size = 0.7 + (index % 4) * 0.12; + const count = 6 + Math.floor(rng() * 5); + + for (let i = 0; i < count; i++) { + const angle = rng() * Math.PI * 2; + const tilt = rng() * 0.6 - 0.3; + const spread = 0.08 + rng() * 0.2 * size; + leaves.push({ + pos: [ + pos[0] + Math.cos(angle) * spread, + pos[1] + (rng() - 0.3) * 0.18 * size, + pos[2] + Math.sin(angle) * spread, + ], + rot: [tilt, angle + rng() * 0.5, rng() * 0.3], + scale: (0.28 + rng() * 0.22) * size, + }); + } + }); + return leaves; + }, [positions]); + + if (allLeaves.length === 0) return null; return ( - - {leaves.map((leaf, i) => ( - - - - + + + + {allLeaves.map((leaf, i) => ( + ))} - + ); } @@ -73,32 +78,28 @@ function BranchRenderer({ branches, barkColor = '#4a3828' }: { const barkTex = getBarkTexture(barkColor); const barkNorm = getBarkNormal(); - const transforms = useMemo(() => { - return branches.map((b) => computeCylinderTransform(b.start, b.end)); + const instancedData = useMemo(() => { + return branches.map((b) => { + const t = computeCylinderTransform(b.start, b.end); + const avgRadius = (b.radiusStart + b.radiusEnd) / 2; + return { + pos: t.position, + rot: t.quaternion, + scale: [avgRadius, t.length, avgRadius] as [number, number, number], + }; + }); }, [branches]); + if (instancedData.length === 0) return null; + return ( - - {branches.map((b, i) => { - const t = transforms[i]; - return ( - - - - - ); - })} - + + + + {instancedData.map((d, i) => ( + + ))} + ); } @@ -107,41 +108,54 @@ function FlowerPanicles({ positions, seed = 0 }: { positions: Vec3[]; seed?: number; }) { - const panicles = useMemo(() => { + const { spheres, stems } = useMemo(() => { + const sArr: any[] = []; + const stArr: any[] = []; const rng = seededRandom(seed + 200); - // Only some tips get flowers - return positions.filter(() => rng() > 0.6).map((pos) => ({ - pos, - count: 10 + Math.floor(rng() * 12), - })); + + positions.forEach((pos, pi) => { + if (rng() > 0.6) { + const count = 10 + Math.floor(rng() * 12); + + stArr.push({ pos }); + + for (let fi = 0; fi < count; fi++) { + const rng2 = seededRandom(pi * 100 + fi); + sArr.push({ + pos: [ + pos[0] + (rng2() - 0.5) * 0.25, + pos[1] + rng2() * 0.3 - 0.15, + pos[2] + (rng2() - 0.5) * 0.25, + ], + scale: 0.018 + rng2() * 0.012, + color: new THREE.Color(`hsl(${65 + rng2() * 15}, ${50 + rng2() * 20}%, ${65 + rng2() * 15}%)`) + }); + } + } + }); + return { spheres: sArr, stems: stArr }; }, [positions, seed]); return ( - {panicles.map((pan, pi) => ( - - {Array.from({ length: pan.count }, (_, fi) => { - const rng2 = seededRandom(pi * 100 + fi); - const fx = (rng2() - 0.5) * 0.25; - const fy = rng2() * 0.3 - 0.15; - const fz = (rng2() - 0.5) * 0.25; - return ( - - - - - ); - })} - {/* Panicle stem */} - - - - - - ))} + {spheres.length > 0 && ( + + + + {spheres.map((s, i) => ( + + ))} + + )} + {stems.length > 0 && ( + + + + {stems.map((st, i) => ( + + ))} + + )} ); } @@ -155,104 +169,96 @@ function MangoFruits({ fruitPositions, seed = 0 }: { useFrame(({ clock }) => { if (groupRef.current) { - groupRef.current.rotation.z = Math.sin(clock.getElapsedTime() * 0.5 + seed) * 0.02; + groupRef.current.rotation.z = Math.sin(clock.getElapsedTime() * 0.3 + seed) * 0.02; } }); - const fruits = useMemo(() => { - const rng = seededRandom(seed + 300); - return fruitPositions.map((pos) => ({ - pos: [pos[0], pos[1] - 0.12 - rng() * 0.15, pos[2]] as Vec3, - rot: [rng() * 0.3, rng() * Math.PI * 2, Math.PI + rng() * 0.4] as [number, number, number], - scale: [ - 0.1 + rng() * 0.04, - 0.14 + rng() * 0.05, - 0.09 + rng() * 0.03, - ] as [number, number, number], - ripeness: rng(), - })); + const { fruits, stems } = useMemo(() => { + const rng = seededRandom(seed + 400); + const fArr: any[] = []; + const stArr: any[] = []; + + fruitPositions.forEach((pos) => { + const scale = 0.08 + rng() * 0.04; + const r = rng(); + const color = new THREE.Color(); + if (r < 0.3) { + color.lerpColors(new THREE.Color('#2a6a2a'), new THREE.Color('#8a9a2a'), r * 3.3); + } else if (r < 0.7) { + color.lerpColors(new THREE.Color('#8a9a2a'), new THREE.Color('#d4a017'), (r - 0.3) * 2.5); + } else { + color.lerpColors(new THREE.Color('#d4a017'), new THREE.Color('#d45017'), (r - 0.7) * 3.3); + } + + const p: Vec3 = [pos[0], pos[1] - 0.15 - rng() * 0.1, pos[2]]; + fArr.push({ pos: p, scale, color }); + stArr.push({ pos: [p[0], p[1] + scale * 0.9, p[2]] }); + }); + return { fruits: fArr, stems: stArr }; }, [fruitPositions, seed]); return ( - {fruits.map((fruit, i) => { - const r = fruit.ripeness; - const color = new THREE.Color(); - if (r < 0.5) { - color.lerpColors(new THREE.Color('#3a7d2a'), new THREE.Color('#c4b52a'), r * 2); - } else { - color.lerpColors(new THREE.Color('#c4b52a'), new THREE.Color('#e8872a'), (r - 0.5) * 2); - } - - return ( - - {/* Mango body */} - - - - - - {/* Stem */} - - - - - - {/* Blush spot */} - {fruit.ripeness > 0.4 && ( - - - - - )} - - ); - })} + {fruits.length > 0 && ( + + + + {fruits.map((f, i) => ( + + ))} + + )} + {stems.length > 0 && ( + + + + {stems.map((st, i) => ( + + ))} + + )} ); } -/** Root buttresses at trunk base */ -function TrunkRoots({ seed = 0 }: { seed?: number }) { - const barkTex = getBarkTexture('#3a2e20'); +/** Base root flares for mango tree */ +function MangoRoots({ seed = 0 }: { seed?: number }) { + const barkTex = getBarkTexture('#4a3828'); const rootTransforms = useMemo(() => { - const rng = seededRandom(seed + 500); - const arr = []; + const rng = seededRandom(seed + 600); const count = 4 + Math.floor(rng() * 3); + const arr = []; for (let i = 0; i < count; i++) { const angle = (i / count) * Math.PI * 2 + rng() * 0.5; - const length = 0.4 + rng() * 0.3; - const thickness = 0.06 + rng() * 0.04; - const start: Vec3 = [0, 0.06, 0]; - const end: Vec3 = [Math.cos(angle) * length, -0.04, Math.sin(angle) * length]; - const transform = computeCylinderTransform(start, end); - arr.push({ ...transform, thickness }); + const length = 0.5 + rng() * 0.4; + const thickness = 0.05 + rng() * 0.04; + const start: Vec3 = [0, 0.05, 0]; + const end: Vec3 = [ + Math.cos(angle) * length, + -0.05, + Math.sin(angle) * length, + ]; + const t = computeCylinderTransform(start, end); + arr.push({ + pos: t.position, + rot: t.quaternion, + scale: [thickness * 0.3, t.length, thickness] as [number, number, number] + }); } return arr; }, [seed]); + if (rootTransforms.length === 0) return null; + return ( - + + + {rootTransforms.map((rt, i) => ( - - - - + ))} - + ); } @@ -335,17 +341,15 @@ export default function MangoTree({ x, z, groundY = 0, scale: treeScale = 1 }: M {/* ── Root buttresses ── */} - + {/* ── Branches + canopy ── */} {/* Connected branch segments */} - {/* Leaf clusters at branch tips */} - {treeData.leafTipPositions.map((pos, i) => ( - - ))} + {/* Aggregated Leaf clusters */} + {/* Flower panicles at some leaf positions */} diff --git a/src/components/scene/OrchadCanvas.tsx b/src/components/scene/OrchadCanvas.tsx index e4af111..b4afcc6 100644 --- a/src/components/scene/OrchadCanvas.tsx +++ b/src/components/scene/OrchadCanvas.tsx @@ -73,10 +73,25 @@ export default function OrchadCanvas() { const [timeConfig, setTimeConfig] = useState(() => getTimeConfig(timeMode)); useEffect(() => { - const update = () => setTimeConfig(getTimeConfig(timeMode)); - update(); - const interval = setInterval(update, 5000); - return () => clearInterval(interval); + setTimeConfig(getTimeConfig(timeMode)); + + // Only set an interval if we are in auto mode (to catch hour changes) + let interval: ReturnType | undefined; + if (timeMode === 'auto') { + // Check every minute instead of every 5 seconds, and only update if the phase actually changed + interval = setInterval(() => { + setTimeConfig((prev) => { + const next = getTimeConfig('auto'); + // Only trigger a re-render if the major phase changed (e.g. day -> sunset) + // We don't care about formattedTime in the Canvas + if (prev.resolvedPhase !== next.resolvedPhase) return next; + return prev; + }); + }, 60000); + } + return () => { + if (interval) clearInterval(interval); + }; }, [timeMode]); return ( diff --git a/src/components/scene/PapayaTree.tsx b/src/components/scene/PapayaTree.tsx index f1bdd2c..5018db8 100644 --- a/src/components/scene/PapayaTree.tsx +++ b/src/components/scene/PapayaTree.tsx @@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; +import { Instances, Instance } from '@react-three/drei'; import { seededRandom, getSmoothBarkTexture, @@ -16,127 +17,98 @@ import { // and fruits clustered beneath the crown. // ───────────────────────────────────────────────────── -/** Single papaya leaf on a petiole — connected to trunk top */ -function PapayaLeaf({ index, total, trunkHeight, trunkRadius, seed }: { - index: number; - total: number; +/** Leaf crown with instanced lobes for performance */ +function PapayaLeafCrown({ trunkHeight, trunkRadius, seed }: { trunkHeight: number; trunkRadius: number; seed: number; }) { const leafTex = getLeafTexture('#1a7030', 'rgba(80, 200, 80, 0.2)', '#2a8838', 'palmate'); - const rng = useMemo(() => seededRandom(seed + index * 17), [seed, index]); - - const angle = useMemo(() => (index / total) * Math.PI * 2 + rng() * 0.3, [index, total, rng]); - const petioleLength = useMemo(() => 1.0 + rng() * 0.5, [rng]); - const leafScale = useMemo(() => 0.65 + rng() * 0.3, [rng]); - // Droop angle — lower leaves droop more, upper ones point outward/up - const isLowerTier = useMemo(() => rng() > 0.5, [rng]); - const droopAngle = useMemo( - () => (isLowerTier ? 0.7 + rng() * 0.5 : 0.3 + rng() * 0.3), - [isLowerTier, rng], - ); - - const leafRef = useRef(null); - useFrame(({ clock }) => { - if (leafRef.current) { - const t = clock.getElapsedTime(); - leafRef.current.rotation.z = Math.sin(t * 0.4 + index * 1.3) * 0.02; - } - }); - - // Compute the petiole as start→end connected cylinder - const petioleTip: Vec3 = useMemo(() => { - const cosD = Math.cos(droopAngle); - const sinD = Math.sin(droopAngle); - return [ - Math.cos(angle) * sinD * petioleLength, - trunkHeight - 0.1 + cosD * petioleLength * 0.3, - Math.sin(angle) * sinD * petioleLength, - ]; - }, [angle, droopAngle, petioleLength, trunkHeight]); + + const leafCount = useMemo(() => { + const rng = seededRandom(seed + 300); + return 10 + Math.floor(rng() * 5); + }, [seed]); - const petioleStart: Vec3 = useMemo(() => [ - Math.cos(angle) * trunkRadius * 0.8, - trunkHeight - 0.1, - Math.sin(angle) * trunkRadius * 0.8, - ], [angle, trunkRadius, trunkHeight]); + const { petioles, lobes } = useMemo(() => { + const pArr: any[] = []; + const lArr: any[] = []; + + for (let i = 0; i < leafCount; i++) { + const rng = seededRandom(seed + i * 17); + const angle = (i / leafCount) * Math.PI * 2 + rng() * 0.3; + const petioleLength = 1.0 + rng() * 0.5; + const leafScale = 0.65 + rng() * 0.3; + const isLowerTier = rng() > 0.5; + const droopAngle = isLowerTier ? 0.7 + rng() * 0.5 : 0.3 + rng() * 0.3; - const petioleTransform = useMemo( - () => computeCylinderTransform(petioleStart, petioleTip), - [petioleStart, petioleTip], - ); + const cosD = Math.cos(droopAngle); + const sinD = Math.sin(droopAngle); + + const petioleTip: Vec3 = [ + Math.cos(angle) * sinD * petioleLength, + trunkHeight - 0.1 + cosD * petioleLength * 0.3, + Math.sin(angle) * sinD * petioleLength, + ]; - return ( - - {/* Petiole — properly connected cylinder from trunk to tip */} - - - - + const petioleStart: Vec3 = [ + Math.cos(angle) * trunkRadius * 0.8, + trunkHeight - 0.1, + Math.sin(angle) * trunkRadius * 0.8, + ]; - {/* Leaf blade lobes — attached at petiole tip */} - - {Array.from({ length: 7 }, (_, li) => { - const lobeRng = seededRandom(seed + index * 100 + li); - const lobeAngle = ((li - 3) / 3) * 0.7; - const lobeDir = angle + lobeAngle; - const lobeTilt = droopAngle * 0.3 + lobeRng() * 0.2; - return ( - - - - - ); - })} - - - ); -} + const petioleTransform = computeCylinderTransform(petioleStart, petioleTip); + pArr.push({ ...petioleTransform, index: i }); -/** Leaf crown */ -function PapayaLeafCrown({ trunkHeight, trunkRadius, seed }: { - trunkHeight: number; - trunkRadius: number; - seed: number; -}) { - const leafCount = useMemo(() => { - const rng = seededRandom(seed + 300); - return 10 + Math.floor(rng() * 5); - }, [seed]); + for (let li = 0; li < 7; li++) { + const lobeRng = seededRandom(seed + i * 100 + li); + const lobeAngle = ((li - 3) / 3) * 0.7; + const lobeDir = angle + lobeAngle; + const lobeTilt = droopAngle * 0.3 + lobeRng() * 0.2; + + lArr.push({ + pos: [ + petioleTip[0] + Math.cos(lobeDir) * 0.1, + petioleTip[1] - 0.02 * li * 0.3, + petioleTip[2] + Math.sin(lobeDir) * 0.1, + ], + rot: [lobeTilt, lobeDir + Math.PI / 2, lobeRng() * 0.15], + scale: leafScale * (0.75 + lobeRng() * 0.3), + }); + } + } + return { petioles: pArr, lobes: lArr }; + }, [leafCount, trunkHeight, trunkRadius, seed]); return ( - {Array.from({ length: leafCount }, (_, i) => ( - - ))} + {petioles.length > 0 && ( + + + + {petioles.map((p, i) => ( + + ))} + + )} + + {lobes.length > 0 && ( + + + + {lobes.map((lobe, i) => ( + + ))} + + )} ); } @@ -155,60 +127,59 @@ function PapayaFruits({ trunkHeight, trunkRadius, seed = 0 }: { } }); - const fruits = useMemo(() => { + const { fruits, stems } = useMemo(() => { const rng = seededRandom(seed + 500); - const arr: { - pos: Vec3; - rot: [number, number, number]; - scaleX: number; - scaleY: number; - ripeness: number; - }[] = []; + const fArr: any[] = []; + const stArr: any[] = []; const numFruits = 4 + Math.floor(rng() * 4); for (let i = 0; i < numFruits; i++) { const angle = (i / numFruits) * Math.PI * 2 + rng() * 0.5; const dist = trunkRadius * 1.1 + rng() * 0.05; - arr.push({ - pos: [ - Math.cos(angle) * dist, - trunkHeight * 0.82 - rng() * 0.35, - Math.sin(angle) * dist, - ], - rot: [rng() * 0.15, rng() * Math.PI * 2, 0], - scaleX: 0.07 + rng() * 0.03, - scaleY: 0.11 + rng() * 0.05, - ripeness: rng(), - }); + const scaleX = 0.07 + rng() * 0.03; + const scaleY = 0.11 + rng() * 0.05; + const r = rng(); + + const color = new THREE.Color(); + if (r < 0.4) { + color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2.5); + } else { + color.lerpColors(new THREE.Color('#e8a020'), new THREE.Color('#f97316'), (r - 0.4) * 1.67); + } + + const p: Vec3 = [ + Math.cos(angle) * dist, + trunkHeight * 0.82 - rng() * 0.35, + Math.sin(angle) * dist, + ]; + const rot: [number, number, number] = [rng() * 0.15, rng() * Math.PI * 2, 0]; + + fArr.push({ pos: p, rot, scale: [scaleX, scaleY, scaleX], color }); + stArr.push({ pos: [p[0], p[1] + scaleY * 0.85, p[2]], rot }); } - return arr; + return { fruits: fArr, stems: stArr }; }, [trunkHeight, trunkRadius, seed]); return ( - {fruits.map((fruit, i) => { - const r = fruit.ripeness; - const color = new THREE.Color(); - if (r < 0.4) { - color.lerpColors(new THREE.Color('#3a8a2a'), new THREE.Color('#8ac44a'), r * 2.5); - } else { - color.lerpColors(new THREE.Color('#e8a020'), new THREE.Color('#f97316'), (r - 0.4) * 1.67); - } - - return ( - - - - - - {/* Short stem connecting fruit to trunk */} - - - - - - ); - })} + {fruits.length > 0 && ( + + + + {fruits.map((f, i) => ( + + ))} + + )} + {stems.length > 0 && ( + + + + {stems.map((st, i) => ( + + ))} + + )} ); } @@ -221,40 +192,33 @@ function TrunkScars({ trunkHeight, trunkRadius, seed }: { }) { const scars = useMemo(() => { const rng = seededRandom(seed + 800); - const arr: { y: number; angle: number }[] = []; + const arr: { pos: Vec3; rot: [number, number, number] }[] = []; const count = 12 + Math.floor(rng() * 8); for (let i = 0; i < count; i++) { + const y = 0.3 + rng() * (trunkHeight * 0.85); + const angle = rng() * Math.PI * 2; arr.push({ - y: 0.3 + rng() * (trunkHeight * 0.85), - angle: rng() * Math.PI * 2, + pos: [ + Math.cos(angle) * trunkRadius * 1.01, + y, + Math.sin(angle) * trunkRadius * 1.01, + ], + rot: [0, -angle + Math.PI / 2, 0], }); } return arr; - }, [trunkHeight, seed]); + }, [trunkHeight, trunkRadius, seed]); + + if (scars.length === 0) return null; return ( - + + + {scars.map((scar, i) => ( - - - - + ))} - + ); } diff --git a/src/components/scene/Terrain.tsx b/src/components/scene/Terrain.tsx index 5e56e84..520d3c4 100644 --- a/src/components/scene/Terrain.tsx +++ b/src/components/scene/Terrain.tsx @@ -7,7 +7,7 @@ export default function Terrain() { const S = TERRAIN_SIZE; // Generate the rolling hills geometry dynamically using our noise function - const hillsGeometry = useMemo(() => { + const visualGeometry = useMemo(() => { const geo = new THREE.PlaneGeometry(S, S, TERRAIN_SEGMENTS, TERRAIN_SEGMENTS); geo.rotateX(-Math.PI / 2); // align plane with the ground plane (XZ) @@ -23,18 +23,36 @@ export default function Terrain() { return geo; }, [S]); + // Generate a much lower resolution geometry for physics to prevent sluggishness + const physicsGeometry = useMemo(() => { + const geo = new THREE.PlaneGeometry(S, S, 16, 16); + geo.rotateX(-Math.PI / 2); + const posAttr = geo.attributes.position; + for (let i = 0; i < posAttr.count; i++) { + const vx = posAttr.getX(i); + const vz = posAttr.getZ(i); + const vy = getTerrainHeight(vx, vz); + posAttr.setY(i, vy); + } + return geo; + }, [S]); + return ( - {/* ── Main rolling hills (physics trimesh + visual mesh) ── */} + {/* ── Main rolling hills ── */} + {/* Visual mesh (high poly) */} + + + + + {/* Physics mesh (low poly) */} - - - + {/* ── Boundary wall slabs (solid, invisible physics boundaries) ── */} diff --git a/src/components/scene/Vehicle.tsx b/src/components/scene/Vehicle.tsx index 2b2686a..861c9fc 100644 --- a/src/components/scene/Vehicle.tsx +++ b/src/components/scene/Vehicle.tsx @@ -9,14 +9,14 @@ import { TREE_PLACEMENTS } from '../../lib/products'; import { getTerrainHeight } from '../../lib/terrain'; import { useOrchardStore } from '../../store/useOrchardStore'; -const ACCEL = 38; // speed units per second forward +const ACCEL = 42; // speed units per second forward const BRAKE = 42; // braking deceleration per second -const REVERSE_ACCEL = 14; // reverse acceleration speed per second +const REVERSE_ACCEL = 18; // reverse acceleration speed per second const DECEL = 15; // passive coasting decay per second -const MAX_SPEED = 15; // max forward m/s +const MAX_SPEED = 20 ; // max forward m/s const MAX_REV_SPEED = 5.5; // max reverse m/s const STEER_VEL = 2.1; // steer speed rad/s -const HARVEST_RADIUS = 14; +const HARVEST_RADIUS = 12; // Pre-allocated vectors — never create new ones in useFrame const _forward = new THREE.Vector3(); @@ -164,8 +164,8 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve // ── 1. ENGINE ACCELERATION AND BRAKING (WITH HILL RESISTANCE) ─────────── if (keys.forward) { // Accelerate forward, fought slightly by gravity but with a high guaranteed minimum engine power - const activeAccel = Math.max(14.0, ACCEL - hillGravityAccel * 0.8); - const uphillSpeedCap = Math.max(10.5, MAX_SPEED - hillGravityAccel * 0.4); + const activeAccel = Math.max(16.0, ACCEL - hillGravityAccel * 0.8); + const uphillSpeedCap = Math.max(12.5, MAX_SPEED - hillGravityAccel * 0.4); currentFwdSpeed = Math.min( currentFwdSpeed + activeAccel * delta, uphillSpeedCap @@ -206,15 +206,18 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve // Apply direct linear velocity (retaining gravity Y) body.setLinvel({ x: targetVelX, y: vel.y, z: targetVelZ }, true); - // ── 2. STEERING — set angular velocity DIRECTLY (bypasses damping fight) + // ── 2. STEERING — Smooth angular velocity { - let yAngVel = 0; + let targetYAngVel = 0; if (Math.abs(currentFwdSpeed) > 0.1) { const steerSign = currentFwdSpeed >= 0 ? 1 : -1; // flip steer direction when reversing - if (keys.left) yAngVel = STEER_VEL * steerSign; - if (keys.right) yAngVel = -STEER_VEL * steerSign; + if (keys.left) targetYAngVel = STEER_VEL * steerSign; + if (keys.right) targetYAngVel = -STEER_VEL * steerSign; } - body.setAngvel({ x: 0, y: yAngVel, z: 0 }, true); + // Lerp angular velocity to remove the instant "sloppy/snappy" feel + const currentAngVel = body.angvel(); + const newYAngVel = currentAngVel.y + (targetYAngVel - currentAngVel.y) * Math.min(delta * 12, 1); + body.setAngvel({ x: 0, y: newYAngVel, z: 0 }, true); } // Snap visual chassis to road/ground height under the car so it follows terrain and road surface @@ -302,10 +305,9 @@ export default function Vehicle({ headlightsIntensity = 8, isNight = false }: Ve ref={chassisRef} position={[0, 3, 0]} // spawn well above ground, settles naturally linearDamping={0.5} - angularDamping={0} + angularDamping={0.5} mass={80} colliders={false} - ccd // continuous collision detection — prevents tunneling >