// ブラウザ版 配膳ロボットシミュレーション — 動きと数えるところ(画面とは分ける) // 決まりの一覧:Project の claude/browser_sim_rules.md // 店の形・マス目はサイト(public/sim/layouts/unity/)のものをそのまま読む。 import { init as recastInit, Crowd } from 'recast-navigation'; import { generateSoloNavMesh } from '@recast-navigation/generators'; export const DT = 0.05; // 1コマ 0.05 秒(固定) // ---- サイト・Unity の値(決まりの一覧 A・B)---- export const RULES = { robotRadius: 0.25, robotMaxSpeed: 0.6, robotAccel: 1.0, sensorRange: 0.6, sensorHalfWidth: 0.25, resumeAfterClear: 1.0, detourAfter: 3.0, // 前がふさがって3秒で回り込む道を探す helpAfter: 5.0, helpRadius: 7.0, // 5秒止まったら7m以内の人が動かしに来る stuckAfter: 30.0, // 30秒で立ち往生(スタッフが動かしに行く) rescueSec: 15.0, // 救出15秒 tableDwell: 25.0, // 卓での受け渡し robotFirstWait: 120.0, // ロボット優先。2分待っても来なければ人が運ぶ staffSpeed: 1.0, guestSpeed: 0.8, personRadius: 0.2, }; // 混み具合 = 来客のペース(サイトの試算:標準ファミレス35卓で 25・35・45 組/時。卓の数に合わせて増減) export const CROWD = { empty: { arrivalsPerH35: 25, label: '空いている' }, normal: { arrivalsPerH35: 35, label: '普通' }, busy: { arrivalsPerH35: 45, label: '混んでいる' }, }; // サイトの試算・Unity の値(決まりの一覧 A・B) export const STORE = { peakMin: 120, guideSec: 45, paySec: 90, clearSec: 40, wipeSec: 40, firstDishes: [[5, 8, 12], [8, 12, 18]], eatMin: [20, 30, 45], groupSizeWeights: [0.20, 0.35, 0.20, 0.25], // Unity の ServiceSpec(1〜4人) walkersPerSeatedPerHour: 0.4, // トイレ・ドリンクバーへ立つ(仮の値) }; function tri(rng, [a, m, b]) { const u = rng(), c = (m - a) / (b - a); return u < c ? a + Math.sqrt(u * (b - a) * (m - a)) : b - Math.sqrt((1 - u) * (b - a) * (b - m)); } function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } // ---------------- マス目と道(Unity と同じ:ロボットは NavMesh を使わずマス目で道を引く)---------------- export class Grid { constructor(map, fc) { this.nx = map.nx; this.ny = map.ny; this.res = map.res; this.fc = fc; this.tmp = new Uint8Array(fc.length); } idx(x, y) { return Math.floor(y / this.res) * this.nx + Math.floor(x / this.res); } ok(x, y) { const i = Math.floor(x / this.res), j = Math.floor(y / this.res); return i >= 0 && j >= 0 && i < this.nx && j < this.ny && this.fc[j * this.nx + i] > 0; } snap(fc, x, y) { const cx = Math.floor(x / this.res), cy = Math.floor(y / this.res); for (let d = 0; d < 60; d++) { let best = -1, bd = 1e9; for (let j = -d; j <= d; j++) for (let i = -d; i <= d; i++) { if (Math.max(Math.abs(i), Math.abs(j)) !== d) continue; const a = cx + i, b = cy + j; if (a < 0 || b < 0 || a >= this.nx || b >= this.ny) continue; if (fc[b * this.nx + a]) { const dd = i * i + j * j; if (dd < bd) { bd = dd; best = b * this.nx + a; } } } if (best >= 0) return best; } return -1; } // blockers: [{x,y,r}] 回り込むときに避ける人・ロボット plan(sx, sy, gx, gy, blockers = []) { const { nx, ny, res } = this; let fc = this.fc; if (blockers.length) { this.tmp.set(this.fc); fc = this.tmp; for (const b of blockers) { const R = Math.ceil((b.r) / res); const cx = Math.floor(b.x / res), cy = Math.floor(b.y / res); for (let j = -R; j <= R; j++) for (let i = -R; i <= R; i++) { if (i * i + j * j > R * R) continue; const a = cx + i, c = cy + j; if (a >= 0 && c >= 0 && a < nx && c < ny) fc[c * nx + a] = 0; } } } const s = this.snap(fc, sx, sy), g = this.snap(fc, gx, gy); if (s < 0 || g < 0) return null; const N = nx * ny, G = this._G || (this._G = new Float32Array(N)), P = this._P || (this._P = new Int32Array(N)), C = this._C || (this._C = new Uint8Array(N)); G.fill(Infinity); P.fill(-1); C.fill(0); const gxI = g % nx, gyI = (g / nx) | 0; const h = (i) => { const dx = Math.abs(i % nx - gxI), dy = Math.abs(((i / nx) | 0) - gyI); return (Math.max(dx, dy) + 0.41421356 * Math.min(dx, dy)) * res; }; const heap = []; const push = (f, i) => { heap.push(f, i); let k = heap.length / 2 - 1; while (k > 0) { const p = (k - 1) >> 1; if (heap[2 * p] <= heap[2 * k]) break; [heap[2 * p], heap[2 * k]] = [heap[2 * k], heap[2 * p]]; [heap[2 * p + 1], heap[2 * k + 1]] = [heap[2 * k + 1], heap[2 * p + 1]]; k = p; } }; const pop = () => { const i = heap[1]; const lf = heap.pop(), lf0 = heap.pop(); if (heap.length) { heap[0] = lf0; heap[1] = lf; let k = 0, n = heap.length / 2; for (;;) { const a = 2 * k + 1, b = a + 1; let m = k; if (a < n && heap[2 * a] < heap[2 * m]) m = a; if (b < n && heap[2 * b] < heap[2 * m]) m = b; if (m === k) break; [heap[2 * m], heap[2 * k]] = [heap[2 * k], heap[2 * m]]; [heap[2 * m + 1], heap[2 * k + 1]] = [heap[2 * k + 1], heap[2 * m + 1]]; k = m; } } return i; }; const D = [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41421356], [1, -1, 1.41421356], [-1, 1, 1.41421356], [-1, -1, 1.41421356]]; G[s] = 0; push(h(s), s); let found = false, exp = 0; while (heap.length && exp < 1500000) { const i = pop(); if (C[i]) continue; C[i] = 1; exp++; if (i === g) { found = true; break; } const x = i % nx, y = (i / nx) | 0; for (const [dx, dy, c] of D) { const a = x + dx, b = y + dy; if (a < 0 || b < 0 || a >= nx || b >= ny) continue; const j = b * nx + a, v = fc[j]; if (!v) continue; if (dx && dy && (!fc[y * nx + a] || !fc[b * nx + x])) continue; const ng = G[i] + c * res * (v === 2 ? 1 : 3); if (ng < G[j]) { G[j] = ng; P[j] = i; push(ng + h(j), j); } } } if (!found) return null; const cells = []; for (let k = g; k >= 0; k = P[k]) cells.push(k); cells.reverse(); // 見通しが通る所は1本の線にまとめる(マス目の折れを消す) const pt = (k) => [(k % nx + 0.5) * res, (((k / nx) | 0) + 0.5) * res]; const los = (a, b) => { const [x0, y0] = pt(a), [x1, y1] = pt(b); const lo = Math.min(fc[a], fc[b]); const n = Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (res * 0.5)); for (let t = 1; t < n; t++) { const x = x0 + (x1 - x0) * t / n, y = y0 + (y1 - y0) * t / n; const v = fc[Math.floor(y / res) * nx + Math.floor(x / res)]; if (v < lo || !v) return false; } return true; }; const out = [pt(cells[0])]; let a = 0; while (a < cells.length - 1) { let c = a + 1; while (c + 1 < cells.length && los(cells[a], cells[c + 1])) c++; out.push(pt(cells[c])); a = c; } let len = 0; for (let k = 1; k < out.length; k++) len += Math.hypot(out[k][0] - out[k - 1][0], out[k][1] - out[k - 1][1]); return { points: out, length: len }; } } export async function decodeMap(map) { const bin = Uint8Array.from(atob(map.free_class), c => c.charCodeAt(0)); let buf; if (typeof DecompressionStream !== 'undefined') { const ds = new Blob([bin]).stream().pipeThrough(new DecompressionStream('gzip')); buf = new Uint8Array(await new Response(ds).arrayBuffer()); } else { const zlib = await import(/* @vite-ignore */ 'node:' + 'zlib'); buf = new Uint8Array(zlib.gunzipSync(bin)); } return buf; } // ---------------- シミュレーション本体 ---------------- export class Sim { constructor(layout, grid, opts) { this.L = layout; this.grid = grid; this.o = { robots: 2, staff: 3, crowd: 'normal', seed: 1, ...opts }; this.rng = mulberry32(this.o.seed); this.rngG = mulberry32(this.o.seed * 7919 + 17); /* 来客・人数・料理・食事の時間は別の乱数(台数を変えても同じ客が来る) */ this.t = 0; this.events = []; this.c = { delivered: 0, byRobot: 0, byStaff: 0, sensorStops: 0, detours: 0, helps: 0, stucks: 0, tripSum: 0, trips: 0, dishWaitSum: 0 }; const P = (k) => layout.places.filter(p => p.kind === k); this.pass = P('FoodPass')[0].point; this.standby = P('RobotStandby').map(p => p.point); this.posts = P('StaffPost').map(p => p.point); this.toilet = P('Toilet')[0].point; this.drink = P('DrinkBar')[0].point; this.tables = layout.tables.filter(t => t.stop); this.entrance = P('Entrance')[0].point; this.waitPt = P('Waiting')[0].point; this.register = P('Register')[0].point; const rb = layout.rooms.find(r => r.walkable && r.rect[0] >= this.register[0]); this.regBack = rb ? [(rb.rect[0] + rb.rect[2]) / 2, this.register[1]] : [this.register[0] + 1.5, this.register[1]]; this.tableState = new Map(this.tables.map(t => [t.id, { t, state: 'free', group: null }])); // free / taken / dirty / clearing this.seated = []; this.groups = []; this.waiting = []; this.payQueue = []; this.jobs = []; this.queue = []; // 受け渡し口で待っている料理 this.arrPerH = CROWD[this.o.crowd].arrivalsPerH35 * this.tables.length / 35; this.nextArrival = this._expG(3600 / this.arrPerH); this.gid = 0; this.c.groups = 0; this.c.seatWaitSum = 0; this.c.seatedGroups = 0; this.c.payWaitSum = 0; this.c.paid = 0; this.c.left = 0; } _expG(mean) { return -Math.log(1 - this.rngG()) * mean; } _pickSize() { let r = this.rngG(), w = STORE.groupSizeWeights; for (let i = 0; i < w.length; i++) { r -= w[i]; if (r <= 0) return i + 1; } return w.length; } _freeTableFor(size) { let best = null; for (const ts of this.tableState.values()) if (ts.state === 'free' && ts.t.seats >= size && (!best || ts.t.seats < best.t.seats)) best = ts; return best; } _post(job) { job.posted = this.t; this.jobs.push(job); } async setup(navMesh) { this.crowd = new Crowd(navMesh, { maxAgents: 64, maxAgentRadius: 0.4 }); this.robots = []; for (let i = 0; i < this.o.robots; i++) { const p = this.standby[i % this.standby.length]; const ag = this.crowd.addAgent({ x: p[0], y: 0, z: p[1] }, { radius: RULES.robotRadius, height: 1.2, maxSpeed: RULES.robotMaxSpeed, maxAcceleration: 1, updateFlags: 0, separationWeight: 0 }); this.robots.push({ id: i + 1, x: p[0], y: p[1], h: Math.PI, v: 0, state: 'idle', home: p, path: null, wp: 0, stopT: 0, clearT: 0, dish: null, tripStart: 0, dwell: 0, ag, helped: false, stuckCounted: false, helper: null, flag: '' }); } this.people = []; for (let i = 0; i < this.o.staff; i++) { const p = this.posts[i % this.posts.length]; const st = this._addPerson('staff', p, RULES.staffSpeed); st.sid = i + 1; this.people.push(st); } } _addPerson(kind, p, speed) { const ag = this.crowd.addAgent({ x: p[0], y: 0, z: p[1] }, { radius: RULES.personRadius, height: 1.7, maxSpeed: speed, maxAcceleration: 2.0, separationWeight: 2 }); return { uid: (this._uid = (this._uid || 0) + 1), kind, ag, x: p[0], y: p[1], job: null, home: p, busyUntil: 0, carrying: false }; } log(msg) { this.events.push({ t: this.t, msg }); (this.allEvents ||= []).push({ t: this.t, msg }); if (this.events.length > 40) this.events.shift(); } step() { const t = (this.t += DT), R = RULES, rng = this.rng; // 来客(ピークの2時間だけ。そのあとは全員が帰るまで回す) if (t >= this.nextArrival && t < STORE.peakMin * 60) { const g = { id: ++this.gid, size: this._pickSize(), arrive: t, state: 'waiting', dishesLeft: 0, rr: mulberry32(Math.floor(this.rngG() * 4294967295)) }; this.groups.push(g); this.waiting.push(g); this.c.groups++; this.nextArrival = t + this._expG(3600 / this.arrPerH); } // 待合の先頭から、入れる卓があれば案内を頼む(スタッフの手が空いたら案内) for (const g of this.waiting) if (!g.guideJob) { const ts = this._freeTableFor(g.size); if (!ts) continue; ts.state = 'taken'; ts.group = g; g.table = ts; g.guideJob = { kind: 'guide', prio: 1, group: g }; this._post(g.guideJob); } for (const d of this.queue) if (!d.staffOk && t - d.ready >= R.robotFirstWait) d.staffOk = true; // 2分でスタッフも運べる // 料理ができる・食べ終わる・会計へ for (const g of this.groups) { if (g.state === 'seated') { for (const c of g.cooks) if (!c.done && t >= c.at) { c.done = true; this.queue.push({ table: g.table.t, group: g, ready: t, staffOk: this.robots.length === 0 }); } } if (g.state === 'eating' && t >= g.eatEnd) this._finishEating(g); if (g.state === 'waitPay' && this.payQueue[0] === g) this._stand(g); } // トイレ・ドリンクバーへ立つ客 const sitting = this.seated.filter(x => !x.away); if (sitting.length && rng() < sitting.length * STORE.walkersPerSeatedPerHour / 3600 * DT) { const s = sitting[Math.floor(rng() * sitting.length)]; s.away = true; const tb = this.tables.find(x => x.id === s.table); const g = this._addPerson('guest', tb ? tb.stop : [s.x, s.y], R.guestSpeed); const tgt = rng() < 0.5 ? this.toilet : this.drink; g.seat = s; g.job = { kind: 'walk', legs: [tgt, tb ? tb.stop : [s.x, s.y]], leg: 0, dwell: tgt === this.toilet ? 120 : 20 }; this._goto(g, tgt); this.people.push(g); } this._robots(t); this._yieldTick(t); this._staff(t); this._guests(t); this.crowd.update(DT); for (const p of this.people) { const q = p.ag.position(); p.x = q.x; p.y = q.z; } } _goto(p, pt) { const q = this.crowd.navMeshQuery.findClosestPoint({ x: pt[0], y: 0, z: pt[1] }); const np = q.point || { x: pt[0], y: 0, z: pt[1] }; p.ag.requestMoveTarget(np); p.tgt = pt; p.tgtNav = [np.x, np.z]; p.progT = 0; p.progAt = [p.x, p.y]; } // 着いたか:地図の上で行ける一番近い点で見る。30秒進めなければ(人ごみで詰まったら)その場で着いたことにする(Unity の staffGiveUpSec と同じ考え) _arrived(p, r = 0.35) { if (!p.tgt) return false; const q = p.tgtNav || p.tgt; if (Math.hypot(p.x - q[0], p.y - q[1]) < Math.max(r, 0.35)) return true; if (Math.hypot(p.x - p.progAt[0], p.y - p.progAt[1]) > 0.3) { p.progAt = [p.x, p.y]; p.progT = this.t; return false; } if (!p.progT) p.progT = this.t; if (this.t - p.progT > 30) { this.c.giveUps = (this.c.giveUps || 0) + 1; p.progT = this.t; return true; } return false; } _robots(t) { const R = RULES; for (const r of this.robots) { if (r.state === 'idle') { // 受け渡し口で待っている料理を取る const d = this.queue.find(q => !q.taken); if (d) { d.taken = r; r.dish = d; this.queue.splice(this.queue.indexOf(d), 1); r.state = 'toPass'; this._plan(r, this.pass); r.tripStart = t; } continue; } if (r.state === 'dwell') { if ((r.dwell -= DT) <= 0) { this._deliver(r.dish, t, true); r.dish = null; r.state = 'home'; this._plan(r, r.home); } continue; } if (r.state === 'rescued') { if ((r.dwell -= DT) <= 0) { r.state = r.resume; r.stopT = 0; r.helped = false; r.stuckCounted = false; r.helper = null; this._plan(r, r.goal, true); } continue; } if (r.state === 'pushed') { this._retreatTick(r, 0.3); if (r.state === 'pushed' && !r.pushPath.length && r.pushT > RULES.rescueSec) this._endRetreat(r); this._syncAgent(r); continue; } if (r.state === 'sidestep') { this._retreatTick(r, RULES.robotMaxSpeed * 0.5); this._syncAgent(r); continue; } // 走る if (this._aisleGate(r)) { r.v = 0; r.flag = 'wait'; this._syncAgent(r); continue; } const blocker = this._sensor(r); if (blocker) { if (r.stopT === 0) { this.c.sensorStops++; } if (blocker.who && blocker.who.ag && !blocker.who.yieldUntil) this._yield(blocker.who, r, t); if (blocker.robot && this._robotYields(r, blocker.robot) && this._retreat(r, blocker.robot, 'sidestep')) { this._syncAgent(r); continue; } // 道をゆずって止まっている台がまだ前にいるなら、その台にもっと離れてもらう if (blocker.robot && blocker.robot.state === 'sidestep' && !blocker.robot.pushPath.length && r.stopT > 2 && !(r.reaskAt > this.t)) { r.reaskAt = this.t + 5; this._retreat(blocker.robot, r, 'sidestep'); } r.stopT += DT; r.v = 0; r.clearT = 0; r.flag = 'stop'; r.blockedBy = blocker.robot ? `ロボット${blocker.robot.id}` : blocker.who ? (blocker.who.kind === 'staff' ? `スタッフ${blocker.who.sid}` : '客') : '人'; if (r.stopT >= R.detourAfter && !r.detoured) { r.detoured = true; this.c.detours++; this._plan(r, r.goal, false, [{ x: blocker.x, y: blocker.y, r: (blocker.rr || R.personRadius) + R.robotRadius + 0.1 }]); } if (r.stopT >= R.helpAfter && !r.helped) { this._callHelp(r, R.helpRadius, 'help'); r.helpRetry = r.stopT + 10; } else if (r.helped && !r.helper && r.stopT >= r.helpRetry) { this._callHelp(r, r.stopT >= R.stuckAfter ? 999 : R.helpRadius, 'retry'); r.helpRetry = r.stopT + 10; } if (r.stopT >= R.stuckAfter && !r.stuckCounted) { r.stuckCounted = true; this.c.stucks++; this.log(`ロボット${r.id} が30秒動けず立ち往生`); if (!r.helper) this._callHelp(r, 999, 'stuck'); } this._syncAgent(r); continue; } if (r.stopT > 0) { r.clearT += DT; if (r.clearT < R.resumeAfterClear) { this._syncAgent(r); continue; } r.stopT = 0; r.detoured = false; r.flag = ''; } this._follow(r); if (!r.path || r.wp >= r.path.length) { // 着いた if (r.state === 'toPass') { r.state = 'toTable'; this._plan(r, r.dish.table.stop); } else if (r.state === 'toTable') { r.state = 'dwell'; r.dwell = R.tableDwell; r.v = 0; } else if (r.state === 'home') { r.state = 'idle'; r.v = 0; this.c.trips++; this.c.tripSum += t - r.tripStart; } } this._syncAgent(r); } } // スタッフが手で押して、来た道を1.5mほど戻す(0.3m/秒・瞬間移動なし)。救出15秒のうちに終える _pushTick(r) { r.pushT += DT; const w = r.pushPath && r.pushPath[0]; if (w) { const dx = w[0] - r.x, dy = w[1] - r.y, d = Math.hypot(dx, dy); if (d < 0.05) r.pushPath.shift(); else { const st = Math.min(0.3 * DT, d); r.x += dx / d * st; r.y += dy / d * st; } } if (r.pushT >= RULES.rescueSec) { r.state = r.resume; r.stopT = 0; r.helped = false; r.stuckCounted = false; r.helper = null; r.detoured = false; r.flag = ''; r.trail = []; r.holdT = 4; this._plan(r, r.goal); } } // ロボットどうし:料理を載せた台が先、空の台がよける。同じなら番号の大きい台がよける(Unity の譲る順番) _robotYields(r, o) { if (o.state === 'pushed' || o.state === 'sidestep') return false; const moving = o.state === 'toPass' || o.state === 'toTable' || o.state === 'home'; if (!moving) return false; // 止まっている台(待機・受け渡し中)は障害物として回り込む if (!!r.dish !== !!o.dish) return !r.dish; // 空の方がよける return r.id > o.id; } // よける場所:相手のこの先4mの道から体2つ分離れた、2.5m以内の空いた場所(Unity の yieldSideSearchM 2.5) _yieldSpot(r, o) { const g = this.grid, segs = []; let px = o.x, py = o.y, acc = 0; if (o.path) for (let k = o.wp; k < o.path.length && acc < 4; k++) { const q = o.path[k]; segs.push([px, py, q[0], q[1]]); acc += Math.hypot(q[0] - px, q[1] - py); px = q[0]; py = q[1]; } else segs.push([o.x, o.y, o.x, o.y]); const dseg = (x, y, [x0, y0, x1, y1]) => { const dx = x1 - x0, dy = y1 - y0, L2 = dx * dx + dy * dy; const u = L2 ? Math.max(0, Math.min(1, ((x - x0) * dx + (y - y0) * dy) / L2)) : 0; return Math.hypot(x - x0 - u * dx, y - y0 - u * dy); }; const clear = 2 * RULES.robotRadius + 0.15; let best = null, bd = 1e9; for (let j = -25; j <= 25; j++) for (let i = -25; i <= 25; i++) { const x = r.x + i * 0.1, y = r.y + j * 0.1, d = Math.hypot(i, j) * 0.1; if (d > 2.5 || d >= bd) continue; if (!g.ok(x, y) || !g.ok(x + 0.2, y) || !g.ok(x - 0.2, y) || !g.ok(x, y + 0.2) || !g.ok(x, y - 0.2)) continue; if (segs.some(sg => dseg(x, y, sg) < clear)) continue; if (o.goal && Math.hypot(o.goal[0] - x, o.goal[1] - y) < 1.0) continue; if (Math.hypot(this.pass[0] - x, this.pass[1] - y) < 0.9) continue; if (this.robots.some(q => q !== r && Math.hypot(q.x - x, q.y - y) < clear)) continue; best = [x, y]; bd = d; } return best; } _retreat(r, o, why) { const spot = this._yieldSpot(r, o); if (!spot) return false; const pl = this.grid.plan(r.x, r.y, spot[0], spot[1], [{ x: o.x, y: o.y, r: 2 * RULES.robotRadius }]); if (!pl) return false; r.resume = r.resume && (r.state === 'sidestep' || r.state === 'pushed') ? r.resume : r.state; r.state = why; r.pushT = 0; r.pushPath = pl.points.slice(1); r.waitFor = o; r.flag = why === 'pushed' ? 'help' : 'yield'; if (why === 'sidestep') this.c.robotYields = (this.c.robotYields || 0) + 1; return true; } _sidestep(r, o) { if (!this._retreat(r, o, 'sidestep')) { r.stopT += 0; } } _endRetreat(r) { r.state = r.resume; r.resume = null; r.stopT = 0; r.flag = ''; r.trail = []; r.detoured = false; r.helped = false; r.stuckCounted = false; r.helper = null; r.waitFor = null; this._plan(r, r.goal); } _retreatTick(r, speed) { r.pushT += DT; const w = r.pushPath[0]; if (w) { const dx = w[0] - r.x, dy = w[1] - r.y, d = Math.hypot(dx, dy); if (d < 0.05) r.pushPath.shift(); else { const st = Math.min(speed * DT, d); r.x += dx / d * st; r.y += dy / d * st; r.h = Math.atan2(dy, dx); } return; } const o = r.waitFor; const gone = !o || Math.hypot(o.x - r.x, o.y - r.y) > 1.5 || !(o.state === 'toPass' || o.state === 'toTable' || o.state === 'home'); if ((gone && r.pushT > 2) || r.pushT > 25) this._endRetreat(r); } // 1本の細い通路に2台を入れない(Unity の oneRobotPerAisle)。通路の手前で待つ _aisleGate(r) { if (r.holdT > 0) { r.holdT -= DT; return true; } if (!r.path) return false; const A = this.L.aisles || []; const inA = (a, x, y) => x >= a.rect[0] - 0.05 && x <= a.rect[2] + 0.05 && y >= a.rect[1] - 0.05 && y <= a.rect[3] + 0.05; const mine = A.find(a => inA(a, r.x, r.y)); if (mine) return false; // この先 1.2m に入る通路 const cx = Math.cos(r.h), cy = Math.sin(r.h); const ahead = A.find(a => a.narrow && inA(a, r.x + cx * 1.0, r.y + cy * 1.0)); if (!ahead) return false; return this.robots.some(o => o !== r && inA(ahead, o.x, o.y)); } _plan(r, goal, force, blockers = []) { r.goal = goal; // 止まっている台(待機・受け渡し中)は動かない障害物として道を引く const still = this.robots.filter(o => o !== r && (o.state === 'idle' || o.state === 'dwell') && Math.hypot(o.x - goal[0], o.y - goal[1]) > 0.3).map(o => ({ x: o.x, y: o.y, r: 2 * RULES.robotRadius + 0.05 })); const p = this.grid.plan(r.x, r.y, goal[0], goal[1], blockers.concat(still)); r.path = p ? p.points : null; r.wp = 1; if (!p) r.path = [[r.x, r.y], goal]; } _follow(r) { const R = RULES; if (!r.path) return; const w = r.path[r.wp]; if (!w) return; const dx = w[0] - r.x, dy = w[1] - r.y, dist = Math.hypot(dx, dy); const remain = dist + this._restLen(r); const vmax = Math.min(R.robotMaxSpeed, Math.sqrt(2 * R.robotAccel * Math.max(remain - 0.02, 0))); r.v = Math.min(vmax, r.v + R.robotAccel * DT); const want = Math.atan2(dy, dx); let dh = want - r.h; while (dh > Math.PI) dh -= 2 * Math.PI; while (dh < -Math.PI) dh += 2 * Math.PI; const turn = Math.PI * DT; r.h += Math.max(-turn, Math.min(turn, dh)); if (Math.abs(dh) > 0.6) r.v = Math.min(r.v, 0.15); const step = Math.min(r.v * DT, dist); r.x += Math.cos(r.h) * step; r.y += Math.sin(r.h) * step; const last = r.trail && r.trail[r.trail.length - 1]; if (!last || Math.hypot(last[0] - r.x, last[1] - r.y) > 0.1) { (r.trail ||= []).push([r.x, r.y]); if (r.trail.length > 60) r.trail.shift(); } if (dist < 0.08) r.wp++; } _restLen(r) { let s = 0; for (let k = r.wp; k < r.path.length - 1; k++) s += Math.hypot(r.path[k + 1][0] - r.path[k][0], r.path[k + 1][1] - r.path[k][1]); return s; } _sensor(r) { // 前のセンサー:0.6m 先・幅0.5m に人かロボット const R = RULES, cx = Math.cos(r.h), cy = Math.sin(r.h); let rest = Infinity; if (r.path && r.path[r.wp]) rest = Math.hypot(r.path[r.wp][0] - r.x, r.path[r.wp][1] - r.y) + this._restLen(r); const reach = Math.min(R.robotRadius + R.sensorRange, rest + R.robotRadius); // 行き先より先のものでは止まらない const check = (x, y, rr) => { const dx = x - r.x, dy = y - r.y; const al = dx * cx + dy * cy, lat = Math.abs(-dx * cy + dy * cx); return al > 0 && al < reach + rr && lat < R.sensorHalfWidth + rr; }; for (const p of this.people) if (check(p.x, p.y, R.personRadius)) return { x: p.x, y: p.y, rr: R.personRadius, who: p }; for (const o of this.robots) if (o !== r && check(o.x, o.y, R.robotRadius)) return { x: o.x, y: o.y, rr: R.robotRadius, robot: o }; return null; } _syncAgent(r) { r.ag.teleport({ x: r.x, y: 0, z: r.y }); } // 画面と人のよけのために位置を合わせるだけ(ロボットの瞬間移動ではない) // 人がロボットに道をゆずる(譲る順番:ロボット>スタッフ>客)。スタッフはすぐ、客は少し止まってから横へ寄る _yield(p, r, t) { if (p.job && (p.job.kind === 'help' || p.job.kind === 'holding') && p.job.robot === r) return; const delay = p.kind === 'staff' ? 0 : 1.0; const cx = Math.cos(r.h), cy = Math.sin(r.h); const side = (-(p.x - r.x) * cy + (p.y - r.y) * cx) >= 0 ? 1 : -1; let best = null; for (const sgn of [side, -side]) for (const d of [0.7, 1.0, 1.4]) { const x = p.x - cy * d * sgn + cx * 0.3, y = p.y + cx * d * sgn + cy * 0.3; const q = this.crowd.navMeshQuery.findClosestPoint({ x, y: 0, z: y }); if (q.success && Math.hypot(q.point.x - x, q.point.z - y) < 0.15) { best = [q.point.x, q.point.z]; break; } if (best) break; } if (!best) return; p.yieldAt = t + delay; p.yieldUntil = t + delay + 4; p.yieldTo = best; p.resumeTgt = p.tgt; this.c.yields = (this.c.yields || 0) + 1; } _yieldTick(t) { for (const p of this.people) { if (!p.yieldUntil) continue; if (t >= p.yieldAt && !p.yielding) { p.yielding = true; const keep = p.tgt; this._goto(p, p.yieldTo); p.tgt = keep; p.ag.requestMoveTarget({ x: p.yieldTo[0], y: 0, z: p.yieldTo[1] }); } if (t >= p.yieldUntil) { p.yieldUntil = 0; p.yielding = false; if (p.resumeTgt) this._goto(p, p.resumeTgt); } } } _callHelp(r, radius, why) { // 料理・皿を持っていない、手の空いた近いスタッフが来る(客は触らない) let best = null, bd = radius; // 救出はほかの仕事より先(優先0)。立ち往生なら、料理を取りに行く途中の人も向かう const free = (p) => !p.carrying && (!p.job || p.job.kind === 'idle' || (why !== 'help' && p.job.stage === 0 && p.job.kind !== 'help' && p.job.kind !== 'holding')); for (const p of this.people) if (p.kind === 'staff' && free(p)) { const d = Math.hypot(p.x - r.x, p.y - r.y); if (d < bd) { bd = d; best = p; } } if (best && best.job && best.job.kind === 'serve') { const d = best.job.dish; d.taken = null; this.queue.unshift(d); } else if (best && best.job && best.job.ref) best.job.ref.taken = null; // 取っていた仕事を戻す(ほかの人が取る) if (why === 'help') r.helped = true; if (!best) return; if (why === 'retry') why = 'help'; r.helper = best; best.job = { kind: 'help', robot: r }; this._goto(best, [r.x - Math.cos(r.h) * 0.6, r.y - Math.sin(r.h) * 0.6]); this.c.helps++; this.log(why === 'stuck' ? `スタッフがロボット${r.id} を助けに向かう` : `5秒止まったロボット${r.id} にスタッフが向かう`); } _deliver(d, t, byRobot) { if (d.group && --d.group.dishesLeft === 0 && d.group.state === 'seated') { d.group.state = 'eating'; d.group.eatEnd = t + tri(d.group.rr, STORE.eatMin) * 60; } this.c.delivered++; byRobot ? this.c.byRobot++ : this.c.byStaff++; this.c.dishWaitSum += t - d.ready; } _staff(t) { for (const p of this.people) { if (p.kind !== 'staff' || p.yielding) continue; const j = p.job; if (!j || j.kind === 'idle') { // 仕事を取る:優先1(案内・料理・会計)を古い順 → 優先2(片付け) const cands = []; for (const d of this.queue) if (!d.taken && d.staffOk) cands.push({ prio: 1, at: d.ready, take: () => { d.taken = p; this.queue.splice(this.queue.indexOf(d), 1); p.job = { kind: 'serve', dish: d, stage: 0 }; this._goto(p, this.pass); } }); for (const jb of this.jobs) { if (jb.taken) continue; if (jb.kind === 'pay' && this.payQueue[0] !== jb.group) continue; cands.push({ prio: jb.prio, at: jb.posted, take: () => { jb.taken = p; p.job = { ...jb, ref: jb, stage: 0 }; this._goto(p, jb.kind === 'guide' ? this.waitPt : jb.kind === 'pay' ? this.regBack : jb.table.t.stop); } }); } cands.sort((x, y) => x.prio - y.prio || x.at - y.at); if (cands.length) { cands[0].take(); continue; } if (!j) { p.job = { kind: 'idle' }; this._goto(p, p.home); } continue; } const done = () => { if (j.ref) this.jobs.splice(this.jobs.indexOf(j.ref), 1); p.job = null; }; if (j.kind === 'serve') { if (j.stage === 0 && this._arrived(p)) { j.stage = 1; p.carrying = true; this._goto(p, j.dish.table.stop); } else if (j.stage === 1 && this._arrived(p, 0.5)) { p.carrying = false; this._deliver(j.dish, t, false); p.job = null; } } else if (j.kind === 'guide') { if (j.stage === 0 && this._arrived(p, 0.6)) { j.stage = 1; j.group.state = 'guided'; this._goto(p, j.group.table.t.stop); } else if (j.stage === 1 && this._arrived(p, 0.5)) { this._seat(j.group, t); j.stage = 2; j.until = t + STORE.guideSec; } else if (j.stage === 2 && t >= j.until) done(); } else if (j.kind === 'pay') { const g = j.group, near = g.walkers && g.walkers.some(w => Math.hypot(w.x - this.register[0], w.y - this.register[1]) < 1.2); if (j.stage === 0 && this._arrived(p, 0.6) && near) { j.stage = 1; j.until = t + STORE.paySec; this.c.payWaitSum += t - g.payStart; } else if (j.stage === 1 && t >= j.until) { this.c.paid++; this.payQueue.shift(); g.state = 'left'; for (const w of g.walkers) { w.job.paid = true; this._goto(w, this.entrance); } done(); } } else if (j.kind === 'clear') { if (j.stage === 0 && this._arrived(p, 0.5)) { j.stage = 1; j.until = t + STORE.clearSec + STORE.wipeSec; j.table.state = 'clearing'; } else if (j.stage === 1 && t >= j.until) { j.stage = 2; p.carrying = true; j.table.state = 'free'; j.table.group = null; this._goto(p, this.pass); } else if (j.stage === 2 && this._arrived(p)) { p.carrying = false; done(); } } else if (j.kind === 'help') { const r = j.robot; if (r.state === 'pushed' || r.state === 'sidestep' || (!r.stopT && !r.stuckCounted)) { p.job = null; r.helper = null; continue; } if (Math.hypot(p.x - r.x, p.y - r.y) < 1.0 || this._arrived(p, 1.0)) { const b = this._sensor(r); let mv = r, other = b && b.robot ? b.robot : null; if (other && !this._robotYields(r, other) && (other.state === 'toPass' || other.state === 'toTable' || other.state === 'home')) { mv = other; other = r; } const ok = this._retreat(mv, other || { x: b ? b.x : r.x + Math.cos(r.h), y: b ? b.y : r.y + Math.sin(r.h), path: null }, 'pushed'); if (!ok) { r.stopT = 0; r.helped = false; r.stuckCounted = false; r.detoured = false; this._plan(r, r.goal); } r.helper = null; p.job = { kind: 'holding', robot: mv, until: t + RULES.rescueSec }; this.log(`スタッフがロボット${mv.id} を動かした`); } } else if (j.kind === 'holding') { if (t >= j.until) p.job = null; } } } _guests(t) { for (let i = this.people.length - 1; i >= 0; i--) { const p = this.people[i]; if (p.kind !== 'guest' || p.yielding) continue; const j = p.job; if (j.kind === 'leave') { if (j.paid && this._arrived(p, 0.6)) { this.crowd.removeAgent(p.ag); this.people.splice(i, 1); if (j.lead) this.c.left++; } continue; } if (j.wait > 0) { j.wait -= DT; continue; } if (this._arrived(p, 0.45)) { if (j.leg === 0) { j.leg = 1; j.wait = j.dwell; this._goto(p, j.legs[1]); } else { p.seat.away = false; this.crowd.removeAgent(p.ag); this.people.splice(i, 1); } } } for (const s of this.seated) if (s.away && s.group && s.group.state === 'paying') s.away = false; } _seat(g, t) { g.state = 'seated'; this.waiting.splice(this.waiting.indexOf(g), 1); this.c.seatedGroups++; this.c.seatWaitSum += t - g.arrive; g.seatedAt = t; g.members = g.table.t.parts.seat_points.slice(0, g.size).map(p => { const m = { x: p[0], y: p[1], table: g.table.t.id, group: g }; this.seated.push(m); return m; }); g.cooks = STORE.firstDishes.map(d => ({ at: t + tri(g.rr, d) * 60, done: false })); g.dishesLeft = g.cooks.length; } // 食べ終わり:卓に座ったままレジの順番を待つ(試算 v2.8)。順番が来た組だけが席を立ってレジへ _finishEating(g) { g.state = 'waitPay'; g.payStart = this.t; this.payQueue.push(g); this._post({ kind: 'pay', prio: 1, group: g, posted: this.t }); } _stand(g) { g.state = 'paying'; const st = g.table.t.stop; g.walkers = g.members.map((m, i) => { m.gone = true; const w = this._addPerson('guest', [st[0] + (i % 2) * 0.3, st[1] + (i >> 1) * 0.3], RULES.guestSpeed); w.job = { kind: 'leave', group: g, lead: i === 0 }; this._goto(w, [this.register[0] - 0.5 - 0.35 * (i % 2), this.register[1] - 0.35 * (i >> 1)]); this.people.push(w); return w; }); this.seated = this.seated.filter(x => !x.gone); g.table.state = 'dirty'; this._post({ kind: 'clear', prio: 2, table: g.table }); } // パネルに出す「いまの仕事」 robotTask(r) { const tb = r.dish ? `卓${r.dish.table.id}` : ''; if (r.state === 'pushed') return { text: 'スタッフが手で動かしている', tone: 'help' }; if (r.state === 'sidestep') return { text: `${r.waitFor ? 'ロボット' + r.waitFor.id : '相手'}に道をゆずって待つ`, tone: 'wait' }; if (r.stopT > 0 && (r.state === 'toPass' || r.state === 'toTable' || r.state === 'home')) return { text: `止まっている(前に${r.blockedBy})${Math.floor(r.stopT)}秒`, tone: r.stopT >= RULES.stuckAfter ? 'stuck' : 'stop' }; if (r.flag === 'wait') return { text: '細い通路の手前で待つ(中に別の台)', tone: 'wait' }; switch (r.state) { case 'idle': return { text: '待機場所で待つ', tone: 'idle' }; case 'toPass': return { text: `受け渡し口へ料理を取りに行く(${tb}の分)`, tone: 'run' }; case 'toTable': return { text: `料理を${tb}へ運ぶ`, tone: 'run' }; case 'dwell': return { text: `${tb}で料理を渡している(あと${Math.ceil(r.dwell)}秒)`, tone: 'run' }; case 'home': return { text: '待機場所へ戻る(空)', tone: 'run' }; } return { text: r.state, tone: 'idle' }; } staffTask(p) { if (p.yielding) return { text: 'ロボットに道をゆずる', tone: 'wait' }; const j = p.job; if (!j || j.kind === 'idle') return { text: '持ち場で待つ', tone: 'idle' }; if (j.kind === 'serve') return j.stage === 0 ? { text: `受け渡し口へ料理を取りに行く(ロボットを2分待った卓${j.dish.table.id}の分)`, tone: 'run' } : { text: `料理を卓${j.dish.table.id}へ運ぶ`, tone: 'run' }; if (j.kind === 'guide') return { text: j.stage === 0 ? `入口へ客を迎えに行く(${j.group.size}人)` : j.stage === 1 ? `客を卓${j.group.table.t.id}へ案内する` : `卓${j.group.table.t.id}で説明している`, tone: 'run' }; if (j.kind === 'pay') return { text: j.stage === 0 ? 'レジへ会計に行く' : '会計している', tone: 'run' }; if (j.kind === 'clear') return { text: j.stage === 0 ? `卓${j.table.t.id}を片付けに行く` : j.stage === 1 ? `卓${j.table.t.id}を片付けて拭いている` : '下げた皿を洗い場へ運ぶ', tone: 'run' }; if (j.kind === 'help') return { text: `ロボット${j.robot.id}を動かしに行く`, tone: 'help' }; if (j.kind === 'holding') return { text: `ロボット${j.robot.id}を手で動かしている`, tone: 'help' }; return { text: j.kind, tone: 'idle' }; } // 1コマ分の様子を記録する(時間のスライダーで戻って見るため) snapshot() { const R = new Float32Array(this.robots.length * 3); const rt = []; this.robots.forEach((r, i) => { R[i * 3] = r.x; R[i * 3 + 1] = r.y; R[i * 3 + 2] = r.h; rt.push(this.robotTask(r)); }); const P = new Float32Array(this.people.length * 4); const st = []; this.people.forEach((p, i) => { P[i * 4] = p.uid; P[i * 4 + 1] = p.x; P[i * 4 + 2] = p.y; P[i * 4 + 3] = p.kind === 'staff' ? p.sid : 0; if (p.kind === 'staff') st.push(this.staffTask(p)); }); const seen = this.seated.filter(x => !x.away); const S = new Float32Array(seen.length * 2); seen.forEach((x, i) => { S[i * 2] = x.x; S[i * 2 + 1] = x.y; }); return { t: this.t, R, rt, P, st, S, waitN: this.waiting.length, sum: this.summary(), ev: this.events.length ? this.events[this.events.length - 1].t : -1 }; } summary() { const c = this.c; let occ = 0; for (const ts of this.tableState.values()) if (ts.state !== 'free') occ++; return { ...c, occupied: occ, tablesN: this.tables.length, waitingN: this.waiting.length, avgSeatWait: c.seatedGroups ? c.seatWaitSum / c.seatedGroups : 0, avgPayWait: c.paid ? c.payWaitSum / c.paid : 0, peakOver: this.t >= STORE.peakMin * 60, allDone: this.t > STORE.peakMin * 60 && this.groups.every(g => g.state === 'left'), avgTrip: c.trips ? c.tripSum / c.trips : 0, avgDishWait: c.delivered ? c.dishWaitSum / c.delivered : 0 }; } } // 人用の NavMesh:店の床から卓・壁・部屋を抜いて作る(人はロボットより狭い所も通れる) export function buildPeopleNavMesh(L) { const res = 0.05, W = L.outer.w, D = L.outer.d, nx = Math.round(W / res), ny = Math.round(D / res); const m = new Uint8Array(nx * ny).fill(1); const cut = (x0, y0, x1, y1) => { for (let j = Math.max(0, Math.floor(y0 / res)); j < Math.min(ny, Math.ceil(y1 / res)); j++) for (let i = Math.max(0, Math.floor(x0 / res)); i < Math.min(nx, Math.ceil(x1 / res)); i++) m[j * nx + i] = 0; }; for (const w of L.walls) cut(...w.rect); for (const r of L.rooms) if (!r.walkable) cut(...r.rect); for (const t of L.tables) { const p = t.parts || {}; if (p.top) cut(...p.top); for (const b of p.benches || []) cut(...b); for (const c of p.chairs || []) cut(...(c.rect || c)); if (!p.top) cut(...t.footprint); } const pos = [], idx = []; for (let y = 0; y < ny; y++) { let x = 0; while (x < nx) { if (!m[y * nx + x]) { x++; continue; } let e = x; while (e + 1 < nx && m[y * nx + e + 1]) e++; const b = pos.length / 3; pos.push(x * res, 0, y * res, (e + 1) * res, 0, y * res, (e + 1) * res, 0, (y + 1) * res, x * res, 0, (y + 1) * res); idx.push(b, b + 2, b + 1, b, b + 3, b + 2); x = e + 1; } } const r = generateSoloNavMesh(new Float32Array(pos), new Uint32Array(idx), { cs: 0.05, ch: 0.05, walkableRadius: 4, walkableClimb: 2, walkableHeight: 30, maxEdgeLen: 0, maxSimplificationError: 1.3 }); return r.navMesh; } export async function createSim(layout, map, opts) { await recastInit(); const fc = await decodeMap(map); const grid = new Grid(map, fc); const nav = buildPeopleNavMesh(layout); const sim = new Sim(layout, grid, opts); await sim.setup(nav); sim.nav = nav; return sim; }