Compare commits

..
2 Commits
Author SHA1 Message Date
marcocosta10000 a43176a68e fix(drone): resolve drone feed fog and flicker issues
- Add preserveDrawingBuffer to enable automated test screenshots
- Neutralize scene fog during drone render pass to prevent misted aerial view
- Render drone feed every frame instead of alternating with ground view
- Update visor styling with scanline effect and improved borders
- Add debug getters for WebGL renderer and scene information
2026-11-01 01:00:56 -03:00
marcocosta10000 68cb4eb9ad feat(drone): add personal drone with first-person camera and control panel
Implements a flyable quadcopter with WASD/arrow controls, altitude hold,
and landing. Features a scissored camera view in a command panel showing
real-time feed, telemetry, and control hints. Includes localized strings
for English, Spanish, and Portuguese.
2026-11-01 00:10:37 -03:00
21 changed files with 354 additions and 18 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+2
View File
@@ -221,6 +221,8 @@
"city.beach_active": "Relaxing at the beach",
"city.atc_missing": "No control tower configured.",
"city.port_missing": "No port authority configured.",
"city.drone": "Drone",
"city.drone_hint": "WASD fly · ↑↓ up/down · ←→ turn · Space up · C down · Q to land",
"city.sign_plaza": "Plaza",
"city.sign_beach": "Beach",
"city.sign_food": "Food",
+2
View File
@@ -221,6 +221,8 @@
"city.beach_active": "Relajándote en la playa",
"city.atc_missing": "Ninguna torre de control configurada.",
"city.port_missing": "Ninguna capitanía del puerto configurada.",
"city.drone": "Dron",
"city.drone_hint": "WASD volar · ↑↓ subir/bajar · ←→ girar · Espacio subir · C bajar · Q para aterrizar",
"city.sign_plaza": "Plaza",
"city.sign_beach": "Playa",
"city.sign_food": "Comida",
+2
View File
@@ -221,6 +221,8 @@
"city.beach_active": "Relaxando na praia",
"city.atc_missing": "Nenhuma torre de controle configurada.",
"city.port_missing": "Nenhuma capitania do porto configurada.",
"city.drone": "Drone",
"city.drone_hint": "WASD voar · ↑↓ subir/descer · ←→ girar · Espaço subir · C descer · Q pousar",
"city.sign_plaza": "Praça",
"city.sign_beach": "Praia",
"city.sign_food": "Comidas",
+300 -2
View File
@@ -30,7 +30,9 @@ scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
camera.position.set(0, 1.7, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
// preserveDrawingBuffer: lets automated tests/screenshots read real
// frame pixels (the PIP visor proof); cost is negligible here.
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -4133,6 +4135,248 @@ function initPort(cfg) {
return system;
}
// ------------------------------------------------------------------
// Personal drone (per logged-in session): first-person quad with a
// command panel whose "video" hole shows the drone camera feed — the
// renderer draws that exact scissored region of the main canvas, so no
// second WebGL context is needed.
// ------------------------------------------------------------------
let drone = {
active: false,
created: false,
pos: new THREE.Vector3(0, 30, 0),
yaw: 0,
pitch: -0.55,
vel: new THREE.Vector3(),
mesh: null,
cam: null,
qPrev: false,
frame: 0,
teleTimer: 0,
view: null,
};
/** Quadcopter: nose toward −Z (matches drone.yaw like ships' convention). */
function buildDroneMesh() {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x232a35, roughness: 0.5, metalness: 0.5 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x141920, roughness: 0.6 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.24, 1.1), bodyMat);
g.add(body);
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.18, 0.45), bodyMat);
nose.position.set(0, 0.04, -0.72);
g.add(nose);
// X-arms.
for (const rot of [Math.PI / 4, -Math.PI / 4]) {
const arm = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.07, 0.14), darkMat);
arm.rotation.y = rot;
g.add(arm);
}
// Motors, spinning rotors and skids at the four tips.
const rotors = [];
for (const [ox, oz] of [[0.7, 0.7], [0.7, -0.7], [-0.7, 0.7], [-0.7, -0.7]]) {
const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.12, 0.15, 8), darkMat);
motor.position.set(ox, 0.1, oz);
g.add(motor);
const rotor = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.45, 0.03, 12),
new THREE.MeshStandardMaterial({
color: 0x9aa4b2, roughness: 0.3, metalness: 0.6, transparent: true, opacity: 0.7,
})
);
rotor.position.set(ox, 0.21, oz);
g.add(rotor);
rotors.push(rotor);
const skid = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.35, 0.07), darkMat);
skid.position.set(ox, -0.25, oz);
g.add(skid);
}
// Camera gimbal + lens pointing forward (−Z) and a status LED.
const gimbal = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 10, 10),
new THREE.MeshStandardMaterial({ color: 0x0e1620, roughness: 0.2, metalness: 0.8 })
);
gimbal.position.set(0, -0.14, -0.55);
g.add(gimbal);
const lens = new THREE.Mesh(
new THREE.CylinderGeometry(0.06, 0.07, 0.1, 10),
new THREE.MeshStandardMaterial({ color: 0x164e63, roughness: 0.2, metalness: 0.7 })
);
lens.rotation.x = -Math.PI / 2;
lens.position.set(0, -0.14, -0.7);
g.add(lens);
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.05, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff3344, emissive: 0xff3344, emissiveIntensity: 2 })
);
led.position.set(0, 0.18, -0.45);
g.add(led);
g.userData = { rotors, led };
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
return g;
}
/** Activate/deactivate the personal drone (spawn above the player). */
function toggleDrone() {
if (!drone.mesh) {
drone.pos.set(
player.pos.x,
Math.max(30, (player.pos.y || 0) + 28),
player.pos.z
);
drone.yaw = player.yaw;
drone.pitch = -0.55;
drone.vel.set(0, 0, 0);
drone.mesh = buildDroneMesh();
drone.mesh.position.copy(drone.pos);
worldGroup.add(drone.mesh);
drone.cam = new THREE.PerspectiveCamera(68, 1.6, 0.1, 800);
drone.created = true;
}
drone.active = !drone.active;
const panel = document.getElementById('drone-panel');
if (panel) panel.style.display = drone.active ? 'block' : 'none';
if (drone.mesh?.userData.led) {
const hex = drone.active ? 0x22ff66 : 0xff3344;
drone.mesh.userData.led.material.color.setHex(hex);
drone.mesh.userData.led.material.emissive.setHex(hex);
}
}
/** Drone flight, rotor spin, telemetry and camera update (per frame). */
function updateDrone(dt) {
if (!drone.mesh) return;
// Q toggles (edge-detected through the shared key map).
const q = !!keys['KeyQ'];
if (q && !drone.qPrev) toggleDrone();
drone.qPrev = q;
const time = performance.now() / 1000;
const spin = drone.active ? 46 : 9;
for (const r of drone.mesh.userData.rotors) r.rotation.y += spin * dt;
let inFwd = 0;
let inRight = 0;
let inAsc = 0;
if (drone.active) {
if (keys['KeyW']) inFwd += 1;
if (keys['KeyS']) inFwd -= 1;
if (keys['KeyD']) inRight += 1;
if (keys['KeyA']) inRight -= 1;
if (keys['Space']) inAsc += 1;
if (keys['KeyC']) inAsc -= 1;
if (keys['ArrowLeft']) drone.yaw += 2.2 * dt;
if (keys['ArrowRight']) drone.yaw -= 2.2 * dt;
if (keys['ArrowUp']) drone.pitch = Math.min(1.2, drone.pitch + 1.6 * dt);
if (keys['ArrowDown']) drone.pitch = Math.max(-1.4, drone.pitch - 1.6 * dt);
const fwd = new THREE.Vector3(-Math.sin(drone.yaw), 0, -Math.cos(drone.yaw));
const right = new THREE.Vector3(Math.cos(drone.yaw), 0, -Math.sin(drone.yaw));
const wish = new THREE.Vector3()
.addScaledVector(fwd, inFwd * 20)
.addScaledVector(right, inRight * 20);
wish.y = inAsc * 9;
// Smooth acceleration (inertial feel).
drone.vel.lerp(wish, 1 - Math.exp(-4 * dt));
drone.pos.addScaledVector(drone.vel, dt);
// Flight envelope: generous box over city, beach and port.
drone.pos.x = Math.max(-500, Math.min(500, drone.pos.x));
drone.pos.z = Math.max(-500, Math.min(500, drone.pos.z));
drone.pos.y = Math.max(4, Math.min(150, drone.pos.y));
} else {
drone.vel.multiplyScalar(Math.exp(-3 * dt)); // rotors spool down
}
// Pose: heading, forward dip, strafe bank, idle hover bob.
drone.mesh.position.copy(drone.pos);
if (!drone.active) drone.mesh.position.y += Math.sin(time * 2) * 0.05;
drone.mesh.rotation.y = drone.yaw;
const targetPitch = drone.active ? -inFwd * 0.12 : 0;
const targetBank = drone.active ? -inRight * 0.22 : 0;
drone.mesh.rotation.x += (targetPitch - drone.mesh.rotation.x) * Math.min(1, dt * 6);
drone.mesh.rotation.z += (targetBank - drone.mesh.rotation.z) * Math.min(1, dt * 6);
// Camera: gimbal just below the nose, following yaw/pitch.
drone.cam.position.set(drone.pos.x, drone.pos.y - 0.12, drone.pos.z);
const cp = Math.cos(drone.pitch);
drone.cam.lookAt(
drone.pos.x - Math.sin(drone.yaw) * cp,
drone.pos.y + Math.sin(drone.pitch),
drone.pos.z - Math.cos(drone.yaw) * cp
);
// Telemetry (5 Hz).
drone.teleTimer -= dt;
if (drone.active && drone.teleTimer <= 0) {
drone.teleTimer = 0.2;
const tele = document.getElementById('drone-tele');
if (tele) {
const spd = Math.round(drone.vel.length());
const deg = Math.round(((drone.yaw * 180) / Math.PI % 360 + 360) % 360);
tele.textContent = `↑${Math.round(drone.pos.y)} m · ${spd} m/s · ${deg}°`;
}
}
}
/**
* Draw the drone camera feed into the panel's transparent region using a
* scissored viewport (30 fps alternate frames to halve the extra cost).
*/
function renderDroneView() {
const video = document.getElementById('drone-video');
if (!video || !drone.cam) return;
const vr = video.getBoundingClientRect();
if (vr.width < 8 || vr.height < 8) return;
const canvas = renderer.domElement;
const cr = canvas.getBoundingClientRect();
const x = Math.round(vr.left - cr.left);
const yTop = Math.round(vr.top - cr.top);
const w = Math.round(vr.width);
const h = Math.round(vr.height);
// GL viewports are bottom-up.
const y = Math.round(cr.height - yTop - h);
drone.cam.aspect = w / h;
drone.cam.updateProjectionMatrix();
// Neutralise the city fog for the drone feed so the aerial view is
// never misted ("embassado"). Only the fog uniform values change for
// this pass — three refreshes them per render, so no material is
// recompiled and the ground view keeps its weather haze.
const fog = scene.fog;
const fogNear = fog ? fog.near : 0;
const fogFar = fog ? fog.far : 0;
if (fog) {
fog.near = 1e7;
fog.far = 2e7; // smoothstep → factor 0 at any real distance
}
renderer.setScissorTest(true);
renderer.setScissor(x, y, w, h);
renderer.setViewport(x, y, w, h);
renderer.render(scene, drone.cam); // clears only the scissored hole
renderer.setScissorTest(false);
renderer.setViewport(0, 0, cr.width, cr.height);
if (fog) {
fog.near = fogNear;
fog.far = fogFar;
}
drone.fogCleared = !!fog;
drone.view = { x, y, w, h };
}
// ------------------------------------------------------------------
// Train system + level crossing
// ------------------------------------------------------------------
@@ -5130,6 +5374,14 @@ function updateExercise(dt) {
return;
}
// Drone mode owns the controls: no ground sessions or prompts.
if (drone.active) {
if (exercise.active) stopExerciseSession(false);
if (relax.active) stopRelaxSession();
setExercisePrompt(null);
return;
}
// ---- Beach leisure (east shore) ----
const onBeach = !!beach
&& player.pos.x >= beach.sand_from - 1
@@ -6154,6 +6406,9 @@ function updatePlayer(dt) {
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3();
// While the drone is airborne the keys pilot IT (updateDrone) — the
// grounded character stays put but keeps gravity and collisions.
if (!drone.active) {
// Allow walking with WASD even before pointer lock so the character is
// never completely stuck on the overlay screen. A/D strafe; the arrow
// keys turn the first-person camera instead of strafing, so every
@@ -6172,6 +6427,7 @@ function updatePlayer(dt) {
player.yaw -= mobileInput.x * 2.4 * dt;
}
}
}
if (wish.lengthSq() > 0) {
wish.normalize();
@@ -6202,7 +6458,7 @@ function updatePlayer(dt) {
player.vel.y = 0;
player.onGround = true;
}
if (keys['Space'] && player.onGround) {
if (!drone.active && keys['Space'] && player.onGround) {
player.vel.y = 6;
player.onGround = false;
}
@@ -6874,6 +7130,7 @@ function animate() {
}
updatePlayer(dt);
updateDrone(dt);
checkVehicleHitOnPlayer();
updateExercise(dt);
updateCameraFromPlayer();
@@ -6899,6 +7156,12 @@ function animate() {
const tRender = performance.now();
renderer.render(scene, camera);
perfFrame.render = performance.now() - tRender;
// Drone command-panel feed: scissored pass every frame so the visor
// never alternates between the drone view and the ground view.
if (drone.active && drone.mesh) {
renderDroneView();
}
}
animate();
@@ -7210,6 +7473,10 @@ async function refreshMarketPanel() {
}
function bindWorldUI() {
document.getElementById('btn-drone')?.addEventListener('click', () => toggleDrone());
document.getElementById('drone-close')?.addEventListener('click', () => {
if (drone.active) toggleDrone();
});
document.getElementById('btn-market')?.addEventListener('click', () => {
const panel = document.getElementById('market-panel');
if (!panel) return;
@@ -7379,6 +7646,26 @@ window.__city10 = {
return out;
},
playerCrossingAllowed,
/** Debug: the WebGL renderer (frame forensics for automated tests). */
get gl() {
return renderer;
},
/** Debug: live scene background/fog hex + camera pose. */
get sceneInfo() {
return {
bg: scene.background ? scene.background.getHexString() : null,
fog: scene.fog ? scene.fog.color.getHexString() : null,
fogNear: scene.fog ? scene.fog.near : null,
fogFar: scene.fog ? scene.fog.far : null,
cam: {
x: +camera.position.x.toFixed(2),
y: +camera.position.y.toFixed(2),
z: +camera.position.z.toFixed(2),
},
toneMapping: renderer.toneMapping,
exposure: renderer.toneMappingExposure,
};
},
/** Current bump-to-talk peer (null when the chat is closed). */
get talkPeer() {
return currentPeer;
@@ -7437,9 +7724,20 @@ window.__city10 = {
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
crosswalks: crosswalkCount,
sidewalkSegments: sidewalkSegmentCount,
hour: +(sim.hour ?? 0).toFixed(1),
night: +(sim.nightFactor ?? 0).toFixed(2),
beach: beachSystem ? beachSystem.counts() : null,
flights: airplaneSystem ? airplaneSystem.flights() : null,
port: portSystem ? portSystem.counts() : null,
drone: drone.mesh ? {
active: drone.active,
x: +drone.pos.x.toFixed(1),
y: +drone.pos.y.toFixed(1),
z: +drone.pos.z.toFixed(1),
yaw: +drone.yaw.toFixed(2),
fogCleared: !!drone.fogCleared,
view: drone.view || { x: 0, y: 0, w: 0, h: 0 },
} : null,
robots: pedestrianSystem
? pedestrianSystem.walkers.filter(w => w.robot).length
: 0,
+32
View File
@@ -118,6 +118,7 @@
<button type="button" id="btn-customize" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_customize') }}</button>
<button type="button" id="btn-ride-bus" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_bus') }}</button>
<button type="button" id="btn-ride-train" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">{{ __('city.act_ride_train') }}</button>
<button type="button" id="btn-drone" class="btn" style="padding:.25rem .45rem; font-size:.75rem;">🚁 {{ __('city.drone') }}</button>
</div>
{{-- Marketplace --}}
@@ -237,6 +238,35 @@
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
</div>
{{-- Drone command panel: header/hints opaque, #drone-video is a
transparent window — the renderer draws the drone camera
into that exact region (scissored viewport) of the canvas. --}}
<div id="drone-panel"
style="display:none; position:absolute; left:50%; top:.9rem; transform:translateX(-50%); z-index:16; width:330px;
border:1px solid rgba(148,163,184,.55); border-radius:.6rem; overflow:hidden;
box-shadow:0 12px 30px rgba(0,0,0,.5); background:transparent;">
<div id="drone-header"
style="display:flex; align-items:center; justify-content:space-between; gap:.4rem;
padding:.4rem .6rem; background:rgba(2,8,20,.88);">
<span style="font-weight:700; font-size:.8rem;">🚁 {{ __('city.drone') }}</span>
<span id="drone-tele" style="font-size:.72rem; color:#93c5fd;">↑ 0 m</span>
<button type="button" id="drone-close"
style="border:0; background:rgba(148,163,184,.18); color:#e2e8f0; border-radius:.35rem; cursor:pointer; padding:.1rem .45rem;">✕</button>
</div>
<div id="drone-video"
style="height:200px;
background:repeating-linear-gradient(0deg,
rgba(0,0,0,.14) 0px, rgba(0,0,0,.14) 2px,
rgba(0,0,0,0) 2px, rgba(0,0,0,0) 4px);
border:7px solid #050a14; border-radius:.4rem;
box-shadow:inset 0 0 16px rgba(0,0,0,.75),
0 0 0 2px rgba(148,163,184,.45);"></div>
<div id="drone-hints"
style="padding:.4rem .6rem; font-size:.7rem; color:#cbd5e1; background:rgba(2,8,20,.88);">
{{ __('city.drone_hint') }}
</div>
</div>
{{-- Chat panel (bottom-right, transparent — opens on bump) --}}
<div id="chat-panel"
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
@@ -376,6 +406,8 @@
signCityhall: @json(__('city.sign_cityhall')),
signUniversity: @json(__('city.sign_university')),
signFirestation: @json(__('city.sign_firestation')),
drone: @json(__('city.drone')),
droneHint: @json(__('city.drone_hint')),
exerciseStopHint: @json(__('city.exercise_stopHint')),
exerciseStart: @json(__('city.exerciseStart')),
exerciseStop: @json(__('city.exerciseStop')),