1 Commits

Author SHA1 Message Date
Alexander Whitestone
d759416fc6 feat: ASCII art Timmy logo with progressive loading reveal
Replace plain "INITIALIZING..." text with a block-character ASCII art
TIMMY logo that fills in character-by-character as each init step
completes. Shows a progress bar and percentage counter.

- js/loading.js: new module — ASCII art, initLoadingArt(), setLoadingProgress()
- js/main.js: import loading module; call setLoadingProgress() at each
  init stage (world 10%, effects 30%, agents 50%, controls 70%,
  UI 80%, WebSocket 90%, visitor 95%, done 100%)
- index.html: loading screen now uses <pre id="ascii-logo">, progress
  bar track/fill, percent label, and status message; CSS styles added
  for lit/unlit characters with green glow

Fixes #7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:06:41 -04:00
4 changed files with 161 additions and 114 deletions

View File

@@ -22,13 +22,56 @@
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: #000;
color: #00ff41; font-size: 14px; letter-spacing: 4px;
text-shadow: 0 0 12px #00ff41;
font-family: 'Courier New', monospace;
}
#loading-screen.hidden { display: none; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
#loading-screen span { animation: blink 1.2s ease-in-out infinite; }
.loading-content {
display: flex; flex-direction: column; align-items: center; gap: 18px;
padding: 24px;
}
/* ASCII logo */
#ascii-logo {
white-space: pre;
font-family: 'Courier New', monospace;
font-size: clamp(6px, 1.1vw, 13px);
line-height: 1.25;
color: #002200;
text-shadow: none;
user-select: none;
}
/* Lit characters glow green */
#ascii-logo .ac { color: #002200; transition: color 0.15s, text-shadow 0.15s; }
#ascii-logo .ac.lit {
color: #00ff41;
text-shadow: 0 0 8px #00ff41, 0 0 20px #00cc33;
}
/* Progress bar */
#loading-bar-track {
width: clamp(220px, 50vw, 500px);
height: 3px;
background: #001800;
border: 1px solid #003300;
border-radius: 2px;
overflow: hidden;
}
#loading-bar-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, #00aa22, #00ff41);
box-shadow: 0 0 10px #00ff41;
transition: width 0.3s ease-out;
}
/* Percent + status */
#loading-percent {
color: #00ff41; font-size: 11px; letter-spacing: 3px;
text-shadow: 0 0 8px #00ff41;
}
#loading-msg {
color: #007722; font-size: 10px; letter-spacing: 4px;
}
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
@@ -162,7 +205,14 @@
</style>
</head>
<body>
<div id="loading-screen"><span>INITIALIZING...</span></div>
<div id="loading-screen">
<div class="loading-content">
<pre id="ascii-logo"></pre>
<div id="loading-bar-track"><div id="loading-bar-fill"></div></div>
<div id="loading-percent">0%</div>
<div id="loading-msg">INITIALIZING...</div>
</div>
</div>
<!-- WebGL context loss overlay (iPad PWA, GPU resets) -->
<div id="webgl-recovery-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.9);color:#00ff41;font-family:monospace;align-items:center;justify-content:center;flex-direction:column">
<p style="font-size:1.4rem">RECOVERING WebGL CONTEXT…</p>

View File

