11
Copper Bars
drag a bar to tear it, click outside to swap palette
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.