← back to Warp Starfield

index.html

359 lines

<!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">
<meta name="theme-color" content="#000000">
<title>Warp Starfield Tunnel</title>
<style>
  html, body {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #000;
    cursor: crosshair;
    -webkit-tap-highlight-color: transparent;
  }
  canvas { display: block; }

  #hud {
    position: fixed;
    left: 14px;
    bottom: 12px;
    font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
    color: rgba(160, 200, 255, 0.55);
    pointer-events: none;
    user-select: none;
    letter-spacing: 0.5px;
    text-shadow: 0 0 6px rgba(80, 140, 255, 0.4);
    white-space: nowrap;
  }
  #hud b { color: rgba(200, 225, 255, 0.9); font-weight: 600; }

  #stats {
    position: fixed;
    right: 14px;
    bottom: 12px;
    font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
    color: rgba(160, 200, 255, 0.45);
    pointer-events: none;
    user-select: none;
    text-align: right;
    text-shadow: 0 0 6px rgba(80, 140, 255, 0.35);
  }
  #stats .val { color: rgba(200, 225, 255, 0.85); }

  #speedbar {
    position: fixed;
    left: 14px;
    bottom: 46px;
    width: 160px;
    height: 3px;
    background: rgba(120, 160, 255, 0.15);
    border-radius: 2px;
    overflow: hidden;
    pointer-events: none;
  }
  #speedfill {
    height: 100%;
    width: 20%;
    background: linear-gradient(90deg, #3af, #aef);
    border-radius: 2px;
    box-shadow: 0 0 8px rgba(90, 180, 255, 0.8);
    transition: background 0.25s;
  }
  #speedfill.boost { background: linear-gradient(90deg, #f6a, #fdb); box-shadow: 0 0 10px rgba(255, 140, 180, 0.9); }

  /* Fade the chrome out when idle so the field is unobstructed */
  #hud, #stats, #speedbar { transition: opacity 0.6s; }
  body.idle #hud, body.idle #stats, body.idle #speedbar { opacity: 0.25; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="speedbar"><div id="speedfill"></div></div>
