๐ŸŽฎArcadeLab

Moonwell Foundry

by NovaCaptain32
420 lines15.0 KB
โ–ถ Play
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="theme-color" content="#171327">
  <title>Moonwell Foundry</title>
  <style>:root {
  color-scheme: dark;
  --back: #171327;
  --edge: #453158;
  --accent: #ffd27c;
  font-family: system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: clamp(8px, 2vw, 20px);
  background: radial-gradient(ellipse at 50% 15%, var(--edge), var(--back) 70%);
  color: #f4f6fa;
}
.cabinet { width: min(100%, 1040px, calc((100dvh - 114px) * 1.7778)); }
.masthead, .console, .brand, .actions { display: flex; align-items: center; }
.masthead, .console { justify-content: space-between; gap: 12px; padding: 8px 2px; }
.brand, .actions { gap: 9px; }
.brand, .eyebrow, .details, .console {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.brand { font-weight: 800; letter-spacing: .08em; }
.mark { width: 10px; height: 10px; background: var(--accent); transform: rotate(45deg); }
.edition { color: #bbc4cf; font-size: 10px; white-space: nowrap; }
button {
  color: inherit;
  background: var(--back);
  border: 1px solid #ffffff42;
  border-radius: 6px;
  cursor: pointer;
  padding: 7px 10px;
  font: inherit;
  font-size: 12px;
  white-space: nowrap;
}
button:focus-visible, canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .45; cursor: default; }
.stage { position: relative; overflow: hidden; border: 1px solid #ffffff42; border-radius: 12px; background: var(--back); }
canvas { display: block; width: 100%; height: auto; touch-action: none; }
.overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; background: #070b14a0; }
.overlay[hidden] { display: none; }
.card {
  width: min(94%, 510px);
  padding: clamp(10px, 3vw, 30px);
  text-align: center;
  background: color-mix(in srgb, var(--back), transparent 6%);
  border: 1px solid var(--accent);
  border-radius: 12px;
  box-shadow: 0 12px 65px #0008;
}
.eyebrow { color: var(--accent); font-weight: 800; letter-spacing: .12em; text-transform: uppercase; margin: 0 0 7px; }
h1 { margin: 0; font-size: clamp(25px, 5.5vw, 54px); line-height: .98; letter-spacing: -.055em; }
.message { max-width: 37ch; margin: 12px auto; line-height: 1.45; font-size: clamp(12px, 2.2vw, 16px); color: #e0e4ed; }
.primary { background: var(--accent); color: #10141a; padding: 9px 22px; font-size: 15px; font-weight: 850; }
.primary:focus-visible { outline-color: white; }
.details { color: #ccd1d9; margin: 8px 0 0; }
.details:empty { display: none; }
.console { align-items: flex-start; color: #d4dae4; line-height: 1.45; }
.console p { margin: 0; }
output { color: var(--accent); text-align: right; min-width: 8ch; }
@media (max-width: 550px) {
  .cabinet { width: 100%; }
  .edition { display: none; }
  .masthead, .console { gap: 6px; }
  .message { margin: 6px auto; }
  .card { padding: 9px; }
  .eyebrow, .details, .console { font-size: 10px; }
  .primary { padding: 5px 17px; }
}
</style>
</head>
<body>
  <main class="cabinet">
    <header class="masthead">
      <div class="brand"><span class="mark"></span><span>D3MONGAMES</span><span class="edition">03 / TIMING</span></div>
      <div class="actions">
        <button id="sound" type="button" aria-pressed="false">Sound off</button>
        <button id="pause" type="button" disabled>Pause</button>
      </div>
    </header>
    <section class="stage" aria-label="Moonwell Foundry game">
      <canvas id="game" width="960" height="540" tabindex="0" aria-label="Moonwell Foundry playfield"></canvas>
      <div id="overlay" class="overlay">
        <div class="card">
          <p id="eyebrow" class="eyebrow">A three-lane timing game</p>
          <h1 id="title">Moonwell Foundry</h1>
          <p id="message" class="message">Catch the bright crystals as they cross the gold line. Let the rough slag fall through.</p>
          <button id="primary" class="primary" type="button">Play</button>
          <p id="details" class="details"></p>
        </div>
      </div>
    </section>
    <footer class="console">
      <p id="help">Press A, S, D or Left, Down, Right. On a touchscreen, tap the lane.</p>
      <output id="status" role="status"></output>
    </footer>
    <noscript>This game needs JavaScript to run.</noscript>
  </main>
  <script>"use strict";

const Arcade = (() => {
  const canvas = document.querySelector("#game");
  const ctx = canvas.getContext("2d");
  const overlay = document.querySelector("#overlay");
  const primary = document.querySelector("#primary");
  const pause = document.querySelector("#pause");
  const sound = document.querySelector("#sound");
  const status = document.querySelector("#status");
  const keys = new Set();
  let audio, soundOn = false, active = false, screen = "menu";
  let game, nextAction, lastTime = 0, announcement = "";

  function setStatus(message) {
    if (announcement !== message) {
      status.value = message;
      announcement = message;
    }
  }

  function show(kind, eyebrow, title, message, button, action, detail = "") {
    active = false;
    screen = kind;
    keys.clear();
    overlay.hidden = false;
    pause.disabled = kind !== "paused";
    pause.textContent = kind === "paused" ? "Resume" : "Pause";
    document.querySelector("#eyebrow").textContent = eyebrow;
    document.querySelector("#title").textContent = title;
    document.querySelector("#message").textContent = message;
    document.querySelector("#details").textContent = detail;
    primary.textContent = button;
    nextAction = action;
    primary.focus({ preventScroll: true });
  }

  function play() {
    active = true;
    screen = "playing";
    overlay.hidden = true;
    pause.disabled = false;
    pause.textContent = "Pause";
    canvas.focus({ preventScroll: true });
  }

  function togglePause() {
    if (screen === "playing") {
      game.release?.();
      show("paused", "Intermission", "Paused", "Your run is ready when you are.", "Resume", play);
    } else if (screen === "paused") {
      play();
    }
  }

  function tone(frequency, duration = .11, type = "sine") {
    if (!soundOn || !audio || audio.state !== "running") return;
    const oscillator = audio.createOscillator();
    const gain = audio.createGain();
    oscillator.type = type;
    oscillator.frequency.value = frequency;
    oscillator.connect(gain);
    gain.connect(audio.destination);
    gain.gain.setValueAtTime(.07, audio.currentTime);
    gain.gain.exponentialRampToValueAtTime(.001, audio.currentTime + duration);
    oscillator.start();
    oscillator.stop(audio.currentTime + duration);
  }

  function best(key, score = 0) {
    try {
      const prior = Number(localStorage.getItem("d3mg." + key)) || 0;
      if (score > prior) localStorage.setItem("d3mg." + key, String(score));
      return Math.max(score, prior);
    } catch {
      return score;
    }
  }

  function pointer(event) {
    const box = canvas.getBoundingClientRect();
    return {
      x: (event.clientX - box.left) * canvas.width / box.width,
      y: (event.clientY - box.top) * canvas.height / box.height
    };
  }

  function start(controller) {
    game = controller;
    nextAction = () => { game.reset(); play(); };
    primary.addEventListener("click", () => nextAction());
    pause.addEventListener("click", togglePause);
    sound.addEventListener("click", () => {
      soundOn = !soundOn;
      if (soundOn && window.AudioContext) {
        audio ??= new AudioContext();
        audio.resume().then(() => tone(480)).catch(() => {});
      }
      sound.textContent = soundOn ? "Sound on" : "Sound off";
      sound.setAttribute("aria-pressed", String(soundOn));
    });
    document.addEventListener("keydown", event => {
      const key = event.key.toLowerCase();
      if (key === "escape" || key === "p") {
        if (!event.repeat) togglePause();
        return;
      }
      if (!active || (event.target instanceof HTMLButtonElement && key === " ")) return;
      if ([" ", "arrowup", "arrowdown", "arrowleft", "arrowright"].includes(key)) event.preventDefault();
      keys.add(key);
      if (!event.repeat) game.key?.(key);
    });
    document.addEventListener("keyup", event => keys.delete(event.key.toLowerCase()));
    canvas.addEventListener("pointerdown", event => {
      if (!active) return;
      canvas.setPointerCapture(event.pointerId);
      game.pointer?.(pointer(event));
    });
    canvas.addEventListener("pointermove", event => {
      if (active && canvas.hasPointerCapture(event.pointerId)) game.drag?.(pointer(event));
    });
    for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) {
      canvas.addEventListener(type, () => game.release?.());
    }
    window.addEventListener("blur", () => {
      keys.clear();
      game.release?.();
    });
    document.addEventListener("visibilitychange", () => {
      if (document.hidden && active) togglePause();
    });
    function frame(time) {
      const dt = Math.min(.04, (time - (lastTime || time)) / 1000);
      lastTime = time;
      if (active) game.update?.(dt);
      game.draw(ctx, time / 1000);
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
  }

  function text(context, value, x, y, color = "#f4f6fa", size = 18, align = "left") {
    context.font = `700 ${size}px ui-monospace, Consolas, monospace`;
    context.textAlign = align;
    context.textBaseline = "middle";
    context.fillStyle = color;
    context.fillText(value, x, y);
  }

  function box(context, x, y, width, height, radius, color) {
    context.beginPath();
    context.roundRect(x, y, width, height, radius);
    context.fillStyle = color;
    context.fill();
  }

  return { start, show, play, setStatus, tone, best, text, box, keys };
})();

(() => {
  const { start, show, play, setStatus, tone, best, text, box } = Arcade;
  const gold = "#ffd27c", lilac = "#ccafff", soot = "#171327", muted = "#b5a9c9";
  const laneX = [274, 480, 686];
  const gateY = 397;
  const shift = 32;
  const lanePattern = [0, 2, 1, 0, 1, 2, 0, 2, 1, 2, 0, 1];
  const slagPattern = [4, 7, 11, 16, 19, 23];
  let elapsed, spawnAt, spawned, drops, seals, score, combo, perfect, caught, glow, feedback;

  function reset() {
    elapsed = 0; spawnAt = .3; spawned = 0; drops = [];
    seals = 5; score = 0; combo = 0; perfect = 0; caught = 0;
    glow = [0, 0, 0]; feedback = "Catch crystals. Let slag pass.";
    announce();
  }

  function announce() {
    setStatus(`${score} points ยท ${seals} seals`);
  }

  function finish() {
    const record = best("moonwell-score", score);
    setStatus(seals ? "Shift complete" : "Furnace stalled");
    tone(seals ? 690 : 129, .33, "triangle");
    show(
      "result", "Foundry report", seals ? "Shift complete" : "Furnace stalled",
      `${caught} crystals caught, ${perfect} dead center. Score ${score}.`,
      "Run another shift", () => { reset(); play(); }, `Best ยท ${record}`
    );
  }

  function fault(message) {
    seals--;
    combo = 0;
    feedback = message;
    announce();
    tone(132, .18, "triangle");
    if (!seals) finish();
  }

  function tap(lane) {
    if (glow[lane] > .04) return;
    glow[lane] = .22;
    const index = drops.findIndex(drop => drop.lane === lane && Math.abs(drop.y - gateY) <= 39);
    if (index < 0) return fault("Early or late. Aim for the bright band.");
    const [drop] = drops.splice(index, 1);
    if (drop.slag) return fault("Slag caught. Let rough rocks pass.");
    const centered = Math.abs(drop.y - gateY) <= 15;
    caught++;
    perfect += Number(centered);
    combo++;
    score += (centered ? 140 : 100) + Math.min(8, combo - 1) * 15;
    feedback = centered ? "Dead center!" : "Crystal sealed.";
    tone(380 + Math.min(8, combo) * 44, .12, "triangle");
    announce();
  }

  function update(dt) {
    elapsed += dt;
    glow = glow.map(value => Math.max(0, value - dt));
    if (elapsed >= spawnAt && spawnAt < shift) {
      const speed = 148 + elapsed * 3;
      drops.push({ lane: lanePattern[spawned % lanePattern.length], y: 60, speed, slag: slagPattern.includes(spawned % 25) });
      spawned++;
      spawnAt += Math.max(.69, 1.22 - elapsed * .014);
    }
    drops.forEach(drop => { drop.y += drop.speed * dt; });
    const missed = drops.filter(drop => drop.y > gateY + 45 && !drop.slag).length;
    drops = drops.filter(drop => drop.y <= gateY + 45);
    for (let i = 0; i < missed && seals > 0; i++) fault("Crystal missed. Watch the next lane.");
    if (seals && elapsed >= shift && drops.length === 0) finish();
  }

  function key(value) {
    const lane = { a: 0, s: 1, d: 2, arrowleft: 0, arrowdown: 1, arrowright: 2 }[value];
    if (lane >= 0 && lane <= 2) tap(lane);
  }

  function pointer({ x, y }) {
    if (y < 83 || y > 491) return;
    const lane = laneX.findIndex(center => Math.abs(center - x) <= 92);
    if (lane >= 0) tap(lane);
  }

  function drawDrop(ctx, x, y, slag) {
    ctx.beginPath();
    if (slag) {
      ctx.moveTo(x - 17, y - 12);
      ctx.lineTo(x + 4, y - 19);
      ctx.lineTo(x + 18, y - 5);
      ctx.lineTo(x + 15, y + 15);
      ctx.lineTo(x - 12, y + 16);
    } else {
      ctx.moveTo(x, y - 20);
      ctx.lineTo(x + 17, y);
      ctx.lineTo(x, y + 20);
      ctx.lineTo(x - 17, y);
    }
    ctx.closePath();
    ctx.fillStyle = slag ? "#8f839f" : gold;
    ctx.fill();
    text(ctx, slag ? "ร—" : "+", x, y + 1, soot, 16, "center");
  }

  function draw(ctx, time) {
    ctx.fillStyle = soot;
    ctx.fillRect(0, 0, 960, 540);
    text(ctx, "MOONWELL / NIGHT SHIFT", 24, 24, lilac, 16);
    text(ctx, `SCORE ${score}    COMBO ${combo}`, 24, 54, gold, 17);
    text(ctx, `SEALS ${"โ—†".repeat(seals)}${"โ—‡".repeat(5 - seals)}`, 935, 27, lilac, 17, "right");
    text(ctx, `${Math.max(0, Math.ceil(shift - elapsed))}s`, 935, 55, muted, 18, "right");
    laneX.forEach((center, index) => {
      box(ctx, center - 92, 82, 184, 408, 10, glow[index] ? "#45315f" : "#261e3a");
      for (let y = 115; y < 365; y += 36) {
        ctx.fillStyle = "#3b2e50";
        ctx.fillRect(center - 1, y + Math.sin(time * 2) * 5, 2, 14);
      }
      box(ctx, center - 91, gateY - 39, 182, 78, 4, glow[index] ? "#7b5791" : "#49374e");
      ctx.strokeStyle = glow[index] ? "#fff3db" : gold;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(center - 90, gateY);
      ctx.lineTo(center + 90, gateY);
      ctx.stroke();
      box(ctx, center - 28, 447, 56, 31, 5, gold);
      text(ctx, "ASD"[index], center, 463, soot, 18, "center");
    });
    ctx.save();
    ctx.beginPath();
    ctx.rect(0, 82, 960, 360);
    ctx.clip();
    drops.forEach(drop => drawDrop(ctx, laneX[drop.lane], drop.y, drop.slag));
    if (!elapsed && !drops.length) {
      drawDrop(ctx, laneX[0], 170, false);
      drawDrop(ctx, laneX[2], 264, true);
    }
    ctx.restore();
    drawDrop(ctx, 839, 157, false);
    text(ctx, "CATCH", 839, 196, gold, 14, "center");
    drawDrop(ctx, 839, 251, true);
    text(ctx, "IGNORE", 839, 290, muted, 14, "center");
    text(ctx, feedback, 25, 514, lilac, 16);
  }

  reset();
  start({ reset, update, draw, key, pointer });
})();
</script>
</body>
</html>

Game Source: Moonwell Foundry

Creator: NovaCaptain32

Libraries: none

Complexity: complex (420 lines, 15.0 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: moonwell-foundry-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.