Files
John Haugabook c46b4a919a Add flight-map-canvas extension (#2482)
* Add flight-map-canvas extension

A canvas port of the Flight Map VSCode extension: a first-person
satellite terrain map flown with flight simulator controls, for
session breaks while an agent works.

The simulator under game/ is copied verbatim from the source
extension's media/ folder. That page already reached its host through
one seam - an acquireVsCodeApi() object and a placeholder in its head -
so extension.mjs fills that seam for the canvas: a loopback server that
injects a policy, the render configuration, and a shim translating
Server-Sent Events into the messages the page already handles.

Two agent actions: fly_to sends the flight to a capital, a geocoded
city, or a raw lat/lng, and picks a random capital when called with no
input; report_job shows the current job step under the HUD.

Adds the vendored three.min.js to the codespell skip list, matching the
existing entry for arcade-canvas's phaser.min.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Apply suggestions from code review

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:32:55 +10:00

386 lines
13 KiB
JavaScript

/*******************************************************************
* FlightControls - Flight simulator camera controls for Three.js. *
* *
* Controls: *
* Mouse (pointer lock): Pitch and Yaw *
* W / Up Arrow: Increase speed *
* S / Down Arrow: Decrease speed *
* A / Left Arrow: Roll left *
* D / Right Arrow: Roll right *
* Q: Yaw left (rudder) *
* E: Yaw right (rudder) *
* R: Climb *
* F: Descend *
* Space: Auto-level (stabilize) *
* Shift: Boost speed *
* *
* Look modes: *
* pointer - pointer lock captures the cursor (preferred) *
* drag - hold the left mouse button and drag to look *
* *
* Drag mode is the fallback for hosts that deny pointer lock, *
* such as an embedded panel whose frame withholds the permission. *
* Escape releases drag mode the way it releases pointer lock. *
******************************************************************/
/**
* True when a key event belongs to the page rather than to the
* simulator: the user is typing, choosing a value, or working inside a
* dialog. Flight input listens on the document, so without this guard a
* space typed into a destination field would trigger auto-level and
* have its default suppressed.
*/
function isTextEntryTarget(target) {
if (!target || !target.tagName) return false;
var tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable === true) return true;
return !!(target.closest && target.closest('[role="dialog"]'));
}
function FlightControls(camera, domElement) {
this.camera = camera;
this.domElement = domElement;
// Position and orientation
this.position = camera.position.clone();
this.quaternion = new THREE.Quaternion();
// Speed
this.speed = 0;
this.targetSpeed = 0;
this.minSpeed = 0;
this.maxSpeed = 12;
this.boostMultiplier = 3;
this.acceleration = 4.0;
this.deceleration = 3.0;
// Rotation
this.rollSpeed = 1.8;
this.yawSpeed = 1.0;
this.mouseSensitivity = 0.002;
// Altitude
this.verticalSpeed = 0;
this.verticalAccel = 4.0;
this.minAltitude = 8;
// Input state
this.keys = {};
this.mouseMovementX = 0;
this.mouseMovementY = 0;
this.isPointerLocked = false;
this.isEnabled = false;
// Look mode: 'pointer' until a lock request is refused, then 'drag'
this.lookMode = 'pointer';
this.isDragging = false;
this.lockTimeoutMs = 700;
this._lockTimer = null;
// Auto-level
this.autoLevel = false;
this.autoLevelSpeed = 3.0;
this._init();
}
FlightControls.prototype._init = function () {
var self = this;
document.addEventListener('keydown', function (e) {
if (isTextEntryTarget(e.target)) return;
self.keys[e.code] = true;
if (e.code === 'Space') {
self.autoLevel = true;
e.preventDefault();
}
// Pointer lock exits on Escape by itself; drag mode needs releasing
if (e.code === 'Escape' && self.lookMode === 'drag') {
self._release();
}
});
document.addEventListener('keyup', function (e) {
if (isTextEntryTarget(e.target)) return;
self.keys[e.code] = false;
if (e.code === 'Space') {
self.autoLevel = false;
}
});
this.domElement.addEventListener('click', function () {
if (self.isEnabled) return;
if (self.lookMode === 'drag') {
self._engage();
} else if (!self.isPointerLocked) {
self._requestLock();
}
});
this.domElement.addEventListener('mousedown', function (e) {
if (self.lookMode !== 'drag' || !self.isEnabled) return;
if (e.button !== 0) return;
self.isDragging = true;
// Suppress the text selection drag that would otherwise start
e.preventDefault();
});
document.addEventListener('mouseup', function () {
self.isDragging = false;
});
document.addEventListener('mousemove', function (e) {
var capturing = self.isPointerLocked ||
(self.lookMode === 'drag' && self.isEnabled && self.isDragging);
if (!capturing) return;
self.mouseMovementX += e.movementX || 0;
self.mouseMovementY += e.movementY || 0;
});
document.addEventListener('pointerlockchange', function () {
self.isPointerLocked = document.pointerLockElement === self.domElement;
clearTimeout(self._lockTimer);
if (self.isPointerLocked) {
self.lookMode = 'pointer';
self._engage();
} else {
self._release();
}
});
document.addEventListener('pointerlockerror', function () {
self._fallbackToDrag();
});
this.domElement.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
};
/**
* Requests pointer lock, falling back to drag look when the host
* refuses the request, throws, or ignores it without an error event.
*/
FlightControls.prototype._requestLock = function () {
var self = this;
if (!this.domElement.requestPointerLock) {
this._fallbackToDrag();
return;
}
var request;
try {
request = this.domElement.requestPointerLock();
} catch (e) {
this._fallbackToDrag();
return;
}
// Newer browsers resolve a promise; older ones return undefined and
// report failure through the pointerlockerror event
if (request && typeof request.catch === 'function') {
request.catch(function () {
self._fallbackToDrag();
});
}
clearTimeout(this._lockTimer);
this._lockTimer = setTimeout(function () {
if (!self.isPointerLocked) self._fallbackToDrag();
}, this.lockTimeoutMs);
};
/**
* Switches to drag look and engages immediately, so the click that
* asked for pointer lock still starts the flight.
*/
FlightControls.prototype._fallbackToDrag = function () {
clearTimeout(this._lockTimer);
if (this.lookMode !== 'drag') {
this.lookMode = 'drag';
}
this._engage();
};
/**
* Enables flight input and notifies the UI.
*/
FlightControls.prototype._engage = function () {
if (this.isEnabled) return;
this.isEnabled = true;
this._dispatch(true);
};
/**
* Disables flight input and clears any state that could read as stuck.
*/
FlightControls.prototype._release = function () {
if (!this.isEnabled) return;
this.isEnabled = false;
this.isDragging = false;
// Keyup events can be missed while released; clear input state
// so no key reads as stuck on the next engage
this.keys = {};
this.autoLevel = false;
this.mouseMovementX = 0;
this.mouseMovementY = 0;
this._dispatch(false);
};
FlightControls.prototype._dispatch = function (locked) {
document.dispatchEvent(new CustomEvent('flightcontrols', {
detail: { locked: locked, mode: this.lookMode }
}));
};
FlightControls.prototype.update = function (delta) {
if (!this.isEnabled) return;
delta = Math.min(delta, 0.1);
// --- Mouse look: pitch and yaw ---
if (this.mouseMovementX !== 0 || this.mouseMovementY !== 0) {
var pitchAngle = -this.mouseMovementY * this.mouseSensitivity;
var yawAngle = -this.mouseMovementX * this.mouseSensitivity;
// Pitch around camera's local X axis
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
var pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, pitchAngle);
this.quaternion.premultiply(pitchQuat);
// Yaw around world Y axis for stability
var worldUp = new THREE.Vector3(0, 1, 0);
var yawQuat = new THREE.Quaternion().setFromAxisAngle(worldUp, yawAngle);
this.quaternion.premultiply(yawQuat);
this.quaternion.normalize();
this.mouseMovementX = 0;
this.mouseMovementY = 0;
}
// --- Keyboard roll (A/D, Left/Right) ---
var rollDelta = 0;
if (this.keys['KeyA'] || this.keys['ArrowLeft']) rollDelta += this.rollSpeed * delta;
if (this.keys['KeyD'] || this.keys['ArrowRight']) rollDelta -= this.rollSpeed * delta;
if (rollDelta !== 0) {
var forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
var rollQuat = new THREE.Quaternion().setFromAxisAngle(forward, rollDelta);
this.quaternion.premultiply(rollQuat);
this.quaternion.normalize();
}
// --- Keyboard yaw / rudder (Q/E) ---
var yawDelta = 0;
if (this.keys['KeyQ']) yawDelta += this.yawSpeed * delta;
if (this.keys['KeyE']) yawDelta -= this.yawSpeed * delta;
if (yawDelta !== 0) {
var localUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
var rudderQuat = new THREE.Quaternion().setFromAxisAngle(localUp, yawDelta);
this.quaternion.premultiply(rudderQuat);
this.quaternion.normalize();
}
// --- Throttle (W/S, Up/Down) ---
var boost = (this.keys['ShiftLeft'] || this.keys['ShiftRight']) ? this.boostMultiplier : 1;
if (this.keys['KeyW'] || this.keys['ArrowUp']) {
this.targetSpeed = Math.min(this.targetSpeed + this.acceleration * delta, this.maxSpeed * boost);
} else if (this.keys['KeyS'] || this.keys['ArrowDown']) {
this.targetSpeed = Math.max(this.targetSpeed - this.deceleration * delta, this.minSpeed);
}
this.targetSpeed = Math.min(this.targetSpeed, this.maxSpeed * boost);
this.speed += (this.targetSpeed - this.speed) * Math.min(5 * delta, 1);
// --- Altitude (R/F) ---
if (this.keys['KeyR']) this.verticalSpeed += this.verticalAccel * delta;
if (this.keys['KeyF']) this.verticalSpeed -= this.verticalAccel * delta;
this.verticalSpeed *= Math.pow(0.93, delta * 60);
// --- Auto-level (Space held) ---
if (this.autoLevel) {
var currentUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
var targetUp = new THREE.Vector3(0, 1, 0);
var correction = new THREE.Quaternion().setFromUnitVectors(currentUp, targetUp);
var t = Math.min(this.autoLevelSpeed * delta, 1);
correction.slerp(new THREE.Quaternion(), 1 - t);
this.quaternion.premultiply(correction);
this.quaternion.normalize();
}
// --- Update position ---
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
this.position.addScaledVector(fwd, this.speed * delta * 60);
this.position.y += this.verticalSpeed * delta * 60;
if (this.position.y < this.minAltitude) {
this.position.y = this.minAltitude;
this.verticalSpeed = Math.max(this.verticalSpeed, 0);
}
// --- Apply to camera ---
this.camera.position.copy(this.position);
this.camera.quaternion.copy(this.quaternion);
};
FlightControls.prototype.getHeading = function () {
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
var heading = Math.atan2(fwd.x, -fwd.z) * 180 / Math.PI;
return ((heading % 360) + 360) % 360;
};
FlightControls.prototype.getPitch = function () {
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
return Math.asin(Math.max(-1, Math.min(1, fwd.y))) * 180 / Math.PI;
};
FlightControls.prototype.getRoll = function () {
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
return Math.asin(Math.max(-1, Math.min(1, right.y))) * 180 / Math.PI;
};
FlightControls.prototype.getSpeed = function () {
return this.speed;
};
FlightControls.prototype.getAltitude = function () {
return this.position.y;
};
/**
* Hands control back to the page. Used when a dialog opens over the
* canvas so the aircraft is not still flying behind it.
*/
FlightControls.prototype.release = function () {
if (this.isPointerLocked && document.exitPointerLock) {
document.exitPointerLock();
}
this._release();
};
FlightControls.prototype.resetTo = function (position, lookAt) {
this.position.copy(position);
this.speed = 0;
this.targetSpeed = 0;
this.verticalSpeed = 0;
var tempCam = new THREE.PerspectiveCamera();
tempCam.position.copy(position);
tempCam.lookAt(lookAt);
this.quaternion.copy(tempCam.quaternion);
this.camera.position.copy(this.position);
this.camera.quaternion.copy(this.quaternion);
};
// Shared with the rest of the page so every document-level shortcut
// applies the same rule about what counts as typing
FlightControls.isTextEntryTarget = isTextEntryTarget;