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.
This commit is contained in:
+272
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user