// 無限程序化地塊 v2(純視覺,Z-up)。以「無人機+鏡頭」雙中心、80m 網格動態生成/ // 回收地塊(城市/公園/田野/水域/丘陵),同一格子固定種子 → 每次/每人同一世界。 // v2:程序化紋理(CanvasTexture:大樓窗格、草地斑駁、道路標線、水面)、共用幾何/ // 材質(省記憶體與 GC)、MeshLambert(避免 r86 Standard 被強光沖白)。 // 不進 gazebo 物理;原點 ±90m 是既有跑道場景,不重疊生成。 (function () { var CHUNK = 80; // 地塊邊長(m) var RADIUS = 2; // 每個中心生成 (2R+1)^2 格 var HOME_CLEAR = 90; // 原點保留區半徑 var MAX_CHUNKS = 60; // 記憶體保險上限(雙中心最多 2×25 格) var FAR_SIZE = 3200; // 遠景大地邊長(m):高空/遠望時鋪滿地平線 var chunks = {}; // "cx,cy" -> THREE.Group var pending = {}; // 佈局 fetch 進行中的格子 var R = null; // 共用資源(幾何/材質),首次可用時建立 var farGround = null; // 遠景大地(單一大平面,跟著鏡頭以 CHUNK 為步長平移) // ---- 固定種子亂數(mulberry32) ---- function rng(cx, cy) { var seed = (cx * 374761393 + cy * 668265263 + 1442695040888963407) >>> 0; return function () { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; var t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function makeTex(w, h, draw, repX, repY) { var cv = document.createElement('canvas'); cv.width = w; cv.height = h; draw(cv.getContext('2d'), w, h); var t = new THREE.CanvasTexture(cv); t.wrapS = t.wrapT = THREE.RepeatWrapping; if (repX) t.repeat.set(repX, repY || repX); t.anisotropy = 4; return t; } function noise(ctx, w, h, base, tones, n, a, b) { ctx.fillStyle = base; ctx.fillRect(0, 0, w, h); for (var i = 0; i < n; i++) { ctx.fillStyle = tones[(Math.random() * tones.length) | 0]; ctx.globalAlpha = 0.25 + Math.random() * 0.3; var r = a + Math.random() * b; ctx.beginPath(); ctx.arc(Math.random() * w, Math.random() * h, r, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; } function initShared() { var lam = function (opt) { return new THREE.MeshLambertMaterial(opt); }; R = { g: {}, m: {} }; // 共用幾何(單位尺寸,實例用 scale) R.g.box = new THREE.BoxGeometry(1, 1, 1); R.g.plane = new THREE.PlaneGeometry(1, 1); R.g.sphere = new THREE.SphereGeometry(1, 8, 8); R.g.trunk = new THREE.CylinderGeometry(0.1, 0.15, 1, 6); R.g.cone = new THREE.ConeGeometry(1, 1, 12); R.g.circle = new THREE.CircleGeometry(1, 20); // 草地:綠色斑駁噪點 R.m.grass = lam({ map: makeTex(256, 256, function (c, w, h) { noise(c, w, h, '#5d7a42', ['#54713b', '#68854b', '#4e6a38', '#72905a'], 900, 2, 8); }, 6, 6) }); // 田野:田畦條帶(幾個色帶+分隔線) R.m.field = lam({ map: makeTex(256, 256, function (c, w, h) { var cols = ['#7d9247', '#9db35c', '#8aa04f', '#b0a355', '#6f8a41']; var n = 5; for (var i = 0; i < n; i++) { c.fillStyle = cols[i % cols.length]; c.fillRect(0, i * h / n, w, h / n); c.fillStyle = 'rgba(60,70,40,.55)'; c.fillRect(0, i * h / n, w, 3); } noise(c, w, h, 'rgba(0,0,0,0)', ['#00000022', '#ffffff14'], 250, 1, 5); }, 1, 1) }); // 道路:瀝青+中央虛線。r86 沒有 texture.rotation,縱/橫各畫一張。 R.m.roadY = lam({ map: makeTex(64, 256, function (c, w, h) { noise(c, w, h, '#3c4046', ['#34383d', '#44484e', '#2f3338'], 300, 1, 5); c.fillStyle = '#c9cf3d'; for (var y = 0; y < h; y += 32) c.fillRect(w / 2 - 2, y, 4, 18); // 中央虛線(直) c.fillStyle = '#c8ccd2'; c.fillRect(3, 0, 3, h); c.fillRect(w - 6, 0, 3, h); // 邊線 }, 1, 10) }); R.m.roadX = lam({ map: makeTex(256, 64, function (c, w, h) { noise(c, w, h, '#3c4046', ['#34383d', '#44484e', '#2f3338'], 300, 1, 5); c.fillStyle = '#c9cf3d'; for (var x = 0; x < w; x += 32) c.fillRect(x, h / 2 - 2, 18, 4); // 中央虛線(橫) c.fillStyle = '#c8ccd2'; c.fillRect(0, 3, w, 3); c.fillRect(0, h - 6, w, 3); // 邊線 }, 10, 1) }); // 大樓立面:數種底色 × 窗格(亮/暗窗隨機) R.m.facade = ['#8d99a6', '#a89a82', '#7f8b9b', '#9aa392', '#b3a58f', '#6e7a89'].map(function (base) { return lam({ map: makeTex(128, 256, function (c, w, h) { c.fillStyle = base; c.fillRect(0, 0, w, h); for (var fy = 0; fy < 12; fy++) { for (var fx = 0; fx < 6; fx++) { var lit = Math.random() < 0.28; c.fillStyle = lit ? '#ffe9a8' : 'rgba(20,30,45,.78)'; c.fillRect(6 + fx * 20, 8 + fy * 20, 12, 10); } } }, 1, 1) }); }); // 斑馬線(縱向路用橫紋/橫向路用直紋) R.m.zebraY = lam({ map: makeTex(64, 64, function (c, w, h) { c.fillStyle = '#3c4046'; c.fillRect(0, 0, w, h); c.fillStyle = '#e8ecf0'; for (var y = 4; y < h; y += 16) c.fillRect(4, y, w - 8, 8); }, 1, 1) }); R.m.zebraX = lam({ map: makeTex(64, 64, function (c, w, h) { c.fillStyle = '#3c4046'; c.fillRect(0, 0, w, h); c.fillStyle = '#e8ecf0'; for (var x = 4; x < w; x += 16) c.fillRect(x, 4, 8, h - 8); }, 1, 1) }); // 紅綠燈/路燈用材質:亮=MeshBasic(不受光,像發光),暗=深色 var bas = function (c2) { return new THREE.MeshBasicMaterial({ color: c2 }); }; R.m.pole = lam({ color: 0x4a5058 }); R.m.tlBox = lam({ color: 0x22262b }); R.m.bulb = { red: bas(0xff2b2b), yellow: bas(0xffc400), green: bas(0x2bff5c), offRed: lam({ color: 0x3a1414 }), offYellow: lam({ color: 0x3a3214 }), offGreen: lam({ color: 0x143a1c }), }; R.m.lampGlow = bas(0xffe9b0); R.m.carBody = [0xc23b3b, 0x3b6fc2, 0xd9d9de, 0x2f3338, 0xc2903b].map(function (c2) { return lam({ color: c2 }); }); R.m.carCab = lam({ color: 0x1c2430 }); R.m.roof = lam({ color: 0x3a3f47 }); // 屋頂細節(水塔/空調機組/天線):增加建築變化與真實感 R.m.tank = lam({ color: 0x8a8f96 }); R.m.tankCap = lam({ color: 0x5a5f66 }); R.m.acUnit = lam({ color: 0xc9cdd2 }); R.m.antenna = lam({ color: 0x2a2d31 }); R.m.trunk = lam({ color: 0x5d4126 }); R.m.leaf = [0x3f7d3a, 0x4a8c40, 0x357033, 0x568f3e].map(function (c) { return lam({ color: c }); }); R.m.conifer = lam({ color: 0x2f6b35 }); R.m.hill = [lam({ color: 0x647f4b }), lam({ color: 0x71905b })]; R.m.water = lam({ map: makeTex(256, 256, function (c, w, h) { noise(c, w, h, '#2f6591', ['#28587f', '#3a76a8', '#356d9c'], 500, 2, 9); }, 3, 3), transparent: true, opacity: 0.94 }); R.m.pondRim = lam({ color: 0xd9cfa8 }); // 遠景大地:拼布農田(高空/遠望時鋪滿地平線,細節地塊疊在上面) R.m.far = lam({ map: makeTex(512, 512, function (c, w, h) { var cols = ['#66813f', '#7d9247', '#8f9e50', '#5d7a42', '#a3a057', '#71905b']; var n = 8, cell = w / n; for (var i = 0; i < n; i++) for (var j = 0; j < n; j++) { c.fillStyle = cols[((i * 7 + j * 13) % cols.length)]; c.fillRect(i * cell, j * cell, cell, cell); c.fillStyle = 'rgba(50,60,35,.5)'; c.fillRect(i * cell, j * cell, cell, 2); c.fillRect(i * cell, j * cell, 2, cell); } }, FAR_SIZE / CHUNK, FAR_SIZE / CHUNK) }); } function mesh(g, geo, m, sx, sy, sz, x, y, z, rotX) { var o = new THREE.Mesh(geo, m); o.scale.set(sx, sy, sz); if (rotX) o.rotation.x = rotX; o.position.set(x, y, z); o.castShadow = true; o.receiveShadow = true; g.add(o); return o; } function tree(g, x, y, r) { if (!g.userData.sway) g.userData.sway = []; // 樹冠註冊給 weather-fx 隨風搖 var conifer = r() < 0.35; if (conifer) { var hh = 4 + r() * 4, rr = 1.3 + r() * 1.1; mesh(g, R.g.trunk, R.m.trunk, 2.2, 1.2, 2.2, x, y, 0.6, Math.PI / 2); g.userData.sway.push(mesh(g, R.g.cone, R.m.conifer, rr, hh, rr, x, y, 1 + hh / 2, Math.PI / 2)); } else { var th = 2.6 + r() * 1.8, cr = 1.6 + r() * 1.4; mesh(g, R.g.trunk, R.m.trunk, 2.2, th, 2.2, x, y, th / 2, Math.PI / 2); var c = mesh(g, R.g.sphere, R.m.leaf[(r() * R.m.leaf.length) | 0], cr, cr, cr * 1.25, x, y, th + cr * 0.7, 0); c.castShadow = true; g.userData.sway.push(c); } } // ---- 依伺服器佈局(/api/world)建內容:渲染與碰撞判定共用同一份世界 ---- function dataTree(g, t) { if (!g.userData.sway) g.userData.sway = []; if (t.conifer) { mesh(g, R.g.trunk, R.m.trunk, 2.2, 1.2, 2.2, t.x, t.y, 0.6, Math.PI / 2); g.userData.sway.push(mesh(g, R.g.cone, R.m.conifer, t.cr, t.th, t.cr, t.x, t.y, 1 + t.th / 2, Math.PI / 2)); } else { mesh(g, R.g.trunk, R.m.trunk, 2.2, t.th, 2.2, t.x, t.y, t.th / 2, Math.PI / 2); var li = Math.abs(((t.x * 7 + t.y * 13) | 0)) % R.m.leaf.length; g.userData.sway.push(mesh(g, R.g.sphere, R.m.leaf[li], t.cr, t.cr, t.cr * 1.25, t.x, t.y, t.th + t.cr * 0.7, 0)); } } // 屋頂細節:依建築座標(伺服器固定值)產生確定性亂數,同一棟建築每次重建都 // 長一樣的屋頂配置(水塔/空調機組/天線),純視覺不參與碰撞判定。 function buildRooftop(g, b) { var r = rng(Math.round(b.x * 131), Math.round(b.y * 131)); var top = b.h + 0.5; // 屋頂平面(已含 roof 板厚度) var marginW = b.w * 0.32, marginD = b.d * 0.32; if (r() < 0.4) { // 水塔(圓柱塔身+錐頂),置於角落 var tr = 0.9 + r() * 0.5, th = 1.6 + r() * 1.0; var tx = b.x + (r() - 0.5) * marginW, ty = b.y + (r() - 0.5) * marginD; mesh(g, R.g.trunk, R.m.tank, tr * 1.6, th, tr * 1.6, tx, ty, top + th / 2, Math.PI / 2); mesh(g, R.g.cone, R.m.tankCap, tr * 1.7, tr * 0.9, tr * 1.7, tx, ty, top + th + tr * 0.45, Math.PI / 2); } var acN = 1 + Math.floor(r() * 3); for (var i = 0; i < acN; i++) { var ax = b.x + (r() - 0.5) * marginW * 1.6, ay = b.y + (r() - 0.5) * marginD * 1.6; mesh(g, R.g.box, R.m.acUnit, 1.1 + r() * 0.5, 1.1 + r() * 0.5, 0.6, ax, ay, top + 0.3, 0); } if (b.h > 22 && r() < 0.5) { // 高樓偶有天線 mesh(g, R.g.trunk, R.m.antenna, 0.15, 3 + r() * 2.5, 0.15, b.x, b.y, top + (1.5 + r()), Math.PI / 2); } } function buildContent(g, d) { if (!d || !d.theme || d.theme === 'home') return; if (d.theme === 'field') mesh(g, R.g.plane, R.m.field, CHUNK - 10, CHUNK - 10, 1, 0, 0, 0.065, 0); if (d.theme === 'water') mesh(g, R.g.plane, R.m.water, CHUNK, CHUNK, 1, 0, 0, 0.11, 0); if (d.pond) { mesh(g, R.g.circle, R.m.pondRim, d.pond.r + 1.6, d.pond.r + 1.6, 1, d.pond.x, d.pond.y, 0.11, 0); mesh(g, R.g.circle, R.m.water, d.pond.r, d.pond.r, 1, d.pond.x, d.pond.y, 0.13, 0); } (d.buildings || []).forEach(function (b) { mesh(g, R.g.box, R.m.facade[(b.f || 0) % R.m.facade.length], b.w, b.d, b.h, b.x, b.y, b.h / 2, 0); mesh(g, R.g.box, R.m.roof, b.w + 0.8, b.d + 0.8, 0.5, b.x, b.y, b.h + 0.25, 0); buildRooftop(g, b); }); (d.trees || []).forEach(function (t) { dataTree(g, t); }); (d.hills || []).forEach(function (h) { mesh(g, R.g.cone, R.m.hill[(h.m || 0) % 2], h.r, h.h, h.r, h.x, h.y, h.h / 2, Math.PI / 2); }); } function buildChunk(cx, cy, d) { if (!R) initShared(); var g = new THREE.Group(); g.name = 'zy-chunk-' + cx + ',' + cy; g.position.set(cx * CHUNK, cy * CHUNK, 0); var r = rng(cx, cy); mesh(g, R.g.plane, R.m.grass, CHUNK, CHUNK, 1, 0, 0, 0.04, 0); // 草地底 var RX = CHUNK / 2 - 4; // 路中心線 mesh(g, R.g.plane, R.m.roadY, 8, CHUNK, 1, RX, 0, 0.08, 0); // 縱向路 mesh(g, R.g.plane, R.m.roadX, CHUNK, 8, 1, 0, RX, 0.095, 0); // 橫向路 // ---- 路口設施:斑馬線 + 紅綠燈 + 路燈 ---- mesh(g, R.g.plane, R.m.zebraY, 8, 3, 1, RX, RX - 7.5, 0.12, 0); // 縱向路的斑馬線 mesh(g, R.g.plane, R.m.zebraX, 3, 8, 1, RX - 7.5, RX, 0.135, 0); // 橫向路的斑馬線 // 紅綠燈(路口西南角,燈頭朝路口;燈泡存進 userData 供全域輪替) var tx = RX - 6, ty = RX - 6; mesh(g, R.g.trunk, R.m.pole, 1.6, 5.2, 1.6, tx, ty, 2.6, Math.PI / 2); mesh(g, R.g.box, R.m.tlBox, 0.55, 0.55, 1.7, tx, ty, 5.6, 0); function bulb(z) { var b = new THREE.Mesh(R.g.sphere, R.m.bulb.offRed); b.scale.set(0.19, 0.19, 0.19); b.position.set(tx + 0.24, ty + 0.24, z); g.add(b); return b; } g.userData.tl = { phase: ((cx * 31 + cy * 17) % 2 + 2) % 2, red: bulb(6.15), yellow: bulb(5.6), green: bulb(5.05) }; // 路燈(沿兩條路各 2 盞,燈罩恆亮暖光) [[RX - 5.2, 6], [RX - 5.2, -26], [6, RX - 5.2], [-26, RX - 5.2]].forEach(function (pp) { mesh(g, R.g.trunk, R.m.pole, 1.2, 6.0, 1.2, pp[0], pp[1], 3.0, Math.PI / 2); var gl = new THREE.Mesh(R.g.sphere, R.m.lampGlow); gl.scale.set(0.3, 0.3, 0.22); gl.position.set(pp[0] + (pp[1] === RX - 5.2 ? 0 : 0.9), pp[1] + (pp[1] === RX - 5.2 ? 0.9 : 0), 6.1); g.add(gl); }); buildContent(g, d); // 建築/樹/山丘/水:伺服器佈局(與碰撞判定同一份) return g; } // 幾何/材質全共用 → 回收只需移出場景,不 dispose function removeChunk(g) { if (g.parent) g.parent.remove(g); } function dronePos() { var sc = window.scene; var iris = sc && sc.scene && sc.scene.getObjectByName('iris'); if (iris) return iris.position; return sc && sc.camera ? sc.camera.position : null; } function update(pos) { var sc = window.scene; if (!sc || !sc.scene) return; var want = {}; function around(p) { if (!p) return; var ccx = Math.round(p.x / CHUNK), ccy = Math.round(p.y / CHUNK); for (var dx = -RADIUS; dx <= RADIUS; dx++) { for (var dy = -RADIUS; dy <= RADIUS; dy++) { var cx = ccx + dx, cy = ccy + dy; if (Math.sqrt(cx * cx + cy * cy) * CHUNK < HOME_CLEAR) continue; // 原點保留區 want[cx + ',' + cy] = [cx, cy]; } } } // 遠景大地:貼齊 CHUNK 網格跟著鏡頭平移(紋理週期=CHUNK,圖案看起來固定於世界) if (!farGround) { if (!R) initShared(); farGround = new THREE.Mesh(R.g.plane, R.m.far); farGround.scale.set(FAR_SIZE, FAR_SIZE, 1); farGround.position.set(0, 0, 0.015); farGround.receiveShadow = true; farGround.name = 'zy-farground'; sc.scene.add(farGround); } var cp = sc.camera ? sc.camera.position : null; if (cp) farGround.position.set(Math.round(cp.x / CHUNK) * CHUNK, Math.round(cp.y / CHUNK) * CHUNK, 0.015); // 雙中心:無人機 + 鏡頭(不開跟隨、鏡頭留原地時身邊也要長地塊) around(pos || dronePos()); if (!pos && sc.camera) around(sc.camera.position); Object.keys(chunks).forEach(function (k) { if (!want[k]) { removeChunk(chunks[k]); delete chunks[k]; } }); var made = 0; // 每次最多 2 格,避免一口氣建太多掉幀 Object.keys(want).forEach(function (k) { if (made < 2 && !chunks[k] && !pending[k] && Object.keys(chunks).length < MAX_CHUNKS) { made++; pending[k] = true; (function (cx2, cy2, key) { // 佈局取自伺服器(單一事實來源);失敗仍鋪地與路,不留破洞 fetch('/api/world/' + cx2 + '/' + cy2) .then(function (r2) { return r2.json(); }) .catch(function () { return null; }) .then(function (d) { delete pending[key]; if (chunks[key] || !window.scene || !window.scene.scene) return; chunks[key] = buildChunk(cx2, cy2, d); window.scene.scene.add(chunks[key]); }); })(want[k][0], want[k][1], k); } }); // 紅綠燈輪替:週期 10s(綠 4s→黃 1s→紅 5s),相鄰路口相位錯開。 // 定義:t<4 = 縱向(y 軸)車流綠燈;t>=5 = 橫向(x 軸)車流綠燈(4-5 全紅緩衝)。 var now = Date.now() / 1000; Object.keys(chunks).forEach(function (k) { var tl = chunks[k].userData.tl; if (!tl) return; var t = (now + tl.phase * 5) % 10; tl.green.material = t < 4 ? R.m.bulb.green : R.m.bulb.offGreen; tl.yellow.material = (t >= 4 && t < 5) ? R.m.bulb.yellow : R.m.bulb.offYellow; tl.red.material = t >= 5 ? R.m.bulb.red : R.m.bulb.offRed; }); } // ===== 地面車流:沿路網行駛、紅燈停(純視覺,各瀏覽器各自的車) ===== var NCARS = 10, cars = [], lastCarT = null; var RXOFF = CHUNK / 2 - 4; // 路中心相對格心偏移(路線 = k*CHUNK + RXOFF) function lightPhaseT(cx, cy, now) { var phase = ((cx * 31 + cy * 17) % 2 + 2) % 2; return (now + phase * 5) % 10; } function makeCar() { var g = new THREE.Group(); var body = new THREE.Mesh(R.g.box, R.m.carBody[(Math.random() * R.m.carBody.length) | 0]); body.scale.set(1.7, 3.6, 1.0); body.position.z = 0.55; body.castShadow = true; g.add(body); var cab = new THREE.Mesh(R.g.box, R.m.carCab); cab.scale.set(1.5, 1.8, 0.7); cab.position.set(0, -0.3, 1.35); g.add(cab); return g; } function spawnCar(c, center) { c.axis = Math.random() < 0.5 ? 'x' : 'y'; c.dir = Math.random() < 0.5 ? 1 : -1; c.speed = 7 + Math.random() * 5; var base = c.axis === 'x' ? center.y : center.x; c.line = Math.round((base + (Math.random() - 0.5) * 240 - RXOFF) / CHUNK) * CHUNK + RXOFF; var along = (c.axis === 'x' ? center.x : center.y) + (Math.random() - 0.5) * 240; c.s = along; // 原點保留區沒有路,別生在那 if (Math.abs(c.line) < 110 && Math.abs(c.s) < 110) c.s += 200; } function carXY(c) { // 靠右行駛:相對前進方向偏右 2m var lane = 2 * c.dir; return c.axis === 'y' ? { x: c.line + lane, y: c.s } : { x: c.s, y: c.line - lane }; } function stepCars(dt, center) { var sc = window.scene; if (!sc || !sc.scene) return; if (!R) return; while (cars.length < NCARS) { var c = { mesh: makeCar() }; spawnCar(c, center); sc.scene.add(c.mesh); cars.push(c); } var now = Date.now() / 1000; cars.forEach(function (c) { var sNew = c.s + c.speed * dt * c.dir; // 找前方最近路口(路口在垂直方向的線 = j*CHUNK + RXOFF) var jAhead = c.dir > 0 ? Math.ceil((c.s - RXOFF + 7.01) / CHUNK) : Math.floor((c.s - RXOFF - 7.01) / CHUNK); var inter = jAhead * CHUNK + RXOFF; var stopLine = inter - 7 * c.dir; // 該路口所屬 chunk 座標(路口 = chunk 格內 +RXOFF,+RXOFF) var cx = c.axis === 'y' ? Math.round((c.line - RXOFF) / CHUNK) : jAhead; var cy = c.axis === 'y' ? jAhead : Math.round((c.line - RXOFF) / CHUNK); var t = lightPhaseT(cx, cy, now); var green = c.axis === 'y' ? (t < 4) : (t >= 5); // 紅燈停線:車在停止線前(含正停在線上)且這幀會越線 → 壓回線上 if (!green && (stopLine - c.s) * c.dir >= -0.01 && (sNew - stopLine) * c.dir > 0) sNew = stopLine; c.s = sNew; var p = carXY(c); // 進原點保留區(沒路)或離鏡頭太遠 → 搬到附近重生 var far = Math.hypot(p.x - center.x, p.y - center.y); if ((Math.abs(p.x) < 105 && Math.abs(p.y) < 105) || far > 320) { spawnCar(c, center); p = carXY(c); } c.mesh.position.set(p.x, p.y, 0.15); c.mesh.rotation.z = c.axis === 'y' ? (c.dir > 0 ? 0 : Math.PI) : (c.dir > 0 ? -Math.PI / 2 : Math.PI / 2); }); } function carLoop(tms) { var dt = lastCarT == null ? 0.016 : Math.min(0.1, (tms - lastCarT) / 1000); lastCarT = tms; var sc = window.scene; if (sc && sc.camera) stepCars(dt, sc.camera.position); requestAnimationFrame(carLoop); } window.zyTerrain = { update: update, stats: function () { return { chunks: Object.keys(chunks).length, keys: Object.keys(chunks) }; }, groups: function () { return chunks; }, // weather-fx 讀 userData.sway 搖樹用 cars: function () { return cars.map(function (c) { return { axis: c.axis, dir: c.dir, s: Math.round(c.s), line: Math.round(c.line), x: Math.round(c.mesh.position.x), y: Math.round(c.mesh.position.y) }; }); } }; var wait = setInterval(function () { if (window.scene && window.scene.scene && window.THREE) { clearInterval(wait); setInterval(function () { update(); }, 700); requestAnimationFrame(carLoop); // 地面車流(每幀移動、紅燈停) console.log('[infinite-terrain] v2 active (chunk=' + CHUNK + 'm, radius=' + RADIUS + ')'); } }, 400); })();