Files
awesome-copilot/extensions/flight-map-canvas/game/terrainFill.js
T
John Haugabook c46b4a919a Add flight-map-canvas extension (#2482)
* Add flight-map-canvas extension

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

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

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

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

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

* Apply suggestions from code review

---------

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

442 lines
16 KiB
JavaScript

/************************************************************************************
* 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;
};