@@ -1,6 +1,5 @@
import * as THREE from 'three';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { getQualityTier } from './quality.js';
const agents = new Map();
let scene;
@@ -67,105 +66,6 @@ class Agent {
const light = new THREE.PointLight(this.color, 1.5, 10);
this.group.add(light);
this.light = light;
this._initParticles();
}
_initParticles() {
const tier = getQualityTier();
// Particle counts scaled by quality tier — fewer on mobile for performance
const count = tier === 'low' ? 20 : tier === 'medium' ? 40 : 64;
this._pCount = count;
this._pPos = new Float32Array(count * 3); // local-space positions
this._pVel = new Float32Array(count * 3); // velocity in units/ms
this._pLife = new Float32Array(count); // remaining lifetime in ms (0 = dead)
this._pEmit = 0; // fractional emit accumulator
// Particles emitted per ms when active
this._pRate = tier === 'low' ? 0.005 : tier === 'medium' ? 0.01 : 0.016;
this._pLastT = null;
// Start all particles hidden
for (let i = 0; i < count; i++) {
this._pPos[i * 3 + 1] = -1000;
this._pLife[i] = 0;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this._pPos, 3));
const mat = new THREE.PointsMaterial({
color: this.color,
size: tier === 'low' ? 0.2 : 0.14,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
this._pMesh = new THREE.Points(geo, mat);
this._pMesh.frustumCulled = false;
this.group.add(this._pMesh);
}
_spawnParticle() {
for (let i = 0; i < this._pCount; i++) {
if (this._pLife[i] > 0) continue;
// Spawn near agent core
const spread = 0.5;
this._pPos[i * 3] = (Math.random() - 0.5) * spread;
this._pPos[i * 3 + 1] = (Math.random() - 0.5) * spread;
this._pPos[i * 3 + 2] = (Math.random() - 0.5) * spread;
// Outward + upward drift (units per ms)
const angle = Math.random() * Math.PI * 2;
const radial = 0.0008 + Math.random() * 0.0018;
const rise = 0.001 + Math.random() * 0.003;
this._pVel[i * 3] = Math.cos(angle) * radial;
this._pVel[i * 3 + 1] = rise;
this._pVel[i * 3 + 2] = Math.sin(angle) * radial;
// Lifetime 6001400 ms
this._pLife[i] = 600 + Math.random() * 800;
return;
}
}
_updateParticles(time) {
if (!this._pMesh) return;
const active = this.state === 'active';
const dt = this._pLastT === null ? 16 : Math.min(time - this._pLastT, 100);
this._pLastT = time;
// Emit sparks while active
if (active) {
this._pEmit += dt * this._pRate;
while (this._pEmit >= 1) {
this._pEmit -= 1;
this._spawnParticle();
}
} else {
this._pEmit = 0;
}
// Integrate existing particles
let anyAlive = false;
for (let i = 0; i < this._pCount; i++) {
if (this._pLife[i] <= 0) continue;
this._pLife[i] -= dt;
if (this._pLife[i] <= 0) {
this._pLife[i] = 0;
this._pPos[i * 3 + 1] = -1000; // move off-screen
continue;
}
this._pPos[i * 3] += this._pVel[i * 3] * dt;
this._pPos[i * 3 + 1] += this._pVel[i * 3 + 1] * dt;
this._pPos[i * 3 + 2] += this._pVel[i * 3 + 2] * dt;
anyAlive = true;
}
this._pMesh.geometry.attributes.position.needsUpdate = true;
// Fade opacity: visible when active or particles still coasting
this._pMesh.material.opacity = (active || anyAlive) ? 0.9 : 0;
}
_buildLabel() {
@@ -204,8 +104,6 @@ class Agent {
this.ring.material.opacity = 0.3 + pulse * 0.2;
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
this._updateParticles(time);
}
setState(state) {
@@ -222,11 +120,6 @@ class Agent {
this.glow.material.dispose();
this.sprite.material.map.dispose();
this.sprite.material.dispose();
if (this._pMesh) {
this._pMesh.geometry.dispose();
this._pMesh.material.dispose();
this._pMesh = null;
}
}
}

86
js/loading.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* Loading screen — ASCII art Timmy logo with progressive character reveal.
*
* Usage:
* import { initLoadingArt, setLoadingProgress } from './loading.js';
* initLoadingArt(); // call once on page load
* setLoadingProgress(50, 'MSG'); // 0100, optional status message
*/
const TIMMY_ASCII = `
████████╗██╗███╗ ███╗███╗ ███╗██╗ ██╗
╚══██╔══╝██║████╗ ████║████╗ ████║╚██╗ ██╔╝
██║ ██║██╔████╔██║██╔████╔██║ ╚████╔╝
██║ ██║██║╚██╔╝██║██║╚██╔╝██║ ╚██╔╝
██║ ██║██║ ╚═╝ ██║██║ ╚═╝ ██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
┌───────────────────────────────┐
│ SOVEREIGN AI · SOUL ON BTC │
└───────────────────────────────┘
`.trimStart();
/** All non-whitespace char indices in the ASCII art, randomised for scatter reveal */
let _charSpans = [];
let _totalChars = 0;
/**
* Initialise the loading screen: build character spans for the ASCII art.
* Call once before the first setLoadingProgress().
*/
export function initLoadingArt() {
const logoEl = document.getElementById('ascii-logo');
if (!logoEl) return;
// Build span-per-char markup — whitespace stays as plain text nodes for layout.
const chars = [...TIMMY_ASCII];
let html = '';
let idx = 0;
for (const ch of chars) {
if (ch === '\n') {
html += '\n';
} else if (ch === ' ') {
html += ' ';
} else {
html += `<span class="ac" data-i="${idx}">${ch}</span>`;
idx++;
}
}
_totalChars = idx;
logoEl.innerHTML = html;
// Cache span elements for fast access
_charSpans = Array.from(logoEl.querySelectorAll('.ac'));
}
/**
* Update loading progress.
* @param {number} percent 0100
* @param {string} [msg] Optional status line text
*/
export function setLoadingProgress(percent, msg) {
const pct = Math.max(0, Math.min(100, percent));
// How many chars should be "lit" at this progress level
const litCount = Math.round((_totalChars * pct) / 100);
for (let i = 0; i < _charSpans.length; i++) {
if (i < litCount) {
_charSpans[i].classList.add('lit');
}
}
// Progress bar fill
const bar = document.getElementById('loading-bar-fill');
if (bar) bar.style.width = `${pct}%`;
// Percentage label
const pctEl = document.getElementById('loading-percent');
if (pctEl) pctEl.textContent = `${Math.round(pct)}%`;
// Status message
if (msg) {
const msgEl = document.getElementById('loading-msg');
if (msgEl) msgEl.textContent = msg;
}
}

View File

@@ -8,6 +8,7 @@ import { initUI, updateUI } from './ui.js';
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
import { initLoadingArt, setLoadingProgress } from './loading.js';
let running = false;
let canvas = null;
@@ -22,26 +23,40 @@ let canvas = null;
* Agent state map captured just before teardown; reapplied after initAgents.
*/
function buildWorld(firstInit, stateSnapshot) {
if (firstInit) setLoadingProgress(10, 'RENDERING ENGINE...');
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
if (firstInit) setLoadingProgress(30, 'EFFECTS SYSTEM...');
initEffects(scene);
if (firstInit) setLoadingProgress(50, 'SPAWNING AGENTS...');
initAgents(scene);
if (stateSnapshot) {
applyAgentStates(stateSnapshot);
}
if (firstInit) setLoadingProgress(70, 'ARMING CONTROLS...');
initInteraction(camera, renderer);
if (firstInit) {
setLoadingProgress(80, 'LOADING INTERFACE...');
initUI();
setLoadingProgress(90, 'CONNECTING TO GRID...');
initWebSocket(scene);
setLoadingProgress(95, 'INITIALIZING VISITOR...');
initVisitor();
// Dismiss loading screen
setLoadingProgress(100, 'WELCOME TO TOWER WORLD');
// Dismiss loading screen after a brief moment so 100% is visible
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) loadingScreen.classList.add('hidden');
if (loadingScreen) {
setTimeout(() => loadingScreen.classList.add('hidden'), 400);
}
}
// Debounce resize to 1 call per frame
@@ -117,6 +132,9 @@ function teardown({ scene, renderer, ac }) {
function main() {
const $overlay = document.getElementById('webgl-recovery-overlay');
initLoadingArt();
setLoadingProgress(0, 'BOOTING MATRIX...');
let handle = buildWorld(true, null);
// WebGL context loss recovery (iPad PWA, GPU driver reset, etc.)