feat(port): add sea port with quay, terminal, berths, and AI port master

Adds a new port district on the north-east coast with walkable quay, container yard, terminal, gantry cranes, and berths. Includes AI-driven port master (Capitão do Porto) and dock workers for ship dispatch, plus full frontend rendering of ships, boats, and trucks.
This commit is contained in:
2026-10-31 23:33:06 -03:00
parent e230c391ad
commit 580c830ffb
13 changed files with 1338 additions and 42 deletions
+699 -7
View File
@@ -247,6 +247,10 @@ let beach = null;
let beachSystem = null;
// Airports payload {visit, sites} — geometry, walk zone and flight paths.
let airportsCfg = null;
// Port payload {visit, water, quay, yard, terminal, cranes, berths, ships…}.
let portCfg = null;
// Port ops runtime (ships, boats, trucks, crane, AI clearances).
let portSystem = null;
let remoteCitizens = new Map(); // characterId -> mesh
let presenceTimer = null;
let vehicleHeadlights = [];
@@ -3017,6 +3021,11 @@ function lerp3(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
/** 2D waypoint interpolation for ground/water paths [x, z]. */
function lerp2(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
/** Distance between two 3D points. */
function dist3(a, b) {
return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
@@ -3468,6 +3477,662 @@ function initAirplanes(cfg) {
};
}
// ------------------------------------------------------------------
// Port — far half of the north-east coast (z −430..−290; the near half
// stays free for city expansion): quay, yard, terminal, gantry cranes,
// container ships, liner, speedboats, trucks — with ship maneuvers
// authorised by the Capitania AI agent (/city/port/dispatch).
// ------------------------------------------------------------------
/** Ship heading: hull nose points local −Z. */
function shipYaw(dx, dz) {
return Math.atan2(-dx, -dz);
}
/** Clamped keyframe interpolation: kf(t, [[time, value], …]). */
function kf(t, frames) {
if (t <= frames[0][0]) return frames[0][1];
for (let i = 1; i < frames.length; i++) {
if (t <= frames[i][0]) {
const [t0, v0] = frames[i - 1];
const [t1, v1] = frames[i];
const k = (t - t0) / Math.max(1e-6, t1 - t0);
return v0 + (v1 - v0) * k;
}
}
return frames[frames.length - 1][1];
}
/**
* Big vessels: hull with stepped bow, decks, bridge/funnel and (for
* container ships) a stacked container block on deck. Nose points −Z.
*
* @param {'container'|'liner'} kind
* @param {{name: string, company: string, accent?: number}} spec
*/
function buildShip(kind, spec) {
const g = new THREE.Group();
const white = new THREE.MeshStandardMaterial({ color: 0xe8ecf1, roughness: 0.55, metalness: 0.2 });
const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.4, metalness: 0.5 });
const accent = new THREE.MeshStandardMaterial({ color: spec.accent ?? 0x1f6fb2, roughness: 0.45, metalness: 0.3 });
const box = (w, h, d, mat, x, y, z) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
m.position.set(x, y, z);
g.add(m);
return m;
};
if (kind === 'liner') {
const L = 88, W = 20;
box(W, 6.5, L, white, 0, 1.2, 0); // hull
box(W + 0.4, 1.3, L + 0.4, accent, 0, 2.4, 0); // company band
box(14, 5, 8, white, 0, 1.8, -L / 2 - 2); // stepped bow
box(17, 4, 60, white, 0, 6.2, 4); // deck tier 1
box(17.3, 1, 60.3, dark, 0, 7.0, 4); // window band
box(14, 3.4, 44, white, 0, 10.0, 6); // tier 2
box(14.3, 1, 44.3, dark, 0, 10.6, 6); // windows
box(11, 2.6, 14, white, 0, 13.4, -14); // bridge
box(11.3, 1, 14.3, dark, 0, 13.8, -14); // bridge glass
for (const fz of [14, 22]) {
const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 2.3, 6, 12), accent);
funnel.position.set(0, 14.0, fz);
g.add(funnel);
}
} else {
const L = 60, W = 16;
box(W, 5.5, L - 2, dark, 0, 1.0, 0); // hull
box(W + 0.3, 1.1, L - 1.6, accent, 0, 3.2, 0); // livery band
box(W - 0.6, 1.2, L - 3, white, 0, 4.0, 0); // deck rim
box(11, 4.5, 6, dark, 0, 1.6, -L / 2 - 1); // stepped bow
box(11, 3.6, 9, white, 0, 5.9, 21); // deckhouse t1
box(9, 2.4, 8, white, 0, 8.6, 21.5); // deckhouse t2
box(9.2, 0.9, 8.2, dark, 0, 8.9, 21.5); // bridge glass
const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.8, 4.4, 10), accent);
funnel.position.set(0, 11.4, 17);
g.add(funnel);
// Container stacks on deck (3 lanes × 6 rows, some double-stacked).
const containerColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910];
let ci = 0;
for (const lx of [-5, 0, 5]) {
for (let r = 0; r < 6; r++) {
const cz = -22 + r * 6.4;
const color = containerColors[ci++ % containerColors.length];
box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color, roughness: 0.7 }), lx, 5.9, cz);
if ((ci + r) % 3 === 0) {
box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color: containerColors[ci % containerColors.length], roughness: 0.7 }), lx, 8.5, cz);
}
}
}
}
g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
g.userData.shipName = spec.name;
return g;
}
/** Small fast boat (speedboat) hull + cabin. */
function buildPortBoat(accentColor) {
const g = new THREE.Group();
const white = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.5 });
const accent = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.45 });
const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.6 });
const hull = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.4, 9), white);
hull.position.y = 0.55;
g.add(hull);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.3, 0.4, 8.6), accent);
stripe.position.y = 0.95;
g.add(stripe);
const nose = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.0, 2.4), white);
nose.position.set(0, 0.6, -5.2);
g.add(nose);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.4, 3), white);
cabin.position.set(0, 1.9, 0.6);
g.add(cabin);
const screen = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.6, 0.5), dark);
screen.position.set(0, 2.2, -0.9);
g.add(screen);
const motor = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.9, 0.8), dark);
motor.position.set(0, 0.9, 4.8);
g.add(motor);
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
return g;
}
/** Yard truck: cab + chassis, optional container on the back (nose +X). */
function buildPortTruck(accentColor, loaded) {
const g = new THREE.Group();
const steel = new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.6, metalness: 0.4 });
const cabMat = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.5, metalness: 0.2 });
const chassis = new THREE.Mesh(new THREE.BoxGeometry(7, 0.5, 2.6), steel);
chassis.position.y = 1.15;
g.add(chassis);
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.3, 2.5), cabMat);
cab.position.set(2.4, 2.3, 0);
g.add(cab);
if (loaded) {
const container = new THREE.Mesh(
new THREE.BoxGeometry(4.2, 2.3, 2.4),
new THREE.MeshStandardMaterial({ color: 0x2980b9, roughness: 0.7 })
);
container.position.set(-1.2, 2.6, 0);
g.add(container);
}
for (const [wx, wz] of [[2.4, 1.35], [2.4, -1.35], [-2.4, 1.35], [-2.4, -1.35]]) {
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.4, 10), steel);
wheel.rotation.x = Math.PI / 2;
wheel.position.set(wx, 0.5, wz);
g.add(wheel);
}
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
return g;
}
/**
* Build the port and its moving fleet from the payload geometry.
*
* Fixed structures (quay, yard, terminal, bollards, container stacks,
* crane frames) merge through a static batcher; ships, boats, trucks
* and crane trolleys stay individual and animated. Container-ship
* maneuvers wait for a Capitania clearance (AI text or deterministic
* fallback) before departing or berthing.
*
* @param {object} cfg port payload {visit, water, quay, yard, terminal, cranes, berths, ships, boats}
*/
function initPort(cfg) {
const quay = cfg.quay;
const yard = cfg.yard;
const terminal = cfg.terminal;
const berths = cfg.berths;
const cranes = cfg.cranes;
const ships = cfg.ships;
const batch = makeStaticBatcher();
const concreteMat = new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.95 });
const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x4a4f56, roughness: 0.95 });
const steelMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.5, metalness: 0.6 });
const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 });
const yellowLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 });
const addFlat = (cx, cz, w, d, mat, y) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat);
m.rotation.x = -Math.PI / 2;
m.position.set(cx, y, cz);
m.receiveShadow = true;
worldGroup.add(m);
};
// ---- Quay + yard pavement ----
addFlat(quay.center[0], quay.center[1], quay.size[0], quay.size[1], concreteMat, 0.06);
const edgeLine = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.02, quay.size[1] - 4), yellowLineMat
);
edgeLine.position.set(quay.center[0] + quay.size[0] / 2 - 0.6, 0.08, quay.center[1]);
worldGroup.add(edgeLine);
addFlat(yard.center[0], yard.center[1], yard.size[0], yard.size[1], asphaltMat, 0.05);
// Bollards along the quay edge (merged).
const bollardGeo = new THREE.CylinderGeometry(0.17, 0.2, 0.7, 8);
for (let z = quay.center[1] - quay.size[1] / 2 + 8; z <= quay.center[1] + quay.size[1] / 2 - 8; z += 13) {
const b = new THREE.Mesh(bollardGeo, steelMat);
b.position.set(quay.center[0] + quay.size[0] / 2 - 1.6, 0.4, z);
b.updateMatrix();
batch.add(bollardGeo, steelMat, b.matrix, true);
}
// ---- Terminal (multi-window facade) ----
const termPair = facadePair('#6d7f93', 3, Math.max(4, Math.round(terminal.size[0] / 7)));
const termMat = new THREE.MeshStandardMaterial({
map: termPair.map, emissiveMap: termPair.emissiveMap,
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
});
facadeMaterials.push(termMat);
const termH = 9;
const termMesh = new THREE.Mesh(new THREE.BoxGeometry(terminal.size[0], termH, terminal.size[1]), termMat);
termMesh.position.set(terminal.center[0], termH / 2, terminal.center[1]);
termMesh.castShadow = true;
termMesh.receiveShadow = true;
worldGroup.add(termMesh);
addCollider(terminal.center[0], 0, terminal.center[1], terminal.size[0], termH, terminal.size[1]);
const termRoof = new THREE.Mesh(
new THREE.BoxGeometry(terminal.size[0] + 0.6, 0.5, terminal.size[1] + 0.6),
new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 })
);
termRoof.position.set(terminal.center[0], termH + 0.15, terminal.center[1]);
worldGroup.add(termRoof);
// ---- Container stacks in the yard (merged per colour) ----
const stackColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910];
const stackMats = stackColors.map(
color => new THREE.MeshStandardMaterial({ color, roughness: 0.7 })
);
let stackIdx = 0;
for (const ox of [-16, -4, 8]) {
for (let z = yard.center[1] - yard.size[1] / 2 + 8;
z <= yard.center[1] + yard.size[1] / 2 - 8;
z += 16) {
const mat = stackMats[stackIdx % stackMats.length];
const sx = yard.center[0] + ox;
const baseGeo = new THREE.BoxGeometry(5, 2.6, 2.8);
const base = new THREE.Mesh(baseGeo, mat);
base.position.set(sx, 1.35, z);
base.updateMatrix();
batch.add(baseGeo, mat, base.matrix, true);
if (stackIdx % 3 === 0) {
const topGeo = new THREE.BoxGeometry(5, 2.6, 2.8);
const top = new THREE.Mesh(topGeo, mat);
top.position.set(sx, 3.95, z);
top.updateMatrix();
batch.add(topGeo, mat, top.matrix, true);
}
stackIdx++;
}
}
// ---- Gantry crane frames (merged) + animated trolleys ----
const craneRuntime = [];
const deckYs = berths.map((b) => {
const ship = ships.find(s => s.berth === b.id);
return ship && ship.kind === 'liner' ? 6.2 : 4.6;
});
cranes.forEach((crane, i) => {
const [cx, cz] = crane.center;
for (const lx of [cx - 2.5, cx + 2.5]) {
for (const lz of [cz - 6.5, cz + 6.5]) {
const legGeo = new THREE.BoxGeometry(1.1, 15, 1.1);
const leg = new THREE.Mesh(legGeo, steelMat);
leg.position.set(lx, 7.5, lz);
leg.updateMatrix();
batch.add(legGeo, steelMat, leg.matrix, true);
}
const tieGeo = new THREE.BoxGeometry(1, 1, 14);
const tie = new THREE.Mesh(tieGeo, steelMat);
tie.position.set(lx, 15.5, cz);
tie.updateMatrix();
batch.add(tieGeo, steelMat, tie.matrix);
}
const beamGeo = new THREE.BoxGeometry(6, 1, 1.2);
for (const bz of [cz - 6.5, cz + 6.5]) {
const beam = new THREE.Mesh(beamGeo, steelMat);
beam.position.set(cx, 15.5, bz);
beam.updateMatrix();
batch.add(beamGeo, steelMat, beam.matrix);
}
// Boom rails reaching out over the water.
for (const rz of [cz - 0.9, cz + 0.9]) {
const railGeo = new THREE.BoxGeometry(36, 0.6, 0.45);
const rail = new THREE.Mesh(railGeo, steelMat);
rail.position.set(cx + 18, 15.6, rz);
rail.updateMatrix();
batch.add(railGeo, steelMat, rail.matrix);
}
const deckGeo = new THREE.BoxGeometry(36, 0.25, 2.6);
const deck = new THREE.Mesh(deckGeo, steelMat);
deck.position.set(cx + 18, 16.1, cz);
deck.updateMatrix();
batch.add(deckGeo, steelMat, deck.matrix);
// Animated trolley + cable + spreader + carried container.
const trolley = new THREE.Group();
const carriage = new THREE.Mesh(new THREE.BoxGeometry(3, 1.5, 3.1), steelMat);
carriage.position.y = 14.75;
trolley.add(carriage);
const cable = new THREE.Mesh(
new THREE.BoxGeometry(0.14, 1, 0.14),
new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.5 })
);
trolley.add(cable);
const spreader = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.4, 2.5), yellowLineMat);
trolley.add(spreader);
const load = new THREE.Mesh(
new THREE.BoxGeometry(4.6, 2.6, 2.9),
new THREE.MeshStandardMaterial({ color: stackColors[i % stackColors.length], roughness: 0.7 })
);
trolley.add(load);
worldGroup.add(trolley);
craneRuntime.push({
cx, cz, trolley, cable, spreader, load,
deckY: deckYs[i], t: i * 0.45,
berthId: berths[i] ? berths[i].id : null,
});
});
batch.flush(worldGroup);
// ---- Fleet: container ships, liner, speedboats, trucks ----
const laneX = quay.center[0] + 52; // clear of the berthed hulls (W/2 sums ~18)
const zoneStart = berths[0].center[1] + 20;
const zoneEnd = berths[1].center[1] - 20;
const oval = [
[laneX, zoneStart],
[laneX + 8, zoneStart - 25],
[laneX + 8, zoneEnd + 25],
[laneX, zoneEnd],
[laneX - 8, zoneEnd + 25],
[laneX - 8, zoneStart - 25],
];
const departLegsFor = berthCenter => [
[berthCenter[0] + 16, berthCenter[1]],
[laneX, zoneStart],
];
const returnLegsFor = berthCenter => [
[berthCenter[0] + 16, berthCenter[1]],
[berthCenter[0], berthCenter[1]],
];
const fleet = [];
ships.forEach((spec) => {
const mesh = buildShip(spec.kind, spec);
worldGroup.add(mesh);
const berth = spec.berth ? berths.find(b => b.id === spec.berth) : null;
const entity = {
spec,
mesh,
berth: berth ? [...berth.center] : null,
cycle: spec.kind === 'container',
state: berth ? 'berthed' : 'sailing',
legs: null,
leg: 0,
t: 0,
p: [0, 0, 0],
yaw: 0,
timer: berth ? 11 + Math.random() * 6 : 22 + Math.random() * 14,
purpose: berth ? 'depart' : 'berth',
awaitedAt: 0,
retry: 0,
phase: Math.random() * Math.PI * 2,
};
if (berth) {
entity.p = [berth.center[0], 0, berth.center[1]];
} else {
entity.legs = oval;
entity.leg = 1;
entity.t = 0.4;
const [sx, sz] = lerp2(oval[0], oval[1], 0.4);
entity.p = [sx, 0, sz];
entity.yaw = shipYaw(oval[1][0] - oval[0][0], oval[1][1] - oval[0][1]);
entity.purpose = 'berth';
}
fleet.push(entity);
});
const boats = [];
const boatOval = [
[laneX - 18, zoneStart],
[laneX - 10, zoneEnd],
[laneX + 18, zoneEnd],
[laneX + 10, zoneStart],
];
[0x2fb6c9, 0xe08a1e, 0xd92b2b].forEach((color, i) => {
const mesh = buildPortBoat(color);
worldGroup.add(mesh);
const start = i % boatOval.length;
const t0 = (i * 0.37) % 1;
boats.push({
mesh,
legs: boatOval,
leg: start,
t: t0,
p: (() => {
const [bx, bz] = lerp2(boatOval[start], boatOval[(start + 1) % boatOval.length], t0);
return [bx, 0, bz];
})(),
yaw: 0,
speed: 8 + i,
phase: i * 2.1,
});
});
const trucks = [];
const yardLoop = [
[yard.center[0] - 16, yard.center[1] - 50],
[yard.center[0] + 16, yard.center[1] - 50],
[yard.center[0] + 16, yard.center[1] + 50],
[yard.center[0] - 16, yard.center[1] + 50],
];
[true, false].forEach((loaded, i) => {
const mesh = buildPortTruck(i === 0 ? 0xd97706 : 0x3f9d4f, loaded);
worldGroup.add(mesh);
const leg = (i * 2) % yardLoop.length;
const t0 = i * 0.5;
trucks.push({
mesh,
legs: yardLoop,
leg,
t: t0,
p: (() => {
const [tx, tz] = lerp2(yardLoop[leg], yardLoop[(leg + 1) % yardLoop.length], t0);
return [tx, 0, tz];
})(),
yaw: 0,
speed: loaded ? 4 : 4.6,
truck: true,
});
});
/** Advance one entity along its waypoint legs; true when finished. */
const stepAlong = (e, speed, dt) => {
if (!e.legs || !e.legs.length) return true;
let guard = 0;
while (guard++ < 4) {
// Perpetual loops wrap; one-shot paths reset via their caller.
if (e.leg >= e.legs.length) e.leg = 0;
const target = e.legs[e.leg]; // [x, z] waypoint pair
const dx = target[0] - e.p[0];
const dz = target[1] - e.p[2];
const len = Math.hypot(dx, dz);
if (len < 1e-3) {
e.leg++;
if (e.leg >= e.legs.length) return true;
continue;
}
const step = speed * dt;
if (step >= len) {
e.p = [target[0], 0, target[1]];
e.leg++;
if (e.leg >= e.legs.length) return true;
continue;
}
e.p = [e.p[0] + (dx / len) * step, 0, e.p[2] + (dz / len) * step];
e.yaw = e.truck ? Math.atan2(-dz, dx) : shipYaw(dx, dz);
return false;
}
return true;
};
const postDispatch = () => {
postJson('/city/port/dispatch', {}).catch(() => { /* throttled server-side */ });
};
const system = {
clearance: null,
shownAt: null,
poll: 4,
counts() {
return {
ships: fleet.length,
boats: boats.length,
trucks: trucks.length,
cranes: craneRuntime.length,
berthed: fleet.filter(f => f.state === 'berthed').length,
underway: fleet.filter(f => ['departing', 'sailing', 'returning', 'turning'].includes(f.state)).length,
states: fleet.map(f => `${f.spec.id}:${f.state}`),
shipPositions: fleet.map(f => ({
id: f.spec.id,
x: +f.p[0].toFixed(1),
z: +f.p[2].toFixed(1),
})),
// Motion sample for tests: positions that must change
// between two readings.
movingSample: {
boat: boats[0] ? [+boats[0].p[0].toFixed(1), +boats[0].p[2].toFixed(1)] : null,
truck: trucks[0] ? [+trucks[0].p[0].toFixed(1), +trucks[0].p[2].toFixed(1)] : null,
},
};
},
update(dt, time) {
// ---- Capitania clearance poll (flash + maneuver gating) ----
this.poll -= dt;
if (this.poll <= 0) {
this.poll = 8;
fetch('/city/port', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
})
.then(r => (r.ok ? r.json() : null))
.then((data) => {
const c = data && data.clearance;
if (c && c.at && c.at !== this.shownAt) {
this.shownAt = c.at;
this.clearance = c;
flashCharMsg('💬 '.concat(c.text));
}
})
.catch(() => { /* offline-safe */ });
}
// ---- Container ships: AI-gated maneuver cycle ----
for (const f of fleet) {
if (!f.cycle) continue;
if (f.state === 'berthed') {
f.timer -= dt;
if (f.timer <= 0) {
f.state = 'awaiting';
f.purpose = 'depart';
f.awaitedAt = Date.now();
f.retry = 1;
postDispatch();
}
} else if (f.state === 'awaiting') {
f.retry -= dt;
if (f.retry <= 0) {
f.retry = 7;
postDispatch();
}
const c = this.clearance;
if (c && Date.parse(c.at) >= f.awaitedAt - 1500
&& c.action === f.purpose) {
if (f.purpose === 'depart') {
if (!f.berth) continue; // nothing to leave
f.legs = departLegsFor(f.berth);
} else {
// Claim the first FREE berth: occupied means
// berthed, or still moored waiting to depart
// (c1 sits on its berth until cleared to leave).
const free = berths.find(b => !fleet.some(o => {
if (o === f || !o.berth) return false;
const sameSlot = o.berth[0] === b.center[0]
&& o.berth[1] === b.center[1];
const occupying = o.state === 'berthed'
|| o.state === 'returning'
|| o.state === 'turning'
|| (o.state === 'awaiting' && o.purpose === 'depart');
return sameSlot && occupying;
}));
if (!free) {
f.retry = 6; // all busy: ask again shortly
continue;
}
f.berth = [...free.center];
f.legs = returnLegsFor(f.berth);
}
f.leg = 0;
f.state = f.purpose === 'depart' ? 'departing' : 'returning';
}
} else if (f.state === 'departing') {
if (stepAlong(f, 4.5, dt)) {
f.legs = oval;
f.leg = 0;
f.timer = 20 + Math.random() * 14;
f.state = 'sailing';
}
} else if (f.state === 'sailing') {
f.timer -= dt;
stepAlong(f, 5.5, dt);
if (f.timer <= 0) {
f.state = 'awaiting';
f.purpose = 'berth';
f.awaitedAt = Date.now();
f.retry = 1;
postDispatch();
}
} else if (f.state === 'returning') {
if (stepAlong(f, 4.5, dt)) {
f.state = 'turning';
f.turnT = 0;
f.fromYaw = f.yaw;
}
} else if (f.state === 'turning') {
f.turnT += dt / 2.2;
const k = Math.min(1, f.turnT);
f.yaw = f.fromYaw + (0 - f.fromYaw) * k;
if (k >= 1) {
f.state = 'berthed';
f.timer = 10 + Math.random() * 6;
f.purpose = 'depart';
}
}
// Pose: position + swell bob + gentle roll.
f.mesh.position.set(
f.p[0],
0.1 + beachWaveY(f.p[0], f.p[2], time) * 0.5,
f.p[2]
);
f.mesh.rotation.y = f.yaw;
f.mesh.rotation.z = Math.sin(time * 0.7 + f.phase) * 0.03;
}
// ---- Speedboats: perpetual loops ----
for (const b of boats) {
if (stepAlong(b, b.speed, dt)) b.leg = 0;
b.mesh.position.set(
b.p[0],
0.15 + beachWaveY(b.p[0], b.p[2], time) * 0.7,
b.p[2]
);
b.mesh.rotation.y = b.yaw;
b.mesh.rotation.z = Math.sin(time * 1.1 + b.phase) * 0.07;
}
// ---- Yard trucks on their loop ----
for (const tr of trucks) {
if (stepAlong(tr, tr.speed, dt)) tr.leg = 0;
tr.mesh.position.set(tr.p[0], 0, tr.p[2]);
tr.mesh.rotation.y = tr.yaw;
}
// ---- Gantry cranes: load cycle while their berth is occupied ----
for (const cr of craneRuntime) {
const busy = fleet.some(
f => f.spec.berth === cr.berthId && f.state === 'berthed'
);
cr.busy = busy;
if (busy) cr.t = (cr.t + dt / 13) % 1;
const t = busy ? cr.t : 0;
cr.trolley.position.x = kf(t, [
[0, cr.cx], [0.18, cr.cx + 32],
[0.60, cr.cx + 32], [0.72, cr.cx], [1, cr.cx],
]);
const hookY = kf(t, [
[0, 13], [0.18, 13], [0.30, cr.deckY],
[0.40, 12.5], [0.60, 12.5],
[0.72, 3.2], [0.82, 13], [1, 13],
]);
cr.cable.scale.y = Math.max(0.1, 14.0 - hookY);
cr.cable.position.y = (14.0 + hookY) / 2;
cr.spreader.position.y = hookY;
const carrying = busy && t >= 0.28 && t < 0.70;
cr.load.visible = carrying;
cr.load.position.y = hookY - 1.6;
}
},
};
return system;
}
// ------------------------------------------------------------------
// Train system + level crossing
// ------------------------------------------------------------------
@@ -3900,7 +4565,7 @@ function beachWaveY(x, z, t) {
* @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max)
* @returns {{update: Function, counts: Function}}
*/
function initBeach(cfg) {
function initBeach(cfg, portCfg = null) {
const sandFrom = cfg.sand_from;
const sandTo = cfg.sand_to;
const seaTo = cfg.sea_to;
@@ -3908,6 +4573,11 @@ function initBeach(cfg) {
const zMax = cfg.z_max;
const midZ = (zMin + zMax) / 2;
const zDepth = zMax - zMin;
// The sea extends north over the port basin (and the free half of
// that coast, kept open for future city expansion).
const seaZMin = Math.min(zMin, portCfg?.water?.z_min ?? zMin);
const seaSpan = zMax - seaZMin;
const seaCenterZ = (zMax + seaZMin) / 2;
const group = new THREE.Group();
const counts = {
@@ -3931,14 +4601,17 @@ function initBeach(cfg) {
// ---- Sea with animated waves (one mesh, vertex displacement) ----
const seaW = seaTo - sandTo;
const seaGeo = new THREE.PlaneGeometry(seaW, zDepth, 40, 56);
const seaGeo = new THREE.PlaneGeometry(
seaW, seaSpan, 40,
Math.max(16, Math.round((56 * seaSpan) / zDepth))
);
const seaMat = new THREE.MeshStandardMaterial({
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
transparent: true, opacity: 0.94,
});
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
seaMesh.rotation.x = -Math.PI / 2;
seaMesh.position.set(sandTo + seaW / 2, 0.09, midZ);
seaMesh.position.set(sandTo + seaW / 2, 0.09, seaCenterZ);
seaMesh.frustumCulled = false;
group.add(seaMesh);
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
@@ -4242,7 +4915,7 @@ function initBeach(cfg) {
for (let i = 0; i < pos.count; i++) {
const lx = seaBase[i * 3];
const ly = seaBase[i * 3 + 1];
arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, midZ - ly, time);
arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, seaCenterZ - ly, time);
}
pos.needsUpdate = true;
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
@@ -4594,6 +5267,8 @@ function buildCity(data) {
beach = null;
beachSystem = null;
airportsCfg = null;
portCfg = null;
portSystem = null;
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
@@ -4936,14 +5611,18 @@ function buildCity(data) {
worldGroup.add(ring);
});
// ---- Beach (east shore) ----
// ---- Beach (east shore, sea also covers the port basin) ----
beach = data.beach || null;
beachSystem = beach ? initBeach(beach) : null;
portCfg = data.port || null;
beachSystem = beach ? initBeach(beach, portCfg) : null;
// ---- Airports (north/south) + flight operations ----
airportsCfg = data.airports || null;
buildAirports(airportsCfg);
// ---- Port (far half of the north-east coast) ----
portSystem = portCfg ? initPort(portCfg) : null;
// ---- Filler buildings ----
// Fillers only exist on empty ground: skip cells near stores AND near
// any placed building (hospital, school, city hall, player houses) so
@@ -5534,6 +6213,9 @@ function updatePlayer(dt) {
if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) {
maxX = Math.max(maxX, beach.sand_to + 1);
}
if (portCfg?.visit && player.pos.z <= portCfg.visit.max_z && player.pos.z >= portCfg.visit.min_z) {
maxX = Math.max(maxX, portCfg.visit.max_x);
}
player.pos.x = cityBounds
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
: Math.max(-400, Math.min(maxX, player.pos.x));
@@ -5545,6 +6227,9 @@ function updatePlayer(dt) {
minZ = airportsCfg.visit.min_z;
maxZ = airportsCfg.visit.max_z;
}
if (portCfg?.visit && player.pos.x >= portCfg.visit.min_x) {
minZ = Math.min(minZ, portCfg.visit.min_z);
}
player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z));
}
@@ -6180,7 +6865,13 @@ function animate() {
if (airplaneSystem) airplaneSystem.update(dt);
if (trainSystem) trainSystem.update(dt);
if (riverSystem) riverSystem.update(dt, simTimeAcc);
if (beachSystem) beachSystem.update(dt, simTimeAcc);
if (beachSystem) {
try { beachSystem.update(dt, simTimeAcc); } catch (err) { console.error('beach update', err); }
}
if (portSystem) {
// A port glitch must never abort the frame (renderer.render below).
try { portSystem.update(dt, simTimeAcc); } catch (err) { console.error('port update', err); }
}
updatePlayer(dt);
checkVehicleHitOnPlayer();
@@ -6748,6 +7439,7 @@ window.__city10 = {
sidewalkSegments: sidewalkSegmentCount,
beach: beachSystem ? beachSystem.counts() : null,
flights: airplaneSystem ? airplaneSystem.flights() : null,
port: portSystem ? portSystem.counts() : null,
robots: pedestrianSystem
? pedestrianSystem.walkers.filter(w => w.robot).length
: 0,