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:
+140
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user