diff --git a/.playwright-mcp/city10-ped-signal.png b/.playwright-mcp/city10-ped-signal.png new file mode 100644 index 0000000..56ac928 Binary files /dev/null and b/.playwright-mcp/city10-ped-signal.png differ diff --git a/lang/en.json b/lang/en.json index 74788d4..c51cdcf 100755 --- a/lang/en.json +++ b/lang/en.json @@ -216,5 +216,6 @@ "city.exercise_tired": "You are too tired to exercise.", "city.exercise_max_health": "Health is already at maximum!", "city.exerciseStart": "Starting exercise…", - "city.exerciseStop": "Exercise stopped." + "city.exerciseStop": "Exercise stopped.", + "city.ped_red": "Red pedestrian light — wait for the walk signal." } diff --git a/lang/es.json b/lang/es.json index f7d5abf..baf748d 100755 --- a/lang/es.json +++ b/lang/es.json @@ -216,5 +216,6 @@ "city.exercise_tired": "Está demasiado cansado para ejercitarse.", "city.exercise_max_health": "¡La salud ya está en el máximo!", "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." } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 9b47375..9218b96 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -216,5 +216,6 @@ "city.exercise_tired": "Você está cansado demais para se exercitar.", "city.exercise_max_health": "Saúde já está no máximo!", "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." } diff --git a/public/js/city/app.js b/public/js/city/app.js index 377e54d..041edb0 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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; diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index f1367b4..40bd053 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -27,6 +27,13 @@ {{ __('city.error') }} + {{-- Pedestrian red-light wait banner --}} + + {{-- Gym equipment proximity prompt --}}