<div id="hud">WARP DRIVE &mdash; move mouse to steer &middot; scroll to change speed &middot; hold <b>space</b>/click for hyperspace</div>
<div id="stats"><span class="val" id="fps">--</span> fps &middot; <span class="val" id="spd">--</span> ly/s</div>
<script>
(function () {
  "use strict";

  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d", { alpha: false });
  var speedfill = document.getElementById("speedfill");
  var fpsEl = document.getElementById("fps");
  var spdEl = document.getElementById("spd");
  var body = document.body;

  var prefersReduced = window.matchMedia &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  var W = 0, H = 0, CX = 0, CY = 0, DPR = 1;

  // ---- config ----
  var DEPTH = 1600;          // z range (near..far)
  var FOV = 320;             // projection focal length
  var MIN_SPEED = 2;
  var MAX_SPEED = 90;
  var BOOST_SPEED = 150;     // hyperspace target
  var STAR_DENSITY = 1800;   // one star per this many screen px^2

  var stars = [];
  var NUM_STARS = 0;

  function targetStarCount() {
    // scale with screen area, clamped to a sane range
    return Math.min(1400, Math.max(400, Math.floor((W * H) / STAR_DENSITY)));
  }

  function spawn(s, randomZ) {
    // spread wide so steering reveals new stars at the edges
    s.x = (Math.random() * 2 - 1) * DEPTH * 1.2;
    s.y = (Math.random() * 2 - 1) * DEPTH * 1.2;
    s.z = randomZ ? Math.random() * DEPTH : DEPTH;
    s.px = null;
    s.py = null;
    s.hue = 200 + Math.random() * 60 - 30;          // blue-white core palette
    if (Math.random() < 0.08) s.hue = 30 + Math.random() * 30; // occasional gold star
    s.mag = 0.5 + Math.random() * 1.0;              // brightness / size multiplier
    s.tw = Math.random() * 6.283;                   // twinkle phase
    return s;
  }

  function buildStars() {
    NUM_STARS = targetStarCount();
    if (stars.length > NUM_STARS) {
      stars.length = NUM_STARS;
    } else {
      while (stars.length < NUM_STARS) stars.push(spawn({}, true));
    }
  }

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    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);
    CX = W / 2;
    CY = H / 2;
    buildStars();
    // hard reset so we don't smear stale pixels after a resize
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, W, H);
  }
  window.addEventListener("resize", resize);
  resize();

  // ---- state ----
  var speed = 16;            // scroll-controlled target speed
  var curSpeed = 16;         // smoothed speed actually rendered
  var mouseX = 0, mouseY = 0; // -1..1 steer target
  var steerX = 0, steerY = 0; // smoothed steer
  var boosting = false;
  var boostAmt = 0;          // 0..1 eased boost
  var idleTimer = null;

  function wake() {
    body.classList.remove("idle");
    if (idleTimer) clearTimeout(idleTimer);
    idleTimer = setTimeout(function () { body.classList.add("idle"); }, 2600);
  }
  wake();

  // ---- input: steering ----
  window.addEventListener("mousemove", function (e) {
    mouseX = (e.clientX / W) * 2 - 1;
    mouseY = (e.clientY / H) * 2 - 1;
    wake();
  });

  // ---- input: speed via scroll ----
  window.addEventListener("wheel", function (e) {
    e.preventDefault();
    speed += (e.deltaY < 0 ? 1 : -1) * (2 + speed * 0.12);
    speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
    wake();
  }, { passive: false });

  // ---- input: hyperspace boost (hold) ----
  function boostOn() { boosting = true; wake(); }
  function boostOff() { boosting = false; }
  window.addEventListener("mousedown", boostOn);
  window.addEventListener("mouseup", boostOff);
  window.addEventListener("keydown", function (e) {
    if (e.code === "Space") { e.preventDefault(); boostOn(); }
    else if (e.key === "ArrowUp")   { speed = Math.min(MAX_SPEED, speed + 6); }
    else if (e.key === "ArrowDown") { speed = Math.max(MIN_SPEED, speed - 6); }
  });
  window.addEventListener("keyup", function (e) {
    if (e.code === "Space") boostOff();
  });

  // ---- input: touch (drag steers, two-finger vertical changes speed) ----
  var lastTouchY = null;
  window.addEventListener("touchstart", function (e) {
    if (e.touches.length === 1) boostOn();
  }, { passive: true });
  window.addEventListener("touchmove", function (e) {
    var t = e.touches[0];
    mouseX = (t.clientX / W) * 2 - 1;
    mouseY = (t.clientY / H) * 2 - 1;
    if (e.touches.length === 2) {
      boostOff();
      if (lastTouchY !== null) {
        speed += (lastTouchY - e.touches[1].clientY) * 0.3;
        speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
      }
      lastTouchY = e.touches[1].clientY;
    }
    wake();
    e.preventDefault();
  }, { passive: false });
  window.addEventListener("touchend", function () { lastTouchY = null; boostOff(); });
  window.addEventListener("contextmenu", function (e) { e.preventDefault(); });

  // ---- pause when tab is hidden (and avoid a huge dt on return) ----
  var running = true;
  document.addEventListener("visibilitychange", function () {
    if (document.hidden) {
      running = false;
    } else if (!running) {
      running = true;
      lastT = performance.now();
      requestAnimationFrame(frame);
    }
  });

  // ---- fps sampling ----
  var fpsAccum = 0, fpsFrames = 0, fpsLast = performance.now(), fpsShown = 0;

  var lastT = performance.now();

  function frame(now) {
    if (!running) return;

    var dt = Math.min((now - lastT) / 16.6667, 3); // normalize to 60fps units, clamp spikes
    lastT = now;

    // fps meter (updated ~4x/sec)
    fpsFrames++;
    if (now - fpsLast >= 250) {
      fpsShown = Math.round((fpsFrames * 1000) / (now - fpsLast));
      fpsFrames = 0;
      fpsLast = now;
      fpsEl.textContent = fpsShown;
    }

    // smooth boost + resulting speed target
    boostAmt += ((boosting ? 1 : 0) - boostAmt) * 0.12 * dt;
    var speedTarget = speed + (BOOST_SPEED - speed) * boostAmt;
    curSpeed += (speedTarget - curSpeed) * 0.08 * dt;

    var speedFrac = (curSpeed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED);
    var displayFrac = Math.min(1.4, speedFrac); // may exceed 1 during boost

    // smooth steering
    steerX += (mouseX - steerX) * 0.05 * dt;
    steerY += (mouseY - steerY) * 0.05 * dt;

    // motion blur: translucent black wash — faster = less wash = longer streaks
    var fade = 0.34 - Math.min(1, speedFrac + boostAmt * 0.6) * 0.26; // 0.34 slow .. 0.08 fast
    ctx.fillStyle = "rgba(0,0,0," + fade.toFixed(3) + ")";
    ctx.fillRect(0, 0, W, H);

    // steering: shift the vanishing point toward the mouse for a "banking" feel,
    // plus a gentle world-space cross-drift (kept subtle so it doesn't lurch)
    var vpx = CX - steerX * W * 0.28;
    var vpy = CY - steerY * H * 0.28;
    var camVX = steerX * curSpeed * 6;
    var camVY = steerY * curSpeed * 6;

    ctx.lineCap = "round";

    for (var i = 0; i < NUM_STARS; i++) {
      var s = stars[i];

      s.z -= curSpeed * dt;
      s.x -= camVX * dt;
      s.y -= camVY * dt;

      if (s.z <= 1) { spawn(s, false); continue; }

      var k = FOV / s.z;
      var sx = vpx + s.x * k;
      var sy = vpy + s.y * k;

      // recycle stars that leave the viewport (with margin)
      if (sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
        spawn(s, false);
        continue;
      }

      if (s.px !== null) {
        var depthFrac = 1 - s.z / DEPTH;                 // 0 far .. 1 near
        var size = (0.3 + depthFrac * 2.2) * s.mag;

        // twinkle only matters for slow, distant stars
        var twinkle = 1 - 0.25 * (1 - depthFrac) * (0.5 + 0.5 * Math.sin(s.tw += 0.15 * dt));
        var alpha = Math.min(1, (0.14 + depthFrac * 0.95) * (0.6 + 0.4 * s.mag)) * twinkle;

        // hotter / whiter at speed
        var sat = Math.max(0, 72 - (speedFrac + boostAmt) * 50);
        var light = 60 + depthFrac * 30 + Math.min(1, speedFrac + boostAmt) * 10;

        ctx.strokeStyle = "hsla(" + s.hue + "," + sat + "%," + light + "%," + alpha.toFixed(3) + ")";
        ctx.lineWidth = size;
        ctx.beginPath();
        ctx.moveTo(s.px, s.py);
        ctx.lineTo(sx, sy);
        ctx.stroke();

        // bright core dot on the nearest stars
        if (depthFrac > 0.72) {
          ctx.fillStyle = "hsla(" + s.hue + ",30%,96%," + (alpha * 0.9).toFixed(3) + ")";
          ctx.beginPath();
          ctx.arc(sx, sy, size * 0.55, 0, 6.2832);
          ctx.fill();
        }
      }

      s.px = sx;
      s.py = sy;
    }

    // central glow that intensifies with speed / boost
    var glow = Math.min(1, speedFrac + boostAmt * 0.8);
    if (glow > 0.05) {
      var glowR = Math.max(60, Math.min(W, H) * (0.1 + glow * 0.4));
      var g = ctx.createRadialGradient(vpx, vpy, 0, vpx, vpy, glowR);
      var tint = boostAmt > 0.3 ? "255,170,200" : "150,190,255";
      g.addColorStop(0, "rgba(" + tint + "," + (0.11 * glow).toFixed(3) + ")");
      g.addColorStop(1, "rgba(" + tint + ",0)");
      ctx.fillStyle = g;
      ctx.fillRect(vpx - glowR, vpy - glowR, glowR * 2, glowR * 2);
    }

    // HUD readouts
    speedfill.style.width = (Math.min(1, displayFrac) * 100).toFixed(1) + "%";
    if (boostAmt > 0.15) speedfill.classList.add("boost");
    else speedfill.classList.remove("boost");
    spdEl.textContent = (curSpeed / 3).toFixed(1);

    requestAnimationFrame(frame);
  }

  // If the user prefers reduced motion, start slow and don't auto-race.
  if (prefersReduced) { speed = 6; curSpeed = 6; }

  requestAnimationFrame(frame);
})();
</script>
</body>
</html>