🎮ArcadeLab

Moon Phases Orrery

by ArcadeLab
875 lines26.7 KB
▶ Play
<!--ARCADELAB
title: Moon Phases Orrery
description: A drag-the-Moon orrery — see how the Sun lights the Moon, watch the phases wax and wane, catch an eclipse, and see the Moon the way you would from Earth's backyard.
emoji: 🌙
color: blue
-->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Moon Phases Orrery</title>
<style>
  :root {
    --bg: #060a18;
    --panel: rgba(18, 26, 52, 0.86);
    --accent: #7db4ff;
    --accent2: #ffd166;
    --text: #eaf1ff;
    --muted: #9fb2da;
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0;
    height: 100%;
    overflow: hidden;
    background: var(--bg);
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    color: var(--text);
    -webkit-tap-highlight-color: transparent;
  }
  #stage { position: fixed; inset: 0; }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
    touch-action: none;
  }
  #hud {
    position: fixed;
    top: 0; left: 0; right: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px 10px;
    padding: clamp(10px, 2.4vw, 18px);
    pointer-events: none;
    z-index: 5;
  }
  .title {
    display: flex;
    align-items: center;
    gap: 8px;
    font-weight: 700;
    font-size: clamp(17px, 4.6vw, 22px);
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.30);
    border-radius: 14px;
    padding: 8px 13px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  .title .em { font-size: 1.15em; }
  .spacer { flex: 1 1 auto; }
  .controls {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    pointer-events: auto;
  }
  button {
    pointer-events: auto;
    font: inherit;
    font-size: clamp(14px, 3.6vw, 16px);
    font-weight: 600;
    color: var(--text);
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.34);
    border-radius: 13px;
    padding: 11px 16px;
    min-height: 44px;
    cursor: pointer;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    transition: transform .12s ease, background .18s ease, border-color .18s ease;
  }
  button:hover { background: rgba(40, 56, 100, 0.95); border-color: var(--accent); }
  button:active { transform: scale(0.94); }

  #dock {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    padding: clamp(10px, 2.4vw, 18px);
    pointer-events: none;
    z-index: 5;
  }
  #sliderWrap {
    pointer-events: auto;
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.26);
    border-radius: 16px;
    padding: 12px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: 94vw;
    width: 460px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  #sliderWrap .end {
    font-size: clamp(13px, 3.4vw, 15px);
    color: var(--muted);
    white-space: nowrap;
  }
  #speedWrap {
    pointer-events: auto;
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.26);
    border-radius: 16px;
    padding: 8px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: 94vw;
    width: 460px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  #speedWrap .end {
    font-size: clamp(13px, 3.4vw, 15px);
    color: var(--muted);
    white-space: nowrap;
  }
  input[type=range] {
    -webkit-appearance: none;
    appearance: none;
    flex: 1 1 auto;
    height: 44px;
    background: transparent;
    cursor: pointer;
    margin: 0;
  }
  input[type=range]::-webkit-slider-runnable-track {
    height: 12px;
    border-radius: 6px;
    background: linear-gradient(90deg, #1a2547, #4a6cff, #ffd166);
  }
  input[type=range]::-moz-range-track {
    height: 12px;
    border-radius: 6px;
    background: linear-gradient(90deg, #1a2547, #4a6cff, #ffd166);
  }
  input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 32px;
    height: 32px;
    margin-top: -10px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #ffffff, #cfe0ff 60%, #7db4ff);
    border: 3px solid #0c1430;
    box-shadow: 0 2px 8px rgba(0,0,0,0.5);
  }
  input[type=range]::-moz-range-thumb {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #ffffff, #cfe0ff 60%, #7db4ff);
    border: 3px solid #0c1430;
  }
  #hint {
    pointer-events: none;
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.22);
    border-radius: 13px;
    padding: 8px 14px;
    font-size: clamp(13px, 3.4vw, 15px);
    color: var(--muted);
    text-align: center;
    max-width: 92vw;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  #howBtn {
    position: fixed;
    z-index: 6;
    right: clamp(10px, 2.4vw, 18px);
    bottom: calc(clamp(10px, 2.4vw, 18px) + 208px);
  }
  #howPanel {
    position: fixed;
    z-index: 7;
    right: clamp(10px, 2.4vw, 18px);
    bottom: calc(clamp(10px, 2.4vw, 18px) + 260px);
    width: min(320px, 90vw);
    background: var(--panel);
    border: 1px solid rgba(125, 180, 255, 0.34);
    border-radius: 16px;
    padding: 16px 18px;
    color: var(--text);
    font-size: 16px;
    line-height: 1.5;
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    box-shadow: 0 12px 40px rgba(0,0,0,0.5);
    display: none;
  }
  #howPanel.open { display: block; }
  #howPanel h3 { margin: 0 0 8px; font-size: 18px; }
  #howPanel p { margin: 0 0 8px; color: var(--muted); }
  #howPanel p:last-child { margin-bottom: 0; }
  #stage { opacity: 0; transition: opacity .8s ease; }
  #stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
  <canvas id="c"></canvas>
