import * as THREE from 'three/webgpu'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { createSim, DT, CROWD } from './sim.js'; import p0 from '../stores/p0_memory.json'; import p0m from '../stores/p0_memory_map.json'; import p1 from '../stores/p1_efficient_corner.json'; import p1m from '../stores/p1_efficient_corner_map.json'; import p2 from '../stores/p2_efficient_narrow.json'; import p2m from '../stores/p2_efficient_narrow_map.json'; import p3 from '../stores/p3_relaxed_center.json'; import p3m from '../stores/p3_relaxed_center_map.json'; import p4 from '../stores/p4_relaxed_side.json'; import p4m from '../stores/p4_relaxed_side_map.json'; import p5 from '../stores/p5_crammed.json'; import p5m from '../stores/p5_crammed_map.json'; // サイトの公開データ(public/sim/layouts/unity/)の6店 const STORES = { p0: [p0, p0m], p1: [p1, p1m], p2: [p2, p2m], p3: [p3, p3m], p4: [p4, p4m], p5: [p5, p5m] }; let layout = p1, map = p1m; const $ = (s) => document.querySelector(s); const state = { store: 'p1', robots: 2, staff: 3, crowd: 'normal', seed: 1, speed: 1, playing: true }; let sim = null, building = false; // ---------------- 画面 ---------------- const renderer = new THREE.WebGPURenderer({ antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); $('#view').appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xeceef2); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.screenSpacePanning = false; // スペースを押している間は、左ドラッグで3Dの位置を動かす(離すと回す動きに戻る) let spaceDown = false; const typing = () => { const a = document.activeElement; return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA'); }; addEventListener('keydown', (e) => { if (e.code !== 'Space' || typing()) return; e.preventDefault(); if (spaceDown) return; spaceDown = true; controls.mouseButtons.LEFT = THREE.MOUSE.PAN; renderer.domElement.style.cursor = 'grab'; }); addEventListener('keyup', (e) => { if (e.code !== 'Space') return; spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; }); addEventListener('blur', () => { spaceDown = false; controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; renderer.domElement.style.cursor = ''; }); renderer.domElement.addEventListener('pointerdown', () => { if (spaceDown) renderer.domElement.style.cursor = 'grabbing'; document.activeElement?.blur?.(); }); renderer.domElement.addEventListener('pointerup', () => { if (spaceDown) renderer.domElement.style.cursor = 'grab'; }); scene.add(new THREE.HemisphereLight(0xffffff, 0xb8b8b0, 2.2)); const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(8, 20, 6); scene.add(sun); let W = layout.outer.w, D = layout.outer.d; // 店の座標 (x, y) → three (x, 0, -y)。奥が画面の奥 const P = (x, y, h = 0) => new THREE.Vector3(x - W / 2, h, -(y - D / 2)); const mat = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, ...o }); // 色は Unity の素材(ServiceSim/Store/Materials の _Color)と同じ const M = { floor: mat(0xdbd6c9), wall: mat(0x666e7a), back: mat(0x8a909a), table: mat(0xcc7d36), bench: mat(0x9e544a), chair: mat(0x215e6b), staff: mat(0x1761e6), guest: mat(0xfacc21), robot: mat(0xf5f5f7, { roughness: 0.4 }), stop: mat(0xd64545), help: mat(0x111111), mark: mat(0xff540a), waiting: mat(0x8c4dbf) }; function box(rect, h, m, y0 = 0) { const [x0, y0r, x1, y1] = rect; const g = new THREE.BoxGeometry(x1 - x0, h, y1 - y0r); const o = new THREE.Mesh(g, m); o.position.copy(P((x0 + x1) / 2, (y0r + y1) / 2, y0 + h / 2)); (storeGroup || scene).add(o); return o; } let storeGroup = null; function buildStore() { if (storeGroup) scene.remove(storeGroup); storeGroup = new THREE.Group(); scene.add(storeGroup); W = layout.outer.w; D = layout.outer.d; box([0, 0, W, D], 0.02, M.floor, -0.02); for (const w of layout.outer_walls) box(w, 1.0, M.wall); for (const w of layout.walls) box(w.rect, 1.0, M.wall); for (const r of layout.rooms) if (!r.walkable) box(r.rect, 0.9, M.back); for (const t of layout.tables) { const p = t.parts || {}; if (p.top) box(p.top, 0.72, M.table); else box(t.footprint, 0.72, M.table); for (const b of p.benches || []) box(b, 0.45, M.bench); for (const c of p.chairs || []) box(c.rect || c, 0.45, M.chair); } } // 人とロボットの形(数は動的) // Unity の prefab と同じ形(LOG364 で公開した Guest / Staff / SeatedGuestBody / ServingRobot) // 人:カプセルの胴(半径0.2m・高さ1.59m)+球の頭(直径0.22m・中心1.59m) // 座った客:カプセルの胴(高さ0.79m・中心0.845m)+頭(中心1.24m) // ロボット:筒(直径0.5m・高さ1.2m)+前の印(0.3×0.06×0.06m・高さ0.96m・前へ0.225m) const bodyGeo = new THREE.CapsuleGeometry(0.2, 1.19, 6, 16), headGeo = new THREE.SphereGeometry(0.11, 16, 12); const seatBodyGeo = new THREE.CapsuleGeometry(0.2, 0.39, 6, 16); const robotGeo = new THREE.CylinderGeometry(0.25, 0.25, 1.2, 24), markGeo = new THREE.BoxGeometry(0.06, 0.06, 0.3); const lampGeo = new THREE.SphereGeometry(0.09, 12, 8); function person(m, seated) { const g = new THREE.Group(); const b = new THREE.Mesh(seated ? seatBodyGeo : bodyGeo, m); b.position.y = seated ? 0.845 : 0.795; const h = new THREE.Mesh(headGeo, m); h.position.y = seated ? 1.24 : 1.59; g.add(b, h); g.userData.mats = [b, h]; return g; } function robot() { const g = new THREE.Group(); const b = new THREE.Mesh(robotGeo, M.robot); b.position.y = 0.6; const k = new THREE.Mesh(markGeo, M.mark); k.position.set(0.225, 0.96, 0); k.rotation.y = Math.PI / 2; g.add(b, k); return g; } let waitMeshes = [], robotMeshes = [], lamps = [], personPool = [], seatMeshes = []; function rebuildDynamic() { for (const o of [...robotMeshes, ...lamps, ...personPool, ...seatMeshes, ...waitMeshes]) scene.remove(o); waitMeshes = []; for (const el of labels.values()) el.remove(); labels.clear(); robotMeshes = sim.robots.map(() => { const m = robot(); scene.add(m); return m; }); lamps = sim.robots.map(() => { const m = new THREE.Mesh(lampGeo, M.stop); scene.add(m); return m; }); personPool = []; seatMeshes = []; } const labelLayer = document.getElementById('labels'); const labels = new Map(); const _v = new THREE.Vector3(); // 立ち往生の吹き出し(ロボットの頭の上。出たら最低6秒は出しておく) const bubble = document.getElementById('bubble'); let bubbleRobot = -1, bubbleUntil = 0, bubbleTitle = ''; function placeBubble(pos) { _v.copy(pos).project(camera); const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight, top = renderer.domElement.offsetTop; const x = Math.min(w - 12, Math.max(12, (_v.x * 0.5 + 0.5) * w)), y = (-_v.y * 0.5 + 0.5) * h + top; bubble.style.left = x + 'px'; bubble.style.top = (y - 26) + 'px'; } function label(key, text, cls, pos) { let el = labels.get(key); if (!el) { el = document.createElement('div'); labelLayer.appendChild(el); labels.set(key, el); } el.className = 'lbl ' + cls; el.textContent = text; _v.copy(pos).project(camera); const w = renderer.domElement.clientWidth, h = renderer.domElement.clientHeight; el.style.transform = `translate(${(_v.x * 0.5 + 0.5) * w}px, ${(-_v.y * 0.5 + 0.5) * h}px) translate(-50%, -100%)`; el.style.display = _v.z < 1 ? '' : 'none'; el.dataset.seen = '1'; } // 記録した様子を描く(f0 と f1 のあいだを a で補間) function draw(f0, f1, a) { if (!f0) return; if (!f1) { f1 = f0; a = 0; } for (const el of labels.values()) el.dataset.seen = ''; const nR = f0.R.length / 3; for (let i = 0; i < nR; i++) { const x = f0.R[i * 3] + (f1.R[i * 3] - f0.R[i * 3]) * a, y = f0.R[i * 3 + 1] + (f1.R[i * 3 + 1] - f0.R[i * 3 + 1]) * a; let h0 = f0.R[i * 3 + 2], dh = f1.R[i * 3 + 2] - h0; while (dh > Math.PI) dh -= 2 * Math.PI; while (dh < -Math.PI) dh += 2 * Math.PI; robotMeshes[i].position.copy(P(x, y, 0)); robotMeshes[i].rotation.y = h0 + dh * a; const tone = f0.rt[i].tone; const l = lamps[i]; l.position.copy(P(x, y, 1.35)); l.visible = tone === 'stop' || tone === 'stuck' || tone === 'help'; l.material = tone === 'help' ? M.help : M.stop; label('r' + (i + 1), String(i + 1), 'robot ' + tone, P(x, y, 1.55)); if (tone === 'stuck' && (bubbleRobot < 0 || performance.now() > bubbleUntil)) { bubbleRobot = i; bubbleUntil = performance.now() + 6000; bubbleTitle = `ロボット${i + 1}が30秒動けず立ち往生`; } if (i === bubbleRobot) placeBubble(P(x, y, 1.55)); } if (bubbleRobot >= nR || (bubbleRobot >= 0 && performance.now() > bubbleUntil && f0.rt[bubbleRobot].tone !== 'stuck')) bubbleRobot = -1; bubble.classList.toggle('on', bubbleRobot >= 0); if (bubbleRobot >= 0) document.getElementById('bubble-t').textContent = bubbleTitle; const nP = f0.P.length / 4, next = new Map(); for (let k = 0; k < f1.P.length / 4; k++) next.set(f1.P[k * 4], k); while (personPool.length < nP) { const m = person(M.guest, false); scene.add(m); personPool.push(m); } personPool.forEach((m, i) => { m.visible = i < nP; if (i >= nP) return; let x = f0.P[i * 4 + 1], y = f0.P[i * 4 + 2]; const k = next.get(f0.P[i * 4]); if (k !== undefined) { x += (f1.P[k * 4 + 1] - x) * a; y += (f1.P[k * 4 + 2] - y) * a; } m.position.copy(P(x, y, 0)); const sid = f0.P[i * 4 + 3]; const mt = sid ? M.staff : M.guest; for (const c of m.userData.mats) c.material = mt; if (sid) label('s' + sid, 'S' + sid, 'staff', P(x, y, 1.95)); }); const nS = f0.S.length / 2; while (seatMeshes.length < nS) { const m = person(M.guest, true); scene.add(m); seatMeshes.push(m); } seatMeshes.forEach((m, i) => { m.visible = i < nS; if (i < nS) m.position.copy(P(f0.S[i * 2], f0.S[i * 2 + 1], 0)); }); const wr = (layout.places.find(p => p.kind === 'Waiting') || {}).rect; const nW = Math.min(8, f0.waitN); while (waitMeshes.length < 8) { const m = person(M.waiting, false); scene.add(m); waitMeshes.push(m); } waitMeshes.forEach((m, i) => { m.visible = !!wr && i < nW; if (wr && i < nW) { const vert = (wr[3] - wr[1]) >= (wr[2] - wr[0]); const f = (i + 0.5) / 8; m.position.copy(vert ? P((wr[0] + wr[2]) / 2, wr[1] + (wr[3] - wr[1]) * f, 0) : P(wr[0] + (wr[2] - wr[0]) * f, (wr[1] + wr[3]) / 2, 0)); } }); for (const [k, el] of labels) if (!el.dataset.seen) { el.remove(); labels.delete(k); } const rows = f0.rt.map((t, i) => `