feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// Shared distance-based trajectories and local traffic clearance, in canvas pixels.
|
||||
export function measure(points) {
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
|
||||
return { points, lengths, length: lengths.at(-1) };
|
||||
}
|
||||
export function at(path, distance) {
|
||||
const d = Math.max(0, Math.min(path.length, distance));
|
||||
let i = path.lengths.findIndex((v, index) => index && v >= d);
|
||||
if (i < 1) i = path.points.length - 1;
|
||||
const a = path.points[i - 1], b = path.points[i], len = path.lengths[i] - path.lengths[i - 1];
|
||||
const t = len ? (d - path.lengths[i - 1]) / len : 0;
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, angle: Math.atan2(b.y - a.y, b.x - a.x) };
|
||||
}
|
||||
export function pointSegmentDistance(p, a, b) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
|
||||
const t = length ? Math.max(0, Math.min(1, ((p.x-a.x)*dx+(p.y-a.y)*dy)/length)) : 0;
|
||||
return Math.hypot(p.x-a.x-dx*t,p.y-a.y-dy*t);
|
||||
}
|
||||
export function hasPriority(a, b) {
|
||||
if (a.yieldingTo === b) return false;
|
||||
if (b.yieldingTo === a) return true;
|
||||
return a.order < b.order;
|
||||
}
|
||||
// A car reserves road conflicts only after departure. Parked cars are physical
|
||||
// obstacles, not reservations for every road they may eventually travel through.
|
||||
export function activeCorridors(traffic, actor) {
|
||||
return traffic.filter(other => other !== actor
|
||||
&& ['board', 'waiting', 'joining'].includes(other.stage)
|
||||
&& other.distance < other.boardLength && hasPriority(other, actor));
|
||||
}
|
||||
// Only the untraveled part of a priority vehicle's route reserves a conflict area.
|
||||
// Other cars travel up to that area, wait there, and resume as it clears.
|
||||
export function advance(path, current, target, obstacles, corridors, clearance) {
|
||||
let distance = current;
|
||||
while (distance < target - 1e-6) {
|
||||
const next = Math.min(target, distance + 1.5), p = at(path, next);
|
||||
if (obstacles.some(o => Math.hypot(p.x-o.x,p.y-o.y) < clearance)) break;
|
||||
let conflict = false;
|
||||
for (const corridor of corridors) {
|
||||
let a = at(corridor.path, corridor.distance);
|
||||
for (let i = 1; i < corridor.path.points.length; i++) {
|
||||
if (corridor.path.lengths[i] <= corridor.distance) continue;
|
||||
if (corridor.path.lengths[i-1] > corridor.boardLength + clearance) break;
|
||||
const b = corridor.path.points[i];
|
||||
if (pointSegmentDistance(p,a,b) < clearance) { conflict = true; break; }
|
||||
a = b;
|
||||
}
|
||||
if (conflict) break;
|
||||
}
|
||||
if (conflict) break;
|
||||
distance = next;
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
// Stable passage priority respects the parked-car dependency graph, avoiding
|
||||
// circular reservations when several intersecting cars are clicked together.
|
||||
export function passageOrder(board, cars, trace) {
|
||||
let remaining = [...cars], rank = 0;
|
||||
const order = new Map();
|
||||
while (remaining.length) {
|
||||
const free = remaining.filter(car => trace(board, car, remaining).status === 'exit');
|
||||
if (!free.length) { remaining.forEach(car => order.set(car.id, rank++)); break; }
|
||||
const ids = new Set(free.map(car => car.id));
|
||||
free.forEach(car => order.set(car.id, rank++));
|
||||
remaining = remaining.filter(car => !ids.has(car.id));
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// Back out of a mutual standstill, using the same swept body clearance as forward travel.
|
||||
// The yielding car keeps its priority override until the other has left the board.
|
||||
export function resolveGridlock(traffic, fixedObstacles, positionOf, clearance, step) {
|
||||
const reversed = new Set();
|
||||
const internal = traffic.filter(t => t.stage === 'board');
|
||||
for (const actor of internal) {
|
||||
if (actor.yieldingTo && !internal.includes(actor.yieldingTo)) {
|
||||
actor.yieldingTo = null; actor.retreating = false;
|
||||
}
|
||||
}
|
||||
const retreat = actor => {
|
||||
const obstacles = [...fixedObstacles, ...traffic.filter(o => o !== actor && o.stage !== 'done').map(positionOf)];
|
||||
let distance = actor.distance;
|
||||
const target = Math.max(0, distance - step);
|
||||
while (distance > target + 1e-6) {
|
||||
const next = Math.max(target, distance - 1.5), p = at(actor.path, next);
|
||||
if (obstacles.some(o => Math.hypot(p.x - o.x, p.y - o.y) < clearance)) break;
|
||||
distance = next;
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
for (let i = 0; i < internal.length; i++) for (let j = i + 1; j < internal.length; j++) {
|
||||
const a = internal[i], b = internal[j];
|
||||
if (a.yieldingTo || b.yieldingTo || !a.stopped || !b.stopped || Math.min(a.blockedFor || 0, b.blockedFor || 0) < .5) continue;
|
||||
const p = positionOf(a), q = positionOf(b);
|
||||
if (Math.hypot(p.x - q.x, p.y - q.y) > clearance * 1.6) continue;
|
||||
const candidates = hasPriority(a, b) ? [b, a] : [a, b];
|
||||
const loser = candidates.find(t => retreat(t) < t.distance - .01);
|
||||
if (loser) { loser.yieldingTo = loser === a ? b : a; loser.retreating = true; }
|
||||
}
|
||||
for (const actor of internal) {
|
||||
if (!actor.retreating || !actor.yieldingTo) continue;
|
||||
// A short probe tests whether the whole body has cleared the winning route.
|
||||
const p = positionOf(actor), probe = measure([p, { x: p.x + .01, y: p.y }]);
|
||||
if (advance(probe, 0, probe.length, [], [actor.yieldingTo], clearance + 3) >= probe.length) {
|
||||
actor.retreating = false;
|
||||
continue;
|
||||
}
|
||||
const next = retreat(actor);
|
||||
actor.distance = next; actor.stopped = true; reversed.add(actor);
|
||||
if (next <= 0) actor.retreating = false;
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
Reference in New Issue
Block a user