11

Copper Bars

drag a bar to tear it, click outside to swap palette

An homage to the Amiga **copper list** — the per-scanline coprocessor that let demo coders change background colour every horizontal raster, painting those signature smooth vertical gradient bands across the screen. Here, eight bars bounce on independent phase envelopes , where the absolute-value term gives the classic bounce-against-the-edge feel and the slower second term breaks the V-shape into something organic. Each bar is drawn as a vertical gradient (`createLinearGradient`) running dark → saturated peak → dark, plus a 1px specular highlight at the peak centre with a 10px white halo for that scanline-burn glow. The whole stack composites with `globalCompositeOperation = 'lighter'` so overlapping bars sum into bright halations. Drag a bar vertically to **tear** its position — the offset is governed by an under-damped spring with , (damping ratio ), so on release it snaps back with a satisfying bounce. Click on the black background to cycle four palettes: **Lotus II red** (the racing-game fire stripe), **Shadow of the Beast purple** (Reflections' fever-dream), **Workbench grey-blue** (the Amiga OS itself), and a **Rasta** red-yellow-green. A drifting starfield scrolls behind everything because no '80s demo was complete without one.

idle
212 lines · vanilla
view source
// Copper Bars — Amiga copper-style horizontal raster bars bouncing vertically
// with bouncy per-bar phase offsets. Each bar is a vertical gradient (dark ->
// bright peak -> dark) with a 1px specular highlight at the peak center.
//
// Interaction:
//   - Drag a bar vertically to "tear" its phase (adds a temporary offset that
//     follows the cursor). On release the offset eases back to zero, giving a
//     springy snap.
//   - Click outside any bar to cycle palettes (Lotus II red, Shadow of the
//     Beast purple, Workbench grey-blue, Rasta).
//
// All math runs per-bar (8 bars), drawing is a handful of gradient fills per
// frame, no per-pixel work. Trivially 60fps. No DOM.

const NUM_BARS = 8;
const BAR_HEIGHT = 58;          // px tall per bar
const STAR_COUNT = 90;
const TAU = 6.283185307179586;

// --- Palettes ------------------------------------------------------------
// Each palette is an array of [r,g,b] peak colours; bars cycle through them
// modulo length. Dark stops are derived per-bar by scaling.
const PALETTES = [
  {
    name: 'Lotus II red',
    peaks: [
      [255, 60, 30],
      [255, 130, 30],
      [255, 200, 60],
      [255, 240, 130],
      [255, 90, 50],
      [255, 170, 40],
    ],
  },
  {
    name: 'Shadow of the Beast purple',
    peaks: [
      [120, 60, 220],
      [180, 80, 230],
      [230, 100, 220],
      [255, 140, 230],
      [110, 90, 255],
      [200, 120, 255],
    ],
  },
  {
    name: 'Workbench grey-blue',
    peaks: [
      [180, 200, 220],
      [120, 160, 200],
      [90,  140, 190],
      [200, 220, 235],
      [110, 150, 195],
      [150, 180, 210],
    ],
  },
  {
    name: 'Rasta',
    peaks: [
      [220, 30, 30],
      [255, 90, 40],
      [255, 220, 60],
      [255, 240, 120],
      [40,  180, 70],
      [80,  220, 90],
    ],
  },
];

let W = 0, H = 0;
let bars;                       // [{ phase, omega, color, tear, tearVel, dragOffset }]
let stars;                      // Float32Array [x,y,bright,twinkle]
let paletteIdx = 0;
let dragging = null;            // bar index currently being dragged, or null
let dragStartY = 0;
let dragStartTear = 0;
let paletteFlash = 0;           // seconds left of palette-changed flash

function rebuildBarColours() {
  const pal = PALETTES[paletteIdx].peaks;
  for (let i = 0; i < bars.length; i++) {
    const c = pal[i % pal.length];
    bars[i].color = c;
  }
}

function init({ width, height }) {
  W = width; H = height;

  bars = [];
  for (let i = 0; i < NUM_BARS; i++) {
    bars.push({
      phase: (i / NUM_BARS) * TAU,
      // each bar bounces at a slightly different rate so they fan out
      omega: 0.55 + 0.10 * i + 0.04 * Math.sin(i * 1.3),
      // amplitude: how far it travels from centre (in fraction of half-H)
      amp: 0.30 + 0.06 * ((i * 7) % 5) / 5,
      color: [255, 255, 255],
      tear: 0,        // phase offset added by user dragging
      tearVel: 0,     // for spring-back when released
    });
  }
  rebuildBarColours();

  // starfield — faint white dots, scrolling slowly leftward for parallax
  stars = new Float32Array(STAR_COUNT * 4);
  for (let i = 0; i < STAR_COUNT; i++) {
    stars[i * 4 + 0] = Math.random() * W;
    stars[i * 4 + 1] = Math.random() * H;
    stars[i * 4 + 2] = 0.25 + Math.random() * 0.55;  // brightness
    stars[i * 4 + 3] = Math.random() * TAU;          // twinkle phase
  }
}

// Locate the centre-y of a bar at time `t`, ignoring its tear offset. We
// reuse this for hit-testing and rendering.
function barY(b, t) {
  // Classic Amiga "bounce off the edges" envelope: |sin| with phase per bar.
  // Multiply by amp * (H/2) and centre, so bars span the full vertical range.
  const env = Math.abs(Math.sin(t * b.omega * 0.45 + b.phase));
  // Blend with a slower sine to break the strict V-shaped corners and give
  // a more organic feel; the |sin| dominates so we keep the bouncy floor/ceiling.
  const drift = 0.5 + 0.5 * Math.sin(t * b.omega * 0.18 + b.phase * 1.7);
  const mix = 0.78 * env + 0.22 * drift;
  // amp .30..36 maps to ~28..68% of half-height; ensure we stay inside frame
  const halfRange = (H * 0.5) - BAR_HEIGHT * 0.5 - 6;
  const y = (H * 0.5) + (mix * 2 - 1) * halfRange * b.amp * 2.2;
  // clamp so the bar is always fully on screen
  const minY = BAR_HEIGHT * 0.5 + 4;
  const maxY = H - BAR_HEIGHT * 0.5 - 4;
  if (y < minY) return minY;
  if (y > maxY) return maxY;
  return y;
}

// User drag adds `tear` to the bar's vertical position directly. We store it
// as a y-pixel offset rather than a phase shift so dragging feels 1:1 with
// the cursor; on release, we spring it back to zero.
function effectiveBarY(b, t) {
  const base = barY(b, t);
  return base + b.tear;
}

function findBarAt(px, py, t) {
  // Hit-test bars in reverse drawing order (top-most first). Bars are drawn
  // back-to-front below; "top-most" visually is roughly the last drawn one.
  for (let i = bars.length - 1; i >= 0; i--) {
    const cy = effectiveBarY(bars[i], t);
    if (py >= cy - BAR_HEIGHT * 0.5 && py <= cy + BAR_HEIGHT * 0.5) {
      // also require the cursor to be within the canvas horizontally (always true
      // since bars span the full width, but keep the check explicit)
      if (px >= 0 && px <= W) return i;
    }
  }
  return -1;
}

function cyclePalette() {
  paletteIdx = (paletteIdx + 1) % PALETTES.length;
  rebuildBarColours();
  paletteFlash = 1.6;
}

function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  if (dt > 0.05) dt = 0.05;

  // ---- handle pointer input ----
  const mx = input.mouseX, my = input.mouseY;
  const clicks = input.consumeClicks();
  // Begin drag on mousedown (we detect transitions by checking if dragging is
  // null but mouseDown is true AND the cursor is over a bar). Worker `input`
  // doesn't expose mousedown-edge directly, so we approximate: when mouseDown
  // and not yet dragging, try to grab; when not mouseDown and dragging, release.
  if (input.mouseDown) {
    if (dragging === null) {
      const hit = findBarAt(mx, my, time);
      if (hit >= 0) {
        dragging = hit;
        dragStartY = my;
        dragStartTear = bars[hit].tear;
      } else {
        // mark as -1 to suppress repeated probe-and-fail every frame; we
        // reset on mouseup
        dragging = -1;
      }
    } else if (dragging >= 0) {
      // update tear so the bar's effective centre tracks the cursor delta
      bars[dragging].tear = dragStartTear + (my - dragStartY);
      bars[dragging].tearVel = 0;     // hold steady while dragging
    }
  } else if (dragging !== null) {
    // released — if we had a real bar grabbed, give the spring an initial
    // velocity so the snap-back feels bouncy
    if (dragging >= 0) {
      // small initial recoil proportional to how far we tore
      bars[dragging].tearVel = -bars[dragging].tear * 1.4;
    }
    dragging = null;
  }

  // Clicks that didn't start a drag cycle the palette. Distinguish from
  // drags by checking the dragging state at click time: if mouseDown is false
  // when the click arrives and we were never on a bar (or the user clicked
  // empty space without moving), cycle.
  for (let i = 0; i < clicks.length; i++) {
    const c = clicks[i];
    const overBar = findBarAt(c.x, c.y, time);
    if (overBar < 0) cyclePalette();
  }

  // ---- physics: spring tear back to zero when not dragging ----
  // Critically-damped-ish: k chosen for ~0.6s settle, with mild overshoot for
  // that bouncy feel (under-damped: zeta ~ 0.5).
  const k = 38;
  const damping = 6.5;
  for (let i = 0; i < bars.length; i++) {
    if (dragging === i) continue;
    const b = bars[i];
    if (b.tear !== 0 || b.tearVel !== 0) {
      const a = -k * b.tear - damping * b.tearVel;
      b.tearVel += a * dt;
      b.tear += b.tearVel * dt;
      if (Math.abs(b.tear) < 0.05 && Math.abs(b.tearVel) < 0.05) {
        b.tear = 0; b.tearVel = 0;
      }
    }
  }

  // ---- draw ----
  // Black background. (No motion blur — copper bars look crispest cleared.)
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, W, H);

  // starfield: scroll slowly leftward, twinkle on per-star sinusoid.
  for (let i = 0; i < STAR_COUNT; i++) {
    const o = i * 4;
    stars[o] -= dt * 8 * stars[o + 2];   // bright stars move faster
    if (stars[o] < -2) stars[o] += W + 4;
    const tw = 0.5 + 0.5 * Math.sin(time * 1.7 + stars[o + 3]);
    const a = stars[o + 2] * (0.35 + 0.45 * tw);
    ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
    ctx.fillRect(stars[o] | 0, stars[o + 1] | 0, 1, 1);
  }

  // bars — additive blend so overlaps glow, very Amiga.
  ctx.globalCompositeOperation = 'lighter';
  for (let i = 0; i < bars.length; i++) {
    const b = bars[i];
    const cy = effectiveBarY(b, time);
    const top = cy - BAR_HEIGHT * 0.5;
    const bot = cy + BAR_HEIGHT * 0.5;
    const [r, g, bl] = b.color;

    // vertical gradient: black -> peak -> black. We bake the colour with a
    // single createLinearGradient per bar per frame — cheap enough at 8 bars.
    const lg = ctx.createLinearGradient(0, top, 0, bot);
    lg.addColorStop(0.00, 'rgba(0,0,0,0)');
    lg.addColorStop(0.18, `rgba(${(r * 0.25) | 0},${(g * 0.25) | 0},${(bl * 0.25) | 0},0.55)`);
    lg.addColorStop(0.50, `rgba(${r},${g},${bl},0.92)`);
    lg.addColorStop(0.82, `rgba(${(r * 0.25) | 0},${(g * 0.25) | 0},${(bl * 0.25) | 0},0.55)`);
    lg.addColorStop(1.00, 'rgba(0,0,0,0)');
    ctx.fillStyle = lg;
    ctx.fillRect(0, top, W, BAR_HEIGHT);

    // specular highlight: a single near-white 1px line at peak centre, with
    // a faint glow halo above/below for the "scanline burn" look.
    const halo = ctx.createLinearGradient(0, cy - 5, 0, cy + 5);
    halo.addColorStop(0,   'rgba(255,255,255,0)');
    halo.addColorStop(0.5, `rgba(255,255,255,0.55)`);
    halo.addColorStop(1,   'rgba(255,255,255,0)');
    ctx.fillStyle = halo;
    ctx.fillRect(0, cy - 5, W, 10);

    ctx.fillStyle = 'rgba(255,255,255,0.85)';
    ctx.fillRect(0, cy | 0, W, 1);
  }
  ctx.globalCompositeOperation = 'source-over';

  // palette name flash — fades over ~1.6s after a click
  if (paletteFlash > 0) {
    paletteFlash -= dt;
    const a = Math.min(1, paletteFlash / 1.6);
    ctx.fillStyle = `rgba(0,0,0,${(a * 0.45).toFixed(3)})`;
    const label = PALETTES[paletteIdx].name;
    ctx.font = '13px ui-monospace, monospace';
    const tw = ctx.measureText(label).width;
    const padX = 10, padY = 6;
    ctx.fillRect(W * 0.5 - tw * 0.5 - padX, 14, tw + padX * 2, 20 + padY * 0.5);
    ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
    ctx.textBaseline = 'top';
    ctx.fillText(label, W * 0.5 - tw * 0.5, 18);
  }
}

Comments (0)

Log in to comment.