chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-06 12:30:14 +00:00
parent ca17038502
commit e321e4a9c7
775 changed files with 72454 additions and 1180 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
/**************************************************************
* World Capitals organized by continent/region. *
* Each capital includes country, city name, and coordinates. *
*************************************************************/
var CAPITALS_BY_REGION = [
{
region: "North America",
capitals: [
{ country: "United States", capital: "Washington, D.C.", lat: 38.8951, lng: -77.0364 },
{ country: "Canada", capital: "Ottawa", lat: 45.4215, lng: -75.6972 },
{ country: "Mexico", capital: "Mexico City", lat: 19.4326, lng: -99.1332 },
{ country: "Cuba", capital: "Havana", lat: 23.1136, lng: -82.3666 },
{ country: "Guatemala", capital: "Guatemala City", lat: 14.6349, lng: -90.5069 },
{ country: "Costa Rica", capital: "San Jose", lat: 9.9281, lng: -84.0907 },
{ country: "Panama", capital: "Panama City", lat: 8.9824, lng: -79.5199 },
{ country: "Jamaica", capital: "Kingston", lat: 18.0179, lng: -76.8099 }
]
},
{
region: "South America",
capitals: [
{ country: "Brazil", capital: "Brasilia", lat: -15.7975, lng: -47.8919 },
{ country: "Argentina", capital: "Buenos Aires", lat: -34.6037, lng: -58.3816 },
{ country: "Chile", capital: "Santiago", lat: -33.4489, lng: -70.6693 },
{ country: "Colombia", capital: "Bogota", lat: 4.7110, lng: -74.0721 },
{ country: "Peru", capital: "Lima", lat: -12.0464, lng: -77.0428 },
{ country: "Venezuela", capital: "Caracas", lat: 10.4806, lng: -66.9036 },
{ country: "Ecuador", capital: "Quito", lat: -0.1807, lng: -78.4678 },
{ country: "Uruguay", capital: "Montevideo", lat: -34.9011, lng: -56.1645 }
]
},
{
region: "Europe",
capitals: [
{ country: "United Kingdom", capital: "London", lat: 51.5074, lng: -0.1278 },
{ country: "France", capital: "Paris", lat: 48.8566, lng: 2.3522 },
{ country: "Germany", capital: "Berlin", lat: 52.5200, lng: 13.4050 },
{ country: "Italy", capital: "Rome", lat: 41.9028, lng: 12.4964 },
{ country: "Spain", capital: "Madrid", lat: 40.4168, lng: -3.7038 },
{ country: "Russia", capital: "Moscow", lat: 55.7558, lng: 37.6173 },
{ country: "Netherlands", capital: "Amsterdam", lat: 52.3676, lng: 4.9041 },
{ country: "Greece", capital: "Athens", lat: 37.9838, lng: 23.7275 },
{ country: "Sweden", capital: "Stockholm", lat: 59.3293, lng: 18.0686 },
{ country: "Norway", capital: "Oslo", lat: 59.9139, lng: 10.7522 },
{ country: "Portugal", capital: "Lisbon", lat: 38.7223, lng: -9.1393 },
{ country: "Czech Republic", capital: "Prague", lat: 50.0755, lng: 14.4378 },
{ country: "Austria", capital: "Vienna", lat: 48.2082, lng: 16.3738 },
{ country: "Switzerland", capital: "Bern", lat: 46.9480, lng: 7.4474 },
{ country: "Poland", capital: "Warsaw", lat: 52.2297, lng: 21.0122 },
{ country: "Turkey", capital: "Ankara", lat: 39.9334, lng: 32.8597 }
]
},
{
region: "Asia",
capitals: [
{ country: "Japan", capital: "Tokyo", lat: 35.6762, lng: 139.6503 },
{ country: "China", capital: "Beijing", lat: 39.9042, lng: 116.4074 },
{ country: "India", capital: "New Delhi", lat: 28.6139, lng: 77.2090 },
{ country: "South Korea", capital: "Seoul", lat: 37.5665, lng: 126.9780 },
{ country: "Thailand", capital: "Bangkok", lat: 13.7563, lng: 100.5018 },
{ country: "Vietnam", capital: "Hanoi", lat: 21.0285, lng: 105.8542 },
{ country: "Indonesia", capital: "Jakarta", lat: -6.2088, lng: 106.8456 },
{ country: "Philippines", capital: "Manila", lat: 14.5995, lng: 120.9842 },
{ country: "Singapore", capital: "Singapore", lat: 1.3521, lng: 103.8198 },
{ country: "Malaysia", capital: "Kuala Lumpur", lat: 3.1390, lng: 101.6869 },
{ country: "Pakistan", capital: "Islamabad", lat: 33.6844, lng: 73.0479 },
{ country: "Israel", capital: "Jerusalem", lat: 31.7683, lng: 35.2137 },
{ country: "UAE", capital: "Abu Dhabi", lat: 24.4539, lng: 54.3773 },
{ country: "Saudi Arabia", capital: "Riyadh", lat: 24.7136, lng: 46.6753 }
]
},
{
region: "Africa",
capitals: [
{ country: "Egypt", capital: "Cairo", lat: 30.0444, lng: 31.2357 },
{ country: "South Africa", capital: "Pretoria", lat: -25.7479, lng: 28.2293 },
{ country: "Kenya", capital: "Nairobi", lat: -1.2921, lng: 36.8219 },
{ country: "Nigeria", capital: "Abuja", lat: 9.0579, lng: 7.4951 },
{ country: "Morocco", capital: "Rabat", lat: 34.0209, lng: -6.8416 },
{ country: "Ethiopia", capital: "Addis Ababa", lat: 9.0250, lng: 38.7469 },
{ country: "Tanzania", capital: "Dodoma", lat: -6.1630, lng: 35.7516 },
{ country: "Ghana", capital: "Accra", lat: 5.6037, lng: -0.1870 }
]
},
{
region: "Oceania",
capitals: [
{ country: "Australia", capital: "Canberra", lat: -35.2809, lng: 149.1300 },
{ country: "New Zealand", capital: "Wellington", lat: -41.2865, lng: 174.7762 },
{ country: "Fiji", capital: "Suva", lat: -18.1416, lng: 178.4419 },
{ country: "Papua New Guinea", capital: "Port Moresby", lat: -6.3149, lng: 143.9556 }
]
}
];
@@ -0,0 +1,310 @@
/***********************************************************************
* DestinationInput - Keyboard entry for destinations that are not one *
* of the built-in capitals. *
* *
* Opened from the "Input Destination" entry at the top of the *
* destination menu. The dialog runs in two stages: *
* *
* 1. Form - state/region and city are typed, country is picked *
* from the menu. Any one of the three is enough. *
* 2. Results - the matches the geocoder returned, one per row. *
* Picking one and pressing Select flies there. *
* *
* The dialog owns the keyboard while it is open: flight input already *
* ignores events from form fields, and the shortcuts below stop at *
* the dialog so a typed letter never reaches the simulator. *
***********************************************************************/
var DestinationInput = (function () {
var dialog, formStage, resultsStage;
var stateInput, cityInput, countrySelect;
var formMessage, resultsMessage, resultList;
var searchBtn, selectBtn;
var onSelect = null;
var onOpen = null;
var onClose = null;
var results = [];
var activeIndex = -1;
var searching = false;
var isOpen = false;
/* ------------------------------------------------------------
Country menu
------------------------------------------------------------ */
/**
* Fills the country menu from the capitals dataset, which is the
* only list of country names the page ships with. Leaving the menu
* on its first entry searches without a country filter.
*/
function populateCountries() {
if (typeof CAPITALS_BY_REGION === 'undefined') return;
var seen = {};
var names = [];
for (var i = 0; i < CAPITALS_BY_REGION.length; i++) {
var capitals = CAPITALS_BY_REGION[i].capitals;
for (var j = 0; j < capitals.length; j++) {
var country = capitals[j].country;
if (seen[country]) continue;
seen[country] = true;
names.push(country);
}
}
names.sort();
for (var k = 0; k < names.length; k++) {
var option = document.createElement('option');
option.value = names[k];
option.textContent = names[k];
countrySelect.appendChild(option);
}
}
/* ------------------------------------------------------------
Stage switching
------------------------------------------------------------ */
function showForm() {
formStage.classList.remove('hidden');
resultsStage.classList.add('hidden');
resultsMessage.textContent = '';
}
function showResults() {
formStage.classList.add('hidden');
resultsStage.classList.remove('hidden');
formMessage.textContent = '';
}
function setSearching(active) {
searching = active;
searchBtn.disabled = active;
searchBtn.textContent = active ? 'Searching...' : 'Search';
}
/* ------------------------------------------------------------
Results list
------------------------------------------------------------ */
function renderResults() {
resultList.innerHTML = '';
for (var i = 0; i < results.length; i++) {
var row = document.createElement('li');
row.className = 'dest-result';
// Focus stays on the listbox, so each option needs an id for
// the list's aria-activedescendant to point a reader at it
row.id = 'dest-result-' + i;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', 'false');
row.dataset.index = String(i);
row.textContent = results[i].label;
resultList.appendChild(row);
}
setActiveIndex(results.length ? 0 : -1);
}
function setActiveIndex(index) {
activeIndex = index;
var rows = resultList.children;
for (var i = 0; i < rows.length; i++) {
var active = i === index;
rows[i].classList.toggle('active', active);
rows[i].setAttribute('aria-selected', active ? 'true' : 'false');
}
selectBtn.disabled = index < 0;
// Point the listbox at the option the arrow keys landed on, and
// drop the reference entirely when nothing is active
if (index >= 0 && rows[index]) {
resultList.setAttribute('aria-activedescendant', rows[index].id);
if (rows[index].scrollIntoView) {
rows[index].scrollIntoView({ block: 'nearest' });
}
} else {
resultList.removeAttribute('aria-activedescendant');
}
}
function moveActive(step) {
if (!results.length) return;
var next = activeIndex + step;
if (next < 0) next = 0;
if (next > results.length - 1) next = results.length - 1;
setActiveIndex(next);
}
/* ------------------------------------------------------------
Actions
------------------------------------------------------------ */
function runSearch() {
if (searching) return;
formMessage.textContent = '';
setSearching(true);
Geocoder.search({
state: stateInput.value,
city: cityInput.value,
country: countrySelect.value
}, function (error, found) {
setSearching(false);
if (error) {
formMessage.textContent = error;
return;
}
results = found;
renderResults();
showResults();
resultsMessage.textContent = found.length
? ''
: 'No matching destination was found. Cancel to adjust the search.';
if (found.length) resultList.focus();
});
}
function commitSelection() {
if (activeIndex < 0 || !results[activeIndex]) return;
var chosen = results[activeIndex];
close();
if (onSelect) onSelect(chosen);
}
/* ------------------------------------------------------------
Open / close
------------------------------------------------------------ */
function open() {
if (isOpen) return;
isOpen = true;
results = [];
activeIndex = -1;
// Rows from the previous search are stale the moment the form
// reopens, so nothing should still be pointing at one of them
resultList.removeAttribute('aria-activedescendant');
formMessage.textContent = '';
setSearching(false);
showForm();
dialog.classList.remove('hidden');
if (onOpen) onOpen();
stateInput.focus();
stateInput.select();
}
function close() {
if (!isOpen) return;
isOpen = false;
dialog.classList.add('hidden');
if (onClose) onClose();
}
/* ------------------------------------------------------------
Wiring
------------------------------------------------------------ */
/**
* Binds the dialog. options:
* onSelect(destination) - a result the user picked
* onOpen() - the dialog became visible
* onClose() - the dialog was dismissed or committed
*/
function init(options) {
options = options || {};
onSelect = options.onSelect || null;
onOpen = options.onOpen || null;
onClose = options.onClose || null;
dialog = document.getElementById('destination-dialog');
if (!dialog) return;
formStage = document.getElementById('destination-form');
resultsStage = document.getElementById('destination-results');
stateInput = document.getElementById('dest-state');
cityInput = document.getElementById('dest-city');
countrySelect = document.getElementById('dest-country');
formMessage = document.getElementById('dest-form-message');
resultsMessage = document.getElementById('dest-results-message');
resultList = document.getElementById('dest-result-list');
searchBtn = document.getElementById('dest-search');
selectBtn = document.getElementById('dest-select');
populateCountries();
searchBtn.addEventListener('click', runSearch);
selectBtn.addEventListener('click', commitSelection);
document.getElementById('dest-cancel').addEventListener('click', close);
document.getElementById('dest-results-cancel').addEventListener('click', close);
// Enter submits from any field in the form stage
formStage.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
runSearch();
}
});
// Click a row to highlight it, double click to fly straight there
resultList.addEventListener('click', function (e) {
var row = e.target.closest ? e.target.closest('.dest-result') : null;
if (!row) return;
setActiveIndex(parseInt(row.dataset.index, 10));
});
resultList.addEventListener('dblclick', function (e) {
var row = e.target.closest ? e.target.closest('.dest-result') : null;
if (!row) return;
setActiveIndex(parseInt(row.dataset.index, 10));
commitSelection();
});
resultList.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
commitSelection();
}
});
// Escape closes from anywhere inside the dialog, and clicking the
// backdrop outside the card does the same
dialog.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
close();
}
});
dialog.addEventListener('mousedown', function (e) {
if (e.target === dialog) close();
});
}
return {
init: init,
open: open,
close: close,
isOpen: function () { return isOpen; }
};
})();
@@ -0,0 +1,385 @@
/*******************************************************************
* 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;
@@ -0,0 +1,314 @@
/************************************************************************
* Geocoder - Talks to the OpenStreetMap Nominatim API in both *
* directions. *
* *
* update() reverse geocodes the camera's latitude/longitude into a *
* human-readable location label ("City, State, Country"), falling back *
* to "Rural <state>, <country>" when the position is not within a *
* city/town/village boundary. Requests are throttled by time and by *
* minimum movement so the HUD label costs as few lookups as possible. *
* *
* search() forward geocodes a typed state/city/country into a list of *
* candidate destinations for the destination input dialog. *
* *
* Both directions go out through one queue, so the module holds to the *
* Nominatim usage policy (absolute maximum of 1 request per second) *
* whichever direction is asking. *
***********************************************************************/
var Geocoder = (function () {
var NOMINATIM = 'https://nominatim.openstreetmap.org/';
var MIN_REQUEST_INTERVAL_MS = 1000; // Nominatim policy: 1 request/second
var MIN_INTERVAL_MS = 5000; // Minimum time between reverse lookups
var MIN_MOVE_DEG = 0.005; // Minimum movement (~500m) before a new lookup
var SEARCH_LIMIT = 10; // Candidates shown in the results list
var lastLat = null;
var lastLng = null;
var lastRequestTime = 0;
var lastLabel = '';
var pending = false;
// Bumped by reset(). A reverse lookup carries the value it was queued
// under, so a response that belongs to the location left behind is
// recognized as stale instead of overwriting the HUD with it.
var generation = 0;
/* ------------------------------------------------------------
Shared request queue
Nominatim counts requests per client across its endpoints, so
reverse lookups and searches cannot each keep their own pace.
One request is in flight at a time and consecutive sends are
spaced by MIN_REQUEST_INTERVAL_MS.
------------------------------------------------------------ */
var queue = [];
var inFlight = null;
var lastSendTime = 0;
var drainTimer = null;
/**
* Queues one request. job is
* { url, success(data), failure(reason), stale() }, where reason is
* { status, malformed } and the optional stale() predicate lets a
* job whose result is no longer wanted be dropped before it is sent.
*/
function enqueue(job) {
queue.push(job);
drain();
}
function drain() {
if (inFlight) return;
// Drop abandoned jobs before they spend a request slot
while (queue.length && queue[0].stale && queue[0].stale()) {
queue.shift();
}
if (!queue.length) return;
var wait = MIN_REQUEST_INTERVAL_MS - (performance.now() - lastSendTime);
if (wait > 0) {
if (drainTimer === null) {
drainTimer = setTimeout(function () {
drainTimer = null;
drain();
}, wait);
}
return;
}
send(queue.shift());
}
function send(job) {
var settled = false;
var xhr = new XMLHttpRequest();
inFlight = xhr;
lastSendTime = performance.now();
// A network failure raises both onerror and a readyState 4 with no
// status, so the first outcome to arrive is the one that counts
function finish(handler, argument) {
if (settled) return;
settled = true;
inFlight = null;
handler(argument);
drain();
}
xhr.open('GET', job.url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status !== 200) {
finish(job.failure, { status: xhr.status, malformed: false });
return;
}
var data;
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
finish(job.failure, { status: xhr.status, malformed: true });
return;
}
finish(job.success, data);
};
xhr.onerror = function () {
finish(job.failure, { status: 0, malformed: false });
};
xhr.send();
}
/**
* Builds a display label from a Nominatim address object.
* Within a city boundary: "City, State, Country".
* Outside one: "Rural State, Country" (e.g. "Rural New York, United States").
*/
function buildLabel(address) {
// Only city/town count as being "within a city"; villages, hamlets,
// and US civil townships ("Town of X" -> village) read as rural
var city = address.city || address.town;
var state = address.state || address.province || address.region ||
address.county;
var country = address.country;
var parts = [];
if (city) {
parts.push(city);
if (state) parts.push(state);
if (country) parts.push(country);
} else {
var area = state || country;
if (!area) return '';
parts.push('Rural ' + area);
if (state && country) parts.push(country);
}
return parts.join(', ');
}
/**
* Requests a label for the given position. Throttled internally,
* so it is safe to call every frame. Invokes callback(label) only
* when the label has changed.
*/
function update(lat, lng, callback) {
var now = performance.now();
if (pending || now - lastRequestTime < MIN_INTERVAL_MS) return;
if (lastLat !== null &&
Math.abs(lat - lastLat) < MIN_MOVE_DEG &&
Math.abs(lng - lastLng) < MIN_MOVE_DEG) return;
pending = true;
lastRequestTime = now;
var token = generation;
function abandoned() {
return token !== generation;
}
enqueue({
url: NOMINATIM + 'reverse?format=jsonv2&zoom=10' +
'&lat=' + encodeURIComponent(lat) +
'&lon=' + encodeURIComponent(lng),
stale: abandoned,
success: function (data) {
// Switched location while this was in flight: the label and
// the position it describes belong to somewhere else now
if (abandoned()) return;
pending = false;
var label = buildLabel((data && data.address) || {});
lastLat = lat;
lastLng = lng;
if (label && label !== lastLabel) {
lastLabel = label;
callback(label);
}
},
failure: function () {
if (abandoned()) return;
pending = false;
// Keep the last label on failure or a malformed response
}
});
}
/**
* Clears cached state when switching capitals so the next lookup
* runs immediately for the new location. Lookups already queued or
* in flight for the old location are abandoned rather than allowed
* to report back over the new one.
*/
function reset() {
generation++;
lastLat = null;
lastLng = null;
lastRequestTime = 0;
lastLabel = '';
pending = false;
}
/**
* Builds the label shown for one search result:
* "State, City, CC" (e.g. "New York, New York City, US").
*
* Nominatim omits whichever administrative levels a place does not
* have, so the parts that exist are joined and the raw display_name
* covers a result that carries no structured address at all.
*/
function buildResultLabel(item) {
var address = item.address || {};
var city = address.city || address.town || address.village ||
address.municipality || address.hamlet;
var state = address.state || address.province || address.region ||
address.county;
var code = address.country_code ? address.country_code.toUpperCase() : '';
var parts = [];
if (state) parts.push(state);
if (city && city !== state) parts.push(city);
if (code) parts.push(code);
if (!parts.length) return item.display_name || 'Unknown location';
return parts.join(', ');
}
/**
* Forward geocodes a destination typed into the input dialog.
*
* Fields are optional individually but at least one must be filled;
* they are sent as Nominatim's structured query parameters rather
* than concatenated into a free-text string, which keeps a city name
* that also exists in another country from outranking the match.
*
* Calls callback(error, results), where each result is
* { label, lat, lng, city, country } ready for loadCapital().
*/
function search(fields, callback) {
var params = [];
var keys = ['state', 'city', 'country'];
for (var i = 0; i < keys.length; i++) {
var value = (fields[keys[i]] || '').trim();
if (value) params.push(keys[i] + '=' + encodeURIComponent(value));
}
if (!params.length) {
callback('Enter a state/region, a city, or a country to search.', []);
return;
}
enqueue({
url: NOMINATIM + 'search?format=jsonv2&addressdetails=1' +
'&limit=' + SEARCH_LIMIT + '&' + params.join('&'),
success: function (data) {
if (!data || !data.length) {
callback(null, []);
return;
}
var results = [];
for (var i = 0; i < data.length; i++) {
var item = data[i];
var lat = parseFloat(item.lat);
var lng = parseFloat(item.lon);
if (isNaN(lat) || isNaN(lng)) continue;
var address = item.address || {};
results.push({
label: buildResultLabel(item),
lat: lat,
lng: lng,
city: address.city || address.town || address.village ||
item.name || 'Destination',
country: address.country || ''
});
}
callback(null, results);
},
failure: function (reason) {
if (reason.malformed) {
callback('Search returned a malformed response.', []);
} else if (!reason.status) {
callback('Search could not reach the geocoding service.', []);
} else {
callback('Search failed (' + reason.status + ').', []);
}
}
});
}
return {
update: update,
reset: reset,
search: search
};
})();
@@ -0,0 +1,73 @@
/************************************************************************
* HostBridge - Thin abstraction over the VS Code webview messaging API. *
* *
* The simulator runs in two places: inside a VS Code webview panel and *
* as a plain page in a browser. Everything that needs the extension *
* host (config injection, saving renderMap.json) goes through here so *
* the rest of the code stays identical in both environments. *
* *
* In the browser the API is absent and every call degrades to a no-op, *
* leaving the original web behavior in place. *
***********************************************************************/
var HostBridge = (function () {
var api = null;
var handlers = {};
// acquireVsCodeApi exists only inside a webview, and may be called
// exactly once per page load
if (typeof acquireVsCodeApi === 'function') {
try {
api = acquireVsCodeApi();
} catch (e) {
api = null;
}
}
if (api) {
window.addEventListener('message', function (event) {
var message = event.data;
if (!message || !message.command) return;
var fn = handlers[message.command];
if (fn) fn(message);
});
}
/**
* True when running inside the VS Code webview panel.
*/
function isHosted() {
return api !== null;
}
/**
* Send a message to the extension host. No-op in the browser.
*/
function post(message) {
if (api) api.postMessage(message);
}
/**
* Register a handler for a command sent by the extension host.
*/
function on(command, fn) {
handlers[command] = fn;
}
/**
* Render configuration injected by the extension host, or null when
* running as a standalone page.
*/
function getInjectedConfig() {
return (window.__flightMapConfig && typeof window.__flightMapConfig === 'object')
? window.__flightMapConfig
: null;
}
return {
isHosted: isHosted,
post: post,
on: on,
getInjectedConfig: getInjectedConfig
};
})();
@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--{{HOST_HEAD}}-->
<title>Flight Map</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Top Bar: capital selector on the left, location readout centered.
The bar owns both so the readout can never sit under the menu. -->
<div id="top-bar">
<div id="selector-panel">
<label for="capital-select">Destination</label>
<!-- The placeholder is hidden from the list so "Input Destination"
reads as the first entry, but still shows as the closed value
until a destination has been chosen. -->
<select id="capital-select">
<option value="" selected hidden>-- Select a Capital --</option>
<option value="__input__" id="opt-input-destination">Input Destination</option>
</select>
<button id="btn-toggle-config" type="button" title="Configure Rendering">Config</button>
<button id="btn-export-json" type="button" title="Export renderMap.json">Export JSON</button>
</div>
<div id="hud-location" class="hidden">
<div id="hud-capital">---</div>
<div id="hud-coords">---</div>
</div>
</div>
<!-- Render Config Panel -->
<div id="config-panel" class="hidden">
<h3>Render Configuration</h3>
<p class="config-hint">Adjust rendering parameters to determine suitability for different gaming applications.</p>
<fieldset>
<legend>Map</legend>
<div class="cfg-row">
<label for="cfg-width">Width</label>
<input type="range" id="cfg-width" data-config="map.width" min="640" max="3840" step="10">
<span id="cfg-width-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-height">Height</label>
<input type="range" id="cfg-height" data-config="map.height" min="480" max="2160" step="10">
<span id="cfg-height-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-tileZoom">Tile Zoom</label>
<input type="range" id="cfg-tileZoom" data-config="map.tileZoom" min="10" max="19" step="1">
<span id="cfg-tileZoom-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-loadRadius">Load Radius</label>
<input type="range" id="cfg-loadRadius" data-config="map.loadRadius" min="2" max="48" step="1">
<span id="cfg-loadRadius-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-radiusFeather">Radius Feather</label>
<input type="range" id="cfg-radiusFeather" data-config="map.radiusFeather" min="0" max="100" step="0.5">
<span id="cfg-radiusFeather-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-startAlt">Start Altitude</label>
<input type="range" id="cfg-startAlt" data-config="map.startAltitude" min="50" max="1000" step="10">
<span id="cfg-startAlt-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-fogDensity">Fog Density</label>
<input type="range" id="cfg-fogDensity" data-config="map.fogDensity" min="0.00001" max="0.01" step="0.00001">
<span id="cfg-fogDensity-val"></span>
</div>
</fieldset>
<fieldset>
<legend>Filler</legend>
<div class="cfg-row">
<label for="cfg-sampleInterval">Sample Interval</label>
<input type="range" id="cfg-sampleInterval" data-config="filler.sampleInterval" min="10" max="120" step="5">
<span id="cfg-sampleInterval-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-updateInterval">Update Time (s)</label>
<input type="range" id="cfg-updateInterval" data-config="filler.updateIntervalSec" min="5" max="60" step="1">
<span id="cfg-updateInterval-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-perimCount">Perimeter Count</label>
<input type="range" id="cfg-perimCount" data-config="filler.perimeter.patchCount" min="1" max="20" step="1">
<span id="cfg-perimCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-perimSize">Perimeter Size</label>
<input type="range" id="cfg-perimSize" data-config="filler.perimeter.patchSize" min="2" max="20" step="1">
<span id="cfg-perimSize-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-centerCount">Center Count</label>
<input type="range" id="cfg-centerCount" data-config="filler.center.patchCount" min="1" max="15" step="1">
<span id="cfg-centerCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-centerSize">Center Size</label>
<input type="range" id="cfg-centerSize" data-config="filler.center.patchSize" min="2" max="20" step="1">
<span id="cfg-centerSize-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-padCount">Padding Count</label>
<input type="range" id="cfg-padCount" data-config="filler.padding.patchCount" min="1" max="12" step="1">
<span id="cfg-padCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-padSize">Padding Size</label>
<input type="range" id="cfg-padSize" data-config="filler.padding.patchSize" min="2" max="15" step="1">
<span id="cfg-padSize-val"></span>
</div>
</fieldset>
<fieldset>
<legend>Cloud Effect</legend>
<div class="cfg-row">
<label for="cfg-cloud-enabled">Enabled</label>
<input type="checkbox" id="cfg-cloud-enabled">
</div>
<div class="cfg-row">
<label for="cfg-cloudOpacity">Opacity</label>
<input type="range" id="cfg-cloudOpacity" data-config="cloud.opacity" min="0" max="1" step="0.05">
<span id="cfg-cloudOpacity-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudSpeed">Speed</label>
<input type="range" id="cfg-cloudSpeed" data-config="cloud.speed" min="0.0001" max="0.002" step="0.0001">
<span id="cfg-cloudSpeed-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudCoverage">Coverage</label>
<input type="range" id="cfg-cloudCoverage" data-config="cloud.coverage" min="0.1" max="1" step="0.05">
<span id="cfg-cloudCoverage-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudScale">Scale</label>
<input type="range" id="cfg-cloudScale" data-config="cloud.scale" min="0.002" max="0.03" step="0.001">
<span id="cfg-cloudScale-val"></span>
</div>
</fieldset>
</div>
<!-- Destination Input Dialog: opened by the "Input Destination" entry
in the destination menu. Stage one collects a typed location,
stage two lists the matches the geocoder returned for it. -->
<div id="destination-dialog" class="hidden" role="dialog" aria-modal="true"
aria-label="Input destination">
<div class="destination-card">
<div id="destination-form">
<div class="dest-row">
<label class="dest-label" for="dest-state">State/region</label>
<input type="text" id="dest-state" placeholder="Keyboard Input"
autocomplete="off" spellcheck="false">
</div>
<div class="dest-row">
<label class="dest-label" for="dest-city">City</label>
<input type="text" id="dest-city" placeholder="Keyboard Input"
autocomplete="off" spellcheck="false">
</div>
<div class="dest-row">
<label class="dest-label" for="dest-country">Country</label>
<select id="dest-country">
<option value="">Dropdown Menu</option>
</select>
</div>
<p id="dest-form-message" class="dest-message" role="status"></p>
<div class="dest-actions">
<button id="dest-search" type="button">Search</button>
<button id="dest-cancel" type="button">Cancel</button>
</div>
</div>
<div id="destination-results" class="hidden">
<ul id="dest-result-list" role="listbox" tabindex="0"
aria-label="Matching destinations"></ul>
<p id="dest-results-message" class="dest-message" role="status"></p>
<div class="dest-actions">
<button id="dest-select" type="button">Select</button>
<button id="dest-results-cancel" type="button">Cancel</button>
</div>
</div>
</div>
</div>
<!-- 3D Canvas Container -->
<div id="canvas-container"></div>
<!-- HUD Overlay -->
<div id="hud" class="hidden">
<div id="hud-heading">
<span id="hud-heading-value">N 000</span>
</div>
<div id="hud-speed">
<div class="hud-label">SPD</div>
<div id="hud-speed-value">0</div>
<div class="hud-unit">km/h</div>
</div>
<div id="hud-altitude">
<div class="hud-label">ALT</div>
<div id="hud-altitude-value">0</div>
<div class="hud-unit">m</div>
</div>
<div id="hud-crosshair">+</div>
<div id="hud-pitch">
<span id="hud-pitch-value">0</span>
</div>
</div>
<!-- Click to Fly Prompt -->
<div id="fly-prompt" class="hidden">
<div class="prompt-text">Click to Fly</div>
<div class="prompt-hint" id="fly-prompt-hint"></div>
</div>
<!-- Controls Help -->
<div id="controls-help" class="hidden">
<div>W/S: Speed &middot; <span id="controls-help-look">Mouse: Look</span> &middot; A/D: Roll</div>
<div>Q/E: Yaw &middot; R/F: Alt &middot; Space: Level &middot; Shift: Boost &middot; P: Pause</div>
</div>
<!-- Static Controls List -->
<div id="controls-list" class="hidden">
<div class="controls-title">Controls</div>
<ul>
<li><span>W / S</span>Throttle</li>
<li><span id="controls-list-look">Mouse</span>Look</li>
<li><span>A / D</span>Roll</li>
<li><span>Q / E</span>Yaw</li>
<li><span>R / F</span>Climb / Descend</li>
<li><span>Space</span>Auto-level</li>
<li><span>Shift</span>Boost</li>
<li><span>P</span>Pause</li>
</ul>
</div>
<!-- Agent Status: what the host reports its agent is working on. Stays
hidden until a host sends a status, so the VS Code panel and the
standalone page never show it. -->
<div id="agent-status" class="hidden">
<span id="agent-status-dot"></span>
<span id="agent-status-text"></span>
<span id="agent-status-tokens"></span>
</div>
<!-- Pause Indicator -->
<div id="pause-indicator" class="hidden">Paused</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="hidden">
<div id="loading-text">Loading terrain...</div>
<div id="loading-bar-container">
<div id="loading-bar"></div>
</div>
<div id="loading-progress">0%</div>
</div>
<!-- Welcome Screen -->
<div id="welcome">
<h1>3D Flight Simulator</h1>
<p>Satellite Terrain Viewer - World Capitals</p>
<p class="welcome-hint">Select a capital city to begin</p>
</div>
<script src="vendor/three.min.js"></script>
<script src="hostBridge.js"></script>
<script src="capitals.js"></script>
<script src="geocoder.js"></script>
<script src="destinationInput.js"></script>
<script src="flightControls.js"></script>
<script src="terrainFill.js"></script>
<script src="renderConfig.js"></script>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,269 @@
/***************************************************************************
* RenderConfig - Loads, applies, and exports map rendering configuration. *
* *
* Inside the VS Code panel the extension host reads renderMap.json and *
* injects it before the page scripts run. As a standalone page the *
* config is fetched from renderMap.json in the project root. Either way *
* the values are merged over the defaults and applied to the live *
* simulation. *
* *
* Export hands the current configuration to the extension host, which *
* writes it through a save dialog. In the browser it falls back to a *
* plain file download. *
**************************************************************************/
var RenderConfig = (function () {
var defaults = {
map: {
width: 1920,
height: 1080,
tileZoom: 15,
tileSize: 100,
loadRadius: 6,
unloadRadius: 9,
startAltitude: 200,
fogDensity: 0.00018,
radiusFeather: 3,
maxTiles: 600
},
filler: {
sampleInterval: 45,
updateIntervalSec: 10,
perimeter: { patchCount: 10, patchSize: 10 },
center: { patchCount: 5, patchSize: 10 },
padding: { patchCount: 4, patchSize: 5 }
},
cloud: {
enabled: true,
opacity: 0.35,
speed: 0.0004,
coverage: 0.5,
scale: 0.008
}
};
var config = JSON.parse(JSON.stringify(defaults));
var listeners = [];
/**
* Deep merge source into target, preserving existing keys.
*/
function merge(target, source) {
for (var key in source) {
if (!source.hasOwnProperty(key)) continue;
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
function freshDefaults() {
return JSON.parse(JSON.stringify(defaults));
}
/**
* Load configuration, merged over defaults. Prefers the config the
* extension host injected; otherwise reads renderMap.json from the
* project root relative to this page.
*/
function load(callback) {
var injected = HostBridge.getInjectedConfig();
if (injected) {
config = merge(freshDefaults(), injected);
if (callback) callback(config);
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', '../renderMap.json', true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
config = merge(freshDefaults(), data);
} catch (e) {
console.warn('RenderConfig: invalid JSON, using defaults');
config = freshDefaults();
}
} else {
console.warn('RenderConfig: could not load renderMap.json, using defaults');
config = freshDefaults();
}
if (callback) callback(config);
};
xhr.send();
}
/**
* Get the full config object.
*/
function get() {
return config;
}
/**
* Set a config value by dot-notation path (e.g. "cloud.opacity").
*/
function set(path, value) {
var keys = path.split('.');
var obj = config;
for (var i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
apply();
notify(path, value);
}
/**
* Subscribe to config changes.
*/
function onChange(fn) {
listeners.push(fn);
}
function notify(path, value) {
for (var i = 0; i < listeners.length; i++) {
listeners[i](path, value, config);
}
}
/**
* Apply current config values to the live simulation globals.
*/
function apply() {
// Map globals
TILE_ZOOM = config.map.tileZoom;
TILE_SIZE = config.map.tileSize;
LOAD_RADIUS = config.map.loadRadius;
UNLOAD_RADIUS = Math.max(config.map.unloadRadius, LOAD_RADIUS + 3);
START_ALTITUDE = config.map.startAltitude;
FOG_DENSITY = config.map.fogDensity;
RADIUS_FEATHER = config.map.radiusFeather;
MAX_TILES = config.map.maxTiles;
// Apply fog density to scene
if (typeof scene !== 'undefined' && scene && scene.fog) {
scene.fog.density = config.map.fogDensity;
}
// Apply render resolution. CSS stretches the canvas buffer over
// the panel, so a buffer shaped differently from the panel would
// render distorted. Keep the configured width x height as the
// pixel budget and reshape it to the panel's aspect ratio, so
// resizing the panel changes how much is in frame rather than
// how the frame is squashed.
if (typeof renderer !== 'undefined' && renderer &&
typeof camera !== 'undefined' && camera &&
config.map.width > 0 && config.map.height > 0) {
var aspect = (window.innerWidth > 0 && window.innerHeight > 0)
? window.innerWidth / window.innerHeight
: config.map.width / config.map.height;
var pixelBudget = config.map.width * config.map.height;
var height = Math.max(1, Math.round(Math.sqrt(pixelBudget / aspect)));
var width = Math.max(1, Math.round(height * aspect));
renderer.setSize(width, height, false);
camera.aspect = aspect;
camera.updateProjectionMatrix();
}
// Apply filler settings to TerrainFill instance
if (typeof terrainFill !== 'undefined' && terrainFill) {
terrainFill.sampleInterval = config.filler.sampleInterval;
terrainFill.updateIntervalSec = config.filler.updateIntervalSec;
terrainFill.perimeterPatchCount = config.filler.perimeter.patchCount;
terrainFill.perimeterPatchSize = config.filler.perimeter.patchSize;
terrainFill.centerPatchCount = config.filler.center.patchCount;
terrainFill.centerPatchSize = config.filler.center.patchSize;
terrainFill.paddingPatchCount = config.filler.padding.patchCount;
terrainFill.paddingPatchSize = config.filler.padding.patchSize;
}
// Apply cloud settings
if (typeof terrainFill !== 'undefined' && terrainFill && terrainFill.cloudOverlay) {
terrainFill.cloudOverlay.enabled = config.cloud.enabled;
terrainFill.cloudOverlay.opacity = config.cloud.opacity;
terrainFill.cloudOverlay.speed = config.cloud.speed;
terrainFill.cloudOverlay.coverage = config.cloud.coverage;
terrainFill.cloudOverlay.scale = config.cloud.scale;
}
}
/**
* Export the current configuration as renderMap.json. The extension
* host owns the file write so the save lands in the workspace; a
* standalone page falls back to a browser download.
*/
function exportJSON() {
var json = JSON.stringify(config, null, 4);
if (HostBridge.isHosted()) {
HostBridge.post({ command: 'exportConfig', json: json });
return;
}
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'renderMap.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Populate slider/input elements from the current config and bind change events.
*/
function bindSliders() {
var sliders = document.querySelectorAll('[data-config]');
for (var i = 0; i < sliders.length; i++) {
(function (el) {
var path = el.getAttribute('data-config');
var keys = path.split('.');
var val = config;
for (var k = 0; k < keys.length; k++) {
val = val[keys[k]];
}
el.value = val;
var display = document.getElementById(el.id + '-val');
if (display) display.textContent = val;
el.addEventListener('input', function () {
var v = parseFloat(el.value);
set(path, v);
if (display) display.textContent = v;
});
})(sliders[i]);
}
// Cloud enabled checkbox
var cloudToggle = document.getElementById('cfg-cloud-enabled');
if (cloudToggle) {
cloudToggle.checked = config.cloud.enabled;
cloudToggle.addEventListener('change', function () {
set('cloud.enabled', cloudToggle.checked);
});
}
}
return {
load: load,
get: get,
set: set,
apply: apply,
exportJSON: exportJSON,
bindSliders: bindSliders,
onChange: onChange
};
})();
@@ -0,0 +1,846 @@
/* ============================================================
3D Flight Simulator - Styles
============================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
background: #000;
color: #e0e0e0;
overflow: hidden;
width: 100vw;
height: 100vh;
}
/* ---- Canvas Container ---- */
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#canvas-container canvas {
display: block;
width: 100%;
height: 100%;
}
/* ---- Top Bar ---- */
/*
The selector panel and the location readout share one row. The bar
mirrors the selector panel's measured width as right padding so the
readout reads as centered in the panel rather than in the space left
over beside the menu. When the panel is too narrow for both, app.js
adds .stacked and the readout takes its own row underneath.
--selector-width and --top-bar-height are published by app.js.
*/
#top-bar {
position: fixed;
top: 0;
left: 0;
width: auto;
z-index: 100;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 12px;
padding-right: calc(12px + var(--selector-width, 0px));
pointer-events: none;
}
#top-bar.stacked {
padding-right: 12px;
}
/* ---- Capital Selector ---- */
#selector-panel {
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
background: rgba(0, 0, 0, 0.65);
padding: 8px 14px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(8px);
pointer-events: auto;
}
#selector-panel label {
font-size: 0.75rem;
color: #88bbdd;
text-transform: uppercase;
letter-spacing: 1px;
}
#capital-select {
padding: 5px 10px;
font-size: 0.85rem;
font-family: inherit;
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 3px;
background: rgba(10, 20, 40, 0.9);
color: #c0e0f0;
cursor: pointer;
flex: 1 1 auto;
min-width: 140px;
max-width: 100%;
outline: none;
}
#capital-select:focus {
border-color: rgba(100, 200, 255, 0.6);
}
#capital-select optgroup {
color: #66aacc;
font-weight: bold;
}
#capital-select option {
color: #c0e0f0;
background: #0a1428;
}
/* ---- HUD Overlay ---- */
#hud {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50;
pointer-events: none;
}
/* Location display (lives in #top-bar; the flex basis below is mirrored
by LOCATION_MIN_WIDTH in app.js, which decides when to stack) */
#hud-location {
/* flex: 1 1 240px; */
min-width: 0;
text-align: center;
}
/* Both lines sit over bright satellite imagery, so they carry a hard
1px drop shadow rather than a blurred glow. A blur radius spreads the
dark halo through the glyphs themselves and softens the strokes, which
is what made the coordinates unreadable against a pale sky. */
#hud-capital {
font-size: calc(1rem + 2pt);
color: rgb(235, 246, 255);
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* white-space: pre keeps the two spaces that separate the latitude from
the longitude; normal whitespace handling would collapse them to one */
#hud-coords {
font-size: calc(0.72rem + 2pt);
color: rgb(214, 234, 250);
margin-top: 2px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
/* Heading: sits under the top bar at whatever height it settled on */
#hud-heading {
position: absolute;
top: calc(var(--top-bar-height, 56px) + 6px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.35);
padding: 3px 16px;
border-radius: 3px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-heading-value {
font-size: 0.9rem;
color: rgba(100, 220, 255, 0.85);
text-shadow: 0 0 6px rgba(0, 100, 200, 0.3);
}
/* Speed (left side) */
#hud-speed {
position: absolute;
left: 30px;
top: 50%;
transform: translateY(-50%);
text-align: center;
background: rgba(0, 0, 0, 0.35);
padding: 10px 14px;
border-radius: 4px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-speed .hud-label {
font-size: 0.65rem;
color: rgba(100, 200, 255, 0.5);
letter-spacing: 2px;
margin-bottom: 4px;
}
#hud-speed-value {
font-size: 1.6rem;
color: rgba(100, 255, 180, 0.9);
font-weight: bold;
text-shadow: 0 0 10px rgba(0, 200, 100, 0.3);
}
#hud-speed .hud-unit {
font-size: 0.6rem;
color: rgba(100, 200, 255, 0.4);
margin-top: 2px;
}
/* Altitude (right side) */
#hud-altitude {
position: absolute;
right: 30px;
top: 50%;
transform: translateY(-50%);
text-align: center;
background: rgba(0, 0, 0, 0.35);
padding: 10px 14px;
border-radius: 4px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-altitude .hud-label {
font-size: 0.65rem;
color: rgba(100, 200, 255, 0.5);
letter-spacing: 2px;
margin-bottom: 4px;
}
#hud-altitude-value {
font-size: 1.6rem;
color: rgba(255, 200, 100, 0.9);
font-weight: bold;
text-shadow: 0 0 10px rgba(200, 150, 0, 0.3);
}
#hud-altitude .hud-unit {
font-size: 0.6rem;
color: rgba(100, 200, 255, 0.4);
margin-top: 2px;
}
/* Crosshair */
#hud-crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: rgba(200, 230, 255, 0.4);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
line-height: 1;
}
/* Pitch indicator */
#hud-pitch {
position: absolute;
right: 30px;
top: 50%;
transform: translateY(80px);
text-align: center;
}
#hud-pitch-value {
font-size: 0.75rem;
color: rgba(180, 200, 255, 0.6);
}
/* ---- Click to Fly Prompt ---- */
#fly-prompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 80;
text-align: center;
pointer-events: none;
}
#fly-prompt .prompt-text {
font-size: 1.8rem;
color: rgba(180, 220, 255, 0.9);
text-shadow: 0 0 20px rgba(50, 150, 255, 0.5);
animation: pulse 2s ease-in-out infinite;
letter-spacing: 2px;
text-transform: uppercase;
}
#fly-prompt .prompt-hint {
margin-top: 8px;
font-size: 0.72rem;
letter-spacing: 1px;
color: rgba(120, 170, 210, 0.6);
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* ---- Controls Help ---- */
#controls-help {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
text-align: center;
background: rgba(0, 0, 0, 0.5);
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.15);
pointer-events: none;
font-size: 0.72rem;
color: rgba(160, 200, 230, 0.8);
line-height: 1.6;
transition: opacity 2s ease;
}
#controls-help.fade-out {
opacity: 0;
}
/* ---- Static Controls List ---- */
#controls-list {
position: fixed;
left: 12px;
bottom: 20px;
z-index: 60;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(100, 200, 255, 0.1);
border-radius: 4px;
padding: 8px 12px;
pointer-events: none;
font-size: 0.62rem;
color: rgba(150, 190, 220, 0.6);
}
#controls-list .controls-title {
font-size: 0.58rem;
color: rgba(120, 170, 210, 0.5);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 5px;
}
#controls-list ul {
list-style: none;
}
#controls-list li {
line-height: 1.55;
white-space: nowrap;
}
#controls-list li span {
display: inline-block;
width: 52px;
color: rgba(120, 210, 255, 0.7);
}
/* ---- Pause Indicator ---- */
#pause-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 95;
background: rgba(0, 0, 0, 0.45);
padding: 12px 28px;
border-radius: 8px;
border: 1px solid rgba(255, 220, 120, 0.3);
pointer-events: none;
font-size: 1.6rem;
letter-spacing: 6px;
text-transform: uppercase;
color: rgba(255, 220, 120, 0.9);
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
}
/* ---- Loading Overlay ---- */
#loading-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 90;
text-align: center;
background: rgba(0, 10, 30, 0.85);
padding: 30px 50px;
border-radius: 10px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(10px);
}
#loading-text {
font-size: 1rem;
color: rgba(150, 200, 240, 0.9);
margin-bottom: 14px;
text-transform: uppercase;
letter-spacing: 2px;
}
#loading-bar-container {
width: 240px;
height: 4px;
background: rgba(50, 80, 120, 0.5);
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
}
#loading-bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2288cc, #44ccff);
border-radius: 2px;
transition: width 0.3s ease;
}
#loading-progress {
font-size: 0.8rem;
color: rgba(100, 180, 230, 0.7);
margin-top: 8px;
}
/* ---- Welcome Screen ---- */
#welcome {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 70;
text-align: center;
pointer-events: none;
}
#welcome h1 {
font-size: 2.2rem;
color: rgba(180, 220, 255, 0.9);
font-weight: 300;
letter-spacing: 4px;
text-transform: uppercase;
text-shadow: 0 0 30px rgba(50, 150, 255, 0.3);
margin-bottom: 8px;
}
#welcome p {
font-size: 0.85rem;
color: rgba(130, 170, 210, 0.6);
letter-spacing: 1px;
}
#welcome .welcome-hint {
margin-top: 20px;
font-size: 0.75rem;
color: rgba(100, 150, 200, 0.5);
animation: pulse 3s ease-in-out infinite;
}
/* ---- Agent Status ---- */
/* Clears the controls help that sits at the bottom edge, so both can be
on screen during the five seconds before the help fades */
#agent-status {
position: fixed;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 90;
max-width: min(560px, calc(100vw - 24px));
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-radius: 20px;
background: rgba(0, 8, 24, 0.8);
border: 1px solid rgba(100, 200, 255, 0.25);
font-size: 0.75rem;
color: rgba(200, 230, 255, 0.9);
pointer-events: none;
}
#agent-status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: #66ddaa;
animation: pulse 1.6s ease-in-out infinite;
}
/* A finished job leaves the last status readable but stops the pulse */
#agent-status.idle #agent-status-dot {
background: rgba(120, 170, 210, 0.5);
animation: none;
}
#agent-status-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#agent-status-tokens {
flex: 0 0 auto;
color: rgba(120, 200, 255, 0.65);
}
#agent-status-tokens:empty {
display: none;
}
/* ---- Utility ---- */
.hidden {
display: none !important;
}
/* ---- Destination Input Dialog ---- */
/* The "Input Destination" entry is the only action in the menu, so it
is set apart from the capitals listed under it. */
#opt-input-destination {
background: #e8edf1;
color: #16283f;
font-weight: bold;
}
/* Sits above the top bar so the dialog is never partly behind the menu,
and covers the canvas so a click cannot engage flight behind it. The
card hangs from just under the bar rather than centering, so it stays
next to the menu entry that opened it. */
#destination-dialog {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px;
padding-top: calc(var(--top-bar-height, 56px) + 16px);
background: rgba(4, 12, 24, 0.45);
}
.destination-card {
width: 100%;
max-width: 400px;
max-height: calc(100vh - var(--top-bar-height, 56px) - 40px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 14px;
background: rgba(238, 242, 245, 0.97);
border: 1px solid rgba(120, 160, 200, 0.45);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
#destination-form,
#destination-results {
display: flex;
flex-direction: column;
gap: 10px;
}
.dest-row {
display: flex;
align-items: center;
gap: 10px;
}
/* Fixed basis keeps the three fields on one left edge regardless of
how wide the individual label text is */
.dest-label {
flex: 0 0 92px;
padding: 8px 10px;
border-radius: 8px;
background: #ccd4da;
color: #1c2b3d;
font-size: 0.7rem;
letter-spacing: 0.5px;
text-align: center;
}
.dest-row input,
.dest-row select {
flex: 1 1 auto;
min-width: 0;
padding: 9px 12px;
font-family: inherit;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
color: #dceaf6;
outline: none;
}
.dest-row input::placeholder {
color: rgba(190, 215, 235, 0.55);
}
.dest-row input:focus,
.dest-row select:focus {
border-color: rgba(120, 200, 255, 0.85);
}
.dest-row select option {
background: #16283f;
color: #dceaf6;
}
#dest-result-list {
list-style: none;
max-height: 210px;
overflow-y: auto;
padding: 6px 4px;
border-radius: 10px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
outline: none;
}
#dest-result-list:focus {
border-color: rgba(120, 200, 255, 0.85);
}
/* The dashed rule between rows is a bottom border on every row but the
last, so the list reads as separated entries rather than a paragraph */
.dest-result {
padding: 9px 10px;
border-radius: 6px;
color: #dceaf6;
font-size: 0.85rem;
cursor: pointer;
border-bottom: 1px dashed rgba(140, 185, 225, 0.4);
}
.dest-result:last-child {
border-bottom: none;
}
.dest-result:hover {
background: rgba(90, 140, 190, 0.25);
}
.dest-result.active {
background: rgba(110, 165, 215, 0.45);
color: #ffffff;
}
.dest-message {
min-height: 1em;
font-size: 0.72rem;
color: #7a1f1f;
}
#dest-results-message {
color: #33465c;
}
.dest-actions {
display: flex;
gap: 12px;
}
.dest-actions button {
flex: 1 1 0;
padding: 10px 12px;
font-family: inherit;
font-size: 0.85rem;
border-radius: 8px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
color: #dceaf6;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.dest-actions button:hover:not(:disabled) {
background: #21395a;
border-color: rgba(120, 200, 255, 0.7);
}
.dest-actions button:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- Selector Panel Buttons ---- */
#selector-panel button {
padding: 5px 12px;
font-size: 0.75rem;
font-family: inherit;
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 3px;
background: rgba(10, 20, 40, 0.9);
color: #c0e0f0;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: border-color 0.2s, background 0.2s;
}
#selector-panel button:hover {
border-color: rgba(100, 200, 255, 0.6);
background: rgba(20, 40, 70, 0.9);
}
#btn-export-json {
color: #88ddaa !important;
border-color: rgba(100, 255, 160, 0.3) !important;
}
#btn-export-json:hover {
border-color: rgba(100, 255, 160, 0.6) !important;
}
/* ---- Render Config Panel ---- */
#config-panel {
position: fixed;
top: calc(var(--top-bar-height, 56px) + 12px);
left: 12px;
z-index: 100;
width: 320px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - var(--top-bar-height, 56px) - 36px);
overflow-y: auto;
background: rgba(0, 8, 24, 0.88);
padding: 14px 16px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(10px);
font-size: 0.72rem;
}
#config-panel h3 {
font-size: 0.85rem;
color: rgba(180, 220, 255, 0.9);
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
#config-panel .config-hint {
font-size: 0.65rem;
color: rgba(120, 170, 210, 0.6);
margin-bottom: 12px;
line-height: 1.4;
}
#config-panel fieldset {
border: 1px solid rgba(100, 200, 255, 0.12);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 10px;
}
#config-panel legend {
font-size: 0.7rem;
color: #66aacc;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 6px;
}
.cfg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.cfg-row label {
flex: 0 0 100px;
font-size: 0.68rem;
color: rgba(160, 200, 230, 0.8);
text-align: right;
}
.cfg-row input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(50, 80, 120, 0.5);
border-radius: 2px;
outline: none;
}
.cfg-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #44ccff;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.3);
}
.cfg-row input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #44ccff;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.3);
}
.cfg-row input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #44ccff;
}
.cfg-row span {
flex: 0 0 50px;
font-size: 0.65rem;
color: rgba(100, 220, 255, 0.85);
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Scrollbar has styling for config panel */
#config-panel::-webkit-scrollbar {
width: 4px;
}
#config-panel::-webkit-scrollbar-track {
background: transparent;
}
#config-panel::-webkit-scrollbar-thumb {
background: rgba(100, 200, 255, 0.2);
border-radius: 2px;
}
@@ -0,0 +1,441 @@
/************************************************************************************
* TerrainFill - Fills unloaded terrain gaps using sampled colors *
* from the rendered viewport. *
* *
* Algorithm: *
* 1. Render scene (without fill) to a low-res offscreen target *
* 2. Sample 10x10px patches from each of the 4 viewport borders (40 samples) *
* 3. Sample 10x10px patches from the middle area (5 samples) *
* 4. Sample 5x5px patches from padding perimeter in flight direction (4 samples) *
* 5. Filter out placeholder/sky/water colors *
* 6. Generate a fill texture by blending sampled colors with value noise *
* 7. Apply the texture to a large fill plane between tiles and water *
***********************************************************************************/
function TerrainFill(renderer, scene, camera) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
// Low-res render target for viewport sampling
this.sampleW = 256;
this.sampleH = 256;
this.sampleTarget = new THREE.WebGLRenderTarget(this.sampleW, this.sampleH);
this.sampleBuffer = new Uint8Array(this.sampleW * this.sampleH * 4);
// Fill texture canvas
this.texW = 256;
this.texH = 256;
this.fillCanvas = document.createElement('canvas');
this.fillCanvas.width = this.texW;
this.fillCanvas.height = this.texH;
this.fillCtx = this.fillCanvas.getContext('2d');
// Initialize with placeholder color
this.fillCtx.fillStyle = '#2a3a2a';
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
// Three.js canvas texture for the fill plane
this.fillTexture = new THREE.CanvasTexture(this.fillCanvas);
this.fillTexture.wrapS = THREE.RepeatWrapping;
this.fillTexture.wrapT = THREE.RepeatWrapping;
this.fillTexture.repeat.set(50, 50);
// Fill plane mesh sitting between tile meshes (y=0) and water (y=-2)
var geo = new THREE.PlaneGeometry(30000, 30000);
var mat = new THREE.MeshBasicMaterial({ map: this.fillTexture });
this.fillMesh = new THREE.Mesh(geo, mat);
this.fillMesh.rotation.x = -Math.PI / 2;
this.fillMesh.position.y = -0.5;
scene.add(this.fillMesh);
// Collected sample colors (array of {r,g,b} objects)
this.sampleColors = [];
// Timing
this.frameCount = 0;
this.sampleInterval = 45; // Sample every N frames
// Time-based filler pattern update interval (seconds)
this.updateIntervalSec = 10;
this.lastUpdateTime = 0;
// Configurable patch counts and sizes (overridden by RenderConfig)
this.perimeterPatchCount = 10;
this.perimeterPatchSize = 10;
this.centerPatchCount = 5;
this.centerPatchSize = 10;
this.paddingPatchCount = 4;
this.paddingPatchSize = 5;
// Cloud overlay effect applied to the fill area
this.cloudOverlay = {
enabled: true,
opacity: 0.35,
speed: 0.0004,
coverage: 0.5,
scale: 0.008,
time: 0
};
// Cloud canvas and texture (rendered on top of fill plane)
this.cloudCanvas = document.createElement('canvas');
this.cloudCanvas.width = this.texW;
this.cloudCanvas.height = this.texH;
this.cloudCtx = this.cloudCanvas.getContext('2d');
this.cloudTexture = new THREE.CanvasTexture(this.cloudCanvas);
this.cloudTexture.wrapS = THREE.RepeatWrapping;
this.cloudTexture.wrapT = THREE.RepeatWrapping;
this.cloudTexture.repeat.set(50, 50);
var cloudGeo = new THREE.PlaneGeometry(30000, 30000);
var cloudMat = new THREE.MeshBasicMaterial({
map: this.cloudTexture,
transparent: true,
depthWrite: false,
opacity: 1.0
});
this.cloudMesh = new THREE.Mesh(cloudGeo, cloudMat);
this.cloudMesh.rotation.x = -Math.PI / 2;
this.cloudMesh.position.y = -0.3;
scene.add(this.cloudMesh);
// Pre-compute noise permutation table
this.perm = new Array(512);
var p = new Array(256);
for (var i = 0; i < 256; i++) p[i] = i;
for (var i = 255; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = p[i]; p[i] = p[j]; p[j] = tmp;
}
for (var i = 0; i < 512; i++) this.perm[i] = p[i & 255];
// Second permutation for cloud noise (different seed)
this.cloudPerm = new Array(512);
var cp = new Array(256);
for (var i = 0; i < 256; i++) cp[i] = i;
for (var i = 255; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = cp[i]; cp[i] = cp[j]; cp[j] = tmp;
}
for (var i = 0; i < 512; i++) this.cloudPerm[i] = cp[i & 255];
// Pre-compute noise map (noise values won't change, only color mapping does)
this.noiseMap = new Float32Array(this.texW * this.texH);
for (var y = 0; y < this.texH; y++) {
for (var x = 0; x < this.texW; x++) {
var n1 = this._noise2d(x * 0.012, y * 0.012);
var n2 = this._noise2d(x * 0.035 + 97.3, y * 0.035 + 97.3) * 0.5;
var n3 = this._noise2d(x * 0.08 + 231.7, y * 0.08 + 231.7) * 0.25;
this.noiseMap[y * this.texW + x] = (n1 + n2 + n3) / 1.75;
}
}
}
/**
* Called each frame after main render. Periodically samples the viewport
* and regenerates the fill texture.
*/
TerrainFill.prototype.update = function (flightHeading) {
// Keep fill plane centered on camera
this.fillMesh.position.x = this.camera.position.x;
this.fillMesh.position.z = this.camera.position.z;
// Keep cloud mesh centered on camera
this.cloudMesh.position.x = this.camera.position.x;
this.cloudMesh.position.z = this.camera.position.z;
this.frameCount++;
if (this.frameCount % this.sampleInterval !== 0) return;
// Collect viewport samples every sampleInterval frames
this._collectSamples(flightHeading);
// Regenerate fill texture on a time-based interval (seconds)
var now = performance.now() / 1000;
if (this.sampleColors.length > 20 && (now - this.lastUpdateTime) >= this.updateIntervalSec) {
this._generateFillTexture();
this.lastUpdateTime = now;
}
// Update cloud effect
this._updateCloud();
};
/**
* Samples colors from the rendered viewport at border, middle,
* and padding perimeter positions.
*/
TerrainFill.prototype._collectSamples = function (flightHeading) {
// Hide generated overlays so we only sample actual terrain + sky
var cloudWasVisible = this.cloudMesh.visible;
this.fillMesh.visible = false;
this.cloudMesh.visible = false;
// Render scene to low-res offscreen target
this.renderer.setRenderTarget(this.sampleTarget);
this.renderer.render(this.scene, this.camera);
this.renderer.setRenderTarget(null);
this.fillMesh.visible = true;
this.cloudMesh.visible = cloudWasVisible;
// Read pixels from the render target
this.renderer.readRenderTargetPixels(
this.sampleTarget, 0, 0,
this.sampleW, this.sampleH,
this.sampleBuffer
);
var w = this.sampleW;
var h = this.sampleH;
var buf = this.sampleBuffer;
var self = this;
this.sampleColors = [];
// Helper: extract valid (non-placeholder/sky/water) colors from a patch
function extractPatch(px, py, size) {
var result = [];
for (var dy = 0; dy < size; dy++) {
for (var dx = 0; dx < size; dx++) {
var sx = Math.max(0, Math.min(px + dx, w - 1));
var sy = Math.max(0, Math.min(py + dy, h - 1));
var idx = (sy * w + sx) * 4;
var r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
if (!self._isSkipColor(r, g, b)) {
result.push({ r: r, g: g, b: b });
}
}
}
return result;
}
var borderDepth = Math.max(Math.floor(h * 0.08), 8);
var patchSize = this.perimeterPatchSize;
var smallPatch = this.paddingPatchSize;
var perimCount = this.perimeterPatchCount;
var centerCount = this.centerPatchCount;
var centerSize = this.centerPatchSize;
var padCount = this.paddingPatchCount;
// ---- Border samples: perimCount per border ----
// Top border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * (w - patchSize));
var y = Math.floor(Math.random() * borderDepth);
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Bottom border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * (w - patchSize));
var y = h - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Left border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * borderDepth);
var y = Math.floor(Math.random() * (h - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Right border
for (var i = 0; i < perimCount; i++) {
var x = w - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
var y = Math.floor(Math.random() * (h - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// ---- Middle area: centerCount samples ----
var midRange = Math.min(w, h) / 4;
for (var i = 0; i < centerCount; i++) {
var x = Math.floor(w / 2 - midRange / 2 + Math.random() * midRange);
var y = Math.floor(h / 2 - midRange / 2 + Math.random() * midRange);
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, centerSize));
}
// ---- Padding perimeter: padCount samples toward flight direction ----
var headRad = ((flightHeading || 0)) * Math.PI / 180;
var dirX = Math.sin(headRad);
var dirY = -Math.cos(headRad);
for (var i = 0; i < padCount; i++) {
var dist = 0.25 + Math.random() * 0.35;
var spread = (Math.random() - 0.5) * 0.4;
var sx = Math.floor(w / 2 + (dirX * dist + dirY * spread) * w * 0.45);
var sy = Math.floor(h / 2 + (dirY * dist - dirX * spread) * h * 0.45);
sx = Math.max(0, Math.min(sx, w - smallPatch));
sy = Math.max(0, Math.min(sy, h - smallPatch));
this.sampleColors = this.sampleColors.concat(extractPatch(sx, sy, smallPatch));
}
};
/**
* Checks whether a pixel color should be skipped during sampling
* (placeholder tiles, sky, water, or black).
*/
TerrainFill.prototype._isSkipColor = function (r, g, b) {
// Placeholder dark green: #2a3a2a (42, 58, 42)
if (Math.abs(r - 42) < 20 && Math.abs(g - 58) < 20 && Math.abs(b - 42) < 20) return true;
// Sky blue: #87CEEB (135, 206, 235)
if (Math.abs(r - 135) < 35 && Math.abs(g - 206) < 35 && Math.abs(b - 235) < 35) return true;
// Water blue: #1a4a7a (26, 74, 122)
if (Math.abs(r - 26) < 25 && Math.abs(g - 74) < 25 && Math.abs(b - 122) < 25) return true;
// Near-black
if (r < 8 && g < 8 && b < 8) return true;
// Fill plane color itself (dark placeholder)
if (r < 50 && g < 65 && b < 50 && Math.abs(r - b) < 10) return true;
return false;
};
/**
* Generates the fill texture by mapping sampled colors through
* a pre-computed noise map. Produces a natural-looking blend of
* terrain colors that fills gaps between loaded tiles.
*/
TerrainFill.prototype._generateFillTexture = function () {
var colors = this.sampleColors;
if (colors.length < 10) return;
var ctx = this.fillCtx;
var w = this.texW;
var h = this.texH;
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
var numColors = colors.length;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
// Look up pre-computed noise value (0-1)
var noise = this.noiseMap[y * w + x];
// Map noise to color index range and blend between neighbors
var fidx = noise * (numColors - 1);
var idx1 = Math.floor(fidx);
var idx2 = Math.min(idx1 + 1, numColors - 1);
var blend = fidx - idx1;
var c1 = colors[idx1];
var c2 = colors[idx2];
var px = (y * w + x) * 4;
data[px] = Math.round(c1.r + (c2.r - c1.r) * blend);
data[px + 1] = Math.round(c1.g + (c2.g - c1.g) * blend);
data[px + 2] = Math.round(c1.b + (c2.b - c1.b) * blend);
data[px + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
this.fillTexture.needsUpdate = true;
};
/**
* 2D value noise using the pre-computed permutation table.
*/
TerrainFill.prototype._noise2d = function (x, y) {
var xi = Math.floor(x) & 255;
var yi = Math.floor(y) & 255;
var xf = x - Math.floor(x);
var yf = y - Math.floor(y);
// Smoothstep fade curves
var u = xf * xf * (3 - 2 * xf);
var v = yf * yf * (3 - 2 * yf);
// Hash four corners
var aa = this.perm[this.perm[xi] + yi] / 255;
var ab = this.perm[this.perm[xi] + yi + 1] / 255;
var ba = this.perm[this.perm[xi + 1] + yi] / 255;
var bb = this.perm[this.perm[xi + 1] + yi + 1] / 255;
// Bilinear interpolation
var x1 = aa * (1 - u) + ba * u;
var x2 = ab * (1 - u) + bb * u;
return x1 * (1 - v) + x2 * v;
};
/**
* Generates animated cloud overlay texture using time-offset noise.
* The cloud effect applies a semi-transparent white layer over the
* fill area, simulating drifting cloud cover.
*/
TerrainFill.prototype._updateCloud = function () {
if (!this.cloudOverlay.enabled) {
this.cloudMesh.visible = false;
return;
}
this.cloudMesh.visible = true;
this.cloudOverlay.time += this.cloudOverlay.speed * this.sampleInterval;
var ctx = this.cloudCtx;
var w = this.texW;
var h = this.texH;
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
var t = this.cloudOverlay.time;
var scale = this.cloudOverlay.scale;
var coverage = this.cloudOverlay.coverage;
var opacity = this.cloudOverlay.opacity;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
// Multi-octave noise with time offset for drift
var n1 = this._cloudNoise2d(x * scale + t, y * scale + t * 0.7);
var n2 = this._cloudNoise2d(x * scale * 2.3 + 50 + t * 1.3, y * scale * 2.3 + 50 + t * 0.9) * 0.5;
var n3 = this._cloudNoise2d(x * scale * 5.1 + 120 - t * 0.5, y * scale * 5.1 + 120 + t * 0.3) * 0.25;
var noise = (n1 + n2 + n3) / 1.75;
// Apply coverage threshold: lower coverage = fewer clouds
var cloudDensity = Math.max(0, noise - (1 - coverage)) / coverage;
cloudDensity = Math.min(1, cloudDensity);
// Smooth edges
cloudDensity = cloudDensity * cloudDensity * (3 - 2 * cloudDensity);
var alpha = Math.round(cloudDensity * opacity * 255);
var px = (y * w + x) * 4;
data[px] = 255; // White clouds
data[px + 1] = 255;
data[px + 2] = 255;
data[px + 3] = alpha;
}
}
ctx.putImageData(imgData, 0, 0);
this.cloudTexture.needsUpdate = true;
};
/**
* 2D value noise using the cloud-specific permutation table.
*/
TerrainFill.prototype._cloudNoise2d = function (x, y) {
var xi = Math.floor(x) & 255;
var yi = Math.floor(y) & 255;
var xf = x - Math.floor(x);
var yf = y - Math.floor(y);
var u = xf * xf * (3 - 2 * xf);
var v = yf * yf * (3 - 2 * yf);
var aa = this.cloudPerm[this.cloudPerm[xi] + yi] / 255;
var ab = this.cloudPerm[this.cloudPerm[xi] + yi + 1] / 255;
var ba = this.cloudPerm[this.cloudPerm[xi + 1] + yi] / 255;
var bb = this.cloudPerm[this.cloudPerm[xi + 1] + yi + 1] / 255;
var x1 = aa * (1 - u) + ba * u;
var x2 = ab * (1 - u) + bb * u;
return x1 * (1 - v) + x2 * v;
};
/**
* Reset fill state when switching capitals.
*/
TerrainFill.prototype.reset = function () {
this.sampleColors = [];
this.frameCount = 0;
this.fillCtx.fillStyle = '#2a3a2a';
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
this.fillTexture.needsUpdate = true;
this.cloudOverlay.time = 0;
this.cloudCtx.clearRect(0, 0, this.texW, this.texH);
this.cloudTexture.needsUpdate = true;
};
File diff suppressed because one or more lines are too long