diff --git a/src/App.tsx b/src/App.tsx index bb53dc7..2507f8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { CameraController, useCameraStore } from './components/CameraController' import { FirstPersonController } from './components/FirstPersonController'; import ErrorBoundary from './components/ErrorBoundary'; import { LoadingScreen } from './components/LoadingScreen'; -import { MachineData, MachineType } from './types'; +import { MachineData } from './types'; import type { ForkliftData } from './components/ForkliftSystem'; import { audioManager } from './utils/audioManager'; import { gpuResourceManager } from './utils/GPUResourceManager'; @@ -21,8 +21,6 @@ import { RENDERER_TONE_MAPPING, TONE_EXPOSURE } from './constants/colorGrade'; import { useUIStore } from './stores/uiStore'; import { useGameSimulationStore } from './stores/gameSimulationStore'; import { useProductionStore } from './stores/productionStore'; -import { useMaterialFlowStore } from './stores/materialFlowStore'; -import { safeDivide } from './utils/typeGuards'; import { initializeSCADASync } from './store'; import { useShallow } from 'zustand/react/shallow'; @@ -499,84 +497,6 @@ const App: React.FC = () => { }; }, [runtimeMode.benchmark]); - // Headless production simulation - runs regardless of camera position - // This ensures bags are counted even when ConveyorSystem isn't rendering - // PERF: Reduced from 1s to 5s interval to minimize store update cascades - // - // GAME TIME SCALING: Production now scales with gameSpeed so that - // the daily target (15,000 bags) is achievable within a game day. - // At gameSpeed=180 (default), 1 game day = 8 real minutes. - useEffect(() => { - // Base production: 12 bags/sec at productionSpeed=1.0, gameSpeed=60 - // This yields ~15,000 bags/game-day at default settings (gameSpeed=180, productionSpeed~0.9) - const BAGS_PER_SECOND_BASE = 12; - const INTERVAL_SECONDS = 5; - const BAGS_PER_TICK = BAGS_PER_SECOND_BASE * INTERVAL_SECONDS; - - const interval = setInterval(() => { - const store = useProductionStore.getState(); - const gameStore = useGameSimulationStore.getState(); - - // Skip if tab is hidden or game is paused - if (!gameStore.isTabVisible) return; - if (gameStore.gameSpeed === 0) return; - - // Scale by game speed: at 180x, production is 3x faster than at 60x - // This makes production happen in "game time" not "real time" - const gameSpeedFactor = gameStore.gameSpeed / 60; - - // Calculate bags based on production speed and game speed - // productionSpeed is typically 0.8-1.2 - const bagsThisTick = BAGS_PER_TICK * productionSpeed * gameSpeedFactor; - - // Only produce if we have running machines (packers) - const runningPackerMachines = store.machines.filter( - (m) => m.type === MachineType.PACKER && (m.status === 'running' || m.status === 'warning') - ); - const runningPackers = runningPackerMachines.length; - - if (runningPackers > 0) { - // Scale by number of running packers (3 packers at full = 100%) - const packerScale = runningPackers / 3; - - // Couple production to the material-flow simulation so silo starvation, - // jams and breakdowns visibly dent throughput. currentPackerFlowRate is - // kg/sec at the final packing stage; nominal max is the packers' - // 25 kg/sec processingRate (materialFlowStore) per running packer. - const NOMINAL_PACKER_KG_PER_SEC = 25; - const flowStore = useMaterialFlowStore.getState(); - const flowRate = flowStore.currentPackerFlowRate; - const flowSimLive = - Number.isFinite(flowRate) && (flowRate > 0 || flowStore.totalMaterialProcessed > 0); - - let healthFactor: number; - if (flowSimLive) { - healthFactor = Math.max( - 0, - Math.min(1, safeDivide(flowRate, NOMINAL_PACKER_KG_PER_SEC * runningPackers, 1)) - ); - } else { - // Flow network not initialized yet: fall back to average packer - // efficiency so degraded machines still produce less than pristine ones. - const avgEfficiency = safeDivide( - runningPackerMachines.reduce((sum, m) => sum + (m.metrics.efficiency ?? 100), 0), - runningPackers * 100, - 1 - ); - healthFactor = Math.max(0, Math.min(1, avgEfficiency)); - } - - const finalBags = Math.round(bagsThisTick * packerScale * healthFactor * 10) / 10; - - if (finalBags > 0) { - store.incrementBagsProduced(finalBags); - } - } - }, INTERVAL_SECONDS * 1000); // Run every 5 seconds - - return () => clearInterval(interval); - }, [productionSpeed]); - // Initialize SCADA system - uses same consolidated subscription const enableSCADA = useGraphicsStore((state) => state.graphics.enableSCADA); useEffect(() => { @@ -900,7 +820,10 @@ const App: React.FC = () => { {/* Mobile touch-to-look handler (inside Canvas for R3F access) */} {isMobile && !fpsMode && } - + diff --git a/src/components/FactoryExterior.tsx b/src/components/FactoryExterior.tsx index 5ba1af6..363abbc 100644 --- a/src/components/FactoryExterior.tsx +++ b/src/components/FactoryExterior.tsx @@ -7,7 +7,6 @@ import { useGameSimulationStore } from '../stores/gameSimulationStore'; import { playCritterSound } from '../utils/critterAudio'; import { HeartParticle } from './effects/HeartParticle'; import { useModelTextures } from '../utils/machineTextures'; -import { useProductionStore } from '../stores/productionStore'; import { EXTERIOR_LAYERS, FLOOR_LAYERS, @@ -19,12 +18,15 @@ import { import { SITE_LAYOUT } from '../constants/siteLayout'; import { UTILITY_ASSET_DEFINITIONS } from '../constants/utilityAssets'; import { createCelestialState, sampleAtmosphere, sampleCelestial } from '../simulation/atmosphere'; -import { - calculateShippingTruckState, - calculateReceivingTruckState, -} from './truckbay/useTruckPhysics'; +import { positionRegistry } from '../utils/positionRegistry'; import { PROCEDURAL_TEXTURES, TREE_MATERIALS } from '../utils/sharedMaterials'; import { generateMachineORM } from '../textures'; +import { shouldCheckpointOpen } from './exterior/checkpointLogic'; +import { + EXTERIOR_LAMP_LENS_MATERIAL, + ExteriorLampDriver, + ExteriorLampPool, +} from './exterior/ExteriorLighting'; // OUTDOOR_MATERIALS removed - grass plane now handled by TerrainGround import { GasStation } from './GasStationInstanced'; import { @@ -3117,6 +3119,7 @@ const PathLamp: React.FC<{ style?: 'modern' | 'victorian'; }> = React.memo(({ position, style = 'modern' }) => ( + {/* Pole */} @@ -3133,9 +3136,8 @@ const PathLamp: React.FC<{ - + - ) : ( @@ -3144,9 +3146,8 @@ const PathLamp: React.FC<{ - + - )} @@ -5590,106 +5591,50 @@ const CheckpointBarrier: React.FC<{ const barrierArm2Ref = useRef(null); const lightRef = useRef(null); const light2Ref = useRef(null); + const openRef = useRef(false); + const dock = checkpointType ?? (position[2] > 0 ? 'shipping' : 'receiving'); + const checkpointPosition = useMemo( + () => ({ x: position[0], z: position[2] }), + [position[0], position[2]] + ); - // Get production speed for synchronized truck timing - const productionSpeed = useProductionStore((s) => s.productionSpeed); - - // Animate the barrier arms - raise when trucks approach - useFrame((state) => { + // Follow the same live tractor and trailer poses that are rendered in the + // yard. This replaces the old duplicate clock animation, which could open a + // barrier for an imaginary truck while the visible one remained elsewhere. + useFrame((state, delta) => { const time = state.clock.elapsedTime; - const adjustedTime = time * (productionSpeed * 0.25 + 0.2); - const CYCLE_LENGTH = 60; - - // Calculate truck positions - const shippingCycle = adjustedTime % CYCLE_LENGTH; - const receivingCycle = (adjustedTime + CYCLE_LENGTH / 2) % CYCLE_LENGTH; - - const shippingState = calculateShippingTruckState(shippingCycle, time); - const receivingState = calculateReceivingTruckState(receivingCycle, time); - - // Checkpoint positions: shipping at z=110, receiving at z=-110 - // Detect when trucks are within range of this checkpoint - const DETECTION_RANGE = 40; // Units from checkpoint to start raising - const checkpointZ = position[2]; - - // Determine if this checkpoint should respond to shipping or receiving trucks - const isShippingCheckpoint = checkpointType === 'shipping' || checkpointZ > 0; - - let shouldRaiseInbound = false; // Barrier 1 (left side, z=+3 relative) - let shouldRaiseOutbound = false; // Barrier 2 (right side, z=-3 relative) - - if (isShippingCheckpoint) { - // Shipping checkpoint at z=110 - // Truck enters from z=200 (coming from positive z towards dock at z=53) - // Truck exits towards z=200 (going from dock back to road) - const truckZ = shippingState.z; - - // Entering phases: truck coming from road towards dock - // 'entering' is when truck is on straight approach, 'slowing' would be deceleration - const isEntering = shippingState.phase === 'entering' || shippingState.phase === 'slowing'; - // Leaving phases: truck going from dock back to road - // 'accelerating' is when truck actually passes checkpoint on the way out - const isLeaving = - shippingState.phase === 'turning_out' || - shippingState.phase === 'accelerating' || - shippingState.phase === 'leaving'; - - if (isEntering && truckZ > checkpointZ - 20 && truckZ < checkpointZ + DETECTION_RANGE) { - shouldRaiseInbound = true; - } - if (isLeaving && truckZ > checkpointZ - 20 && truckZ < checkpointZ + DETECTION_RANGE) { - shouldRaiseOutbound = true; - } - } else { - // Receiving checkpoint at z=-110 - const truckZ = receivingState.z; - - // Entering phases: truck coming from road (z=-200) towards dock (z=-53) - const isEntering = receivingState.phase === 'entering' || receivingState.phase === 'slowing'; - // Leaving phases: truck going from dock back to road - // 'accelerating' is when truck actually passes checkpoint on the way out - const isLeaving = - receivingState.phase === 'turning_out' || - receivingState.phase === 'accelerating' || - receivingState.phase === 'leaving'; - - if (isEntering && truckZ < checkpointZ + 20 && truckZ > checkpointZ - DETECTION_RANGE) { - shouldRaiseInbound = true; - } - if (isLeaving && truckZ < checkpointZ + 20 && truckZ > checkpointZ - DETECTION_RANGE) { - shouldRaiseOutbound = true; - } - } - - // Target angles: 0 = down, PI/2 = up - // Both booms raise together when truck approaches from either direction - const shouldRaiseBoth = shouldRaiseInbound || shouldRaiseOutbound; - const targetAngle1 = shouldRaiseBoth ? Math.PI / 2 : 0; - const targetAngle2 = shouldRaiseBoth ? Math.PI / 2 : 0; + openRef.current = shouldCheckpointOpen( + openRef.current, + checkpointPosition, + positionRegistry.get(`${dock}-truck-cab`), + positionRegistry.get(`${dock}-truck-trailer`) + ); + const targetAngle = openRef.current ? Math.PI / 2 : 0; + const safeDelta = Math.min(Math.max(delta, 0), 0.1); - // Smooth animation for barrier 1 (faster response) if (barrierArmRef.current) { - const currentAngle1 = barrierArmRef.current.rotation.z; - const diff1 = targetAngle1 - currentAngle1; - barrierArmRef.current.rotation.z += diff1 * 0.08; + barrierArmRef.current.rotation.z = THREE.MathUtils.damp( + barrierArmRef.current.rotation.z, + targetAngle, + 5.2, + safeDelta + ); } - - // Smooth animation for barrier 2 if (barrierArm2Ref.current) { - const currentAngle2 = barrierArm2Ref.current.rotation.z; - const diff2 = targetAngle2 - currentAngle2; - barrierArm2Ref.current.rotation.z += diff2 * 0.08; + barrierArm2Ref.current.rotation.z = THREE.MathUtils.damp( + barrierArm2Ref.current.rotation.z, + targetAngle, + 5.2, + safeDelta + ); } - // Flashing warning lights when barrier is down (truck approaching) const flash = Math.sin(time * 4) > 0; - const isUp1 = barrierArmRef.current && barrierArmRef.current.rotation.z > Math.PI / 4; - const isUp2 = barrierArm2Ref.current && barrierArm2Ref.current.rotation.z > Math.PI / 4; if (lightRef.current) { - lightRef.current.color.setHex(flash && !isUp1 ? 0xff0000 : 0x440000); + lightRef.current.color.setHex(flash && openRef.current ? 0xff2b1f : 0x440500); } if (light2Ref.current) { - light2Ref.current.color.setHex(flash && !isUp2 ? 0xff0000 : 0x440000); + light2Ref.current.color.setHex(flash && openRef.current ? 0xff2b1f : 0x440500); } }); @@ -5801,7 +5746,12 @@ const CheckpointBarrier: React.FC<{ {/* Barrier arm pivot - swings inward across road */} - + @@ -5844,7 +5794,13 @@ const CheckpointBarrier: React.FC<{ {/* Barrier arm pivot - swings inward across road (rotated 180°) */} - + @@ -5967,6 +5923,7 @@ export const FactoryExterior: React.FC = ({ showFactoryShe return ( + {/* ========== EXTERIOR GRASS GROUND ========== */} {/* DISABLED: Replaced by TerrainGround unified terrain system */} @@ -7272,6 +7229,7 @@ export const FactoryExterior: React.FC = ({ showFactoryShe ].map(([x, z], i) => ( + {/* Pole */} @@ -7283,9 +7241,8 @@ export const FactoryExterior: React.FC = ({ showFactoryShe {/* Light bulb area */} - + - ))} diff --git a/src/components/RuntimeController.tsx b/src/components/RuntimeController.tsx index 7248850..cf2eb5b 100644 --- a/src/components/RuntimeController.tsx +++ b/src/components/RuntimeController.tsx @@ -103,6 +103,13 @@ export interface RuntimeMotionTelemetry { stopped?: boolean; } +export interface RuntimeNamedObjectPose { + name: string; + position: [number, number, number]; + rotation: [number, number, number]; + visible: boolean; +} + interface RuntimeMotionEntity extends RuntimeMotionTelemetry { id: string; type: 'forklift' | 'truck'; @@ -170,6 +177,8 @@ export interface MillOSRuntimeTelemetry { reset: () => void; snapshot: () => RuntimeTelemetrySnapshot; motionSnapshot: () => RuntimeMotionState; + namedObjectsSnapshot: (names: string[]) => RuntimeNamedObjectPose[]; + setCameraPose: (position: [number, number, number], target: [number, number, number]) => void; setPerfDebug: (patch: Partial) => void; } @@ -181,6 +190,7 @@ declare global { interface RuntimeControllerProps { adaptiveEnabled: boolean; + orbitControlsRef?: React.RefObject; } interface OrbitLikeControls { @@ -314,7 +324,10 @@ export function rendererCounterPerFrame( return Math.round(total / divisor); } -export const RuntimeController: React.FC = ({ adaptiveEnabled }) => { +export const RuntimeController: React.FC = ({ + adaptiveEnabled, + orbitControlsRef, +}) => { const mode = getRuntimeMode(); const { camera, gl, scene, controls } = useThree(); const firstFrameAtRef = useRef(null); @@ -580,6 +593,32 @@ export const RuntimeController: React.FC = ({ adaptiveEn }; }; + const namedObjectPosition = new THREE.Vector3(); + const namedObjectsSnapshot = (names: string[]): RuntimeNamedObjectPose[] => { + scene.updateMatrixWorld(true); + return names.flatMap((name) => { + const object = scene.getObjectByName(name); + if (!object) return []; + object.getWorldPosition(namedObjectPosition); + return [ + { + name, + position: [ + rounded(namedObjectPosition.x), + rounded(namedObjectPosition.y), + rounded(namedObjectPosition.z), + ], + rotation: [ + rounded(object.rotation.x, 4), + rounded(object.rotation.y, 4), + rounded(object.rotation.z, 4), + ], + visible: object.visible, + }, + ]; + }); + }; + const snapshot = (): RuntimeTelemetrySnapshot => { const values = frameTimesRef.current; const sorted = [...values].sort((a, b) => a - b); @@ -835,6 +874,14 @@ export const RuntimeController: React.FC = ({ adaptiveEn reset, snapshot, motionSnapshot, + namedObjectsSnapshot, + setCameraPose: (position, target) => { + camera.position.set(...position); + camera.lookAt(...target); + const orbitControls = orbitControlsRef?.current ?? (controls as OrbitLikeControls | null); + orbitControls?.target?.set(...target); + orbitControls?.update?.(); + }, setPerfDebug: (patch) => { useGraphicsStore.setState((state) => ({ graphics: { @@ -852,7 +899,7 @@ export const RuntimeController: React.FC = ({ adaptiveEn observer?.disconnect(); delete window.__MILLOS_RUNTIME__; }; - }, [camera, gl, mode, scene]); + }, [camera, controls, gl, mode, orbitControlsRef, scene]); useFrame((_state, delta) => { const frameMs = delta * 1000; diff --git a/src/components/TruckBay.tsx b/src/components/TruckBay.tsx index e3520d8..f7a42c2 100644 --- a/src/components/TruckBay.tsx +++ b/src/components/TruckBay.tsx @@ -8,7 +8,6 @@ import { useAudioInitialized } from '../hooks/useAudioState'; import { useProductionStore } from '../stores/productionStore'; import { selectSafetyHoldActive, useGameSimulationStore } from '../stores/gameSimulationStore'; import { useGraphicsStore } from '../stores/graphicsStore'; -import { useMaterialFlowStore } from '../stores/materialFlowStore'; import { useOperationsCampaignStore } from '../stores/operationsCampaignStore'; import { useTruckScheduleStore, type TruckLifecyclePhase } from '../stores/truckScheduleStore'; import { FLOOR_LAYERS, POLYGON_OFFSET, RENDER_ORDER } from '../constants/renderLayers'; @@ -35,7 +34,14 @@ import { vehicleTelemetryRegistry } from '../simulation/vehicles/vehicleTelemetr import { positionRegistry } from '../utils/positionRegistry'; import { OptimizedTruckVisual, TRUCK_WHEEL_RADIUS } from './truckbay/OptimizedTruckBay'; import { getRuntimeMode } from '../runtime/runtimeMode'; +import { toSimulationMinutes } from '../simulation/simulationClock'; import { PROCEDURAL_TEXTURES } from '../utils/sharedMaterials'; +import { + EXTERIOR_LAMP_LENS_MATERIAL, + ExteriorLampPool, + ExteriorPointLight, +} from './exterior/ExteriorLighting'; +import { IndustrialRoadTunnel } from './scenery/Tunnel'; // Import animation system functions and TruckAnimationManager import { TruckAnimationManager, @@ -364,9 +370,9 @@ const LABEL_ANCHORS = [ * iteration on every terrain, wall, machine and vehicle fragment in frame. * * They are therefore mounted only on `high` and `ultra`. This is a stated - * medium-and-below fidelity trade: the poles and the status housings still - * render (the status lens keeps its emissive), but at night on medium the yard - * loses the lamp pools on the asphalt. + * medium-and-below fidelity trade: real point lights remain high/ultra only, + * while shared emissive lenses and additive asphalt pools preserve the night + * read without adding a scene-wide light loop. * * GATED ON QUALITY, NEVER ON TIME OF DAY. A light count that changed at dusk * would change the program cache key of every material in the scene and @@ -2244,95 +2250,6 @@ const NoIdlingSign: React.FC<{ position: [number, number, number]; rotation?: nu ); -// Road tunnel - clean mountain tunnel for trucks to disappear into -const RoadTunnel: React.FC<{ - position: [number, number, number]; - rotation?: number; - roadWidth?: number; -}> = ({ position, rotation = 0, roadWidth = 10 }) => { - const tunnelWidth = roadWidth + 2; - const tunnelHeight = 7; - const tunnelDepth = 90; - - return ( - - {/* ========== MOUNTAIN/HILLSIDE ========== */} - {/* Sloped hillside - left */} - - - - - {/* Sloped hillside - right */} - - - - - {/* Mountain top */} - - - - - - {/* ========== TUNNEL PORTAL ========== */} - {/* Concrete portal frame - left */} - - - - - {/* Concrete portal frame - right */} - - - - - {/* Concrete portal top */} - - - - - - {/* ========== TUNNEL INTERIOR ========== */} - {/* Ceiling */} - - - - - {/* Left wall */} - - - - - {/* Right wall */} - - - - - {/* Back wall - pure black void */} - - - - - - {/* ========== ROAD SURFACE ========== */} - {/* Road into tunnel */} - - - - - {/* Road approach */} - - - - - - {/* Center line marking */} - - - - - - ); -}; - // Pallet staging area with stacked pallets export const PalletStaging: React.FC<{ position: [number, number, number] }> = ({ position }) => ( @@ -2691,7 +2608,7 @@ const DockLeveler: React.FC<{ }; const TRUCK_CONTROLLER_STEP_SECONDS = 1 / 60; -const MAXIMUM_TRUCK_CONTROLLER_DELTA_SECONDS = 0.5; +const MAXIMUM_TRUCK_CONTROLLER_DELTA_SECONDS = 0.1; interface DockVisualState { readonly docked: boolean; @@ -2818,8 +2735,6 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { const receivingControllerRef = useRef(initialReceivingController); const shippingAccumulatorRef = useRef(0); const receivingAccumulatorRef = useRef(0); - const priorSimulationTimeRef = useRef(0); - const simulationTimeInitializedRef = useRef(false); // PERFORMANCE: Consolidate store subscriptions with useShallow const isTabVisible = useGameSimulationStore((state) => state.isTabVisible); @@ -2873,7 +2788,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { } }, [audioReady, productionSpeed, safetyHoldActive]); - useFrame(({ camera }) => { + useFrame(({ camera }, delta) => { // Signage gate. Runs before the tab-visibility guard so a tab that comes // back never spends a frame with 33 labels drawn from 180 m away. labelFrameRef.current += 1; @@ -2890,13 +2805,26 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { } if (!isTabVisible) return; - const simulationTime = useMaterialFlowStore.getState().simulationTime; - const simulationDelta = simulationTimeInitializedRef.current - ? Math.max(0, simulationTime - priorSimulationTimeRef.current) - : 0; - simulationTimeInitializedRef.current = true; - priorSimulationTimeRef.current = simulationTime; - const controllerDelta = Math.min(MAXIMUM_TRUCK_CONTROLLER_DELTA_SECONDS, simulationDelta); + const gameSimulation = useGameSimulationStore.getState(); + const gameSpeed = gameSimulation.gameSpeed; + const campaignProductionMultiplier = useOperationsCampaignStore + .getState() + .getProductionMultiplier(); + // The simulation store publishes at 2 Hz. Reading its accumulated time here + // made each truck execute roughly 30 fixed steps in one render and then + // remain still for the next half second. Feed the deterministic controller + // from render delta instead, while retaining the same production scaling. + const controllerDelta = + gameSpeed > 0 + ? Math.min( + MAXIMUM_TRUCK_CONTROLLER_DELTA_SECONDS, + Math.max(0, delta) * productionSpeed * campaignProductionMultiplier + ) + : 0; + const simulationMinutes = toSimulationMinutes({ + day: gameSimulation.gameDay, + hour: gameSimulation.gameTime, + }); const shippingServiceComplete = useOperationsCampaignStore.getState().execution.dispatchLoad.status === 'ready'; @@ -2936,7 +2864,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { useTruckScheduleStore.getState().consumeTruckArrival(dock); } if (departedThisFrame) { - useTruckScheduleStore.getState().recordTruckDeparture(dock, simulationTime / 60); + useTruckScheduleStore.getState().recordTruckDeparture(dock, simulationMinutes); } const truckState = getTruckControllerPose(controller, safetyHoldActive); @@ -3373,6 +3301,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { [30, 55], ].map(([x, z], i) => ( + {/* Light pole - 14 units tall, centered at y=7, so top is at y=14 */} @@ -3383,8 +3312,11 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { + + + {yardLampsEnabled && ( - + )} ))} @@ -3402,7 +3334,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { {/* Road tunnel - trucks enter and disappear into mountains */} {/* Positioned so truck at z=250 is inside the 50-unit deep tunnel */} - + {/* Road extension connecting truck yard to tunnel */} @@ -3779,6 +3711,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { [30, -55], ].map(([x, z], i) => ( + {/* Light pole - 14 units tall, centered at y=7, so top is at y=14 */} @@ -3789,8 +3722,11 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { + + + {yardLampsEnabled && ( - + )} ))} @@ -3808,7 +3744,7 @@ export const TruckBay: React.FC = ({ productionSpeed }) => { {/* Road tunnel - trucks enter and disappear into mountains */} {/* Positioned so truck at z=-250 is inside the 50-unit deep tunnel */} - + {/* Road extension connecting truck yard to tunnel */} diff --git a/src/components/environment/NearHorizonCity.test.ts b/src/components/environment/NearHorizonCity.test.ts new file mode 100644 index 0000000..eb84028 --- /dev/null +++ b/src/components/environment/NearHorizonCity.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildNearCitySpecs } from './NearHorizonCity'; + +describe('near horizon city layout', () => { + it('is deterministic, finite, and outside the operational yard', () => { + const first = buildNearCitySpecs(); + expect(buildNearCitySpecs()).toEqual(first); + expect(first).toHaveLength(32); + first.forEach((building) => { + expect(Object.values(building).every(Number.isFinite)).toBe(true); + expect(Math.hypot(building.x, building.z)).toBeGreaterThanOrEqual(225); + expect(Math.hypot(building.x, building.z)).toBeLessThanOrEqual(240); + }); + }); + + it('stays clear of the authored castle footprint', () => { + buildNearCitySpecs().forEach((building) => { + expect(Math.hypot(building.x - 45, building.z + 200)).toBeGreaterThan(45); + }); + }); +}); diff --git a/src/components/environment/NearHorizonCity.tsx b/src/components/environment/NearHorizonCity.tsx new file mode 100644 index 0000000..60d172f --- /dev/null +++ b/src/components/environment/NearHorizonCity.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { RENDER_ORDER } from '../../constants/renderLayers'; +import { useGameSimulationStore } from '../../stores/gameSimulationStore'; +import { getExteriorLampLevel } from '../exterior/ExteriorLighting'; + +export interface NearCityBuildingSpec { + readonly x: number; + readonly z: number; + readonly width: number; + readonly depth: number; + readonly height: number; + readonly yaw: number; + readonly tone: number; +} + +const deterministicNoise = (index: number, channel: number): number => { + const value = Math.sin(index * 91.731 + channel * 37.117 + 8.913) * 43758.5453; + return value - Math.floor(value); +}; + +/** A compact, world-anchored skyline that restores nearby parallax. */ +export const buildNearCitySpecs = (count = 32): NearCityBuildingSpec[] => + Array.from({ length: count }, (_, index) => { + const progress = count <= 1 ? 0.5 : index / (count - 1); + const angle = THREE.MathUtils.lerp(-1.08, -0.25, progress); + const radius = 225 + deterministicNoise(index, 0) * 15; + const landmark = deterministicNoise(index, 1) > 0.9; + const height = 8 + deterministicNoise(index, 2) * 20 + (landmark ? 8 : 0); + const x = Math.cos(angle) * radius; + const z = Math.sin(angle) * radius; + return { + x, + z, + width: 4 + deterministicNoise(index, 3) * 3.2, + depth: 4 + deterministicNoise(index, 4) * 4.5, + height, + yaw: Math.atan2(-x, -z), + tone: deterministicNoise(index, 5), + }; + }); + +const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1); +const CITY_BODY_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#ffffff', + roughness: 0.88, + metalness: 0.04, + vertexColors: true, +}); +const CITY_ROOF_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#59636b', + roughness: 0.78, + metalness: 0.12, +}); +const CITY_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#4b7189', + emissive: '#ffca72', + emissiveIntensity: 0, + roughness: 0.28, + metalness: 0.12, +}); +const CITY_BODY_COLOURS = [ + new THREE.Color('#84949c'), + new THREE.Color('#958d82'), + new THREE.Color('#758c9a'), + new THREE.Color('#899693'), +] as const; + +interface WindowSpec { + readonly x: number; + readonly y: number; + readonly z: number; + readonly width: number; + readonly yaw: number; +} + +export const NearHorizonCity: React.FC = () => { + const buildings = useMemo(() => buildNearCitySpecs(), []); + const windows = useMemo(() => { + const result: WindowSpec[] = []; + buildings.forEach((building, buildingIndex) => { + const inwardX = -building.x / Math.hypot(building.x, building.z); + const inwardZ = -building.z / Math.hypot(building.x, building.z); + const columns = building.width > 7 ? 4 : 3; + const floors = Math.max(2, Math.floor((building.height - 3) / 3.5)); + for (let floor = 0; floor < floors; floor += 1) { + for (let column = 0; column < columns; column += 1) { + if (deterministicNoise(buildingIndex * 31 + floor * 5 + column, 8) < 0.18) continue; + const across = (column + 0.5) / columns - 0.5; + const tangentX = -inwardZ; + const tangentZ = inwardX; + result.push({ + x: + building.x + + inwardX * (building.depth / 2 + 0.05) + + tangentX * across * building.width * 0.76, + y: 2.3 + floor * 3.5, + z: + building.z + + inwardZ * (building.depth / 2 + 0.05) + + tangentZ * across * building.width * 0.76, + width: (building.width / columns) * 0.38, + yaw: building.yaw, + }); + } + } + }); + return result; + }, [buildings]); + const bodiesRef = useRef(null); + const roofsRef = useRef(null); + const windowsRef = useRef(null); + const initialLightLevel = getExteriorLampLevel( + useGameSimulationStore.getState().gameTime, + useGameSimulationStore.getState().weather + ); + const lightTargetRef = useRef(initialLightLevel); + const lightLevelRef = useRef(initialLightLevel); + + useLayoutEffect(() => { + const bodies = bodiesRef.current; + const roofs = roofsRef.current; + const windowMesh = windowsRef.current; + if (!bodies || !roofs || !windowMesh) return; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const rotation = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const euler = new THREE.Euler(); + + buildings.forEach((building, index) => { + position.set(building.x, building.height / 2 - 0.8, building.z); + rotation.setFromEuler(euler.set(0, building.yaw, 0)); + scale.set(building.width, building.height, building.depth); + bodies.setMatrixAt(index, matrix.compose(position, rotation, scale)); + bodies.setColorAt(index, CITY_BODY_COLOURS[Math.floor(building.tone * 4) % 4]); + + position.set(building.x, building.height - 0.35, building.z); + scale.set(building.width * 0.72, 0.7, building.depth * 0.72); + roofs.setMatrixAt(index, matrix.compose(position, rotation, scale)); + }); + windows.forEach((window, index) => { + position.set(window.x, window.y, window.z); + rotation.setFromEuler(euler.set(0, window.yaw, 0)); + scale.set(window.width, 0.54, 0.09); + windowMesh.setMatrixAt(index, matrix.compose(position, rotation, scale)); + }); + bodies.instanceMatrix.needsUpdate = true; + bodies.instanceColor!.needsUpdate = true; + roofs.instanceMatrix.needsUpdate = true; + windowMesh.instanceMatrix.needsUpdate = true; + bodies.computeBoundingSphere(); + roofs.computeBoundingSphere(); + windowMesh.computeBoundingSphere(); + }, [buildings, windows]); + + useEffect( + () => + useGameSimulationStore.subscribe((state) => { + lightTargetRef.current = getExteriorLampLevel(state.gameTime, state.weather); + }), + [] + ); + + useFrame((_, delta) => { + lightLevelRef.current = THREE.MathUtils.damp( + lightLevelRef.current, + lightTargetRef.current, + 3.2, + Math.min(Math.max(delta, 0), 0.1) + ); + CITY_WINDOW_MATERIAL.emissiveIntensity = lightLevelRef.current * 3.4; + }); + + return ( + + + + + + ); +}; diff --git a/src/components/environment/OptimizedFactoryEnvironment.tsx b/src/components/environment/OptimizedFactoryEnvironment.tsx index 8dcb2ae..97ede38 100644 --- a/src/components/environment/OptimizedFactoryEnvironment.tsx +++ b/src/components/environment/OptimizedFactoryEnvironment.tsx @@ -2,6 +2,7 @@ import { InteriorLightRig } from './InteriorLightRig'; import { OptimizedSkySystem } from './OptimizedSkySystem'; import { SceneEnvironmentIBL } from './SceneEnvironmentIBL'; import { SunShadowRig } from './SunShadowRig'; +import { NearHorizonCity } from './NearHorizonCity'; /** * Default-quality environment. The shell belongs to @@ -31,6 +32,7 @@ export function OptimizedFactoryEnvironment() { return ( + {/* Owns the hemisphere fill as well as `scene.environment`, so the two cannot drift: both are driven from the sky's live band colours. */} diff --git a/src/components/environment/OptimizedSkySystem.tsx b/src/components/environment/OptimizedSkySystem.tsx index 4ff8eaf..e2992b5 100644 --- a/src/components/environment/OptimizedSkySystem.tsx +++ b/src/components/environment/OptimizedSkySystem.tsx @@ -367,7 +367,7 @@ interface MountainRidgeSpec { /** * Angular resolution of one ridge ring. * - * Raised from 192. At a ring radius of 280 to 325 world units, 192 segments put + * Raised from 192. At a ring radius of 248 to 318 world units, 192 segments put * a facet edge every 1.875 degrees, which is plainly visible as straight * chords along a summit. 384 halves that, and the whole backdrop is still only * 5,775 vertices across all three rings - nothing at a 60k-245k triangle @@ -477,10 +477,10 @@ export function createMountainRidgeGeometry({ const horizonRadius = SITE_LAYOUT.world.horizonRadius; const farMountainGeometry = createMountainRidgeGeometry({ - radius: horizonRadius + 65, + radius: horizonRadius + 58, baseY: -17, // THE FAR RING IS ALSO AN OCCLUDER, NOT ONLY A SILHOUETTE. The authored - // ground disc stops at radius 255 while this ring is camera-locked at 325, so + // ground disc stops at radius 255 while this ring is camera-locked at 318, so // wherever the ring's profile dips below the ground's apparent horizon the // terrain BEYOND it shows through the gap - a strip of ground hanging in the // sky above the mountains. The old linear fog hid that strip by saturating; @@ -500,7 +500,7 @@ const farMountainGeometry = createMountainRidgeGeometry({ colors: ['#42574c', '#717a76', '#e9eff2'], }); const midMountainGeometry = createMountainRidgeGeometry({ - radius: horizonRadius + 40, + radius: horizonRadius + 20, baseY: -20, minHeight: 3, maxHeight: 58, @@ -511,7 +511,10 @@ const midMountainGeometry = createMountainRidgeGeometry({ colors: ['#3f5548', '#6e7873', '#eaf0f2'], }); const nearHillGeometry = createMountainRidgeGeometry({ - radius: horizonRadius + 18, + // Pull the green foothill inside the nominal horizon so it once again frames + // the playable zone. Its slope grows outward to 276, behind the city and + // authored landmarks, while the sky remains a genuine analytic skybox. + radius: horizonRadius - 12, baseY: -16, minHeight: 2, maxHeight: 42, @@ -580,7 +583,7 @@ const cameraForwardScratch = new THREE.Vector3(); * One ridge material per ring, differing only in how much air is in front of it. * * `uAerial` is the whole depth cue. The three rings sit at camera-relative - * radii 278 / 300 / 325 - close enough together that geometry alone cannot + * radii 248 / 280 / 318 - close enough together that geometry alone cannot * separate them - so the separation has to come from extinction. */ function createRidgeMaterial(aerial: number, name: string): THREE.ShaderMaterial { @@ -591,7 +594,7 @@ function createRidgeMaterial(aerial: number, name: string): THREE.ShaderMaterial vertexColors: true, side: THREE.FrontSide, // DEPTH TEST AND WRITE STAY ON. The rings are camera-locked in X and Z, so - // they sit at a fixed 278-325 from the viewer, while site geometry on the + // they sit at a fixed 248-318 from the viewer, while site geometry on the // far side of the world reaches roughly 413 away from the `overview` // camera. The ring is supposed to occlude that - it is what hides the far // rim of the ground disc where the frustum clips it. Turning depth off @@ -1058,7 +1061,7 @@ export function OptimizedSkySystem() { {/* fog stays OFF on all three rings even after the switch to - exponential fog: at a camera-locked 278-325 they would all sit on + exponential fog: at a camera-locked 248-318 they would all sit on roughly the same fog factor, which would flatten exactly the per-ring separation `uAerial` exists to create. */} { + it('is fully lit at night and off at clear noon', () => { + expect(getExteriorLampLevel(23, 'clear')).toBe(1); + expect(getExteriorLampLevel(12, 'clear')).toBe(0); + }); + + it('fades smoothly at dawn and dusk', () => { + expect(getExteriorLampLevel(6, 'clear')).toBeCloseTo(0.5); + expect(getExteriorLampLevel(18, 'clear')).toBeCloseTo(0.5); + }); + + it('raises a daytime minimum in severe weather', () => { + expect(getExteriorLampLevel(12, 'storm')).toBe(0.7); + }); +}); diff --git a/src/components/exterior/ExteriorLighting.tsx b/src/components/exterior/ExteriorLighting.tsx new file mode 100644 index 0000000..04643a1 --- /dev/null +++ b/src/components/exterior/ExteriorLighting.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { POLYGON_OFFSET, RENDER_ORDER } from '../../constants/renderLayers'; +import { useGameSimulationStore } from '../../stores/gameSimulationStore'; + +type ExteriorWeather = ReturnType['weather']; + +export const getExteriorLampLevel = (gameTime: number, weather: ExteriorWeather): number => { + const hour = (((Number.isFinite(gameTime) ? gameTime : 12) % 24) + 24) % 24; + let darkness = 0; + if (hour >= 19 || hour < 5) darkness = 1; + else if (hour < 7) darkness = (7 - hour) / 2; + else if (hour >= 17) darkness = (hour - 17) / 2; + + const weatherFloor = + weather === 'storm' ? 0.7 : weather === 'rain' ? 0.42 : weather === 'cloudy' ? 0.14 : 0; + return Math.max(darkness, weatherFloor); +}; + +const createLampPoolTexture = (): THREE.DataTexture => { + const size = 64; + const data = new Uint8Array(size * size * 4); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const dx = (x + 0.5) / size - 0.5; + const dy = (y + 0.5) / size - 0.5; + const distance = Math.sqrt(dx * dx + dy * dy) * 2; + const alpha = Math.pow(Math.max(0, 1 - distance), 2.2); + const offset = (y * size + x) * 4; + data[offset] = 255; + data[offset + 1] = 235; + data[offset + 2] = 176; + data[offset + 3] = Math.round(alpha * 255); + } + } + const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat); + texture.colorSpace = THREE.SRGBColorSpace; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +}; + +export const EXTERIOR_LAMP_LENS_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#fff2bd', + emissive: '#ffd37a', + emissiveIntensity: 0.06, + roughness: 0.28, + metalness: 0, + transparent: true, + opacity: 0.88, +}); + +const LAMP_POOL_MATERIAL = new THREE.MeshBasicMaterial({ + color: '#ffd98a', + map: createLampPoolTexture(), + transparent: true, + opacity: 0, + blending: THREE.AdditiveBlending, + depthWrite: false, + toneMapped: false, + polygonOffset: true, + polygonOffsetFactor: POLYGON_OFFSET.exteriorOverlay.factor, + polygonOffsetUnits: POLYGON_OFFSET.exteriorOverlay.units, +}); +const LAMP_POOL_GEOMETRY = new THREE.CircleGeometry(1, 28); + +interface RegisteredPointLight { + readonly light: THREE.PointLight; + readonly baseIntensity: number; +} + +const pointLights = new Set(); + +/** One scalar driver for every exterior lens, pool, and real high-quality light. */ +export const ExteriorLampDriver: React.FC = () => { + const targetRef = useRef( + getExteriorLampLevel( + useGameSimulationStore.getState().gameTime, + useGameSimulationStore.getState().weather + ) + ); + const levelRef = useRef(targetRef.current); + + useEffect( + () => + useGameSimulationStore.subscribe((state) => { + targetRef.current = getExteriorLampLevel(state.gameTime, state.weather); + }), + [] + ); + + useFrame((_, delta) => { + levelRef.current = THREE.MathUtils.damp( + levelRef.current, + targetRef.current, + 3.8, + Math.min(Math.max(delta, 0), 0.1) + ); + const level = levelRef.current; + EXTERIOR_LAMP_LENS_MATERIAL.emissiveIntensity = 0.06 + level * 3.4; + // Additive pools are deliberately restrained. At full night they should + // reveal the road surface and fixture spacing without merging into a flat + // amber carpet when several yard poles overlap. + LAMP_POOL_MATERIAL.opacity = level * 0.36; + pointLights.forEach(({ light, baseIntensity }) => { + light.intensity = baseIntensity * level; + }); + }); + + return null; +}; + +export const ExteriorLampPool: React.FC<{ radius?: number }> = ({ radius = 5 }) => ( + +); + +export const ExteriorPointLight: React.FC<{ + position: [number, number, number]; + intensity: number; + distance: number; + color?: THREE.ColorRepresentation; +}> = ({ position, intensity, distance, color = '#fef3c7' }) => { + const lightRef = useRef(null); + + useLayoutEffect(() => { + const light = lightRef.current; + if (!light) return undefined; + const registration = { light, baseIntensity: intensity }; + pointLights.add(registration); + return () => { + pointLights.delete(registration); + }; + }, [intensity]); + + return ( + + ); +}; diff --git a/src/components/exterior/checkpointLogic.test.ts b/src/components/exterior/checkpointLogic.test.ts new file mode 100644 index 0000000..0151fd7 --- /dev/null +++ b/src/components/exterior/checkpointLogic.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { CHECKPOINT_HOLD_DISTANCE, shouldCheckpointOpen } from './checkpointLogic'; + +const checkpoint = { x: 20, z: 110 }; + +describe('shouldCheckpointOpen', () => { + it('opens for the live cab before it reaches the barrier', () => { + expect(shouldCheckpointOpen(false, checkpoint, { id: 'cab', x: 20, z: 150 }, undefined)).toBe( + true + ); + }); + + it('holds for the articulated trailer after the cab clears', () => { + expect( + shouldCheckpointOpen( + true, + checkpoint, + { id: 'cab', x: 20, z: 110 + CHECKPOINT_HOLD_DISTANCE + 1 }, + { id: 'trailer', x: 20, z: 154 } + ) + ).toBe(true); + }); + + it('closes only after both tractor and trailer clear the hold radius', () => { + expect( + shouldCheckpointOpen( + true, + checkpoint, + { id: 'cab', x: 20, z: 180 }, + { id: 'trailer', x: 20, z: 170 } + ) + ).toBe(false); + }); + + it('does not react to a missing scheduled truck', () => { + expect(shouldCheckpointOpen(false, checkpoint, undefined, undefined)).toBe(false); + }); +}); diff --git a/src/components/exterior/checkpointLogic.ts b/src/components/exterior/checkpointLogic.ts new file mode 100644 index 0000000..bff4de4 --- /dev/null +++ b/src/components/exterior/checkpointLogic.ts @@ -0,0 +1,38 @@ +import type { EntityPosition } from '../../utils/positionRegistry'; + +export interface CheckpointPosition { + readonly x: number; + readonly z: number; +} + +const OPEN_DISTANCE = 48; +const HOLD_OPEN_DISTANCE = 56; + +const isWithin = ( + vehicle: EntityPosition | undefined, + checkpoint: CheckpointPosition, + distance: number +): boolean => { + if (!vehicle) return false; + const dx = vehicle.x - checkpoint.x; + const dz = vehicle.z - checkpoint.z; + return dx * dx + dz * dz <= distance * distance; +}; + +/** + * Checkpoint hysteresis based on the live tractor and trailer positions. + * The larger hold radius prevents an articulated vehicle near the threshold + * from making the boom chatter while its trailer follows through the turn. + */ +export const shouldCheckpointOpen = ( + wasOpen: boolean, + checkpoint: CheckpointPosition, + cab: EntityPosition | undefined, + trailer: EntityPosition | undefined +): boolean => { + const distance = wasOpen ? HOLD_OPEN_DISTANCE : OPEN_DISTANCE; + return isWithin(cab, checkpoint, distance) || isWithin(trailer, checkpoint, distance); +}; + +export const CHECKPOINT_OPEN_DISTANCE = OPEN_DISTANCE; +export const CHECKPOINT_HOLD_DISTANCE = HOLD_OPEN_DISTANCE; diff --git a/src/components/scenery/Tunnel.test.ts b/src/components/scenery/Tunnel.test.ts new file mode 100644 index 0000000..0982089 --- /dev/null +++ b/src/components/scenery/Tunnel.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { createRoadTunnelHillsideGeometry } from './Tunnel'; + +describe('industrial road tunnel hillside', () => { + it('builds a finite full-depth perforated embankment', () => { + const geometry = createRoadTunnelHillsideGeometry(90); + const positions = geometry.getAttribute('position'); + expect(Array.from(positions.array).every(Number.isFinite)).toBe(true); + expect(geometry.boundingBox?.min.x).toBeCloseTo(-22); + expect(geometry.boundingBox?.max.x).toBeCloseTo(22); + expect(geometry.boundingBox?.min.z).toBeCloseTo(-90); + expect(geometry.boundingBox?.max.z).toBeCloseTo(0); + geometry.dispose(); + }); +}); diff --git a/src/components/scenery/Tunnel.tsx b/src/components/scenery/Tunnel.tsx index a3849bc..871985b 100644 --- a/src/components/scenery/Tunnel.tsx +++ b/src/components/scenery/Tunnel.tsx @@ -15,13 +15,14 @@ * of them needs a picking proxy the way `raycastSiloShell` does. */ -import React, { useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { TUNNEL_MATERIALS, PROCEDURAL_TEXTURES } from '../../utils/sharedMaterials'; import { useGameSimulationStore } from '../../stores/gameSimulationStore'; import { createAtmosphereState, sampleAtmosphere } from '../../simulation/atmosphere'; import { getCulvertWaterHeight } from '../../simulation/ambientWorld'; +import { EXTERIOR_LAMP_LENS_MATERIAL, ExteriorLampPool } from '../exterior/ExteriorLighting'; interface TunnelProps { position: [number, number, number]; @@ -241,6 +242,95 @@ const CULVERT_END_RING = createCulvertEndRingGeometry(); const CORRUGATED_CULVERT = createCorrugatedCulvertGeometry(); const BRICK_ARCH = createBrickArchGeometry(); const _culvertAtmosphere = createAtmosphereState(); +const CULVERT_BORE = new THREE.CylinderGeometry(0.89, 0.89, 1, 32, 1, true); +const CULVERT_WATER = new THREE.PlaneGeometry(1, 1); +const CULVERT_RIPRAP = new THREE.IcosahedronGeometry(1, 1); +const CULVERT_BORE_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#222724', + roughness: 1, + metalness: 0, + side: THREE.BackSide, +}); +const CULVERT_RIPRAP_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#77766e', + roughness: 0.98, + metalness: 0, +}); + +const CULVERT_RIPRAP_OFFSETS = [ + [-0.56, -0.7, -1.28, 0.22], + [0.5, -0.68, -1.24, 0.18], + [-0.7, -0.74, 1.2, 0.2], + [0.62, -0.72, 1.26, 0.24], +] as const; + +export function createRoadTunnelHillsideGeometry(depth = 90): THREE.ExtrudeGeometry { + const shape = new THREE.Shape(); + shape.moveTo(-22, 0); + shape.lineTo(22, 0); + shape.lineTo(19, 9); + shape.lineTo(12, 16); + shape.lineTo(3, 20); + shape.lineTo(-7, 18.2); + shape.lineTo(-17, 12); + shape.closePath(); + + const opening = new THREE.Path(); + opening.moveTo(-5, 0); + opening.lineTo(-5, 3.5); + opening.absarc(0, 3.5, 5, Math.PI, 0, true); + opening.lineTo(5, 0); + opening.closePath(); + shape.holes.push(opening); + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 18, + steps: 1, + }); + geometry.translate(0, 0, -depth); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +} + +const ROAD_TUNNEL_DEPTH = 90; +const ROAD_TUNNEL_HILLSIDE = createRoadTunnelHillsideGeometry(ROAD_TUNNEL_DEPTH); +const ROAD_TUNNEL_EARTH_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#ffffff', + map: PROCEDURAL_TEXTURES.grassColor, + roughnessMap: PROCEDURAL_TEXTURES.grassRoughness, + roughness: 1, + metalness: 0, + emissive: '#33462f', + emissiveMap: PROCEDURAL_TEXTURES.grassColor, + emissiveIntensity: 0.72, +}); +const ROAD_TUNNEL_PORTAL_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#a7aaa4', + roughness: 0.94, + metalness: 0, +}); +const ROAD_TUNNEL_ROAD_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#ffffff', + map: PROCEDURAL_TEXTURES.tarmacColor, + roughnessMap: PROCEDURAL_TEXTURES.tarmacRoughness, + roughness: 1, + metalness: 0, +}); +const ROAD_TUNNEL_VOID_MATERIAL = new THREE.MeshBasicMaterial({ color: '#030405' }); +const ROAD_TUNNEL_LINE_MATERIAL = new THREE.MeshBasicMaterial({ + color: '#d6b14e', + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2, +}); +const ROAD_TUNNEL_WING_GEOMETRY = new THREE.BoxGeometry(0.75, 4.8, 8); +const ROAD_TUNNEL_WALL_GEOMETRY = new THREE.BoxGeometry(1, 3.5, ROAD_TUNNEL_DEPTH); +const ROAD_TUNNEL_LENS_GEOMETRY = new THREE.BoxGeometry(1.1, 0.22, 0.18); /** * Drainage Culvert - precast concrete pipe for water drainage @@ -259,27 +349,43 @@ const _culvertAtmosphere = createAtmosphereState(); export const DrainageCulvert: React.FC = React.memo( ({ position, rotation = 0, length = 5, radius = 0.8 }) => { const waterRef = useRef(null); + const targetWaterHeightRef = useRef(-radius * 0.52); + + useEffect(() => { + const updateTarget = (state: ReturnType): void => { + const atmosphere = sampleAtmosphere( + state.gameDay, + state.gameTime, + state.weather, + _culvertAtmosphere + ); + targetWaterHeightRef.current = getCulvertWaterHeight( + radius, + atmosphere.wetness, + atmosphere.precipitation + ); + }; + updateTarget(useGameSimulationStore.getState()); + return useGameSimulationStore.subscribe(updateTarget); + }, [radius]); useFrame((_, delta) => { if (!waterRef.current) return; - const { gameDay, gameTime, weather, isTabVisible } = useGameSimulationStore.getState(); - if (!isTabVisible) return; - const atmosphere = sampleAtmosphere(gameDay, gameTime, weather, _culvertAtmosphere); - const targetHeight = getCulvertWaterHeight( - radius, - atmosphere.wetness, - atmosphere.precipitation - ); + if (!useGameSimulationStore.getState().isTabVisible) return; waterRef.current.position.y = THREE.MathUtils.damp( waterRef.current.position.y, - targetHeight, + targetWaterHeightRef.current, 3, Math.min(delta, 0.1) ); }); return ( - + {/* Jointed precast barrel */} = React.memo( + {/* A dark back-facing liner makes the pipe read as a hollow bore from + either mouth instead of a one-sided white shell. */} + + {/* Flared end sections, each facing out of its own mouth */} = React.memo( + {/* Cast headwalls, angled wing walls, and splash aprons give each mouth + a believable load path into the embankment. */} + {[-1, 1].map((end) => ( + + + + + + {[-1, 1].map((side) => ( + + + + + ))} + + + + + {CULVERT_RIPRAP_OFFSETS.map(([x, y, z, scale], index) => ( + + ))} + + ))} + {/* Water surface inside */} - - + @@ -385,6 +544,134 @@ export const BrickTunnel: React.FC = React.memo(({ position, rotation = 0, roadWidth = 10 }) => { + const outerHalfWidth = roadWidth / 2 + 1; + const horizontalScale = roadWidth / 10; + const roadLength = ROAD_TUNNEL_DEPTH + 20; + + return ( + + + + {/* Segmented concrete vault, including the visible portal archivolt. */} + + + + + {/* A shallow, separately lit portal ring keeps the entrance legible + against the earth cut. The long barrel remains recessed behind it, + so the mouth has a real reveal instead of one dark coplanar edge. */} + + + {[-1, 1].map((side) => ( + + + + + + + + + + + + + + + + ))} + + {/* Continuous road, centre line, and drainage channels cross the portal + without coplanar surfaces. */} + + + {[-1, 1].map((side) => ( + + + + + ))} + + {/* A recessed end cap preserves depth while hiding the vehicle reset. */} + + + ); +}); +IndustrialRoadTunnel.displayName = 'IndustrialRoadTunnel'; + /** * Metal Culvert - corrugated steel pipe */ diff --git a/src/components/truckbay/animationSystem.ts b/src/components/truckbay/animationSystem.ts index 9c5554a..a99c07f 100644 --- a/src/components/truckbay/animationSystem.ts +++ b/src/components/truckbay/animationSystem.ts @@ -199,10 +199,36 @@ export const TruckAnimationManager: React.FC = () => { ? 3 : 4; + // Structural motion must remain smooth at every preset. Throttling a dock + // leveler or door to 20 Hz made its transform visibly step even when the + // renderer itself sustained 60 Hz. Decorative loops remain throttled. + animationRegistry.forEach((anim) => { + if (anim.type !== 'lerp') return; + const mesh = anim.mesh as THREE.Object3D; + const { + target, + speed = 0.1, + property = 'position', + axis = 'x', + autoHide, + hideThreshold, + } = anim.data as LerpAnimData; + if (!mesh) return; + + const currVal = mesh[property][axis]; + if (Math.abs(currVal - target) <= 0.001) return; + const newVal = THREE.MathUtils.lerp(currVal, target, getAnimationDampingAlpha(speed, delta)); + mesh[property][axis] = newVal; + if (autoHide && property === 'position') { + mesh.visible = newVal > (hideThreshold ?? 0); + } + }); + if (shouldRunThisFrame(throttle)) { const adjustDelta = delta * throttle; animationRegistry.forEach((anim) => { + if (anim.type === 'lerp') return; // 1. Rotation Animation if (anim.type === 'rotation') { const mesh = anim.mesh as THREE.Object3D; @@ -222,37 +248,6 @@ export const TruckAnimationManager: React.FC = () => { } } - // 3. Lerp (Position/Rotation/Scale) Animation - else if (anim.type === 'lerp') { - const mesh = anim.mesh as THREE.Object3D; - const lerpData = anim.data as LerpAnimData; - const { - target, - speed = 0.1, - property = 'position', - axis = 'x', - autoHide, - hideThreshold, - } = lerpData; - - if (mesh) { - const currVal = mesh[property][axis]; - if (Math.abs(currVal - target) > 0.001) { - const newVal = THREE.MathUtils.lerp( - currVal, - target, - getAnimationDampingAlpha(speed, adjustDelta) - ); - mesh[property][axis] = newVal; - - // Optional visibility toggle for "slide out" effects - if (autoHide && property === 'position') { - mesh.visible = newVal > (hideThreshold ?? 0); - } - } - } - } - // 4. Oscillation else if (anim.type === 'oscillation') { const mesh = anim.mesh as THREE.Object3D; diff --git a/src/components/ui-new/GameInterface.tsx b/src/components/ui-new/GameInterface.tsx index 3a2a416..1ec3344 100644 --- a/src/components/ui-new/GameInterface.tsx +++ b/src/components/ui-new/GameInterface.tsx @@ -24,25 +24,27 @@ import { useAnnouncementsStore } from '../../stores/announcementsStore'; import { useMobileControlStore } from '../../stores/mobileControlStore'; import { KeyboardShortcutsModal } from '../ui/KeyboardShortcutsModal'; import { OnboardingGuide, type OnboardingStep } from './onboarding/OnboardingGuide'; +import { useCameraStore } from '../CameraController'; +import { getTourCameraPreset } from './onboarding/tourCamera'; const INTRO_STEPS: OnboardingStep[] = [ { title: 'Follow the process', icon: 'factory', content: - 'Grain moves from the rear silos through milling and sifting, then reaches packing and shipping. Drag to orbit. Scroll or pinch to zoom.', + 'The camera is flying to the full site. Grain moves from the rear silos through milling and sifting, then reaches packing and shipping. Drag to orbit. Scroll or pinch to zoom.', }, { title: 'Protect today’s target', icon: 'goal', content: - 'The status bar compares output with the active run target. Alarms, stoppages, quality loss, and route conflicts reduce throughput.', + 'The tour is flying to packing. The status bar compares output with the active run target. Alarms, stoppages, quality loss, and route conflicts reduce throughput.', }, { title: 'Inspect before acting', icon: 'controls', content: - 'Select a machine to inspect it. The bottom dock opens production, safety, autonomy, and simulated SCADA. Press ? for keyboard controls.', + 'The tour is flying to milling. Select a machine to inspect it. The bottom dock opens production, safety, autonomy, and simulated SCADA. Press ? for keyboard controls.', }, ]; @@ -130,6 +132,11 @@ export const GameInterface: React.FC = ({ }; }, [hasSeenIntro]); + useEffect(() => { + const preset = getTourCameraPreset(introStep); + if (preset !== null) useCameraStore.getState().setPreset(preset); + }, [introStep]); + const handleIntroNext = () => { const next = (introStep ?? 0) + 1; if (next >= INTRO_STEPS.length) { diff --git a/src/components/ui-new/onboarding/tourCamera.test.ts b/src/components/ui-new/onboarding/tourCamera.test.ts new file mode 100644 index 0000000..1b05706 --- /dev/null +++ b/src/components/ui-new/onboarding/tourCamera.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { getTourCameraPreset } from './tourCamera'; + +describe('tour camera route', () => { + it('flies through overview, packing, and milling', () => { + expect([0, 1, 2].map(getTourCameraPreset)).toEqual([0, 4, 2]); + }); + + it('ignores inactive or invalid tour steps', () => { + expect(getTourCameraPreset(null)).toBeNull(); + expect(getTourCameraPreset(3)).toBeNull(); + }); +}); diff --git a/src/components/ui-new/onboarding/tourCamera.ts b/src/components/ui-new/onboarding/tourCamera.ts new file mode 100644 index 0000000..9195585 --- /dev/null +++ b/src/components/ui-new/onboarding/tourCamera.ts @@ -0,0 +1,8 @@ +export const TOUR_CAMERA_PRESETS = [0, 4, 2] as const; + +export const getTourCameraPreset = (step: number | null): number | null => { + if (step === null || !Number.isInteger(step) || step < 0 || step >= TOUR_CAMERA_PRESETS.length) { + return null; + } + return TOUR_CAMERA_PRESETS[step]; +}; diff --git a/src/constants/worldContract.ts b/src/constants/worldContract.ts index 0308b0c..968ba00 100644 --- a/src/constants/worldContract.ts +++ b/src/constants/worldContract.ts @@ -19,6 +19,7 @@ export const CONTINUOUS_WORLD_LAYER_IDS = [ export const PRESENT_WORLD_LAYER_IDS = [ 'optimized-sky-system', 'optimized-horizon-backdrop', + 'near-horizon-city', 'sun-visual', 'moon-visual', ] as const; diff --git a/src/simulation/vehicles/vehicleTelemetryRegistry.ts b/src/simulation/vehicles/vehicleTelemetryRegistry.ts index 5977ce4..f1708c0 100644 --- a/src/simulation/vehicles/vehicleTelemetryRegistry.ts +++ b/src/simulation/vehicles/vehicleTelemetryRegistry.ts @@ -1,14 +1,14 @@ export type VehicleTelemetryType = 'forklift' | 'truck'; export interface VehicleTelemetrySnapshot { - readonly id: string; - readonly type: VehicleTelemetryType; - readonly speedMps: number; - readonly steeringRadians: number; - readonly phase: string; - readonly stopReason: string; - readonly articulationRadians: number; - readonly transferReady: boolean; + id: string; + type: VehicleTelemetryType; + speedMps: number; + steeringRadians: number; + phase: string; + stopReason: string; + articulationRadians: number; + transferReady: boolean; } class VehicleTelemetryRegistry { @@ -23,6 +23,17 @@ class VehicleTelemetryRegistry { ) { return; } + const existing = this.vehicles.get(snapshot.id); + if (existing) { + existing.type = snapshot.type; + existing.speedMps = snapshot.speedMps; + existing.steeringRadians = snapshot.steeringRadians; + existing.phase = snapshot.phase; + existing.stopReason = snapshot.stopReason; + existing.articulationRadians = snapshot.articulationRadians; + existing.transferReady = snapshot.transferReady; + return; + } this.vehicles.set(snapshot.id, { ...snapshot }); } diff --git a/src/systems/UnifiedGameTick.ts b/src/systems/UnifiedGameTick.ts index 66383cb..fbb0a91 100644 --- a/src/systems/UnifiedGameTick.ts +++ b/src/systems/UnifiedGameTick.ts @@ -51,6 +51,36 @@ const GRAIN_DELIVERY_KG = 15000; // A shipping truck can load up to 5 t of finished flour or semolina. const FINISHED_GOODS_SHIPMENT_KG = 5000; const SHIPPING_LOAD_RATE_KG_PER_SECOND = 400; +const BAGS_PER_SECOND_BASE = 12; +const NOMINAL_PACKER_KG_PER_SECOND = 25; +const NOMINAL_PACKER_COUNT = 3; +let _bagProductionCarry = 0; + +export const calculateBagsProducedForTick = ( + deltaSeconds: number, + productionSpeed: number, + gameSpeed: number, + runningPackerCount: number, + healthFactor: number +): number => { + if ( + !Number.isFinite(deltaSeconds) || + !Number.isFinite(productionSpeed) || + !Number.isFinite(gameSpeed) || + !Number.isFinite(runningPackerCount) || + !Number.isFinite(healthFactor) + ) { + return 0; + } + return ( + BAGS_PER_SECOND_BASE * + Math.max(0, deltaSeconds) * + Math.max(0, productionSpeed) * + (Math.max(0, gameSpeed) / 60) * + (Math.max(0, runningPackerCount) / NOMINAL_PACKER_COUNT) * + Math.max(0, Math.min(1, healthFactor)) + ); +}; function sumMaterialInventory(flow: MaterialFlowState, materialType: MaterialType): number { let total = 0; @@ -460,10 +490,12 @@ function unifiedGameTick(ctx: TickContext): void { // 3. Count running packers for throughput calculation let runningPackerCount = 0; + let runningPackerEfficiencySum = 0; for (let i = 0; i < machines.length; i++) { const m = machines[i]; if (m.type === 'PACKER' && (m.status === 'running' || m.status === 'warning')) { runningPackerCount++; + runningPackerEfficiencySum += m.metrics.efficiency ?? 100; } } @@ -517,7 +549,6 @@ function unifiedGameTick(ctx: TickContext): void { // Throughput: actual production rate in bags per game-hour // Based on App.tsx production formula: 12 bags/sec base × productionSpeed × gameSpeedFactor × packerScale // Converted to bags per game-hour for display - const BAGS_PER_SECOND_BASE = 12; const gameSpeedFactor = safeGameSpeed / 60; const packerScale = runningPackerCount / 3; // 3 packers at full capacity @@ -628,7 +659,35 @@ function unifiedGameTick(ctx: TickContext): void { : undefined ); - // 4c. Grain deliveries: when a receiving truck docks, it refills the + // 4c. Count completed bags on the same central 500 ms cadence as the + // material network. The former five-second interval produced visually large + // jumps and created a second simulation clock. Fractional carry keeps this + // smooth without losing production to integer rounding. + if (runningPackerCount > 0) { + const liveFlow = useMaterialFlowStore.getState(); + const flowRate = liveFlow.currentPackerFlowRate; + const flowSimulationLive = + Number.isFinite(flowRate) && (flowRate > 0 || liveFlow.totalMaterialProcessed > 0); + const fallbackEfficiency = runningPackerEfficiencySum / Math.max(1, runningPackerCount * 100); + const healthFactor = flowSimulationLive + ? Math.max(0, Math.min(1, flowRate / (NOMINAL_PACKER_KG_PER_SECOND * runningPackerCount))) + : Math.max(0, Math.min(1, fallbackEfficiency)); + + _bagProductionCarry += calculateBagsProducedForTick( + deltaSeconds, + effectiveProductionSpeed, + safeGameSpeed, + runningPackerCount, + healthFactor + ); + const completedBags = Math.floor(_bagProductionCarry); + if (completedBags > 0) { + _bagProductionCarry -= completedBags; + useProductionStore.getState().incrementBagsProduced(completedBags); + } + } + + // 4d. Grain deliveries: when a receiving truck docks, it refills the // emptiest silo — without this the silos drain dry in under an hour of // simulation and the flow network starves permanently. const receivingTransferReady = @@ -641,7 +700,7 @@ function unifiedGameTick(ctx: TickContext): void { } _lastReceivingTransferReady = receivingTransferReady; - // 4d. Loading is a docked operation. Product remains conserved in finished + // 4e. Loading is a docked operation. Product remains conserved in finished // goods until the vehicle actually departs, when the material store creates // the authoritative dispatch manifest. The load snapshot is operational // intent only, never a second inventory ledger. diff --git a/src/systems/__tests__/UnifiedGameTick.production.test.ts b/src/systems/__tests__/UnifiedGameTick.production.test.ts new file mode 100644 index 0000000..7a6fbd2 --- /dev/null +++ b/src/systems/__tests__/UnifiedGameTick.production.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { calculateBagsProducedForTick } from '../UnifiedGameTick'; + +describe('UnifiedGameTick bag cadence', () => { + it('emits small half-second increments at the default simulation speed', () => { + const bags = calculateBagsProducedForTick(0.5, 0.8, 180, 3, 1); + expect(bags).toBeCloseTo(14.4); + expect(bags).toBeLessThan(20); + }); + + it('tracks packer availability and flow health', () => { + expect(calculateBagsProducedForTick(0.5, 0.8, 180, 1, 0.5)).toBeCloseTo(2.4); + expect(calculateBagsProducedForTick(0.5, 0.8, 180, 0, 1)).toBe(0); + }); + + it('rejects invalid values instead of contaminating the counter', () => { + expect(calculateBagsProducedForTick(Number.NaN, 1, 180, 3, 1)).toBe(0); + expect(calculateBagsProducedForTick(0.5, 1, 180, 3, Number.POSITIVE_INFINITY)).toBe(0); + }); +}); diff --git a/src/utils/positionRegistry.test.ts b/src/utils/positionRegistry.test.ts new file mode 100644 index 0000000..51727c7 --- /dev/null +++ b/src/utils/positionRegistry.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { positionRegistry } from './positionRegistry'; + +const TEST_ID = 'position-registry-test-truck'; + +afterEach(() => positionRegistry.unregister(TEST_ID)); + +describe('positionRegistry', () => { + it('updates a live entry in place for allocation-free frame publishing', () => { + positionRegistry.register(TEST_ID, 1, 2, 0, 1, false, 0, 'truck'); + const original = positionRegistry.get(TEST_ID); + + positionRegistry.register(TEST_ID, 4, 8, 1, 0, true, 0, 'truck'); + + expect(positionRegistry.get(TEST_ID)).toBe(original); + expect(original).toMatchObject({ x: 4, z: 8, dirX: 1, dirZ: 0, isStopped: true }); + }); + + it('removes entries cleanly', () => { + positionRegistry.register(TEST_ID, 1, 2); + positionRegistry.unregister(TEST_ID); + expect(positionRegistry.get(TEST_ID)).toBeUndefined(); + }); +}); diff --git a/src/utils/positionRegistry.ts b/src/utils/positionRegistry.ts index a43fdbb..41e74a5 100644 --- a/src/utils/positionRegistry.ts +++ b/src/utils/positionRegistry.ts @@ -36,9 +36,25 @@ class PositionRegistry { y = 0, kind: EntityPosition['kind'] = 'forklift' ): void { + const existing = this.positions.get(id); + if (existing) { + existing.x = x; + existing.y = y; + existing.z = z; + existing.dirX = dirX; + existing.dirZ = dirZ; + existing.isStopped = isStopped; + existing.kind = kind; + return; + } this.positions.set(id, { id, x, y, z, dirX, dirZ, isStopped, kind }); } + /** Read the live position without allocating a registry snapshot. */ + get(id: string): EntityPosition | undefined { + return this.positions.get(id); + } + unregister(id: string): void { this.positions.delete(id); }