feat(city): add pedestrian traffic signals with walk/wait indicators

- Adds 3D pedestrian signal heads at each intersection corner
- Implements pedestrian signal state logic tied to vehicle traffic phases
- Displays localized wait banner when pedestrian signal is red
- Includes test asset for pedestrian signal visualization
This commit is contained in:
2026-10-17 22:20:34 -03:00
parent 090b530481
commit fa25dd7307
6 changed files with 154 additions and 6 deletions
+140 -3
View File
@@ -458,6 +458,45 @@ const PHASES = [
{ name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' },
];
/**
* Pedestrian signal for a road axis.
*
* Pedestrians may cross a road when vehicles on that road are stopped (red).
* Yellow is treated as "do not start crossing" for safety.
*
* @param {string} axis
* @returns {'green'|'red'}
*/
function pedStateForAxis(axis) {
if (!trafficSystem) return 'green';
const vehicle = trafficSystem.axisState(axis);
return vehicle === 'red' ? 'green' : 'red';
}
function makePedSignal() {
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.28, 0.7, 0.22),
new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 })
);
g.add(box);
// Top = red "don't walk", bottom = green "walk".
const red = new THREE.Mesh(
new THREE.SphereGeometry(0.09, 10, 10),
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 0.08 })
);
red.position.set(0, 0.16, 0.13);
g.add(red);
const green = new THREE.Mesh(
new THREE.SphereGeometry(0.09, 10, 10),
new THREE.MeshStandardMaterial({ color: 0x22dd66, emissive: 0x22dd66, emissiveIntensity: 0.08 })
);
green.position.set(0, -0.16, 0.13);
g.add(green);
g.userData.lamps = { red, green };
return g;
}
function makeSignalHead() {
const g = new THREE.Group();
const box = new THREE.Mesh(
@@ -483,7 +522,9 @@ function makeSignalHead() {
function initTrafficLights(g) {
const group = new THREE.Group();
const heads = [];
const pedHeads = [];
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
const pedPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.65, metalness: 0.4 });
for (const x of g.xs) {
for (const z of g.zs) {
@@ -502,6 +543,26 @@ function initTrafficLights(g) {
headEW.rotation.y = Math.PI / 2;
group.add(headEW);
heads.push({ head: headEW, axis: 'x' });
// Pedestrian signals on the SE corner, facing the crosswalks.
// Cross-Z head: allows walking E-W across the vertical (z-axis) road.
const pedZ = makePedSignal();
const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedZPole.position.set(x + g.roadW / 2 + 2.4, 1.2, z + g.roadW / 2 - 1.2);
group.add(pedZPole);
pedZ.position.set(x + g.roadW / 2 + 2.4, 2.5, z + g.roadW / 2 - 1.2);
group.add(pedZ);
pedHeads.push({ head: pedZ, axis: 'z' });
// Cross-X head: allows walking N-S across the horizontal (x-axis) road.
const pedX = makePedSignal();
const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedXPole.position.set(x + g.roadW / 2 - 1.2, 1.2, z + g.roadW / 2 + 2.4);
group.add(pedXPole);
pedX.position.set(x + g.roadW / 2 - 1.2, 2.5, z + g.roadW / 2 + 2.4);
pedX.rotation.y = Math.PI / 2;
group.add(pedX);
pedHeads.push({ head: pedX, axis: 'x' });
}
}
@@ -511,10 +572,22 @@ function initTrafficLights(g) {
phaseIndex: 0,
phaseTimer: 0,
heads,
pedHeads,
axisState(axis) {
return PHASES[this.phaseIndex][axis];
},
pedState(axis) {
return pedStateForAxis(axis);
},
get phaseName() { return PHASES[this.phaseIndex].name; },
/** Jump to a named phase (used by tests / debug). */
setPhase(name) {
const idx = PHASES.findIndex(p => p.name === name);
if (idx >= 0) {
this.phaseIndex = idx;
this.phaseTimer = 0;
}
},
update(dt) {
this.phaseTimer += dt;
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
@@ -528,6 +601,12 @@ function initTrafficLights(g) {
lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06;
}
for (const { head, axis } of this.pedHeads) {
const ped = pedStateForAxis(axis);
const lamps = head.userData.lamps;
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
}
},
};
}
@@ -2692,6 +2771,47 @@ function isHospitalizedNow() {
return new Date(meCharacter.hospitalized_until).getTime() > Date.now();
}
let lastPedBlockAt = 0;
/**
* Check whether the player may step from their current position to (toX, toZ).
*
* Crossing onto a road is gated by the pedestrian signal for that road's
* axis: vehicles green ⇒ pedestrians red. Players already mid-road may
* finish the crossing so they never get stuck in the asphalt.
*/
function playerCrossingAllowed(toX, toZ) {
if (!grid || !trafficSystem) return true;
const fromOnRoad = onRoad(player.pos.x, player.pos.z, grid);
const toOnRoad = onRoad(toX, toZ, grid);
if (!toOnRoad) return true;
if (fromOnRoad) return true; // already committed to the road
const half = grid.roadW / 2;
const enteringZRoad = grid.xs.some(vx => Math.abs(toX - vx) <= half);
const enteringXRoad = grid.zs.some(vz => Math.abs(toZ - vz) <= half);
// Blocked when the matching pedestrian signal is red.
if (enteringZRoad && trafficSystem.pedState('z') === 'red') return false;
if (enteringXRoad && trafficSystem.pedState('x') === 'red') return false;
return true;
}
function flashPedRed() {
const now = performance.now();
if (now - lastPedBlockAt < 1200) return;
lastPedBlockAt = now;
const el = document.getElementById('ped-wait');
if (el) {
el.style.display = 'block';
el.textContent = `🚶 ${i18n.pedRed || 'Red pedestrian light — wait.'}`;
clearTimeout(flashPedRed._t);
flashPedRed._t = setTimeout(() => { el.style.display = 'none'; }, 1600);
}
}
function updatePlayer(dt) {
// Hospitalized characters stay in bed until discharge.
if (isHospitalizedNow()) {
@@ -2700,7 +2820,7 @@ function updatePlayer(dt) {
return;
}
// Auto-release when the two-game-day stay elapses while online.
// Auto-release when the hospital stay elapses while online.
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
meCharacter.state = 'awake';
meCharacter.hospitalized_until = null;
@@ -2726,10 +2846,20 @@ function updatePlayer(dt) {
const step = wish.multiplyScalar(speed * dt);
const tryX = player.pos.x + step.x;
if (!resolveCollision(tryX, player.pos.y, player.pos.z)) player.pos.x = tryX;
if (!resolveCollision(tryX, player.pos.y, player.pos.z)
&& playerCrossingAllowed(tryX, player.pos.z)) {
player.pos.x = tryX;
} else if (playerCrossingAllowed(tryX, player.pos.z) === false) {
flashPedRed();
}
const tryZ = player.pos.z + step.z;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)
&& playerCrossingAllowed(player.pos.x, tryZ)) {
player.pos.z = tryZ;
} else if (playerCrossingAllowed(player.pos.x, tryZ) === false) {
flashPedRed();
}
}
player.vel.y -= 18 * dt;
@@ -3272,6 +3402,13 @@ window.__city10 = {
get hour() { return sim.hour; },
get weather() { return weatherState.state; },
get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; },
get pedStateX() { return trafficSystem ? trafficSystem.pedState('x') : null; },
get pedStateZ() { return trafficSystem ? trafficSystem.pedState('z') : null; },
setTrafficPhase(name) {
if (trafficSystem) trafficSystem.setPhase(name);
return this.trafficPhase;
},
playerCrossingAllowed,
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
setHour(h) {
sim.hour = ((h % 24) + 24) % 24;