feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+92
View File
@@ -0,0 +1,92 @@
import { measure, at, pointSegmentDistance } from './motion.js';
const TAU = Math.PI * 2;
const mod = value => (value % TAU + TAU) % TAU;
// One continuous trip includes the merge, perimeter and garage. Booking only
// the internal road would move the same queue to the perimeter entrance.
export function journey(departure, perimeter, finishPath) {
const lane = measure(perimeter.points);
const ringLength = mod(-departure.entry) * perimeter.unit;
const entryDistance = mod(departure.entry) * perimeter.unit;
const lanePoints = lane.points.slice(0, -1).map((point, i) => ({
point, ahead: mod((lane.lengths[i] - entryDistance) / perimeter.unit) * perimeter.unit,
})).filter(p => p.ahead > 1e-6 && p.ahead < ringLength - 1e-6).sort((a, b) => a.ahead - b.ahead);
const points = [...departure.path.points, ...lanePoints.map(p => p.point), ...finishPath.points];
// Remove coincident track endpoints so every timed segment has a duration.
const path = measure(points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 1e-7));
const boardLength = departure.boardLength;
const timeAt = d => Math.min(d, boardLength) / 240 + Math.max(0, d - boardLength) / 180;
return { path, boardLength, ringStart: departure.path.length,
finishStart: departure.path.length + ringLength,
times: path.lengths.map(timeAt), duration: timeAt(path.length) };
}
export function journeyPosition(trip, elapsed) {
const boardTime = trip.boardLength / 240;
const distance = Math.min(trip.path.length, Math.max(0, Math.min(elapsed, boardTime) * 240 + Math.max(0, elapsed - boardTime) * 180));
return { ...at(trip.path, distance), distance };
}
function cursor(actor, time) {
const elapsed = time - actor.start, times = actor.journey.times;
let lo = 0, hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (times[mid] <= elapsed + 1e-9) lo = mid + 1; else hi = mid;
}
return lo;
}
function position(actor, i, time) {
const { path, times } = actor.journey;
if (!i) return path.points[0];
const a = path.points[i - 1], b = path.points[i];
const t = (time - actor.start - times[i - 1]) / (times[i] - times[i - 1]);
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// Exact closest approach on each overlapping pair of linear time segments.
// This checks the swept interval, including between rendered frames at 3×.
export function tripsConflict(a, b, now, clearance, parkedClearance = clearance) {
const end = Math.min(a.start + a.journey.duration, b.start + b.journey.duration);
let time = now, i = cursor(a, time), j = cursor(b, time);
while (time < end - 1e-8) {
const nextA = a.start + a.journey.times[i], nextB = b.start + b.journey.times[j];
const next = Math.min(end, nextA, nextB);
const p = position(a, i, time), q = position(b, j, time);
const r = position(a, i, next), s = position(b, j, next);
const gap = time < a.start - 1e-8 || time < b.start - 1e-8 ? parkedClearance : clearance;
if (pointSegmentDistance({ x: 0, y: 0 }, { x: p.x - q.x, y: p.y - q.y }, { x: r.x - s.x, y: r.y - s.y }) < gap) return true;
time = next;
if (nextA <= next + 1e-8) i++;
if (nextB <= next + 1e-8) j++;
}
return false;
}
// The caller supplies the parked-car dependency order. Each trip gets the first
// free time slot; unrelated trips can share a start or overlap most of a trip.
// All times use one simulation clock, so changing playback speed preserves gaps.
export function scheduleDepartures(candidates, { now = 0, active = [], parked = [], clearance, parkedClearance = clearance }) {
const planned = [], reservations = [...active];
const released = new Set();
for (const candidate of candidates) {
released.add(candidate.car.id);
const fixed = parked.filter(p => !released.has(p.id));
const points = candidate.journey.path.points;
if (fixed.some(p => points.some((b, i) => i && pointSegmentDistance(p, points[i - 1], b) < parkedClearance))) {
throw new Error(`Departure order crosses a parked car: ${candidate.car.id}`);
}
const actor = { ...candidate, start: now };
// After the last reserved arrival, a statically clear route is always free.
const horizon = Math.max(now, ...reservations.map(r => r.start + r.journey.duration));
while (actor.start < horizon && reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
actor.start = Math.min(horizon, actor.start + .06);
}
if (reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
throw new Error(`Existing traffic crosses a waiting car: ${candidate.car.id}`);
}
planned.push(actor); reservations.push(actor);
}
return planned;
}