--- /tmp/main.bak.js 2026-11-14 07:32:29.557516637 +0900 +++ tools/serving_sim_3d/src/main.js 2026-11-14 07:38:43.166492010 +0900 @@ -1,18 +1,33 @@ 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'; +import { loadStore } from 'p2m-stores'; // 店のデータ:試作(チャットの画面)は中に含む・サイトは public/sim/layouts/unity/ を読む(build のときに選ぶ) // サイトの公開データ(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 STORE_KEYS = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5']; +let layout = null, map = null; const $ = (s) => document.querySelector(s); const state = { store: 'p1', robots: 2, staff: 3, crowd: 'normal', seed: 1, speed: 1, playing: true }; +// 試算(/sim/serving-robot)の店の形 ②〜⑦ と同じ名前・人数・来客のペース(試算 v2.9 の STORES の short・staff・staff_all・arrivals) +const CALC = { + p0: { short: '② 定番の郊外型', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [14, 19, 24] }, + p1: { short: '③ 効率重視・入口が角', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] }, + p2: { short: '④ 効率重視・小さめの店', staff: [1, 2, 3], staffAll: [1, 2, 3, 4, 5, 6], arrivals: [20, 28, 36] }, + p3: { short: '⑤ ゆったり・入口が正面', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [33, 46, 59] }, + p4: { short: '⑥ ゆったり・入口が横', staff: [2, 3, 4], staffAll: [1, 2, 3, 4, 5, 6, 7], arrivals: [27, 38, 49] }, + p5: { short: '⑦ ③の通路を95cmに', staff: [3, 4, 5], staffAll: [2, 3, 4, 5, 6, 7, 8], arrivals: [30, 42, 54] }, +}; +const CROWD_KEYS = ['empty', 'normal', 'busy']; +const ROBOTS_MAIN = [0, 1, 2, 3], ROBOTS_ALL = [0, 1, 2, 3, 4, 5, 6]; // 試算と同じ(4〜6台は「その他」) +// 住所の条件から始める(?store=p1&staff=3&robots=2&crowd=normal&day=1)。試算から同じ条件で開くため +{ const q = new URLSearchParams(location.search); + if (CALC[q.get('store')]) state.store = q.get('store'); + if (CROWD_KEYS.includes(q.get('crowd'))) state.crowd = q.get('crowd'); + const n = (k) => parseInt(q.get(k), 10); + if (ROBOTS_ALL.includes(n('robots'))) state.robots = n('robots'); + if (CALC[state.store].staffAll.includes(n('staff'))) state.staff = n('staff'); + else if (!CALC[state.store].staffAll.includes(state.staff)) state.staff = CALC[state.store].staff[1]; + if (n('day') >= 1) state.seed = n('day'); } let sim = null, building = false; // ---------------- 画面 ---------------- @@ -34,7 +49,7 @@ 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; +let W = 24, D = 16; // 店を読むまでの仮の大きさ(読んだら buildStore で入れ替わる) // 店の座標 (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 }); @@ -134,29 +149,48 @@ $('#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(''); } +let rebuildAgain = false; // 建てている間に条件が変わったら、終わってからもう一度建てる async function rebuild() { - if (building) return; building = true; $('#status').textContent = '店を建てています…'; + if (building) { rebuildAgain = true; return; } building = true; $('#status').textContent = '店を建てています…'; + try { [layout, map] = await loadStore(state.store); } + catch (e) { building = false; $('#status').textContent = '店のデータを読めませんでした。ページを読み込み直してください'; return; } 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 }); + buildStore(); $('#store').value = state.store; + sim = await createSim(layout, map, { robots: state.robots, staff: state.staff, crowd: state.crowd, seed: state.seed, arrPerH: CALC[state.store].arrivals[CROWD_KEYS.indexOf(state.crowd)] }); bubbleRobot = -1; rebuildDynamic(); frames = [sim.snapshot()]; viewT = 0; simDone = false; draw(frames[0]); panel(frames[0]); building = false; $('#status').textContent = ''; + if (rebuildAgain) { rebuildAgain = false; rebuild(); } } -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(); }); +function seg(id, values, key, label = (v) => v, more = []) { + // 試算と同じ形:よく使う値はボタン、残りは「その他」の選ぶ欄 + const el = $(id); + el.innerHTML = values.map(v => ``).join('') + + (more.length ? `` : ''); + const sel = el.querySelector('select'); + const sync = () => { + el.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(state[key]) === b.dataset.v)); + if (sel) { const hit = more.map(String).includes(String(state[key])); sel.value = hit ? String(state[key]) : ''; sel.classList.toggle('on', hit); } + }; + const set = (v) => { state[key] = isNaN(+v) ? v : +v; sync(); if (key !== 'speed') rebuild(); }; + el.onclick = (e) => { const b = e.target.closest('button'); if (b) set(b.dataset.v); }; + if (sel) sel.onchange = () => { if (sel.value !== '') set(sel.value); }; 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); +function staffSeg() { + const c = CALC[state.store]; + if (!c.staffAll.includes(state.staff)) state.staff = c.staff[1]; + seg('#c-staff', c.staff, 'staff', v => `${v}人`, c.staffAll.filter(v => !c.staff.includes(v))); +} +function crowdSeg() { seg('#c-crowd', CROWD_KEYS, 'crowd', v => `${CROWD[v].label}`); $('#crowdNote').textContent = `${CALC[state.store].short}の来客は、空いている ${CALC[state.store].arrivals[0]}・普通 ${CALC[state.store].arrivals[1]}・混雑 ${CALC[state.store].arrivals[2]} 組/時(試算と同じ)。混む時間は120分。`; } +$('#store').innerHTML = STORE_KEYS.map((k) => ``).join(''); +$('#store').addEventListener('change', (e) => { state.store = e.target.value; staffSeg(); crowdSeg(); rebuild(); }); +seg('#c-robots', ROBOTS_MAIN, 'robots', v => `${v}台`, ROBOTS_ALL.filter(v => !ROBOTS_MAIN.includes(v))); +staffSeg(); +crowdSeg(); 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; +$('#dayPrev').addEventListener('click', () => setDay(state.seed - 1)); $('#dayNext').addEventListener('click', () => setDay(state.seed + 1)); $('#dayPrev').disabled = state.seed <= 1; $('#dayLabel').textContent = `${state.seed}日目`; document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); // ---------------- 回す ----------------