</div>

<div id="hud">
  <div class="title"><span class="em">🌙</span><span>Moon Phases Orrery</span></div>
  <div class="spacer"></div>
  <div class="controls">
    <button id="play" type="button">⏸ Pause</button>
    <button id="reset" type="button">Reset</button>
  </div>
</div>

<button id="howBtn" type="button">💡 How it works</button>
<div id="howPanel">
  <h3>🌙 How it works</h3>
  <p>The Sun's light only ever lights up <b>half</b> of the Moon.</p>
  <p>As the Moon orbits Earth, we see different amounts of that lit half — that makes the phases.</p>
  <p><b>Waxing</b> means the bright part is growing. <b>Waning</b> means it's shrinking.</p>
  <p>When the Sun, Earth and Moon line up you get an <b>eclipse</b>! 🌑</p>
  <p>Drag the Moon or use the sliders — one moves the Moon, one sets the orbit speed. The circle shows what you'd see from Earth!</p>
</div>

<div id="dock">
  <div id="hint">Drag the Moon around its orbit 👆</div>
  <div id="sliderWrap">
    <span class="end">🌑 New</span>
    <input id="orbit" type="range" min="0" max="1000" value="125" aria-label="Moon position">
    <span class="end">Full 🌕</span>
  </div>
  <div id="speedWrap">
    <span class="end">🐢 Slow</span>
    <input id="speed" type="range" min="0" max="100" value="40" aria-label="Orbit speed">
    <span class="end">Fast 🚀</span>
  </div>
</div>

