[object Object]

← back to Games Agentabrams

Arcade: add Starling build + per-game icon + open-in-own-window pop-out

f7583fecdae48389520314c027dfe260c6efc4bc · 2026-07-24 23:59:50 -0700 · Steve

Files touched

Diff

commit f7583fecdae48389520314c027dfe260c6efc4bc
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:59:50 2026 -0700

    Arcade: add Starling build + per-game icon + open-in-own-window pop-out
---
 games.json                               |   9 +
 games/particle-text-starling/Starling.aa |   7 +
 games/particle-text-starling/index.html  | 395 +++++++++++++++++++++++++++++++
 index.html                               |  49 +++-
 4 files changed, 455 insertions(+), 5 deletions(-)

diff --git a/games.json b/games.json
index 385ca3f..74f14d5 100644
--- a/games.json
+++ b/games.json
@@ -88,6 +88,15 @@
       "path": "games/particle-text/",
       "added": "2026-07-24"
     },
+    {
+      "id": "particle-text-starling",
+      "file": "Starling.aa",
+      "icon": "🐦",
+      "title": "Starling",
+      "desc": "Type a word or phrase and thousands of particles murmurate into it — sweep the mouse to scatter, watch them reform, click for a burst. 5 color themes, live typing, multi-line wrapping. Pure Canvas 2D, zero dependencies.",
+      "path": "games/particle-text-starling/",
+      "added": "2026-07-24"
+    },
     {
       "id": "rubiks-cube",
       "file": "AbramsCube.aa",
diff --git a/games/particle-text-starling/Starling.aa b/games/particle-text-starling/Starling.aa
new file mode 100644
index 0000000..f247949
--- /dev/null
+++ b/games/particle-text-starling/Starling.aa
@@ -0,0 +1,7 @@
+name: Starling
+build: particle-text
+title: Free-Roster — Particle Text
+tagline: Thousands of particles murmurate into text — scatter on hover, reform, burst on click.
+category: Games
+entry: index.html
+created: 2026-07-24
diff --git a/games/particle-text-starling/index.html b/games/particle-text-starling/index.html
new file mode 100644
index 0000000..0c4f92a
--- /dev/null
+++ b/games/particle-text-starling/index.html
@@ -0,0 +1,395 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Particle Text</title>
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  html, body { height: 100%; overflow: hidden; background: #05060a; }
+  body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+    color: #e8ecf4;
+    -webkit-user-select: none; user-select: none;
+    touch-action: none;
+  }
+  #stage { position: fixed; inset: 0; }
+  canvas { display: block; width: 100%; height: 100%; }
+
+  .ui {
+    position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
+    display: flex; gap: 8px; align-items: center;
+    padding: 9px 10px;
+    background: rgba(18, 22, 34, 0.55);
+    border: 1px solid rgba(255,255,255,0.10);
+    border-radius: 16px;
+    backdrop-filter: blur(14px);
+    -webkit-backdrop-filter: blur(14px);
+    box-shadow: 0 12px 40px rgba(0,0,0,0.45);
+    z-index: 10;
+    max-width: calc(100vw - 20px);
+    flex-wrap: wrap; justify-content: center;
+  }
+  .ui input[type="text"] {
+    width: min(46vw, 300px);
+    padding: 11px 14px;
+    font-size: 15px;
+    color: #fff;
+    background: rgba(255,255,255,0.06);
+    border: 1px solid rgba(255,255,255,0.12);
+    border-radius: 11px;
+    outline: none;
+    transition: border-color .2s, background .2s;
+  }
+  .ui input[type="text"]:focus {
+    border-color: rgba(120,180,255,0.6);
+    background: rgba(255,255,255,0.10);
+  }
+  .ui input::placeholder { color: rgba(255,255,255,0.35); }
+
+  .btn {
+    padding: 11px 16px;
+    font-size: 14px; font-weight: 600;
+    color: #071018;
+    background: linear-gradient(135deg, #8fd3ff, #b7a6ff);
+    border: none; border-radius: 11px;
+    cursor: pointer;
+    transition: transform .12s, filter .2s;
+    white-space: nowrap;
+  }
+  .btn:hover { filter: brightness(1.08); }
+  .btn:active { transform: scale(0.95); }
+  .btn.ghost {
+    color: #cdd6e6;
+    background: rgba(255,255,255,0.07);
+    border: 1px solid rgba(255,255,255,0.12);
+    font-weight: 500;
+  }
+  .btn.ghost:hover { background: rgba(255,255,255,0.13); }
+
+  .hint {
+    position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
+    font-size: 12.5px; letter-spacing: .3px;
+    color: rgba(255,255,255,0.42);
+    z-index: 10; text-align: center; pointer-events: none;
+    width: 100%; padding: 0 12px;
+    transition: opacity .6s;
+  }
+  .badge {
+    position: fixed; top: 18px; right: 16px;
+    font-size: 11px; font-variant-numeric: tabular-nums;
+    color: rgba(255,255,255,0.30);
+    z-index: 10; pointer-events: none;
+    letter-spacing: .4px;
+  }
+</style>
+</head>
+<body>
+  <div id="stage"><canvas id="c"></canvas></div>
+  <div class="hint" id="hint">Type a word &middot; move through the letters to scatter &middot; click for a burst</div>
+  <div class="badge" id="badge"></div>
+  <div class="ui">
+    <input id="word" type="text" value="HELLO" maxlength="24" spellcheck="false" autocomplete="off" placeholder="type a word…">
+    <button class="btn" id="go">Form</button>
+    <button class="btn ghost" id="theme" title="Cycle color theme">Theme</button>
+    <button class="btn ghost" id="rand" title="Random word">Shuffle</button>
+  </div>
+
+<script>
+(function () {
+  "use strict";
+
+  var canvas = document.getElementById("c");
+  var ctx = canvas.getContext("2d", { alpha: false });
+  var input = document.getElementById("word");
+  var goBtn = document.getElementById("go");
+  var themeBtn = document.getElementById("theme");
+  var randBtn = document.getElementById("rand");
+  var hintEl = document.getElementById("hint");
+  var badgeEl = document.getElementById("badge");
+
+  var reduceMotion = window.matchMedia &&
+    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+  var DPR = Math.min(window.devicePixelRatio || 1, 2);
+  var W = 0, H = 0;
+  var particles = [];
+  var mouse = { x: -9999, y: -9999, active: false, r: 90 };
+
+  // Offscreen canvas for sampling text pixels
+  var sampler = document.createElement("canvas");
+  var sctx = sampler.getContext("2d", { willReadFrequently: true });
+
+  // ---- color themes: t is normalized 0..1 across the text width ----
+  var themes = [
+    { name: "Aurora",  fn: function (t) { return "hsl(" + (188 + t * 96) + ",90%,66%)"; } },
+    { name: "Sunset",  fn: function (t) { return "hsl(" + (8 + t * 52) + ",95%,62%)"; } },
+    { name: "Neon",    fn: function (t) { return "hsl(" + (t * 320) + ",92%,63%)"; } },
+    { name: "Emerald", fn: function (t) { return "hsl(" + (135 + t * 60) + ",80%,58%)"; } },
+    { name: "Ice",     fn: function (t) { return "hsl(205,70%," + (62 + t * 26) + "%)"; } }
+  ];
+  var themeIdx = 0;
+
+  var WORDS = ["HELLO", "PARTICLES", "CANVAS", "MAGIC", "REFORM", "SCATTER",
+               "GALAXY", "AURORA", "TYPE ME", "PHYSICS", "SWARM", "GLOW"];
+
+  var FONT = "800 %spx -apple-system, 'Segoe UI', Roboto, Arial, sans-serif";
+
+  function resize() {
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width = Math.floor(W * DPR);
+    canvas.height = Math.floor(H * DPR);
+    canvas.style.width = W + "px";
+    canvas.style.height = H + "px";
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    mouse.r = Math.max(70, Math.min(W, H) * 0.11);
+  }
+
+  // Greedy word-wrap at a given font size (font must already be set on sctx)
+  function wrapLines(words, maxW) {
+    var lines = [];
+    var cur = "";
+    for (var i = 0; i < words.length; i++) {
+      var test = cur ? cur + " " + words[i] : words[i];
+      if (cur && sctx.measureText(test).width > maxW) {
+        lines.push(cur);
+        cur = words[i];
+      } else {
+        cur = test;
+      }
+    }
+    if (cur) lines.push(cur);
+    return lines;
+  }
+
+  // Fit text: choose the largest size where wrapped lines fit the box.
+  function fitText(text, maxW, maxH) {
+    var words = text.split(/\s+/).filter(Boolean);
+    if (!words.length) words = [text];
+    var size = Math.min(H * 0.30, 340);
+    var minSize = 24;
+    var best = null;
+    while (size >= minSize) {
+      sctx.font = FONT.replace("%s", size);
+      var lines = wrapLines(words, maxW);
+      var widest = 0;
+      for (var i = 0; i < lines.length; i++) {
+        var w = sctx.measureText(lines[i]).width;
+        if (w > widest) widest = w;
+      }
+      var lineH = size * 1.08;
+      var totalH = lines.length * lineH;
+      if (widest <= maxW && totalH <= maxH) {
+        best = { size: size, lines: lines, lineH: lineH };
+        break;
+      }
+      size -= 6;
+    }
+    if (!best) {
+      size = minSize;
+      sctx.font = FONT.replace("%s", size);
+      best = { size: size, lines: wrapLines(words, maxW), lineH: size * 1.08 };
+    }
+    return best;
+  }
+
+  // Sample target points from rendered (possibly multi-line) text
+  function targetsForText(text) {
+    text = (text || "").trim();
+    if (!text) return [];
+
+    sampler.width = W;
+    sampler.height = H;
+    sctx.clearRect(0, 0, W, H);
+    sctx.fillStyle = "#fff";
+    sctx.textAlign = "center";
+    sctx.textBaseline = "middle";
+
+    var fit = fitText(text, W * 0.86, H * 0.60);
+    sctx.font = FONT.replace("%s", fit.size);
+
+    var n = fit.lines.length;
+    var blockH = n * fit.lineH;
+    var startY = H / 2 - blockH / 2 + fit.lineH / 2;
+    for (var i = 0; i < n; i++) {
+      sctx.fillText(fit.lines[i], W / 2, startY + i * fit.lineH);
+    }
+
+    var img = sctx.getImageData(0, 0, W, H).data;
+    // gap scales so particle count stays in a pleasing few-thousand range
+    var area = W * H;
+    var gap = area > 2200000 ? 5 : (area > 1100000 ? 4 : 3);
+    var pts = [];
+    for (var y = 0; y < H; y += gap) {
+      for (var x = 0; x < W; x += gap) {
+        if (img[(y * W + x) * 4 + 3] > 128) {
+          // slight jitter so the grid never looks mechanical
+          pts.push({ x: x + (Math.random() - 0.5) * gap, y: y + (Math.random() - 0.5) * gap });
+        }
+      }
+    }
+    return pts;
+  }
+
+  function color(t) { return themes[themeIdx].fn(Math.max(0, Math.min(1, t))); }
+
+  function build(text) {
+    var pts = targetsForText(text);
+
+    // shuffle for organic fly-in
+    for (var i = pts.length - 1; i > 0; i--) {
+      var j = (Math.random() * (i + 1)) | 0;
+      var tmp = pts[i]; pts[i] = pts[j]; pts[j] = tmp;
+    }
+
+    var need = pts.length;
+    while (particles.length < need) {
+      particles.push({
+        x: Math.random() * W, y: Math.random() * H,
+        vx: 0, vy: 0, tx: 0, ty: 0,
+        size: 1.6, color: "#8fd3ff", twk: Math.random() * 6.283
+      });
+    }
+    if (particles.length > need) particles.length = need;
+
+    for (var k = 0; k < need; k++) {
+      var p = particles[k];
+      p.tx = pts[k].x;
+      p.ty = pts[k].y;
+      p.color = color(pts[k].x / W);
+      p.size = 1.4 + Math.random() * 1.7;
+      // a gentle inward kick so formation reads as a "fly-in"
+      p.vx += (Math.random() - 0.5) * 2;
+      p.vy += (Math.random() - 0.5) * 2;
+    }
+
+    badgeEl.textContent = need.toLocaleString() + " particles · " + themes[themeIdx].name;
+  }
+
+  // click / tap = radial burst impulse from the point
+  function burst(x, y) {
+    var R = Math.min(W, H) * 0.45, R2 = R * R;
+    for (var i = 0; i < particles.length; i++) {
+      var p = particles[i];
+      var dx = p.x - x, dy = p.y - y;
+      var d2 = dx * dx + dy * dy;
+      if (d2 < R2) {
+        var d = Math.sqrt(d2) || 1;
+        var f = (1 - d / R) * 22;
+        p.vx += (dx / d) * f;
+        p.vy += (dy / d) * f;
+      }
+    }
+  }
+
+  function tick() {
+    // fade trail
+    ctx.globalCompositeOperation = "source-over";
+    ctx.fillStyle = "rgba(5, 6, 10, 0.30)";
+    ctx.fillRect(0, 0, W, H);
+
+    // additive glow for the particles
+    ctx.globalCompositeOperation = "lighter";
+
+    var mr = mouse.r, mr2 = mr * mr;
+    var spring = reduceMotion ? 0.10 : 0.020;
+    var friction = reduceMotion ? 0.75 : 0.88;
+
+    for (var i = 0; i < particles.length; i++) {
+      var p = particles[i];
+
+      var dx = p.tx - p.x, dy = p.ty - p.y;
+      p.vx += dx * spring;
+      p.vy += dy * spring;
+
+      if (mouse.active) {
+        var mx = p.x - mouse.x, my = p.y - mouse.y;
+        var d2 = mx * mx + my * my;
+        if (d2 < mr2 && d2 > 0.0001) {
+          var d = Math.sqrt(d2);
+          var force = (mr - d) / mr;
+          var f = force * 5.4;
+          p.vx += (mx / d) * f;
+          p.vy += (my / d) * f;
+        }
+      }
+
+      p.vx *= friction;
+      p.vy *= friction;
+      p.x += p.vx;
+      p.y += p.vy;
+
+      // subtle twinkle in size when at rest
+      var s = p.size;
+      if (!reduceMotion) {
+        p.twk += 0.05;
+        s += Math.sin(p.twk) * 0.25;
+      }
+
+      ctx.fillStyle = p.color;
+      ctx.beginPath();
+      ctx.arc(p.x, p.y, s, 0, 6.2832);
+      ctx.fill();
+    }
+
+    requestAnimationFrame(tick);
+  }
+
+  // ---- events ----
+  function moveHandler(x, y) { mouse.x = x; mouse.y = y; mouse.active = true; }
+  window.addEventListener("mousemove", function (e) { moveHandler(e.clientX, e.clientY); });
+  window.addEventListener("mouseout", function () { mouse.active = false; });
+  window.addEventListener("touchmove", function (e) {
+    if (e.touches[0]) moveHandler(e.touches[0].clientX, e.touches[0].clientY);
+  }, { passive: true });
+  window.addEventListener("touchend", function () { mouse.active = false; });
+
+  // burst on click (ignore clicks on the UI)
+  function isUI(t) { return t && t.closest && t.closest(".ui"); }
+  window.addEventListener("pointerdown", function (e) {
+    if (isUI(e.target)) return;
+    burst(e.clientX, e.clientY);
+  });
+
+  var resizeTimer;
+  window.addEventListener("resize", function () {
+    clearTimeout(resizeTimer);
+    resizeTimer = setTimeout(function () { resize(); build(input.value); }, 150);
+  });
+
+  // live rebuild while typing (debounced)
+  var typeTimer;
+  input.addEventListener("input", function () {
+    clearTimeout(typeTimer);
+    typeTimer = setTimeout(function () { build(input.value); }, 260);
+  });
+  input.addEventListener("keydown", function (e) {
+    if (e.key === "Enter") { clearTimeout(typeTimer); build(input.value); input.blur(); }
+  });
+
+  goBtn.addEventListener("click", function () { build(input.value); input.blur(); });
+  themeBtn.addEventListener("click", function () {
+    themeIdx = (themeIdx + 1) % themes.length;
+    build(input.value);
+  });
+  randBtn.addEventListener("click", function () {
+    var w;
+    do { w = WORDS[(Math.random() * WORDS.length) | 0]; } while (w === input.value);
+    input.value = w;
+    build(w);
+  });
+
+  // fade the hint after a bit
+  setTimeout(function () { hintEl.style.opacity = "0.35"; }, 6000);
+
+  // ---- init ----
+  resize();
+  ctx.fillStyle = "#05060a";
+  ctx.fillRect(0, 0, W, H);
+  build(input.value);
+  requestAnimationFrame(tick);
+})();
+</script>
+</body>
+</html>
diff --git a/index.html b/index.html
index 55a7e43..ded0dad 100644
--- a/index.html
+++ b/index.html
@@ -28,18 +28,36 @@
   #list { flex: 1; overflow-y: auto; padding: 10px 8px; }
   #list .dir { font-size: 10px; opacity: .4; letter-spacing: 1px; padding: 4px 8px 8px; }
   .game {
-    display: block; width: 100%; text-align: left; cursor: pointer;
+    display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left; cursor: pointer;
     background: none; border: 1px solid transparent; border-radius: 8px;
     color: #e8e9ef; font: inherit; padding: 9px 10px; margin-bottom: 2px;
   }
   .game:hover { background: #222839; }
   .game.active { background: #2a3147; border-color: #3d4763; }
+  .game .ficon { font-size: 16px; line-height: 1.25; flex: 0 0 auto; width: 20px; text-align: center; }
+  .game .fbody { flex: 1; min-width: 0; }
   .game .fname { font-size: 13px; font-weight: 600; }
-  .game .fname::before { content: "▸ "; color: #7c5cff; }
-  .game.active .fname::before { content: "▾ "; }
+  .game.active .fname::before { content: "▾ "; color: #7c5cff; }
   .game .fdesc { font-size: 10.5px; opacity: .5; margin-top: 3px; line-height: 1.4;
     display: none; }
   .game.active .fdesc { display: block; }
+  /* open-in-own-window pop-out button */
+  .game .popout {
+    flex: 0 0 auto; align-self: center; cursor: pointer;
+    font-size: 13px; line-height: 1; color: #8a93a8; opacity: 0;
+    background: none; border: 1px solid transparent; border-radius: 6px; padding: 4px 6px;
+    transition: opacity .15s, background .15s, color .15s;
+  }
+  .game:hover .popout, .game.active .popout { opacity: .8; }
+  .game .popout:hover { opacity: 1; color: #ffd23e; background: #1b2030; border-color: #3d4763; }
+  /* stage pop-out for the current game */
+  #popCurrent {
+    position: absolute; top: 12px; right: 12px; z-index: 5; cursor: pointer;
+    font-family: inherit; font-size: 11px; letter-spacing: .5px; color: #cdd3e0;
+    background: rgba(20,23,31,.72); border: 1px solid #2b3140; border-radius: 8px;
+    padding: 7px 11px; backdrop-filter: blur(6px); display: none;
+  }
+  #popCurrent:hover { color: #ffd23e; border-color: #3d4763; }
   #panel footer {
     padding: 12px 16px; border-top: 1px solid #2b3140; font-size: 10px; opacity: .4;
     letter-spacing: .5px; line-height: 1.6;
@@ -129,6 +147,7 @@
   <footer>zero dependencies · pure browser<br>more games landing over time</footer>
 </aside>
 <main id="stage">
+  <button id="popCurrent" title="Open this game in its own window">⧉ own window</button>
   <iframe id="frame" title="game" hidden></iframe>
   <div id="empty"><div class="big">🕹</div><div>select a game</div></div>
 </main>
@@ -137,13 +156,25 @@
   var list = document.getElementById('list');
   var frame = document.getElementById('frame');
   var empty = document.getElementById('empty');
-  var games = [], buttons = {};
+  var popCurrent = document.getElementById('popCurrent');
+  var games = [], buttons = {}, DEFAULT_ICON = '🎮';
+
+  // open a game in its OWN browser window (not the iframe)
+  function openWindow(g) {
+    if (!g) return;
+    var w = Math.min(1200, Math.round(screen.availWidth * 0.8));
+    var h = Math.min(820, Math.round(screen.availHeight * 0.85));
+    var feat = 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no,status=no';
+    window.open(g.path + 'index.html', 'aa_' + g.id, feat);
+  }
 
   function select(id, push) {
     var g = games.find(function (x) { return x.id === id; });
     if (!g) return;
     Object.keys(buttons).forEach(function (k) { buttons[k].classList.toggle('active', k === id); });
     frame.hidden = false; empty.style.display = 'none';
+    popCurrent.style.display = 'block';
+    popCurrent.onclick = function () { openWindow(g); };
     // keyboard-primary games (e.g. Skyfire) need the iframe focused or key events
     // never reach them — setting src alone does NOT focus an iframe.
     frame.onload = function () { try { frame.focus(); if (frame.contentWindow) frame.contentWindow.focus(); } catch (e) {} };
@@ -159,10 +190,18 @@
     games.forEach(function (g) {
       var b = document.createElement('button');
       b.className = 'game';
-      b.innerHTML = '<div class="fname"></div><div class="fdesc"></div>';
+      b.innerHTML = '<span class="ficon"></span>' +
+                    '<span class="fbody"><div class="fname"></div><div class="fdesc"></div></span>' +
+                    '<span class="popout" role="button" tabindex="0" title="Open in its own window">⧉</span>';
+      b.querySelector('.ficon').textContent = g.icon || DEFAULT_ICON;
       b.querySelector('.fname').textContent = g.file;
       b.querySelector('.fdesc').textContent = g.desc || '';
       b.onclick = function () { select(g.id); };
+      var pop = b.querySelector('.popout');
+      pop.onclick = function (e) { e.stopPropagation(); openWindow(g); };
+      pop.onkeydown = function (e) {
+        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); openWindow(g); }
+      };
       list.appendChild(b);
       buttons[g.id] = b;
     });

← 36c7d11 games.aa: upgrade Emoji Memory to the richer free-roster bui  ·  back to Games Agentabrams  ·  Arcade: sync.sh + sources.tsv — reconcile standalone source b8cd4c8 →