mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-12 20:29:12 +00:00
chore: publish from main
This commit is contained in:
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 · <span id="controls-help-look">Mouse: Look</span> · A/D: Roll</div>
|
||||
<div>Q/E: Yaw · R/F: Alt · Space: Level · Shift: Boost · 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
Reference in New Issue
Block a user