<script>
(function () {
  "use strict";

  var stage = document.getElementById("stage");
  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  if (!ctx) return;

  var slider = document.getElementById("orbit");
  var speedSlider = document.getElementById("speed");
  var playBtn = document.getElementById("play");
  var resetBtn = document.getElementById("reset");
  var howBtn = document.getElementById("howBtn");
  var howPanel = document.getElementById("howPanel");

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

  // angle: position of Moon around Earth, measured so that
  //   angle = 0  -> Moon between Earth and Sun (New Moon)
  //   angle = PI -> Moon on far side from Sun (Full Moon)
  // The Sun is to the LEFT of the screen.
  var SLIDER_MAX = 1000;
  var angle = (parseFloat(slider.value) / SLIDER_MAX) * Math.PI * 2;
  var playing = true;
  // orbit speed in radians/second, set by the speed slider (0..100 -> 0.05..0.95)
  var SPEED = 0.28;
  function speedFromSlider() {
    var v = parseFloat(speedSlider.value);
    if (isNaN(v)) v = 40;
    return 0.05 + (v / 100) * 0.9;
  }
  SPEED = speedFromSlider();
  var dragging = false;
  var dragPointerId = null;

  // background stars
  var bgStars = [];

  function makeBgStars() {
    bgStars = [];
    var n = Math.round((W * H) / 11000);
    n = Math.max(50, Math.min(280, n));
    for (var i = 0; i < n; i++) {
      bgStars.push({
        x: Math.random() * W,
        y: Math.random() * H,
        r: 0.3 + Math.random() * 1.4,
        a: 0.15 + Math.random() * 0.7,
        tw: Math.random() * Math.PI * 2,
        sp: 0.4 + Math.random() * 1.6
      });
    }
  }

  function resize() {
    var rect = stage.getBoundingClientRect();
    W = Math.max(1, rect.width);
    H = Math.max(1, rect.height);
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(W * DPR);
    canvas.height = Math.round(H * DPR);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    makeBgStars();
  }

  // ---- layout of the orrery scene ----
  function layout() {
    // Earth sits slightly right of centre so the Sun (left) has room.
    var cx = W * 0.54;
    var cy = H * 0.46;
    var minSide = Math.min(W, H);
    var orbitR = Math.min(minSide * 0.30, W * 0.30);
    orbitR = Math.max(orbitR, 60);
    var earthR = Math.max(minSide * 0.052, 22);
    var moonR = Math.max(earthR * 0.42, 12);
    return { cx: cx, cy: cy, orbitR: orbitR, earthR: earthR, moonR: moonR };
  }

  // phase name from angle (0 = new, PI = full)
  function phaseInfo(a) {
    // normalise 0..2PI
    var t = a % (Math.PI * 2);
    if (t < 0) t += Math.PI * 2;
    var deg = t * 180 / Math.PI;
    // fraction of disk illuminated, as seen from Earth
    var illum = (1 - Math.cos(t)) / 2; // 0 at new, 1 at full
    var name;
    if (deg < 22.5 || deg >= 337.5) name = "New Moon";
    else if (deg < 67.5) name = "Waxing Crescent";
    else if (deg < 112.5) name = "First Quarter";
    else if (deg < 157.5) name = "Waxing Gibbous";
    else if (deg < 202.5) name = "Full Moon";
    else if (deg < 247.5) name = "Waning Gibbous";
    else if (deg < 292.5) name = "Last Quarter";
    else name = "Waning Crescent";
    // waxing = first half of cycle (lit side grows)
    var waxing = t < Math.PI;
    // eclipse: Sun-Earth-Moon (almost) in a line.
    // near angle 0 -> Moon between Sun & Earth -> Solar eclipse
    // near angle PI -> Earth between Sun & Moon -> Lunar eclipse
    var ECL = 0.13; // radians of tolerance (~7.5 degrees)
    var eclipse = null, eclipseStrength = 0;
    var dNew = Math.min(t, Math.PI * 2 - t);   // distance from New Moon
    var dFull = Math.abs(t - Math.PI);          // distance from Full Moon
    if (dNew < ECL) {
      eclipse = "solar";
      eclipseStrength = 1 - dNew / ECL;
    } else if (dFull < ECL) {
      eclipse = "lunar";
      eclipseStrength = 1 - dFull / ECL;
    }
    return {
      illum: illum, name: name, waxing: waxing, t: t,
      eclipse: eclipse, eclipseStrength: eclipseStrength
    };
  }

  // Draw the Moon-as-seen-from-Earth into a circle.
  // illum: 0..1 illuminated fraction. waxing true -> lit on the right.
  // Drawn by scanning rows: for each row the lit part runs between the
  // circular limb and the terminator ellipse. This is correct for every
  // phase (crescent, quarter, gibbous) with no winding-direction pitfalls.
  function drawMoonDisk(x, y, r, illum, waxing) {
    ctx.save();
    // dark moon base
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = "#2a2f44";
    ctx.fill();

    // terminator half-width factor k in -1..1
    //   illum 0   -> k =  1   illum 0.5 -> k = 0   illum 1 -> k = -1
    var k = 1 - 2 * illum;

    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.clip(); // never draw outside the moon circle

    var g = ctx.createLinearGradient(x - r, y - r, x + r, y + r);
    g.addColorStop(0, "#fff7d6");
    g.addColorStop(1, "#ffe9a8");
    ctx.fillStyle = g;

    if (illum >= 0.999) {
      // full moon: the whole disk is lit
      ctx.fillRect(x - r, y - r, r * 2, r * 2);
    } else if (illum > 0.001) {
      var rows = Math.max(36, Math.round(r * 1.6));
      for (var i = 0; i < rows; i++) {
        var fy = (i + 0.5) / rows;          // 0..1
        var yy = y - r + fy * 2 * r;        // row centre y
        var ny = (yy - y) / r;              // -1..1
        var halfW = Math.sqrt(Math.max(0, 1 - ny * ny)) * r; // limb half-width
        if (halfW <= 0.2) continue;
        var termX = x + k * halfW;          // terminator x at this row
        var rowH = (2 * r) / rows + 1;
        var lx, rx;
        if (waxing) {
          // lit on the right: from terminator to right limb
          lx = termX; rx = x + halfW;
        } else {
          // lit on the left: from left limb to terminator
          lx = x - halfW; rx = termX;
        }
        if (rx > lx) {
          ctx.fillRect(lx, yy - rowH / 2, rx - lx, rowH);
        }
      }
    }
    ctx.restore();

    // outline
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.strokeStyle = "rgba(255,255,255,0.35)";
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // ---- drawing the whole scene ----
  function draw(time) {
    // sky gradient
    var bg = ctx.createRadialGradient(W * 0.1, H * 0.3, 0, W * 0.1, H * 0.3, Math.max(W, H));
    bg.addColorStop(0, "#0d1838");
    bg.addColorStop(1, "#05070f");
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    // background stars
    for (var i = 0; i < bgStars.length; i++) {
      var b = bgStars[i];
      var tw = 0.55 + 0.45 * Math.sin(time * 0.001 * b.sp + b.tw);
      ctx.globalAlpha = b.a * tw;
      ctx.fillStyle = "#dce6ff";
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    var L = layout();
    var cx = L.cx, cy = L.cy;

    // ---- Sun (off to the left) ----
    var sunX = -W * 0.04;
    var sunY = cy;
    var sunR = Math.max(Math.min(W, H) * 0.14, 44);
    var halo = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 4.2);
    halo.addColorStop(0, "rgba(255,221,120,0.55)");
    halo.addColorStop(0.3, "rgba(255,200,80,0.22)");
    halo.addColorStop(1, "rgba(255,200,80,0)");
    ctx.fillStyle = halo;
    ctx.beginPath();
    ctx.arc(sunX, sunY, sunR * 4.2, 0, Math.PI * 2);
    ctx.fill();
    var sunCore = ctx.createRadialGradient(sunX - sunR * 0.2, sunY - sunR * 0.2, 0, sunX, sunY, sunR);
    sunCore.addColorStop(0, "#fffceb");
    sunCore.addColorStop(0.55, "#ffd766");
    sunCore.addColorStop(1, "#ff9e2e");
    ctx.fillStyle = sunCore;
    ctx.beginPath();
    ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
    ctx.fill();

    // sun rays toward the scene (subtle)
    ctx.save();
    ctx.globalAlpha = 0.10;
    ctx.strokeStyle = "#ffe6a0";
    ctx.lineWidth = 2;
    for (var ry = -3; ry <= 3; ry++) {
      ctx.beginPath();
      ctx.moveTo(sunX + sunR, sunY + ry * sunR * 0.5);
      ctx.lineTo(W, sunY + ry * sunR * 1.6);
      ctx.stroke();
    }
    ctx.restore();

    // ---- Moon orbit ring ----
    ctx.beginPath();
    ctx.arc(cx, cy, L.orbitR, 0, Math.PI * 2);
    ctx.strokeStyle = "rgba(125,180,255,0.30)";
    ctx.lineWidth = 2;
    ctx.setLineDash([6, 8]);
    ctx.stroke();
    ctx.setLineDash([]);

    // ---- Earth ----
    // Sunlight comes from the left, so Earth's left half is day.
    drawWorld(cx, cy, L.earthR, true, time);

    // ---- Moon position ----
    // angle measured from the Sun direction (toward -x = toward Sun).
    // angle 0 => Moon between Earth and Sun (to the LEFT of Earth).
    var mx = cx - Math.cos(angle) * L.orbitR;
    var my = cy - Math.sin(angle) * L.orbitR;

    drawWorld(mx, my, L.moonR, false, time);

    // sunlight direction arrow near moon (toward where light comes from)
    // (kept subtle)

    var ph = phaseInfo(angle);

    // ---- eclipse alignment beam (Sun -> Earth -> Moon all in a row) ----
    if (ph.eclipse) {
      var beamPulse = 0.45 + 0.35 * Math.sin(time * 0.006);
      ctx.save();
      ctx.globalAlpha = ph.eclipseStrength * beamPulse;
      var beamCol = ph.eclipse === "solar" ? "#ffd166" : "#c79bff";
      ctx.strokeStyle = beamCol;
      ctx.lineWidth = 4;
      ctx.setLineDash([10, 8]);
      ctx.beginPath();
      ctx.moveTo(sunX, sunY);
      ctx.lineTo(W, sunY); // the Sun-Earth-Moon line runs straight across
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.restore();
    }

    // connecting glow line Earth->Moon
    ctx.save();
    ctx.globalAlpha = 0.25;
    ctx.strokeStyle = "#7db4ff";
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(mx, my);
    ctx.stroke();
    ctx.restore();

    // ---- inset: Moon as seen from Earth ----
    drawInset(ph, time);

    // ---- eclipse celebration banner (drawn last, on top) ----
    drawEclipseBanner(ph, time);
  }

  // draws a lit/dark world (Earth or Moon). Light comes from the left (Sun).
  // isEarth: tints day/night differently.
  function drawWorld(x, y, r, isEarth, time) {
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.clip();

    // night side base
    ctx.fillStyle = isEarth ? "#10254a" : "#2a2f44";
    ctx.fillRect(x - r, y - r, r * 2, r * 2);

    // day side: a circle of light offset toward the Sun (left)
    var lit = ctx.createRadialGradient(
      x - r * 0.55, y, r * 0.2,
      x - r * 0.55, y, r * 2.0);
    if (isEarth) {
      lit.addColorStop(0, "#7fd0ff");
      lit.addColorStop(0.5, "#3a8fd6");
      lit.addColorStop(1, "rgba(58,143,214,0)");
    } else {
      lit.addColorStop(0, "#fff7d6");
      lit.addColorStop(0.5, "#e6d9a8");
      lit.addColorStop(1, "rgba(230,217,168,0)");
    }
    ctx.fillStyle = lit;
    ctx.fillRect(x - r, y - r, r * 2, r * 2);

    if (isEarth) {
      // simple continents blobs (decorative, day side greenish)
      ctx.globalAlpha = 0.5;
      ctx.fillStyle = "#3fae6b";
      var blobs = [
        [-0.35, -0.35, 0.4], [-0.1, 0.3, 0.5], [0.3, -0.1, 0.35]
      ];
      for (var i = 0; i < blobs.length; i++) {
        ctx.beginPath();
        ctx.arc(x + blobs[i][0] * r, y + blobs[i][1] * r, blobs[i][2] * r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    } else {
      // moon craters
      ctx.globalAlpha = 0.35;
      ctx.fillStyle = "#9b8f6e";
      var cr = [[-0.2, -0.25, 0.22], [0.25, 0.1, 0.18], [-0.05, 0.35, 0.14]];
      for (var j = 0; j < cr.length; j++) {
        ctx.beginPath();
        ctx.arc(x + cr[j][0] * r, y + cr[j][1] * r, cr[j][2] * r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    }
    ctx.restore();

    // glow ring
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.strokeStyle = isEarth ? "rgba(127,208,255,0.6)" : "rgba(255,247,214,0.6)";
    ctx.lineWidth = isEarth ? 2.5 : 2;
    ctx.stroke();

    // labels
    ctx.fillStyle = "rgba(234,241,255,0.95)";
    ctx.font = "600 " + Math.max(13, r * 0.42) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "bottom";
    ctx.fillText(isEarth ? "Earth" : "Moon", x, y - r - 6);
    ctx.textBaseline = "alphabetic";
  }

  // the inset showing the phase as seen from Earth
  function drawInset(ph, time) {
    var pad = clamp(12, W * 0.03, 22);
    var size = clamp(96, Math.min(W, H) * 0.26, 180);
    var ix = pad;
    var iy = clamp(70, H * 0.16, 110);
    var panelH = size + 58; // extra room for the waxing/waning line

    // panel
    roundRect(ix, iy, size, panelH, 16);
    ctx.fillStyle = "rgba(18,26,52,0.9)";
    ctx.fill();
    ctx.strokeStyle = "rgba(125,180,255,0.34)";
    ctx.lineWidth = 1.5;
    ctx.stroke();

    // "From Earth" label
    ctx.fillStyle = "#9fb2da";
    ctx.font = "600 " + clamp(12, size * 0.13, 15) + "px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.fillText("From Earth you see", ix + size / 2, iy + clamp(16, size * 0.16, 22));

    // the moon disk
    var mr = size * 0.30;
    var mcx = ix + size / 2;
    var mcy = iy + size * 0.50;
    // soft glow behind
    var gl = ctx.createRadialGradient(mcx, mcy, 0, mcx, mcy, mr * 2);
    gl.addColorStop(0, "rgba(255,240,190," + (0.15 + ph.illum * 0.4) + ")");
    gl.addColorStop(1, "rgba(255,240,190,0)");
    ctx.fillStyle = gl;
    ctx.beginPath();
    ctx.arc(mcx, mcy, mr * 2, 0, Math.PI * 2);
    ctx.fill();

    drawMoonDisk(mcx, mcy, mr, ph.illum, ph.waxing);

    // phase name
    ctx.fillStyle = "#ffd166";
    ctx.font = "700 " + clamp(14, size * 0.155, 19) + "px system-ui, sans-serif";
    ctx.fillText(ph.name, ix + size / 2, iy + size + 14);

    // waxing / waning sub-label (skip at the exact New/Full points)
    var isQuarterPoint = (ph.name === "New Moon" || ph.name === "Full Moon");
    if (!isQuarterPoint) {
      ctx.fillStyle = "#9fb2da";
      ctx.font = "600 " + clamp(11, size * 0.115, 14) + "px system-ui, sans-serif";
      var wText = ph.waxing ? "▶ Waxing (getting bigger)" : "◀ Waning (getting smaller)";
      ctx.fillText(wText, ix + size / 2, iy + size + 36);
    }
    ctx.textAlign = "left";
  }

  // big celebratory banner shown when an eclipse lines up
  function drawEclipseBanner(ph, time) {
    if (!ph.eclipse) return;
    var s = ph.eclipseStrength; // 0..1
    var pulse = 0.7 + 0.3 * Math.sin(time * 0.006);
    var isSolar = ph.eclipse === "solar";
    var msg = isSolar ? "☀️ Solar Eclipse! ☀️" : "🌑 Lunar Eclipse! 🌑";
    var sub = isSolar
      ? "The Moon blocks the Sun"
      : "Earth's shadow hides the Moon";

    var fontSize = clamp(18, W * 0.052, 34);
    ctx.save();
    ctx.font = "800 " + fontSize + "px system-ui, sans-serif";
    var tw = ctx.measureText(msg).width;
    ctx.font = "600 " + (fontSize * 0.62) + "px system-ui, sans-serif";
    var sw = ctx.measureText(sub).width;
    var boxW = Math.min(W - 24, Math.max(tw, sw) + 48);
    var boxH = fontSize * 2.5;
    var bx = (W - boxW) / 2;
    // sit below the "From Earth" inset so the two never overlap
    var insetSize = clamp(96, Math.min(W, H) * 0.26, 180);
    var insetBottom = clamp(70, H * 0.16, 110) + insetSize + 58;
    var by = insetBottom + clamp(10, H * 0.02, 20);

    ctx.globalAlpha = Math.min(1, s * 1.4);
    roundRect(bx, by, boxW, boxH, 18);
    var bg = isSolar ? "rgba(70,48,12,0.94)" : "rgba(36,22,52,0.94)";
    ctx.fillStyle = bg;
    ctx.fill();
    ctx.lineWidth = 2.5;
    ctx.strokeStyle = isSolar
      ? "rgba(255,210,90," + pulse + ")"
      : "rgba(190,150,255," + pulse + ")";
    ctx.stroke();

    ctx.textAlign = "center";
    ctx.fillStyle = isSolar ? "#ffd166" : "#d9b8ff";
    ctx.font = "800 " + fontSize + "px system-ui, sans-serif";
    ctx.fillText(msg, W / 2, by + fontSize * 1.0);
    ctx.fillStyle = "#eaf1ff";
    ctx.font = "600 " + (fontSize * 0.62) + "px system-ui, sans-serif";
    ctx.fillText(sub, W / 2, by + fontSize * 1.85);
    ctx.textAlign = "left";
    ctx.restore();
  }

  // ---- small helpers ----
  function clamp(lo, v, hi) { return Math.max(lo, Math.min(hi, v)); }

  function roundRect(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }

  // ---- main loop ----
  var lastTime = 0;
  function frame(time) {
    var dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
    lastTime = time;

    if (playing && !dragging) {
      angle += SPEED * dt;
      if (angle > Math.PI * 2) angle -= Math.PI * 2;
      syncSlider();
    }
    draw(time);
    requestAnimationFrame(frame);
  }

  function syncSlider() {
    var t = angle % (Math.PI * 2);
    if (t < 0) t += Math.PI * 2;
    slider.value = String(Math.round((t / (Math.PI * 2)) * SLIDER_MAX));
  }

  // ---- input: drag the moon ----
  function pointFromEvent(e) {
    var rect = canvas.getBoundingClientRect();
    return { x: e.clientX - rect.left, y: e.clientY - rect.top };
  }

  function moonScreenPos() {
    var L = layout();
    return {
      x: L.cx - Math.cos(angle) * L.orbitR,
      y: L.cy - Math.sin(angle) * L.orbitR,
      r: L.moonR, cx: L.cx, cy: L.cy
    };
  }

  function setAngleFromPoint(px, py) {
    var L = layout();
    var dx = px - L.cx;
    var dy = py - L.cy;
    // screen angle of pointer from Earth
    var sa = Math.atan2(dy, dx);
    // moon at orbit: mx = cx - cos(angle)*R => direction (cos = -dx/R...)
    // we want angle such that (-cos(angle), -sin(angle)) points to pointer
    angle = Math.atan2(-dy, -dx);
    if (angle < 0) angle += Math.PI * 2;
    syncSlider();
  }

  canvas.addEventListener("pointerdown", function (e) {
    e.preventDefault();
    var pt = pointFromEvent(e);
    var m = moonScreenPos();
    var dx = pt.x - m.x, dy = pt.y - m.y;
    var grab = Math.max(m.r + 24, 34);
    if (dx * dx + dy * dy <= grab * grab) {
      dragging = true;
      dragPointerId = e.pointerId;
      setAngleFromPoint(pt.x, pt.y);
      if (canvas.setPointerCapture) {
        try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
      }
    } else {
      // tapping elsewhere on the orbit also moves the moon there
      var ddx = pt.x - m.cx, ddy = pt.y - m.cy;
      var dist = Math.sqrt(ddx * ddx + ddy * ddy);
      var L = layout();
      if (dist > L.orbitR * 0.45) {
        dragging = true;
        dragPointerId = e.pointerId;
        setAngleFromPoint(pt.x, pt.y);
        if (canvas.setPointerCapture) {
          try { canvas.setPointerCapture(e.pointerId); } catch (err2) {}
        }
      }
    }
  });

  canvas.addEventListener("pointermove", function (e) {
    if (!dragging || e.pointerId !== dragPointerId) return;
    e.preventDefault();
    var pt = pointFromEvent(e);
    setAngleFromPoint(pt.x, pt.y);
  });

  function endDrag(e) {
    if (!dragging || e.pointerId !== dragPointerId) return;
    dragging = false;
    dragPointerId = null;
  }
  canvas.addEventListener("pointerup", endDrag);
  canvas.addEventListener("pointercancel", endDrag);

  // ---- sliders ----
  slider.addEventListener("input", function () {
    angle = (parseFloat(slider.value) / SLIDER_MAX) * Math.PI * 2;
  });

  speedSlider.addEventListener("input", function () {
    SPEED = speedFromSlider();
  });

  // ---- buttons ----
  playBtn.addEventListener("click", function () {
    playing = !playing;
    playBtn.textContent = playing ? "⏸ Pause" : "▶ Play";
  });

  resetBtn.addEventListener("click", function () {
    angle = Math.PI * 0.25;
    playing = true;
    playBtn.textContent = "⏸ Pause";
    syncSlider();
  });

  howBtn.addEventListener("click", function () {
    howPanel.classList.toggle("open");
  });

  // ---- boot ----
  window.addEventListener("resize", resize);
  resize();
  requestAnimationFrame(frame);
  requestAnimationFrame(function () {
    stage.classList.add("ready");
  });
})();
</script>
</body>
</html>

Game Source: Moon Phases Orrery

Creator: ArcadeLab

Libraries: none

Complexity: complex (875 lines, 26.7 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: moon-phases-orrery-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.