mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
c46b4a919a
* 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>
442 lines
16 KiB
JavaScript
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;
|
|
};
|