PageSourceSearch

https://the-far-colony.netlify.app/human-rover.js

js the-far-colony.netlify.app collected 2026-10-03 11:29:55 UTC 26,866 bytes, 654 lines download raw bytes

1(function () {
2    const ROVER_MODEL_PATH = 'assets/models/human-rover.glb';
3    const GLTF_LOADER_LOCAL_PATH = 'GLTFLoader.js';
4    const GLTF_LOADER_CDN_BASE = 'https://cdn.jsdelivr.net/npm/three@';
5    const ROVER_MODEL_SCALE = 1.45;
6    const ROVER_SPEED = 1.38;
7    const ROVER_RETURN_SPEED = 1.64;
8    const ROVER_TURN_RATE = 5.4;
9    const ROVER_WAYPOINT_RADIUS = 0.38;
10    const ROVER_SCAN_RADIUS = 5.8;
11    const ROVER_SCAN_TIME = 2.15;
12    const ROVER_SCAN_COOLDOWN = 8.5;
13    const ROVER_TARGET_COOLDOWN = 28.0;
14    const ROVER_MISSION_DURATION = 72.0;
15    const ROVER_ALERT_BASE = 12;
16    const ROVER_SCOUT_POINT_COUNT = 6;
17    const ROVER_SCOUT_STEP = 6.8;
18    const ROVER_SCOUT_LATERAL_MIN = 2.8;
19    const ROVER_SCOUT_LATERAL_MAX = 8.2;
20    const ROVER_SCOUT_EDGE_MARGIN = 3.2;
21    const ROVER_BLOCKED_TIME = 1.15;
22    const ROVER_BLOCKED_PAUSE = 1.4;
23    const ROVER_TERRAIN_NORMAL_SAMPLE = 0.82;
24    const ROVER_GROUND_ALIGN_SHARPNESS = 10.0;
25    let gltfLoaderPromise = null;
26    let roverTemplatePromise = null;
27
28    function clamp01(v) {
29        return Math.max(0, Math.min(1, v));
30    }
31
32    function smoothstep01(v) {
33        const t = clamp01(v);
34        return t * t * (3 - 2 * t);
35    }
36
37    function normalizeAngle(angle) {
38        let out = angle;
39        while (out <= -Math.PI) out += Math.PI * 2;
40        while (out > Math.PI) out -= Math.PI * 2;
41        return out;
42    }
43
44    function turnToward(current, target, maxStep) {
45        const delta = normalizeAngle(target - current);
46        if (Math.abs(delta) <= maxStep) return target;
47        return normalizeAngle(current + Math.sign(delta) * maxStep);
48    }
49
50    function distance2D(a, b) {
51        const dx = a.x - b.x;
52        const dz = a.z - b.z;
53        return Math.sqrt(dx * dx + dz * dz);
54    }
55
56    function sampleTerrainNormalInto(target, api, x, z) {
57        const step = ROVER_TERRAIN_NORMAL_SAMPLE;
58        const hLeft = api.terrainHeight(x - step, z);
59        const hRight = api.terrainHeight(x + step, z);
60        const hBack = api.terrainHeight(x, z - step);
61        const hForward = api.terrainHeight(x, z + step);
62        target.set(hLeft - hRight, step * 2, hBack - hForward);
63        if (target.lengthSq() <= 0.000001) target.set(0, 1, 0);
64        return target.normalize();
65    }
66
67    function updateGroundAlignedRoot(actor, api, stepDt, immediate) {
68        const pos = actor.root.position;
69        sampleTerrainNormalInto(actor.groundUp, api, pos.x, pos.z);
70
71        actor.groundForward.set(Math.sin(actor.heading), 0, Math.cos(actor.heading));
72        actor.groundForward.addScaledVector(actor.groundUp, -actor.groundForward.dot(actor.groundUp));
73        if (actor.groundForward.lengthSq() <= 0.000001) {
74            actor.groundForward.set(Math.sin(actor.heading), 0, Math.cos(actor.heading));
75        }
76        actor.groundForward.normalize();
77        actor.groundRight.crossVectors(actor.groundUp, actor.groundForward).normalize();
78        actor.groundForward.crossVectors(actor.groundRight, actor.groundUp).normalize();
79        actor.groundMatrix.makeBasis(actor.groundRight, actor.groundUp, actor.groundForward);
80        actor.groundQuaternion.setFromRotationMatrix(actor.groundMatrix);
81
82        if (immediate) {
83            actor.root.quaternion.copy(actor.groundQuaternion);
84            return;
85        }
86        const blend = 1 - Math.exp(-ROVER_GROUND_ALIGN_SHARPNESS * Math.max(0, stepDt || 0));
87        actor.root.quaternion.slerp(actor.groundQuaternion, clamp01(blend));
88    }
89
90    function getThreePackageVersion(THREE) {
91        const revision = Number(THREE && THREE.REVISION);
92        return Number.isFinite(revision) && revision > 0 ? `0.${revision}.0` : '0.128.0';
93    }
94
95    function ensureGLTFLoader(THREE) {
96        if (THREE.GLTFLoader) return Promise.resolve(THREE.GLTFLoader);
97        if (gltfLoaderPromise) return gltfLoaderPromise;
98
99        gltfLoaderPromise = new Promise((resolve, reject) => {
100            const script = document.createElement('script');
101            script.async = true;
102            script.onload = () => {
103                if (THREE.GLTFLoader) resolve(THREE.GLTFLoader);
104                else reject(new Error('GLTFLoader did not register on THREE.'));
105            };
106            script.onerror = () => {
107                const fallback = document.createElement('script');
108                fallback.src = `${GLTF_LOADER_CDN_BASE}${getThreePackageVersion(THREE)}/examples/js/loaders/GLTFLoader.js`;
109                fallback.async = true;
110                fallback.onload = () => {
111                    if (THREE.GLTFLoader) resolve(THREE.GLTFLoader);
112                    else reject(new Error('GLTFLoader did not register on THREE.'));
113                };
114                fallback.onerror = () => reject(new Error('GLTFLoader could not be loaded.'));
115                document.head.appendChild(fallback);
116            };
117            script.src = GLTF_LOADER_LOCAL_PATH;
118            document.head.appendChild(script);
119        });
120
121        return gltfLoaderPromise;
122    }
123
124    function loadRoverTemplate(THREE) {
125        if (roverTemplatePromise) return roverTemplatePromise;
126        roverTemplatePromise = ensureGLTFLoader(THREE).then(Loader => new Promise((resolve, reject) => {
127            const loader = new Loader();
128            loader.load(
129                ROVER_MODEL_PATH,
130                gltf => {
131                    const scene = gltf.scene || (gltf.scenes && gltf.scenes[0]);
132                    if (!scene) {
133                        reject(new Error('Rover GLB did not contain a scene.'));
134                        return;
135                    }
136                    scene.updateMatrixWorld(true);
137                    scene.traverse(obj => {
138                        if (!obj.isMesh) return;
139                        obj.frustumCulled = false;
140                        if (obj.material) {
141                            const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
142                            materials.forEach(material => {
143                                if (material.map) {
144                                    material.map.magFilter = THREE.NearestFilter;
145                                    material.map.minFilter = THREE.NearestMipmapNearestFilter;
146                                }
147                            });
148                        }
149                    });
150                    resolve(scene);
151                },
152                undefined,
153                reject
154            );
155        }));
156        return roverTemplatePromise;
157    }
158
159    function materialColor(material) {
160        if (material && material.color) return material.color;
161        return { r: 0.72, g: 0.74, b: 0.72 };
162    }
163
164    function convertMaterial(source, api, THREE) {
165        const color = materialColor(source);
166        if (source && source.map && api.objTexEdgeMat) {
167            const material = api.objTexEdgeMat(source.map, color.r, color.g, color.b);
168            material.side = source.side || THREE.FrontSide;
169            return material;
170        }
171        const make = api.objEdgeMat || api.objMat;
172        return make(color.r, color.g, color.b);
173    }
174
175    function convertModelMaterials(root, actor, api, THREE) {
176        root.traverse(obj => {
177            if (!obj.isMesh) return;
178            const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
179            const converted = materials.map(material => convertMaterial(material, api, THREE));
180            obj.material = Array.isArray(obj.material) ? converted : converted[0];
181            actor.ownedMaterials.push(...converted);
182        });
183    }
184
185    function makeFallbackRover(api, seed) {
186        const THREE = api.THREE;
187        const make = api.objEdgeMat || api.objMat;
188        const bodyMat = make(0.78, 0.80, 0.78);
189        const darkMat = make(0.12, 0.13, 0.14);
190        const blueMat = make(0.18, 0.45, 0.90);
191        const group = new THREE.Group();
192        const wheels = [];
193
194        const body = new THREE.Mesh(new THREE.BoxGeometry(0.98, 0.28, 1.18), bodyMat);
195        body.position.y = 0.32;
196        group.add(body);
197
198        const cabin = new THREE.Mesh(new THREE.BoxGeometry(0.58, 0.32, 0.48), bodyMat);
199        cabin.position.set(0, 0.58, -0.08);
200        cabin.rotation.x = -0.05;
201        group.add(cabin);
202
203        const sensor = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.10, 0.14), blueMat);
204        sensor.position.set(0, 0.73, 0.34);
205        sensor.userData.noShadowCaster = true;
206        group.add(sensor);
207
208        for (const side of [-1, 1]) {
209            for (const z of [-0.38, 0.38]) {
210                const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.13, 8), darkMat);
211                wheel.position.set(side * 0.54, 0.19, z);
212                wheel.rotation.z = Math.PI * 0.5;
213                wheel.userData.baseZ = z;
214                wheel.userData.phase = api.hash(seed, z * 20 + side * 10) * Math.PI * 2;
215                group.add(wheel);
216                wheels.push(wheel);
217            }
218        }
219
220        group.userData.fallbackWheels = wheels;
221        return group;
222    }
223
224    function disposeFallback(actor, api) {
225        if (!actor || !actor.placeholder) return;
226        api.unmarkShadowCaster(actor.placeholder);
227        if (actor.placeholder.parent) actor.placeholder.parent.remove(actor.placeholder);
228        api.disposeObjectTree(actor.placeholder);
229        actor.placeholder = null;
230        actor.fallbackWheels = [];
231    }
232
233    function attachRoverModel(actor, template, api) {
234        if (!actor || actor.disposed || !actor.visualRoot) return;
235        const THREE = api.THREE;
236        disposeFallback(actor, api);
237
238        const model = template.clone(true);
239        model.scale.setScalar(ROVER_MODEL_SCALE);
240        model.position.y = 0;
241        convertModelMaterials(model, actor, api, THREE);
242        actor.visualRoot.add(model);
243        actor.model = model;
244        actor.modelLoaded = true;
245        api.markShadowCaster(actor.root);
246    }
247
248    function makeScanRing(THREE) {
249        const segments = 64;
250        const positions = [];
251        for (let i = 0; i <= segments; i++) {
252            const a = (i / segments) * Math.PI * 2;
253            positions.push(Math.cos(a), 0, Math.sin(a));
254        }
255        const geometry = new THREE.BufferGeometry();
256        geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
257        const material = new THREE.LineBasicMaterial({
258            color: 0x63a7ff,
259            transparent: true,
260            opacity: 0.0,
261            depthWrite: false
262        });
263        const ring = new THREE.Line(geometry, material);
264        ring.visible = false;
265        ring.userData.noShadowCaster = true;
266        return ring;
267    }
268
269    function clampScoutPoint(point, api) {
270        const half = Number.isFinite(api.worldHalfSize) ? api.worldHalfSize : 40;
271        const margin = ROVER_SCOUT_EDGE_MARGIN;
272        return {
273            x: Math.max(-half + margin, Math.min(half - margin, point.x)),
274            z: Math.max(-half + margin, Math.min(half - margin, point.z))
275        };
276    }
277
278    function defaultScoutPoints(x, z, seed, api) {
279        const points = [];
280        const focus = api.getHumanScoutFocus ? api.getHumanScoutFocus({ x, z }, seed) : null;
281        const target = focus && Number.isFinite(focus.x) && Number.isFinite(focus.z)
282            ? focus
283            : { x: x - 18, z: z + 18 };
284        const baseAngle = Math.atan2(target.z - z, target.x - x);
285        const count = Math.max(3, ROVER_SCOUT_POINT_COUNT);
286        const maxDistance = Math.max(ROVER_SCOUT_STEP * 2, distance2D({ x, z }, target));
287        for (let i = 0; i < count; i++) {
288            const p = (i + 1) / count;
289            const travel = Math.min(maxDistance + 5.5, ROVER_SCOUT_STEP * (i + 1) * (0.92 + api.hash(seed, 1710 + i) * 0.22));
290            const lateralScale = ROVER_SCOUT_LATERAL_MIN + (ROVER_SCOUT_LATERAL_MAX - ROVER_SCOUT_LATERAL_MIN) * p;
291            const lateral = (api.hash(seed, 1730 + i) - 0.5) * 2 * lateralScale;
292            const alongX = Math.cos(baseAngle);
293            const alongZ = Math.sin(baseAngle);
294            const sideX = -alongZ;
295            const sideZ = alongX;
296            points.push(clampScoutPoint({
297                x: x + alongX * travel + sideX * lateral,
298                z: z + alongZ * travel + sideZ * lateral
299            }, api));
300        }
301        return points;
302    }
303
304    function makeTooltip(actor) {
305        let stateText = 'Human scout';
306        if (actor.state === 'scan') stateText = 'Surveying nearby ground';
307        else if (actor.state === 'blocked') stateText = 'Blocked by bone wall';
308        else if (actor.state === 'return') stateText = 'Returning to human hardware';
309
310        const hasHull = Number.isFinite(actor.health) && Number.isFinite(actor.maxHealth) && actor.maxHealth > 0;
311        const meta = hasHull
312            ? `${stateText} | Hull ${Math.max(0, Math.ceil(actor.health))}/${Math.max(1, Math.ceil(actor.maxHealth))}`
313            : stateText;
314        return {
315            title: 'SCOUT ROVER',
316            body: 'Small human rover used to scout colony growth and send target data.',
317            meta
318        };
319    }
320
321    function create(api, x, z, seed, options) {
322        options = options || {};
323        const THREE = api.THREE;
324        const root = new THREE.Group();
325        const visualRoot = new THREE.Group();
326        root.add(visualRoot);
327
328        const scoutPoints = Array.isArray(options.patrolPoints) && options.patrolPoints.length
329            ? options.patrolPoints.map(point => clampScoutPoint({ x: point.x, z: point.z }, api))
330            : defaultScoutPoints(x, z, seed, api);
331        const waypointIndex = 0;
332        const first = scoutPoints[waypointIndex] || { x, z };
333        const startX = Number.isFinite(options.startX) ? options.startX : x;
334        const startZ = Number.isFinite(options.startZ) ? options.startZ : z;
335        const initialHeading = Math.atan2(first.x - startX, first.z - startZ);
336
337        root.position.set(startX, api.terrainHeight(startX, startZ), startZ);
338        root.rotation.y = initialHeading;
339        root.userData.tooltip = makeTooltip({ state: 'patrol' });
340        root.userData.tooltipFn = owner => {
341            const rover = owner.userData.humanRoverActor;
342            return makeTooltip(rover || { state: 'patrol' });
343        };
344        root.userData.tooltipRequiresSight = true;
345
346        const placeholder = makeFallbackRover(api, seed);
347        visualRoot.add(placeholder);
348
349        const lightMat = new THREE.MeshBasicMaterial({
350            color: 0x6aa8ff,
351            transparent: true,
352            opacity: 0.58,
353            depthWrite: false
354        });
355        const statusLight = new THREE.Mesh(new THREE.SphereGeometry(0.085, 8, 6), lightMat);
356        statusLight.position.set(0, 0.86, 0.18);
357        statusLight.userData.noShadowCaster = true;
358        visualRoot.add(statusLight);
359
360        const scanRing = makeScanRing(THREE);
361        api.scene.add(scanRing);
362
363        const actor = {
364            root,
365            visualRoot,
366            placeholder,
367            fallbackWheels: placeholder.userData.fallbackWheels || [],
368            statusLight,
369            lightMat,
370            scanRing,
371            patrolPoints: scoutPoints,
372            waypointIndex,
373            loopRoute: !!options.loopRoute,
374            home: { x, z },
375            homeSite: options.homeSite || null,
376            seed,
377            phase: Number.isFinite(options.phase) ? options.phase : api.hash(seed, 1901) * Math.PI * 2,
378            heading: initialHeading,
379            speed: Number.isFinite(options.speed) ? options.speed : ROVER_SPEED,
380            returnSpeed: Number.isFinite(options.returnSpeed) ? options.returnSpeed : ROVER_RETURN_SPEED,
381            missionDuration: Number.isFinite(options.missionDuration) ? options.missionDuration : ROVER_MISSION_DURATION,
382            missionAge: 0,
383            state: 'patrol',
384            stateTime: 0,
385            scanCooldown: 2.0 + api.hash(seed, 1931) * 4.0,
386            scanTarget: null,
387            scanProgress: 0,
388            targetCooldowns: {},
389            relayDeployed: false,
390            blockedTime: 0,
391            groundUp: new THREE.Vector3(0, 1, 0),
392            groundForward: new THREE.Vector3(0, 0, 1),
393            groundRight: new THREE.Vector3(1, 0, 0),
394            groundMatrix: new THREE.Matrix4(),
395            groundQuaternion: new THREE.Quaternion(),
396            model: null,
397            modelLoaded: false,
398            ownedMaterials: [],
399            disposed: false,
400            expired: false
401        };
402        root.userData.humanRoverActor = actor;
403        updateGroundAlignedRoot(actor, api, 1, true);
404
405        api.scene.add(root);
406        api.markShadowCaster(root);
407
408        loadRoverTemplate(THREE)
409            .then(template => attachRoverModel(actor, template, api))
410            .catch(err => {
411                actor.modelLoadFailed = true;
412                if (window && window.console) console.warn('Human rover model load failed:', err);
413            });
414
415        return actor;
416    }
417
418    function reduceTargetCooldowns(actor, dt) {
419        for (const key of Object.keys(actor.targetCooldowns)) {
420            actor.targetCooldowns[key] -= dt;
421            if (actor.targetCooldowns[key] <= 0) delete actor.targetCooldowns[key];
422        }
423    }
424
425    function chooseScanTarget(actor, api) {
426        if (!api.getHumanScoutTargets) return null;
427        const origin = actor.root.position;
428        const targets = api.getHumanScoutTargets({ x: origin.x, z: origin.z }, ROVER_SCAN_RADIUS);
429        if (!Array.isArray(targets) || !targets.length) return null;
430
431        let best = null;
432        let bestScore = -Infinity;
433        for (const target of targets) {
434            if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.z)) continue;
435            if (target.id && actor.targetCooldowns[target.id]) continue;
436            const dist = Math.max(0.001, target.distance ?? distance2D(origin, target));
437            const score = (target.priority || 1) * 3.0 - dist * 0.35 + api.hash(actor.seed, dist * 91) * 0.35;
438            if (score > bestScore) {
439                bestScore = score;
440                best = target;
441            }
442        }
443        return best;
444    }
445
446    function moveActorToward(actor, target, speed, dt, api) {
447        const pos = actor.root.position;
448        const dx = target.x - pos.x;
449        const dz = target.z - pos.z;
450        const dist = Math.sqrt(dx * dx + dz * dz);
451        if (dist <= 0.001) return dist;
452
453        const step = Math.min(dist, speed * dt);
454        const nx = dx / dist;
455        const nz = dz / dist;
456        const next = {
457            x: pos.x + nx * step,
458            z: pos.z + nz * step
459        };
460        if (api.isGroundPathBlocked && api.isGroundPathBlocked({ x: pos.x, z: pos.z }, next, 0.44)) {
461            actor.lastMoveBlocked = true;
462            return dist;
463        }
464        actor.lastMoveBlocked = false;
465        actor.blockedTime = 0;
466        pos.x = next.x;
467        pos.z = next.z;
468        actor.heading = turnToward(actor.heading, Math.atan2(nx, nz), ROVER_TURN_RATE * dt);
469        return dist;
470    }
471
472    function updatePatrol(actor, stepDt, api) {
473        actor.scanCooldown = Math.max(0, actor.scanCooldown - stepDt);
474        if (actor.scanCooldown <= 0) {
475            const target = chooseScanTarget(actor, api);
476            if (target) {
477                actor.state = 'scan';
478                actor.stateTime = 0;
479                actor.scanProgress = 0;
480                actor.scanTarget = target;
481                return;
482            }
483        }
484
485        if (actor.missionAge >= actor.missionDuration) {
486            actor.state = 'return';
487            actor.stateTime = 0;
488            return;
489        }
490
491        const point = actor.patrolPoints[actor.waypointIndex] || actor.home;
492        const remaining = moveActorToward(actor, point, actor.speed, stepDt, api);
493        if (actor.lastMoveBlocked) {
494            actor.blockedTime += stepDt;
495            if (actor.blockedTime >= ROVER_BLOCKED_TIME) {
496                actor.state = 'blocked';
497                actor.stateTime = 0;
498                actor.scanCooldown = Math.min(actor.scanCooldown, 0.35);
499            }
500            return;
501        }
502        if (remaining <= ROVER_WAYPOINT_RADIUS) {
503            if (actor.waypointIndex >= actor.patrolPoints.length - 1 && !actor.loopRoute) {
504                actor.state = 'return';
505                actor.stateTime = 0;
506            } else {
507                actor.waypointIndex = (actor.waypointIndex + 1) % actor.patrolPoints.length;
508            }
509        }
510    }
511
512    function completeScan(actor, api) {
513        const target = actor.scanTarget;
514        if (target && target.id) actor.targetCooldowns[target.id] = ROVER_TARGET_COOLDOWN;
515        actor.scanCooldown = ROVER_SCAN_COOLDOWN + api.hash(actor.seed, actor.missionAge * 11.0) * 4.0;
516        actor.state = 'patrol';
517        actor.stateTime = 0;
518        actor.scanTarget = null;
519        actor.scanProgress = 0;
520
521        if (target && api.markHumanDetection) {
522            const duration = api.getHumanRoverScanDetectionDuration
523                ? api.getHumanRoverScanDetectionDuration()
524                : 70;
525            api.markHumanDetection(target, 'rover-scan', duration, 1.0);
526        }
527        if (!actor.relayDeployed && target && api.deployHumanFieldRelay) {
528            actor.relayDeployed = !!api.deployHumanFieldRelay(target, actor);
529        }
530        const alertScore = ROVER_ALERT_BASE + Math.max(0, target && target.priority ? target.priority * 4 : 0);
531        if (api.worldSignal) api.worldSignal('HUMAN ROVER SENT SURVEY DATA', true, 1500);
532        if (api.addHumanAlertScore) api.addHumanAlertScore(alertScore);
533    }
534
535    function updateScan(actor, t, stepDt, api) {
536        actor.scanProgress = clamp01(actor.stateTime / ROVER_SCAN_TIME);
537        const target = actor.scanTarget;
538        if (target && Number.isFinite(target.x) && Number.isFinite(target.z)) {
539            const desired = Math.atan2(target.x - actor.root.position.x, target.z - actor.root.position.z);
540            actor.heading = turnToward(actor.heading, desired, ROVER_TURN_RATE * 0.82 * stepDt);
541        }
542        if (actor.stateTime >= ROVER_SCAN_TIME) completeScan(actor, api);
543    }
544
545    function updateReturn(actor, stepDt, api) {
546        const remaining = moveActorToward(actor, actor.home, actor.returnSpeed, stepDt, api);
547        if (remaining <= 0.5) actor.expired = true;
548    }
549
550    function updateBlocked(actor, stepDt, api) {
551        actor.scanCooldown = Math.max(0, actor.scanCooldown - stepDt);
552        if (actor.scanCooldown <= 0) {
553            const target = chooseScanTarget(actor, api);
554            if (target) {
555                actor.state = 'scan';
556                actor.stateTime = 0;
557                actor.scanProgress = 0;
558                actor.scanTarget = target;
559                actor.blockedTime = 0;
560                return;
561            }
562        }
563        if (actor.stateTime >= ROVER_BLOCKED_PAUSE) {
564            actor.state = 'return';
565            actor.stateTime = 0;
566            actor.blockedTime = 0;
567        }
568    }
569
570    function updateVisuals(actor, t, stepDt, api) {
571        const pos = actor.root.position;
572        pos.y = api.terrainHeight(pos.x, pos.z);
573        const moving = actor.state === 'patrol' || actor.state === 'return';
574        const speedBob = moving ? 1 : 0.25;
575        updateGroundAlignedRoot(actor, api, stepDt, false);
576        actor.visualRoot.position.y = Math.sin(t * 8.0 + actor.phase) * 0.015 * speedBob;
577        actor.visualRoot.rotation.x = Math.sin(t * 3.1 + actor.phase) * 0.018 * speedBob;
578        actor.visualRoot.rotation.z = Math.cos(t * 3.5 + actor.phase) * 0.014 * speedBob;
579
580        actor.fallbackWheels.forEach((wheel, index) => {
581            wheel.rotation.x += (moving ? 8.0 : 0.9) * stepDt * (index % 2 ? 1 : -1);
582            wheel.position.z = wheel.userData.baseZ + Math.sin(t * 7.2 + wheel.userData.phase) * 0.012;
583        });
584
585        const scanning = actor.state === 'scan';
586        const pulse = scanning
587            ? 0.65 + Math.sin(t * 9.0 + actor.phase) * 0.24
588            : 0.36 + Math.sin(t * 3.4 + actor.phase) * 0.10;
589        actor.lightMat.opacity = pulse;
590        actor.statusLight.scale.setScalar(scanning ? 1.20 + Math.sin(t * 8.5) * 0.12 : 1.0);
591
592        const visibleHere = !api.isWorldVisible || api.isWorldVisible(pos.x, pos.z);
593        actor.root.visible = visibleHere;
594        actor.scanRing.visible = scanning && visibleHere;
595        if (actor.scanRing.visible) {
596            const p = smoothstep01(actor.scanProgress);
597            const radius = 0.86 + p * ROVER_SCAN_RADIUS;
598            actor.scanRing.position.set(pos.x, api.terrainHeight(pos.x, pos.z) + 0.045, pos.z);
599            actor.scanRing.scale.set(radius, 1, radius);
600            actor.scanRing.material.opacity = (1 - p) * 0.48;
601            actor.scanRing.rotation.y = t * 0.4;
602        }
603    }
604
605    function animate(actor, t, dt, api) {
606        if (!actor || actor.disposed || actor.expired || !actor.root) return;
607        const stepDt = Math.min(0.1, Math.max(0.0001, dt || 0.016));
608        actor.missionAge += stepDt;
609        actor.stateTime += stepDt;
610        reduceTargetCooldowns(actor, stepDt);
611
612        if (actor.homeSite && actor.homeSite.userData && actor.homeSite.userData.assimilated) {
613            actor.state = 'return';
614            actor.expired = true;
615            return;
616        }
617
618        if (actor.state === 'scan') updateScan(actor, t, stepDt, api);
619        else if (actor.state === 'blocked') updateBlocked(actor, stepDt, api);
620        else if (actor.state === 'return') updateReturn(actor, stepDt, api);
621        else updatePatrol(actor, stepDt, api);
622
623        updateVisuals(actor, t, stepDt, api);
624    }
625
626    function dispose(actor, api) {
627        if (!actor || actor.disposed) return;
628        actor.disposed = true;
629        if (actor.scanRing) {
630            if (actor.scanRing.parent) actor.scanRing.parent.remove(actor.scanRing);
631            if (actor.scanRing.geometry) actor.scanRing.geometry.dispose();
632            if (actor.scanRing.material) actor.scanRing.material.dispose();
633        }
634        disposeFallback(actor, api);
635        if (actor.ownedMaterials) {
636            actor.ownedMaterials.forEach(material => {
637                if (material && material.dispose) material.dispose();
638            });
639            actor.ownedMaterials.length = 0;
640        }
641        if (actor.lightMat) actor.lightMat.dispose();
642        if (actor.statusLight && actor.statusLight.geometry) actor.statusLight.geometry.dispose();
643        if (actor.root) {
644            api.unmarkShadowCaster(actor.root);
645            if (actor.root.parent) actor.root.parent.remove(actor.root);
646        }
647    }
648
649    window.FAR_COLONY_HUMAN_ROVER_DEFINITION = {
650        create,
651        animate,
652        dispose
653    };
654})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.