Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
165 lines
4.8 KiB
JavaScript
165 lines
4.8 KiB
JavaScript
/**
|
|
* commit-banners.js — Floating commit banner sprites
|
|
*
|
|
* Category: REAL
|
|
* Data source: Gitea commits API (via data/gitea.js — fetchNexusCommits)
|
|
*
|
|
* Loads the last N commits and renders each as a floating canvas-texture
|
|
* sprite near the island surface. Banners fade in, float gently, then fade
|
|
* out and respawn in sequence.
|
|
*/
|
|
|
|
import * as THREE from 'three';
|
|
import { fetchNexusCommits } from '../data/gitea.js';
|
|
|
|
let _scene = null;
|
|
|
|
/** @type {THREE.Sprite[]} */
|
|
const banners = [];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Canvas texture
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {string} hash 7-char short SHA
|
|
* @param {string} message Commit subject line
|
|
* @returns {THREE.CanvasTexture}
|
|
*/
|
|
function _createCommitTexture(hash, message) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 512;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
ctx.fillStyle = 'rgba(0, 0, 16, 0.75)';
|
|
ctx.fillRect(0, 0, 512, 64);
|
|
|
|
ctx.strokeStyle = '#4488ff';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(0.5, 0.5, 511, 63);
|
|
|
|
ctx.font = 'bold 11px "Courier New", monospace';
|
|
ctx.fillStyle = '#4488ff';
|
|
ctx.fillText(hash, 10, 20);
|
|
|
|
ctx.font = '12px "Courier New", monospace';
|
|
ctx.fillStyle = '#ccd6f6';
|
|
const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message;
|
|
ctx.fillText(displayMsg, 10, 46);
|
|
|
|
return new THREE.CanvasTexture(canvas);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Banner lifecycle constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SPREAD_X = [-7, -3.5, 0, 3.5, 7];
|
|
const SPREAD_Y = [1.0, -1.5, 2.2, -0.8, 1.6];
|
|
const SPREAD_Z = [-1.5, -2.5, -1.0, -2.0, -1.8];
|
|
const FADE_IN_S = 0.5;
|
|
const VISIBLE_S = 10.0;
|
|
const FADE_OUT_S = 0.8;
|
|
const TOTAL_S = FADE_IN_S + VISIBLE_S + FADE_OUT_S;
|
|
const FLOAT_AMP = 0.18; // world-units peak-to-peak amplitude
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {THREE.Scene} scene
|
|
* @param {object} _state
|
|
* @param {object} _theme
|
|
*/
|
|
export async function init(scene, _state, _theme) {
|
|
_scene = scene;
|
|
|
|
const raw = await fetchNexusCommits(5);
|
|
const commits = raw.length > 0
|
|
? raw.map(c => ({
|
|
hash: c.sha.slice(0, 7),
|
|
message: c.commit.message.split('\n')[0],
|
|
}))
|
|
: [
|
|
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
|
|
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
|
|
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
|
|
{ hash: 'm0n1o2p', message: "feat: overview mode bird's-eye view" },
|
|
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
|
|
];
|
|
|
|
commits.forEach((commit, i) => {
|
|
const texture = _createCommitTexture(commit.hash, commit.message);
|
|
const material = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
opacity: 0,
|
|
depthWrite: false,
|
|
});
|
|
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.scale.set(12, 1.5, 1);
|
|
|
|
const baseY = SPREAD_Y[i % SPREAD_Y.length];
|
|
sprite.position.set(
|
|
SPREAD_X[i % SPREAD_X.length],
|
|
baseY,
|
|
SPREAD_Z[i % SPREAD_Z.length]
|
|
);
|
|
|
|
sprite.userData = {
|
|
baseY,
|
|
floatPhase: (i / commits.length) * Math.PI * 2,
|
|
floatSpeed: 0.25 + i * 0.07,
|
|
// Stagger start so banners do not all appear simultaneously
|
|
startDelay: i * (TOTAL_S / commits.length),
|
|
zoomLabel: `Commit: ${commit.hash}`,
|
|
};
|
|
|
|
scene.add(sprite);
|
|
banners.push(sprite);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {number} elapsed Total seconds since start
|
|
* @param {number} _delta
|
|
*/
|
|
export function update(elapsed, _delta) {
|
|
for (const sprite of banners) {
|
|
const ud = sprite.userData;
|
|
|
|
// Position within the banner's own cyclic timeline
|
|
const localT = (elapsed - ud.startDelay) % TOTAL_S;
|
|
|
|
let opacity = 0;
|
|
if (localT < 0) {
|
|
opacity = 0;
|
|
} else if (localT < FADE_IN_S) {
|
|
opacity = localT / FADE_IN_S;
|
|
} else if (localT < FADE_IN_S + VISIBLE_S) {
|
|
opacity = 1;
|
|
} else {
|
|
const fadeOutT = localT - FADE_IN_S - VISIBLE_S;
|
|
opacity = Math.max(0, 1 - fadeOutT / FADE_OUT_S);
|
|
}
|
|
|
|
sprite.material.opacity = opacity;
|
|
|
|
// Vertical float
|
|
sprite.position.y =
|
|
ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * FLOAT_AMP;
|
|
}
|
|
}
|
|
|
|
export function dispose() {
|
|
for (const sprite of banners) {
|
|
_scene.remove(sprite);
|
|
if (sprite.material.map) sprite.material.map.dispose();
|
|
sprite.material.dispose();
|
|
}
|
|
banners.length = 0;
|
|
}
|