diff --git a/.playwright-mcp/faixa-zebra-canto-se.png b/.playwright-mcp/faixa-zebra-canto-se.png new file mode 100644 index 0000000..a93d2e8 Binary files /dev/null and b/.playwright-mcp/faixa-zebra-canto-se.png differ diff --git a/.playwright-mcp/faixa-zebra-vista-da-rua.png b/.playwright-mcp/faixa-zebra-vista-da-rua.png new file mode 100644 index 0000000..4666562 Binary files /dev/null and b/.playwright-mcp/faixa-zebra-vista-da-rua.png differ diff --git a/public/js/city/app.js b/public/js/city/app.js index 50071d2..2715922 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -104,6 +104,31 @@ const sidewalkTex = canvasTexture(256, (ctx, s) => { } }, 4, 1); +// Zebra crosswalk: white longitudinal bars parallel to vehicle travel +// (MUTCD "longitudinal bar" design), transparent gaps so the asphalt +// underneath shows through. Six bars (~0.6 unit each with ~0.6 gaps, +// NZTA proportions) across the 8-unit road width. `barsAcrossU` draws +// bars along V (for planes spanning roadW × band) — otherwise along U. +function zebraTexture(barsAcrossU) { + return canvasTexture(512, (ctx, s) => { + ctx.clearRect(0, 0, s, s); + ctx.fillStyle = '#f5f5f5'; + const bars = 6, bar = 38, pitch = 76; + const start = (s - (bars * bar + (bars - 1) * (pitch - bar))) / 2; + for (let i = 0; i < bars; i++) { + const p = start + i * pitch; + if (barsAcrossU) { + ctx.fillRect(p, 0, bar, s); + } else { + ctx.fillRect(0, p, s, bar); + } + } + }, 1, 1); +} + +const zebraTexOverZRoad = zebraTexture(true); +const zebraTexOverXRoad = zebraTexture(false); + function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) { const w = 512, h = 512; const c = document.createElement('canvas'); @@ -183,6 +208,8 @@ const crowdMeshes = []; const facadeMaterials = []; const lampHeads = []; let grid = null; +let crosswalkCount = 0; +let sidewalkSegmentCount = 0; let trafficSystem = null; let vehicleSystem = null; let pedestrianSystem = null; @@ -463,6 +490,45 @@ function onRoad(x, z, g) { return false; } +/** + * Split the interval [a, b] of a road's travel axis into segments that + * exclude ±half bands around every perpendicular crossing line. Sidewalks + * and curbs use this so they stop at each crosswalk instead of running + * across the roadway like a plain walkway. + * + * @param {number[]} lines grid lines of the perpendicular roads + * @return {Array<[number, number]>} + */ +function stripSegments(lines, a, b, half) { + if (a > b) [a, b] = [b, a]; + const gaps = []; + for (const line of lines) { + const g0 = line - half; + const g1 = line + half; + if (g1 > a && g0 < b) gaps.push([Math.max(g0, a), Math.min(g1, b)]); + } + gaps.sort((p, q) => p[0] - q[0]); + + const merged = []; + for (const gap of gaps) { + const last = merged[merged.length - 1]; + if (last && gap[0] <= last[1] + 1e-6) { + last[1] = Math.max(last[1], gap[1]); + } else { + merged.push([gap[0], gap[1]]); + } + } + + const segs = []; + let cursor = a; + for (const [g0, g1] of merged) { + if (g0 - cursor > 0.05) segs.push([cursor, g0]); + cursor = Math.max(cursor, g1); + } + if (b - cursor > 0.05) segs.push([cursor, b]); + return segs; +} + // ------------------------------------------------------------------ // Traffic light system // ------------------------------------------------------------------ @@ -2704,6 +2770,8 @@ function buildCity(data) { mayorSystem = null; meMesh = null; spawnApplied = false; + crosswalkCount = 0; + sidewalkSegmentCount = 0; plazas = []; gymEquipment = []; exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null }; @@ -2799,45 +2867,119 @@ function buildCity(data) { const swWidth = 2; const offset = w / 2 + swWidth / 2; + // Sidewalks stop at each perpendicular roadway: the crossing band + // itself is painted as a zebra crosswalk below. + const crossingLines = r.axis === 'z' ? grid.zs : grid.xs; + const travel1 = r.axis === 'z' ? z1 : x1; + const travel2 = r.axis === 'z' ? z2 : x2; + const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2); [-1, 1].forEach(side => { - const sg = r.axis === 'z' - ? new THREE.PlaneGeometry(swWidth, len) - : new THREE.PlaneGeometry(len, swWidth); - const sm = new THREE.Mesh(sg, walkMat); - sm.rotation.x = -Math.PI / 2; - if (r.axis === 'z') { - sm.position.set(mx + side * offset, 0.12, mz); - } else { - sm.position.set(mx, 0.12, mz + side * offset); + for (const [a, b] of spans) { + const segLen = b - a; + const cmid = (a + b) / 2; + const sg = r.axis === 'z' + ? new THREE.PlaneGeometry(swWidth, segLen) + : new THREE.PlaneGeometry(segLen, swWidth); + const sm = new THREE.Mesh(sg, walkMat); + sm.rotation.x = -Math.PI / 2; + if (r.axis === 'z') { + sm.position.set(mx + side * offset, 0.12, cmid); + } else { + sm.position.set(cmid, 0.12, mz + side * offset); + } + sm.receiveShadow = true; + worldGroup.add(sm); + sidewalkSegmentCount++; } - sm.receiveShadow = true; - worldGroup.add(sm); }); }); - // ---- Curbs ---- + // ---- Curbs (interrupted at crosswalks — dropped-curb look) ---- layout.roads.forEach(r => { if (r === layoutExtras.bridgeRoad) return; const [x1,, z1] = r.from; const [x2,, z2] = r.to; const w = r.width; - const len = Math.hypot(x2 - x1, z2 - z1); const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2; const offset = w / 2 + 0.1; + const crossingLines = r.axis === 'z' ? grid.zs : grid.xs; + const travel1 = r.axis === 'z' ? z1 : x1; + const travel2 = r.axis === 'z' ? z2 : x2; + const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2); [-1, 1].forEach(side => { - const curbGeo = r.axis === 'z' - ? new THREE.BoxGeometry(0.2, 0.14, len) - : new THREE.BoxGeometry(len, 0.14, 0.2); - const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 })); - if (r.axis === 'z') { - curb.position.set(mx + side * offset, 0.07, mz); - } else { - curb.position.set(mx, 0.07, mz + side * offset); + for (const [a, b] of spans) { + const segLen = b - a; + const cmid = (a + b) / 2; + const curbGeo = r.axis === 'z' + ? new THREE.BoxGeometry(0.2, 0.14, segLen) + : new THREE.BoxGeometry(segLen, 0.14, 0.2); + const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 })); + if (r.axis === 'z') { + curb.position.set(mx + side * offset, 0.07, cmid); + } else { + curb.position.set(cmid, 0.07, mz + side * offset); + } + worldGroup.add(curb); } - worldGroup.add(curb); }); }); + // ---- Zebra crosswalks (real markings, not sidewalk-coloured bands) ---- + // + // Four approaches per intersection; each band sits exactly where the + // perpendicular sidewalk used to cross the roadway (2 units deep = + // the sidewalk width), directly over the asphalt. Bars run parallel + // to vehicle travel (MUTCD longitudinal-bar zebra design). + const zebraMatOverZ = new THREE.MeshStandardMaterial({ + map: zebraTexOverZRoad, transparent: true, alphaTest: 0.4, roughness: 0.9, + }); + const zebraMatOverX = new THREE.MeshStandardMaterial({ + map: zebraTexOverXRoad, transparent: true, alphaTest: 0.4, roughness: 0.9, + }); + const crossHalf = grid.roadW / 2; + const crossBand = 2; + const eps = 0.01; + const crossRoadX = layoutExtras.crossRoad ? layoutExtras.crossRoad.from[0] : null; + + for (const vx of grid.xs) { + for (const vz of grid.zs) { + // Crossings over the vertical (z) road — south & north bands. + const zSpans = [ + { side: 1, valid: vz + crossHalf + crossBand <= grid.maxZ + eps || vx === crossRoadX }, + { side: -1, valid: vz - crossHalf - crossBand >= grid.minZ - eps }, + ]; + for (const { side, valid } of zSpans) { + if (!valid) continue; + const zebra = new THREE.Mesh( + new THREE.PlaneGeometry(grid.roadW, crossBand), zebraMatOverZ + ); + zebra.rotation.x = -Math.PI / 2; + zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2)); + zebra.receiveShadow = true; + worldGroup.add(zebra); + crosswalkCount++; + } + + // Crossings over the horizontal (x) road — east & west bands. + // At the bridge corner the west approach rides on the deck. + const xSpans = [ + { side: 1, valid: vx + crossHalf + crossBand <= grid.maxX + eps }, + { side: -1, valid: vx - crossHalf - crossBand >= grid.minX - eps }, + ]; + for (const { side, valid } of xSpans) { + if (!valid) continue; + const zebra = new THREE.Mesh( + new THREE.PlaneGeometry(crossBand, grid.roadW), zebraMatOverX + ); + zebra.rotation.x = -Math.PI / 2; + zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz); + zebra.receiveShadow = true; + worldGroup.add(zebra); + crosswalkCount++; + } + } + } + // ---- Street lamps (on the sidewalk, never in the roadway) ---- const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 }); const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre @@ -4666,6 +4808,8 @@ window.__city10 = { })(), pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0, trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0, + crosswalks: crosswalkCount, + sidewalkSegments: sidewalkSegmentCount, airplanes: airplaneSystem ? airplaneSystem.count() : 0, trains: trainSystem ? 1 : 0, river: !!riverSystem,