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) => `
  • ${i + 1}${t.text}
  • `).concat(f0.st.map((t, i) => `
  • S${i + 1}${t.text}
  • `)); const html = rows.join('') || '
  • ロボット 0台
  • '; if (taskList.innerHTML !== html) taskList.innerHTML = html; } const taskList = document.getElementById('tasks'); const VIEWS = { wide: [0, 34, 25], left: [-19, 31, 26], right: [19, 31, 26] }; function setView(k) { const v = VIEWS[k].map(x => x * (mobile.matches ? 1.12 : 1)); camera.position.set(...v); controls.target.set(0, 0, 0); controls.update(); document.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === k)); } const mobile = matchMedia('(max-width: 760px)'); function resize() { const el = $('#view'); const w = el.clientWidth, h = mobile.matches ? Math.round(innerHeight * 0.45) : el.clientHeight; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); const lb = $('#labels'); lb.style.height = h + 'px'; lb.style.bottom = 'auto'; lb.style.top = renderer.domElement.offsetTop + 'px'; el.style.setProperty('--cvt', renderer.domElement.offsetTop + 'px'); el.style.setProperty('--cvh', h + 'px'); $('#hint').textContent = mobile.matches ? '1本指で回す・2本指で寄る/動かす' : 'ドラッグで回す・ホイールで寄る・スペース+ドラッグで動かす'; } mobile.addEventListener('change', resize); addEventListener('resize', resize); // ---------------- パネル ---------------- const fmt = (s) => { const m = Math.floor(s / 60), x = Math.floor(s % 60); return `${m}:${String(x).padStart(2, '0')}`; }; function panel(f) { const s = f.sum; $('#clock').textContent = fmt(f.t); $('#n-delivered').textContent = s.delivered; $('#n-robot').textContent = s.delivered ? Math.round(s.byRobot / s.delivered * 100) : 0; $('#n-wait').textContent = (s.avgDishWait / 60).toFixed(1); $('#n-occ').textContent = `${s.occupied}/${s.tablesN}`; $('#n-waiting').textContent = s.waitingN; $('#n-seatwait').textContent = (s.avgSeatWait / 60).toFixed(1); $('#n-paywait').textContent = (s.avgPayWait / 60).toFixed(1); $('#phase').textContent = s.allDone ? '全員が帰りました' : s.peakOver ? 'ピークが終わり、残りのお客さんが帰るまで' : `ピーク(来客 ${Math.round(sim.arrPerH)}組/時)`; $('#n-stops').textContent = s.sensorStops; $('#n-helps').textContent = s.helps; $('#n-stuck').textContent = s.stucks; $('#log').innerHTML = (sim.allEvents || []).filter(e => e.t <= f.t).slice(-5).reverse().map(e => `
  • ${fmt(e.t)}${e.msg}
  • `).join(''); } async function rebuild() { if (building) return; building = true; $('#status').textContent = '店を建てています…'; if (sim) { sim.crowd.destroy(); } [layout, map] = STORES[state.store]; buildStore(); $('#store').value = state.store; sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed }); bubbleRobot = -1; rebuildDynamic(); frames = [sim.snapshot()]; viewT = 0; simDone = false; draw(frames[0]); panel(frames[0]); building = false; $('#status').textContent = ''; } function seg(id, values, key, label = (v) => v) { const el = $(id); el.innerHTML = values.map(v => ``).join(''); const sync = () => el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v)); el.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; const v = b.dataset.v; state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); }); sync(); } $('#store').innerHTML = Object.entries(STORES).map(([k, [l]]) => ``).join(''); $('#store').addEventListener('change', (e) => { state.store = e.target.value; rebuild(); }); seg('#c-robots', [0, 1, 2, 3, 4, 5, 6], 'robots', v => `${v}台`); seg('#c-staff', [2, 3, 4], 'staff', v => `${v}人`); seg('#c-crowd', ['empty', 'normal', 'busy'], 'crowd', v => CROWD[v].label); seg('#c-speed', [1, 5, 20], 'speed', v => v === 1 ? '実際の速さ' : `${v}倍`); $('#play').addEventListener('click', () => { state.playing = !state.playing; $('#play').textContent = state.playing ? '一時停止' : '再生'; }); $('#restart').addEventListener('click', () => rebuild()); const setDay = (d) => { state.seed = Math.max(1, d); $('#dayLabel').textContent = `${state.seed}日目`; $('#dayPrev').disabled = state.seed <= 1; rebuild(); }; $('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = true; document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); // ---------------- 回す ---------------- // 計算は先に進めて1秒ごとに記録し、画面は記録を見る(時間のスライダーで前後に動かせる) const REC = 1.0, END_CAP = 6 * 3600; let frames = [], viewT = 0, simDone = false, lastT = performance.now(), dragging = false; const slider = $('#seek'); window.__p2m = { get frames() { return frames; } }; // 確かめ用 function computeAhead(budgetMs) { if (!sim || simDone || building) return; const t0 = performance.now(), per = Math.round(REC / DT); while (performance.now() - t0 < budgetMs) { for (let k = 0; k < per; k++) sim.step(); frames.push(sim.snapshot()); const s = frames[frames.length - 1].sum; if (s.allDone || sim.t >= END_CAP) { simDone = true; break; } } } function frameAt(t) { const i = Math.min(frames.length - 1, Math.max(0, Math.floor(t / REC))); return [frames[i], frames[i + 1], (t - i * REC) / REC]; } async function main() { await renderer.init(); $('#backend').textContent = renderer.backend.isWebGPUBackend ? 'WebGPU' : 'WebGL'; resize(); setView('left'); await rebuild(); slider.addEventListener('input', () => { dragging = true; viewT = Math.min(+slider.value, (frames.length - 1) * REC); }); slider.addEventListener('change', () => { dragging = false; }); renderer.setAnimationLoop(() => { const now = performance.now(), dt = Math.min(0.1, (now - lastT) / 1000); lastT = now; if (sim && !building) { computeAhead(10); const end = (frames.length - 1) * REC; if (state.playing && !dragging) viewT = Math.min(end, viewT + dt * state.speed); const [f0, f1, a] = frameAt(viewT); draw(f0, f1, a); panel(f0); slider.max = simDone ? end : Math.max(end, 3 * 3600); if (!dragging) slider.value = viewT; const pct = slider.max > 0 ? end / slider.max * 100 : 0; slider.style.setProperty('--done', pct + '%'); $('#seekinfo').textContent = simDone ? `終わりまで計算済み(${fmt(end)})` : `計算中…(${fmt(end)} まで)`; } controls.update(); renderer.render(scene, camera); }); } main();