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:
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
+2
-1
@@ -216,5 +216,6 @@
|
|||||||
"city.exercise_tired": "You are too tired to exercise.",
|
"city.exercise_tired": "You are too tired to exercise.",
|
||||||
"city.exercise_max_health": "Health is already at maximum!",
|
"city.exercise_max_health": "Health is already at maximum!",
|
||||||
"city.exerciseStart": "Starting exercise…",
|
"city.exerciseStart": "Starting exercise…",
|
||||||
"city.exerciseStop": "Exercise stopped."
|
"city.exerciseStop": "Exercise stopped.",
|
||||||
|
"city.ped_red": "Red pedestrian light — wait for the walk signal."
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -216,5 +216,6 @@
|
|||||||
"city.exercise_tired": "Está demasiado cansado para ejercitarse.",
|
"city.exercise_tired": "Está demasiado cansado para ejercitarse.",
|
||||||
"city.exercise_max_health": "¡La salud ya está en el máximo!",
|
"city.exercise_max_health": "¡La salud ya está en el máximo!",
|
||||||
"city.exerciseStart": "Empezando a ejercitarse…",
|
"city.exerciseStart": "Empezando a ejercitarse…",
|
||||||
"city.exerciseStop": "Ejercicio terminado."
|
"city.exerciseStop": "Ejercicio terminado.",
|
||||||
|
"city.ped_red": "Semáforo de peatón en rojo — espere la señal verde."
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -216,5 +216,6 @@
|
|||||||
"city.exercise_tired": "Você está cansado demais para se exercitar.",
|
"city.exercise_tired": "Você está cansado demais para se exercitar.",
|
||||||
"city.exercise_max_health": "Saúde já está no máximo!",
|
"city.exercise_max_health": "Saúde já está no máximo!",
|
||||||
"city.exerciseStart": "Começando a se exercitar…",
|
"city.exerciseStart": "Começando a se exercitar…",
|
||||||
"city.exerciseStop": "Exercício encerrado."
|
"city.exerciseStop": "Exercício encerrado.",
|
||||||
|
"city.ped_red": "Semáforo de pedestre vermelho — aguarde o sinal verde."
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-3
@@ -458,6 +458,45 @@ const PHASES = [
|
|||||||
{ name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' },
|
{ 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() {
|
function makeSignalHead() {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const box = new THREE.Mesh(
|
const box = new THREE.Mesh(
|
||||||
@@ -483,7 +522,9 @@ function makeSignalHead() {
|
|||||||
function initTrafficLights(g) {
|
function initTrafficLights(g) {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const heads = [];
|
const heads = [];
|
||||||
|
const pedHeads = [];
|
||||||
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
|
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 x of g.xs) {
|
||||||
for (const z of g.zs) {
|
for (const z of g.zs) {
|
||||||
@@ -502,6 +543,26 @@ function initTrafficLights(g) {
|
|||||||
headEW.rotation.y = Math.PI / 2;
|
headEW.rotation.y = Math.PI / 2;
|
||||||
group.add(headEW);
|
group.add(headEW);
|
||||||
heads.push({ head: headEW, axis: 'x' });
|
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,
|
phaseIndex: 0,
|
||||||
phaseTimer: 0,
|
phaseTimer: 0,
|
||||||
heads,
|
heads,
|
||||||
|
pedHeads,
|
||||||
axisState(axis) {
|
axisState(axis) {
|
||||||
return PHASES[this.phaseIndex][axis];
|
return PHASES[this.phaseIndex][axis];
|
||||||
},
|
},
|
||||||
|
pedState(axis) {
|
||||||
|
return pedStateForAxis(axis);
|
||||||
|
},
|
||||||
get phaseName() { return PHASES[this.phaseIndex].name; },
|
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) {
|
update(dt) {
|
||||||
this.phaseTimer += dt;
|
this.phaseTimer += dt;
|
||||||
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
|
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[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
|
||||||
lamps[2].material.emissiveIntensity = state === 'green' ? 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();
|
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) {
|
function updatePlayer(dt) {
|
||||||
// Hospitalized characters stay in bed until discharge.
|
// Hospitalized characters stay in bed until discharge.
|
||||||
if (isHospitalizedNow()) {
|
if (isHospitalizedNow()) {
|
||||||
@@ -2700,7 +2820,7 @@ function updatePlayer(dt) {
|
|||||||
return;
|
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()) {
|
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
|
||||||
meCharacter.state = 'awake';
|
meCharacter.state = 'awake';
|
||||||
meCharacter.hospitalized_until = null;
|
meCharacter.hospitalized_until = null;
|
||||||
@@ -2726,10 +2846,20 @@ function updatePlayer(dt) {
|
|||||||
const step = wish.multiplyScalar(speed * dt);
|
const step = wish.multiplyScalar(speed * dt);
|
||||||
|
|
||||||
const tryX = player.pos.x + step.x;
|
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;
|
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;
|
player.vel.y -= 18 * dt;
|
||||||
@@ -3272,6 +3402,13 @@ window.__city10 = {
|
|||||||
get hour() { return sim.hour; },
|
get hour() { return sim.hour; },
|
||||||
get weather() { return weatherState.state; },
|
get weather() { return weatherState.state; },
|
||||||
get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; },
|
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; },
|
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
|
||||||
setHour(h) {
|
setHour(h) {
|
||||||
sim.hour = ((h % 24) + 24) % 24;
|
sim.hour = ((h % 24) + 24) % 24;
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
{{ __('city.error') }}
|
{{ __('city.error') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Pedestrian red-light wait banner --}}
|
||||||
|
<div id="ped-wait"
|
||||||
|
style="display:none; position:absolute; bottom:4.2rem; left:50%; transform:translateX(-50%);
|
||||||
|
z-index:20; background:rgba(127,29,29,.92); color:#fecaca; border:1px solid #f87171;
|
||||||
|
border-radius:.6rem; padding:.5rem 1rem; font-size:.95rem; font-weight:600; white-space:nowrap;">
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Gym equipment proximity prompt --}}
|
{{-- Gym equipment proximity prompt --}}
|
||||||
<div id="exercise-prompt"
|
<div id="exercise-prompt"
|
||||||
style="display:none; position:absolute; bottom:1.4rem; left:50%; transform:translateX(-50%);
|
style="display:none; position:absolute; bottom:1.4rem; left:50%; transform:translateX(-50%);
|
||||||
@@ -282,6 +289,7 @@
|
|||||||
exerciseStart: @json(__('city.exerciseStart')),
|
exerciseStart: @json(__('city.exerciseStart')),
|
||||||
exerciseStop: @json(__('city.exerciseStop')),
|
exerciseStop: @json(__('city.exerciseStop')),
|
||||||
exerciseMaxHealth: @json(__('city.exercise_max_health')),
|
exerciseMaxHealth: @json(__('city.exercise_max_health')),
|
||||||
|
pedRed: @json(__('city.ped_red')),
|
||||||
},
|
},
|
||||||
citySlug: @json($city->slug),
|
citySlug: @json($city->slug),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user