experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add help docs extension

+782
+50
extensions/tentacles/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Tentacles Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+225
extensions/tentacles/background.js
··· 1 + /** 2 + * Tentacles Extension Background Script 3 + * 4 + * Spawns a fullscreen transparent overlay window where animated tentacles 5 + * creep in from screen edges and flee from the cursor. 6 + * 7 + * Runs in isolated extension process (peek://ext/tentacles/background.html) 8 + */ 9 + 10 + const api = window.app; 11 + const debug = api.debug; 12 + 13 + console.log('[ext:tentacles] background init'); 14 + 15 + const OVERLAY_ADDRESS = 'peek://ext/tentacles/tentacles.html'; 16 + const STORAGE_KEY = 'tentacles_enabled'; 17 + 18 + let enabled = false; 19 + let overlayWindowId = null; 20 + let appFocused = true; 21 + 22 + /** 23 + * Load enabled state 24 + */ 25 + const loadState = async () => { 26 + if (api?.settings?.getKey) { 27 + try { 28 + const result = await api.settings.getKey('overlay_visible'); 29 + if (result.success && result.data !== undefined && result.data !== null) { 30 + enabled = !!result.data; 31 + console.log('[ext:tentacles] Loaded state from datastore - enabled:', enabled); 32 + return enabled; 33 + } 34 + } catch (err) { 35 + console.log('[ext:tentacles] Failed to load from datastore:', err); 36 + } 37 + } 38 + const stored = localStorage.getItem(STORAGE_KEY); 39 + enabled = stored === null ? true : stored === 'true'; // Default enabled 40 + console.log('[ext:tentacles] Loaded state - enabled:', enabled); 41 + return enabled; 42 + }; 43 + 44 + /** 45 + * Save enabled state 46 + */ 47 + const saveState = (value) => { 48 + enabled = value; 49 + try { 50 + localStorage.setItem(STORAGE_KEY, String(value)); 51 + } catch (err) { 52 + debug && console.log('[ext:tentacles] Failed to cache state:', err); 53 + } 54 + if (api?.settings?.setKey) { 55 + api.settings.setKey('overlay_visible', value).catch(err => { 56 + console.error('[ext:tentacles] Failed to save state:', err); 57 + }); 58 + } 59 + }; 60 + 61 + /** 62 + * Open the overlay window (fullscreen, transparent, click-through) 63 + */ 64 + const openOverlay = async () => { 65 + if (overlayWindowId) { 66 + const exists = await api.window.exists(overlayWindowId); 67 + if (exists.success && exists.data) { 68 + await api.window.show(overlayWindowId); 69 + return; 70 + } 71 + overlayWindowId = null; 72 + } 73 + 74 + const screenW = window.screen.width; 75 + const screenH = window.screen.height; 76 + 77 + const params = { 78 + key: OVERLAY_ADDRESS, 79 + width: screenW, 80 + height: screenH, 81 + x: 0, 82 + y: 0, 83 + transparent: true, 84 + alwaysOnTop: true, 85 + skipTaskbar: true, 86 + focusable: false, 87 + resizable: false, 88 + frame: false, 89 + hasShadow: false, 90 + escapeMode: 'ignore', 91 + title: 'Tentacles', 92 + centerOnParent: false, 93 + show: true 94 + }; 95 + 96 + try { 97 + const result = await api.window.open(OVERLAY_ADDRESS, params); 98 + if (result.success) { 99 + overlayWindowId = result.id; 100 + console.log('[ext:tentacles] Overlay opened:', overlayWindowId); 101 + 102 + // Make the window click-through but still receive mouse move events 103 + // forward: true means mouse moves are still forwarded to the renderer 104 + setTimeout(async () => { 105 + try { 106 + await api.window.setIgnoreMouseEvents(overlayWindowId, true, true); 107 + console.log('[ext:tentacles] Set click-through with forward'); 108 + } catch (err) { 109 + console.error('[ext:tentacles] Failed to set click-through:', err); 110 + } 111 + }, 500); 112 + } else { 113 + console.error('[ext:tentacles] Failed to open overlay:', result.error); 114 + } 115 + } catch (error) { 116 + console.error('[ext:tentacles] Error opening overlay:', error); 117 + } 118 + }; 119 + 120 + const showOverlay = async () => { 121 + if (overlayWindowId) { 122 + const exists = await api.window.exists(overlayWindowId); 123 + if (exists.success && exists.data) { 124 + await api.window.show(overlayWindowId); 125 + } 126 + } 127 + }; 128 + 129 + const hideOverlay = async () => { 130 + if (overlayWindowId) { 131 + const exists = await api.window.exists(overlayWindowId); 132 + if (exists.success && exists.data) { 133 + await api.window.hide(overlayWindowId); 134 + } 135 + } 136 + }; 137 + 138 + const closeOverlay = async () => { 139 + if (overlayWindowId) { 140 + await api.window.close(overlayWindowId); 141 + overlayWindowId = null; 142 + console.log('[ext:tentacles] Overlay closed'); 143 + } 144 + }; 145 + 146 + const toggle = async () => { 147 + const newState = !enabled; 148 + saveState(newState); 149 + if (newState) { 150 + await openOverlay(); 151 + return { output: 'Tentacles enabled', mimeType: 'text/plain' }; 152 + } else { 153 + await closeOverlay(); 154 + return { output: 'Tentacles disabled', mimeType: 'text/plain' }; 155 + } 156 + }; 157 + 158 + // Commands 159 + const commandDefinitions = [ 160 + { 161 + name: 'tentacles', 162 + description: 'Toggle tentacles easter egg on/off', 163 + execute: async () => { 164 + console.log('[ext:tentacles] Toggle command executed'); 165 + return await toggle(); 166 + } 167 + } 168 + ]; 169 + 170 + let registeredCommands = []; 171 + 172 + const initCommands = () => { 173 + commandDefinitions.forEach(cmd => { 174 + api.commands.register(cmd); 175 + registeredCommands.push(cmd.name); 176 + }); 177 + console.log('[ext:tentacles] Registered commands:', registeredCommands); 178 + }; 179 + 180 + const uninitCommands = () => { 181 + registeredCommands.forEach(name => { 182 + api.commands.unregister(name); 183 + }); 184 + registeredCommands = []; 185 + }; 186 + 187 + const init = async () => { 188 + console.log('[ext:tentacles] init'); 189 + 190 + await loadState(); 191 + initCommands(); 192 + 193 + // Track app focus to show/hide overlay 194 + api.subscribe('app:focus-changed', async (msg) => { 195 + appFocused = !!msg.focused; 196 + if (enabled && overlayWindowId) { 197 + if (appFocused) { 198 + await showOverlay(); 199 + } else { 200 + await hideOverlay(); 201 + } 202 + } 203 + }, api.scopes.GLOBAL); 204 + 205 + if (enabled) { 206 + await openOverlay(); 207 + } 208 + 209 + console.log('[ext:tentacles] Initialized, enabled:', enabled); 210 + }; 211 + 212 + const uninit = () => { 213 + console.log('[ext:tentacles] uninit'); 214 + uninitCommands(); 215 + closeOverlay(); 216 + }; 217 + 218 + export default { 219 + id: 'tentacles', 220 + labels: { 221 + name: 'Tentacles' 222 + }, 223 + init, 224 + uninit 225 + };
+9
extensions/tentacles/manifest.json
··· 1 + { 2 + "id": "tentacles", 3 + "shortname": "tentacles", 4 + "name": "Tentacles", 5 + "description": "Animated tentacles that occasionally creep in from screen edges and flee from the cursor", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true 9 + }
+30
extensions/tentacles/tentacles.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Tentacles</title> 8 + <style> 9 + * { margin: 0; padding: 0; } 10 + html, body { 11 + width: 100%; 12 + height: 100%; 13 + overflow: hidden; 14 + background: transparent; 15 + -webkit-app-region: no-drag; 16 + } 17 + canvas { 18 + position: absolute; 19 + top: 0; 20 + left: 0; 21 + width: 100%; 22 + height: 100%; 23 + } 24 + </style> 25 + </head> 26 + <body> 27 + <canvas id="tentacle-canvas"></canvas> 28 + <script type="module" src="tentacles.js"></script> 29 + </body> 30 + </html>
+468
extensions/tentacles/tentacles.js
··· 1 + /** 2 + * Tentacles Animation Script 3 + * 4 + * Renders organic tentacles that creep in from screen edges, 5 + * undulate gently, and flee from the cursor. 6 + * 7 + * Each tentacle is a chain of segments rendered as a smooth bezier curve. 8 + * Control points animate with sinusoidal wobble for organic motion. 9 + * Cursor proximity triggers a fear/recoil response. 10 + */ 11 + 12 + const canvas = document.getElementById('tentacle-canvas'); 13 + const ctx = canvas.getContext('2d'); 14 + 15 + // --- Configuration --- 16 + const CONFIG = { 17 + // Spawn timing 18 + spawnIntervalMin: 30000, // 30s minimum between spawns 19 + spawnIntervalMax: 60000, // 60s maximum between spawns 20 + 21 + // Tentacle properties 22 + minSegments: 6, 23 + maxSegments: 14, 24 + minSegmentLength: 12, 25 + maxSegmentLength: 28, 26 + minThickness: 3, 27 + maxThickness: 12, 28 + 29 + // Animation 30 + creepSpeed: 0.3, // pixels per frame when creeping in 31 + retreatSpeed: 0.15, // pixels per frame when retreating naturally 32 + fleeSpeed: 3.0, // pixels per frame when fleeing from cursor 33 + wobbleSpeed: 0.02, // radians per frame for sinusoidal wobble 34 + wobbleAmplitude: 0.15, // max radians of wobble per segment 35 + 36 + // Lifecycle 37 + creepInDuration: 3000, // ms to creep in 38 + lingerDurationMin: 4000, // ms to linger at max extension 39 + lingerDurationMax: 10000, // ms to linger at max extension 40 + retreatDuration: 4000, // ms to retreat 41 + 42 + // Fear response 43 + fearRadius: 100, // px distance to trigger fear 44 + fearRecoveryTime: 2000, // ms before tentacle can return after fear 45 + 46 + // Visual 47 + maxAlpha: 0.35, 48 + colors: [ 49 + { r: 90, g: 60, b: 120 }, // muted purple 50 + { r: 60, g: 90, b: 80 }, // dark teal 51 + { r: 70, g: 70, b: 100 }, // slate blue 52 + { r: 80, g: 60, b: 90 }, // dusty violet 53 + { r: 50, g: 80, b: 70 }, // deep sea green 54 + ], 55 + 56 + // Max simultaneous tentacles 57 + maxTentacles: 4, 58 + }; 59 + 60 + // --- State --- 61 + let tentacles = []; 62 + let mouseX = -1000; 63 + let mouseY = -1000; 64 + let animationId = null; 65 + let spawnTimer = null; 66 + let lastTime = 0; 67 + 68 + // --- Edge enum --- 69 + const EDGE = { TOP: 0, RIGHT: 1, BOTTOM: 2, LEFT: 3 }; 70 + 71 + // --- Utility --- 72 + const rand = (min, max) => Math.random() * (max - min) + min; 73 + const randInt = (min, max) => Math.floor(rand(min, max + 1)); 74 + const lerp = (a, b, t) => a + (b - a) * t; 75 + const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); 76 + const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 77 + 78 + /** 79 + * Create a new tentacle emerging from a random edge 80 + */ 81 + function createTentacle() { 82 + const edge = randInt(0, 3); 83 + const numSegments = randInt(CONFIG.minSegments, CONFIG.maxSegments); 84 + const segmentLength = rand(CONFIG.minSegmentLength, CONFIG.maxSegmentLength); 85 + const thickness = rand(CONFIG.minThickness, CONFIG.maxThickness); 86 + const color = CONFIG.colors[randInt(0, CONFIG.colors.length - 1)]; 87 + 88 + // Base position along the edge 89 + let baseX, baseY, angle; 90 + const W = canvas.width; 91 + const H = canvas.height; 92 + const margin = 80; 93 + 94 + switch (edge) { 95 + case EDGE.TOP: 96 + baseX = rand(margin, W - margin); 97 + baseY = 0; 98 + angle = Math.PI / 2 + rand(-0.3, 0.3); // mostly downward 99 + break; 100 + case EDGE.BOTTOM: 101 + baseX = rand(margin, W - margin); 102 + baseY = H; 103 + angle = -Math.PI / 2 + rand(-0.3, 0.3); // mostly upward 104 + break; 105 + case EDGE.LEFT: 106 + baseX = 0; 107 + baseY = rand(margin, H - margin); 108 + angle = rand(-0.3, 0.3); // mostly rightward 109 + break; 110 + case EDGE.RIGHT: 111 + baseX = W; 112 + baseY = rand(margin, H - margin); 113 + angle = Math.PI + rand(-0.3, 0.3); // mostly leftward 114 + break; 115 + } 116 + 117 + // Each segment has a base angle offset and a wobble phase 118 + const segments = []; 119 + for (let i = 0; i < numSegments; i++) { 120 + segments.push({ 121 + length: segmentLength * (1 - i * 0.03), // taper slightly 122 + angleOffset: rand(-0.15, 0.15), // slight random curvature 123 + wobblePhase: rand(0, Math.PI * 2), // random initial phase 124 + wobbleFreq: rand(0.6, 1.4), // varied wobble frequency 125 + }); 126 + } 127 + 128 + const lingerDuration = rand(CONFIG.lingerDurationMin, CONFIG.lingerDurationMax); 129 + 130 + return { 131 + edge, 132 + baseX, 133 + baseY, 134 + baseAngle: angle, 135 + segments, 136 + thickness, 137 + color, 138 + numSegments, 139 + 140 + // Extension: 0 = fully retracted, 1 = fully extended 141 + extension: 0, 142 + targetExtension: 1, 143 + 144 + // Lifecycle phase: 'creeping', 'lingering', 'retreating', 'dead' 145 + phase: 'creeping', 146 + phaseTime: 0, 147 + lingerDuration, 148 + 149 + // Fear state 150 + afraid: false, 151 + fearTime: 0, 152 + 153 + // Wobble time accumulator 154 + wobbleTime: rand(0, 100), 155 + 156 + // Alpha 157 + alpha: 0, 158 + }; 159 + } 160 + 161 + /** 162 + * Compute the joint positions of a tentacle 163 + * Returns array of {x, y} points from base to tip 164 + */ 165 + function computePoints(t) { 166 + const points = [{ x: t.baseX, y: t.baseY }]; 167 + let currentAngle = t.baseAngle; 168 + let x = t.baseX; 169 + let y = t.baseY; 170 + 171 + // Only draw segments up to current extension 172 + const visibleSegments = Math.ceil(t.extension * t.numSegments); 173 + const partialFrac = (t.extension * t.numSegments) - Math.floor(t.extension * t.numSegments); 174 + 175 + for (let i = 0; i < visibleSegments && i < t.segments.length; i++) { 176 + const seg = t.segments[i]; 177 + 178 + // Wobble: sinusoidal oscillation that propagates down the tentacle 179 + const wobble = Math.sin(t.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.8) 180 + * CONFIG.wobbleAmplitude * (1 + i * 0.1); 181 + 182 + currentAngle += seg.angleOffset + wobble; 183 + 184 + // If this is the last visible segment and partial, shorten it 185 + let len = seg.length; 186 + if (i === visibleSegments - 1 && visibleSegments < t.numSegments) { 187 + len *= partialFrac || 1; 188 + } 189 + 190 + x += Math.cos(currentAngle) * len; 191 + y += Math.sin(currentAngle) * len; 192 + points.push({ x, y }); 193 + } 194 + 195 + return points; 196 + } 197 + 198 + /** 199 + * Draw a smooth tentacle through points using quadratic bezier curves 200 + */ 201 + function drawTentacle(t) { 202 + const points = computePoints(t); 203 + if (points.length < 2) return; 204 + 205 + const { r, g, b } = t.color; 206 + const alpha = t.alpha; 207 + 208 + // Draw the tentacle as a tapered, smooth path 209 + // We'll draw it as a filled shape with varying width 210 + ctx.save(); 211 + 212 + for (let i = 0; i < points.length - 1; i++) { 213 + const p0 = points[i]; 214 + const p1 = points[i + 1]; 215 + 216 + // Thickness tapers from base to tip 217 + const t0 = t.thickness * (1 - (i / points.length) * 0.7); 218 + const t1 = t.thickness * (1 - ((i + 1) / points.length) * 0.7); 219 + 220 + // Direction perpendicular to segment 221 + const dx = p1.x - p0.x; 222 + const dy = p1.y - p0.y; 223 + const len = Math.sqrt(dx * dx + dy * dy) || 1; 224 + const nx = -dy / len; 225 + const ny = dx / len; 226 + 227 + // Draw a quadrilateral for each segment 228 + ctx.beginPath(); 229 + ctx.moveTo(p0.x + nx * t0 / 2, p0.y + ny * t0 / 2); 230 + ctx.lineTo(p1.x + nx * t1 / 2, p1.y + ny * t1 / 2); 231 + ctx.lineTo(p1.x - nx * t1 / 2, p1.y - ny * t1 / 2); 232 + ctx.lineTo(p0.x - nx * t0 / 2, p0.y - ny * t0 / 2); 233 + ctx.closePath(); 234 + 235 + // Gradient alpha along the tentacle 236 + const segAlpha = alpha * (1 - (i / points.length) * 0.5); 237 + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${segAlpha})`; 238 + ctx.fill(); 239 + } 240 + 241 + // Draw a smooth overlay using bezier curves for the center line 242 + // This gives the "core" of the tentacle a richer look 243 + ctx.beginPath(); 244 + ctx.moveTo(points[0].x, points[0].y); 245 + 246 + if (points.length === 2) { 247 + ctx.lineTo(points[1].x, points[1].y); 248 + } else { 249 + // Smooth curve through points 250 + for (let i = 0; i < points.length - 1; i++) { 251 + const p0 = points[i]; 252 + const p1 = points[i + 1]; 253 + 254 + if (i === 0) { 255 + // First segment: quadratic to midpoint 256 + const midX = (p0.x + p1.x) / 2; 257 + const midY = (p0.y + p1.y) / 2; 258 + ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 259 + } else if (i === points.length - 2) { 260 + // Last segment: quadratic to end 261 + ctx.quadraticCurveTo(points[i].x, points[i].y, p1.x, p1.y); 262 + } else { 263 + // Middle segments: quadratic through midpoints 264 + const midX = (p0.x + p1.x) / 2; 265 + const midY = (p0.y + p1.y) / 2; 266 + ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 267 + } 268 + } 269 + } 270 + 271 + // Core line (slightly lighter, thinner) 272 + ctx.strokeStyle = `rgba(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)}, ${alpha * 0.6})`; 273 + ctx.lineWidth = Math.max(1, t.thickness * 0.3); 274 + ctx.lineCap = 'round'; 275 + ctx.lineJoin = 'round'; 276 + ctx.stroke(); 277 + 278 + // Tip: small circle at the end 279 + if (points.length >= 2) { 280 + const tip = points[points.length - 1]; 281 + const tipRadius = t.thickness * 0.2 * (1 - ((points.length - 1) / t.numSegments) * 0.5); 282 + ctx.beginPath(); 283 + ctx.arc(tip.x, tip.y, Math.max(1, tipRadius), 0, Math.PI * 2); 284 + ctx.fillStyle = `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, ${alpha * 0.8})`; 285 + ctx.fill(); 286 + } 287 + 288 + // Suckers/spots along the tentacle for extra organic feel 289 + for (let i = 1; i < points.length - 1; i += 2) { 290 + const p = points[i]; 291 + const spotSize = t.thickness * 0.12 * (1 - (i / points.length) * 0.5); 292 + ctx.beginPath(); 293 + ctx.arc(p.x, p.y, Math.max(0.5, spotSize), 0, Math.PI * 2); 294 + ctx.fillStyle = `rgba(${Math.max(0, r - 20)}, ${Math.max(0, g - 20)}, ${Math.max(0, b - 20)}, ${alpha * 0.4})`; 295 + ctx.fill(); 296 + } 297 + 298 + ctx.restore(); 299 + } 300 + 301 + /** 302 + * Check if the cursor is near any point of a tentacle 303 + */ 304 + function isCursorNear(t) { 305 + const points = computePoints(t); 306 + for (const p of points) { 307 + if (dist(mouseX, mouseY, p.x, p.y) < CONFIG.fearRadius) { 308 + return true; 309 + } 310 + } 311 + return false; 312 + } 313 + 314 + /** 315 + * Update a single tentacle's state 316 + */ 317 + function updateTentacle(t, dt) { 318 + // Advance wobble time 319 + t.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 320 + 321 + // Check for cursor proximity 322 + const cursorNear = isCursorNear(t); 323 + 324 + if (cursorNear && !t.afraid && t.phase !== 'dead') { 325 + // FEAR! Rapidly retract 326 + t.afraid = true; 327 + t.fearTime = 0; 328 + t.phase = 'fleeing'; 329 + t.targetExtension = 0; 330 + } 331 + 332 + // Update phase 333 + t.phaseTime += dt; 334 + 335 + switch (t.phase) { 336 + case 'creeping': 337 + // Slowly extend 338 + t.extension = clamp(t.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 339 + t.alpha = clamp(t.alpha + 0.01 * (dt / 16), 0, CONFIG.maxAlpha); 340 + 341 + if (t.extension >= 1) { 342 + t.phase = 'lingering'; 343 + t.phaseTime = 0; 344 + } 345 + break; 346 + 347 + case 'lingering': 348 + // Just wobble in place 349 + t.alpha = CONFIG.maxAlpha; 350 + if (t.phaseTime >= t.lingerDuration) { 351 + t.phase = 'retreating'; 352 + t.phaseTime = 0; 353 + t.targetExtension = 0; 354 + } 355 + break; 356 + 357 + case 'retreating': 358 + // Slowly retract 359 + t.extension = clamp(t.extension - CONFIG.retreatSpeed * (dt / 1000), 0, 1); 360 + t.alpha = clamp(t.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 361 + 362 + if (t.extension <= 0) { 363 + t.phase = 'dead'; 364 + } 365 + break; 366 + 367 + case 'fleeing': 368 + // Rapidly retract with increased wobble 369 + t.extension = clamp(t.extension - CONFIG.fleeSpeed * (dt / 1000), 0, 1); 370 + t.alpha = clamp(t.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 371 + 372 + // Extra frantic wobble when afraid 373 + t.wobbleTime += CONFIG.wobbleSpeed * 3 * (dt / 16); 374 + 375 + if (t.extension <= 0) { 376 + t.phase = 'dead'; 377 + } 378 + break; 379 + } 380 + } 381 + 382 + /** 383 + * Main animation loop 384 + */ 385 + function animate(timestamp) { 386 + const dt = lastTime ? Math.min(timestamp - lastTime, 100) : 16; // cap dt to avoid jumps 387 + lastTime = timestamp; 388 + 389 + // Clear canvas 390 + ctx.clearRect(0, 0, canvas.width, canvas.height); 391 + 392 + // Update and draw all tentacles 393 + for (const t of tentacles) { 394 + updateTentacle(t, dt); 395 + if (t.phase !== 'dead') { 396 + drawTentacle(t); 397 + } 398 + } 399 + 400 + // Remove dead tentacles 401 + tentacles = tentacles.filter(t => t.phase !== 'dead'); 402 + 403 + animationId = requestAnimationFrame(animate); 404 + } 405 + 406 + /** 407 + * Spawn a new tentacle if under the limit 408 + */ 409 + function spawnTentacle() { 410 + if (tentacles.length < CONFIG.maxTentacles) { 411 + tentacles.push(createTentacle()); 412 + console.log('[tentacles] Spawned tentacle, total:', tentacles.length); 413 + } 414 + scheduleNextSpawn(); 415 + } 416 + 417 + /** 418 + * Schedule the next tentacle spawn 419 + */ 420 + function scheduleNextSpawn() { 421 + const delay = rand(CONFIG.spawnIntervalMin, CONFIG.spawnIntervalMax); 422 + spawnTimer = setTimeout(spawnTentacle, delay); 423 + } 424 + 425 + /** 426 + * Resize canvas to fill the window 427 + */ 428 + function resizeCanvas() { 429 + const dpr = window.devicePixelRatio || 1; 430 + canvas.width = window.innerWidth * dpr; 431 + canvas.height = window.innerHeight * dpr; 432 + canvas.style.width = window.innerWidth + 'px'; 433 + canvas.style.height = window.innerHeight + 'px'; 434 + ctx.scale(dpr, dpr); 435 + } 436 + 437 + /** 438 + * Initialize 439 + */ 440 + function init() { 441 + console.log('[tentacles] Initializing canvas'); 442 + resizeCanvas(); 443 + window.addEventListener('resize', resizeCanvas); 444 + 445 + // Track mouse position (forwarded even though window is click-through) 446 + document.addEventListener('mousemove', (e) => { 447 + mouseX = e.clientX; 448 + mouseY = e.clientY; 449 + }); 450 + 451 + // Also handle mouse leaving the window 452 + document.addEventListener('mouseleave', () => { 453 + mouseX = -1000; 454 + mouseY = -1000; 455 + }); 456 + 457 + // Start animation loop 458 + animationId = requestAnimationFrame(animate); 459 + 460 + // Spawn first tentacle after a short delay, then schedule periodic spawns 461 + setTimeout(() => { 462 + spawnTentacle(); 463 + }, 3000); 464 + 465 + console.log('[tentacles] Animation started'); 466 + } 467 + 468 + init();