diff --git a/.playwright-mcp/drone-camera-quadro.png b/.playwright-mcp/drone-camera-quadro.png new file mode 100644 index 0000000..c98bed0 Binary files /dev/null and b/.playwright-mcp/drone-camera-quadro.png differ diff --git a/.playwright-mcp/drone-painel-topo-centro.png b/.playwright-mcp/drone-painel-topo-centro.png new file mode 100644 index 0000000..2aa1776 Binary files /dev/null and b/.playwright-mcp/drone-painel-topo-centro.png differ diff --git a/lang/en.json b/lang/en.json index ac040e1..5a95219 100755 --- a/lang/en.json +++ b/lang/en.json @@ -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", diff --git a/lang/es.json b/lang/es.json index 5b5e3bc..2982efe 100755 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index cb05f33..0ca58d2 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -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", diff --git a/public/js/city/app.js b/public/js/city/app.js index 3e71648..6bc1cc1 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -4133,6 +4133,230 @@ 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(); + + 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); + + drone.view = { x, y, w, h }; +} + // ------------------------------------------------------------------ // Train system + level crossing // ------------------------------------------------------------------ @@ -5130,6 +5354,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,22 +6386,26 @@ function updatePlayer(dt) { const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw)); const wish = new THREE.Vector3(); - // 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 - // forward step always follows the direction the camera is facing. - if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward); - if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward); - if (keys['KeyA']) wish.sub(right); - if (keys['KeyD']) wish.add(right); - const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0); - if (turn) player.yaw += turn * 2.4 * dt; - if (mobileInput.active) { - // Joystick: vertical walks along the camera facing, horizontal - // turns the camera (mobile first-person convention). - wish.addScaledVector(forward, -mobileInput.y); - if (Math.abs(mobileInput.x) > 0.15) { - player.yaw -= mobileInput.x * 2.4 * dt; + // 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 + // forward step always follows the direction the camera is facing. + if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward); + if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward); + if (keys['KeyA']) wish.sub(right); + if (keys['KeyD']) wish.add(right); + const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0); + if (turn) player.yaw += turn * 2.4 * dt; + if (mobileInput.active) { + // Joystick: vertical walks along the camera facing, horizontal + // turns the camera (mobile first-person convention). + wish.addScaledVector(forward, -mobileInput.y); + if (Math.abs(mobileInput.x) > 0.15) { + player.yaw -= mobileInput.x * 2.4 * dt; + } } } @@ -6202,7 +6438,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 +7110,7 @@ function animate() { } updatePlayer(dt); + updateDrone(dt); checkVehicleHitOnPlayer(); updateExercise(dt); updateCameraFromPlayer(); @@ -6899,6 +7136,12 @@ function animate() { const tRender = performance.now(); renderer.render(scene, camera); perfFrame.render = performance.now() - tRender; + + // Drone command-panel feed: scissored second pass on alternate frames. + if (drone.active && drone.mesh) { + drone.frame += 1; + if (drone.frame % 2 === 0) renderDroneView(); + } } animate(); @@ -7210,6 +7453,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; @@ -7440,6 +7687,14 @@ window.__city10 = { 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), + view: drone.view || { x: 0, y: 0, w: 0, h: 0 }, + } : null, robots: pedestrianSystem ? pedestrianSystem.walkers.filter(w => w.robot).length : 0, diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 24a16b2..3d40281 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -118,6 +118,7 @@ + {{-- Marketplace --}} @@ -237,6 +238,31 @@ padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }} + {{-- 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. --}} + + {{-- Chat panel (bottom-right, transparent — opens on bump) --}}