Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43176a68e | ||
|
|
68cb4eb9ad |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,22 +6406,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 +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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||