๐ŸŽฎArcadeLab

Tideglass Courier

by NovaCaptain32
437 lines15.6 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="#0c2033">
  <title>Tideglass Courier</title>
  <style>:root {
  color-scheme: dark;
  --back: #0c2033;
  --edge: #244761;
  --accent: #69e5eb;
  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">02 / ARCADE</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="Tideglass Courier game">
      <canvas id="game" width="960" height="540" tabindex="0" aria-label="Tideglass Courier playfield"></canvas>
      <div id="overlay" class="overlay">
        <div class="card">
          <p id="eyebrow" class="eyebrow">An arcade delivery run</p>
          <h1 id="title">Tideglass Courier</h1>
          <p id="message" class="message">Pick up four parcels and deliver them across a harbor full of moving currents. You have 75 seconds.</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">Move with WASD or arrows. On a touchscreen, hold or drag where you want to sail.</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, keys } = Arcade;
  const cyan = "#69e5eb", amber = "#ffca7a", coral = "#ff8673", slate = "#9ebbc9";
  const docks = [
    { x: 140, y: 170, name: "NORTH QUAY" },
    { x: 818, y: 408, name: "SOUTH QUAY" },
    { x: 804, y: 160, name: "EAST LIGHT" },
    { x: 156, y: 406, name: "WEST LIGHT" }
  ];
  const jobs = [[0, 1], [2, 3], [1, 0], [3, 2]];
  const limit = 75;
  let x, y, heading, elapsed, life, job, cargo, invulnerable, target, pulse;

  function reset() {
    x = 105; y = 280; heading = 0;
    elapsed = 0; life = 3; job = 0; cargo = false;
    invulnerable = 0; target = null; pulse = 0;
    announce();
  }

  function announce() {
    setStatus(`${job}/${jobs.length} delivered ยท ${life} hull`);
  }

  function finish(won) {
    const remaining = Math.max(0, Math.ceil(limit - elapsed));
    const score = job * 100 + (won ? remaining + life * 20 : 0);
    const record = best("tideglass-score", score);
    setStatus(won ? "Harbor supplied" : "Route ended");
    tone(won ? 740 : 155, .35, "triangle");
    show(
      "result", won ? "Delivery route complete" : "Coast report",
      won ? "Harbor supplied" : life === 0 ? "Hull breached" : "Tide is out",
      `${job} of ${jobs.length} parcels delivered. Score ${score}.`,
      "Sail again", () => { reset(); play(); }, `Best ยท ${record}`
    );
  }

  function currents() {
    return [
      { x: 314, y: 280 + Math.sin(elapsed * 1.21) * 127, r: 31 },
      { x: 485, y: 278 + Math.sin(elapsed * 1.52 + 2.1) * 144, r: 36 },
      { x: 650, y: 283 + Math.sin(elapsed * 1.38 + 3.8) * 123, r: 30 },
      { x: 493 + Math.cos(elapsed * 1.23 + 1.5) * 146, y: 284, r: 21 }
    ];
  }

  function update(dt) {
    elapsed += dt;
    if (elapsed >= limit) return finish(false);
    invulnerable = Math.max(0, invulnerable - dt);
    pulse = Math.max(0, pulse - dt);
    const pressed = (...options) => Number(options.some(key => keys.has(key)));
    let dx = pressed("arrowright", "d") - pressed("arrowleft", "a");
    let dy = pressed("arrowdown", "s") - pressed("arrowup", "w");
    if (target && !dx && !dy) {
      dx = target.x - x;
      dy = target.y - y;
      if (Math.hypot(dx, dy) < 9) dx = dy = 0;
    }
    const length = Math.hypot(dx, dy);
    if (length) {
      heading = Math.atan2(dy, dx);
      const speed = cargo ? 212 : 252;
      x = Math.max(49, Math.min(911, x + dx / length * speed * dt));
      y = Math.max(107, Math.min(461, y + dy / length * speed * dt));
    }
    if (!invulnerable && currents().some(c => Math.hypot(c.x - x, c.y - y) < c.r + 12)) {
      life--;
      invulnerable = 1.3;
      announce();
      tone(146, .24, "sawtooth");
      if (life === 0) return finish(false);
    }
    const dock = docks[jobs[job][cargo ? 1 : 0]];
    if (Math.hypot(dock.x - x, dock.y - y) > 34) return;
    if (!cargo) {
      cargo = true;
      tone(440, .12, "triangle");
      return;
    }
    job++;
    cargo = false;
    pulse = .7;
    announce();
    tone(690, .17, "triangle");
    if (job === jobs.length) finish(true);
  }

  function draw(ctx, time) {
    ctx.fillStyle = "#0c2033";
    ctx.fillRect(0, 0, 960, 540);
    box(ctx, 22, 82, 916, 405, 14, "#112e44");
    ctx.save();
    ctx.beginPath();
    ctx.roundRect(22, 82, 916, 405, 14);
    ctx.clip();
    ctx.strokeStyle = "#31536a";
    ctx.lineWidth = 1;
    for (let row = 102; row < 500; row += 36) {
      ctx.beginPath();
      for (let col = 22; col <= 938; col += 8) {
        const wave = row + Math.sin(col / 45 + row / 37 - time) * 3;
        if (col === 22) ctx.moveTo(col, wave);
        else ctx.lineTo(col, wave);
      }
      ctx.stroke();
    }
    ctx.restore();
    text(ctx, "TIDEGLASS / COASTAL DISPATCH", 26, 29, cyan, 16);
    text(ctx, `DELIVERED ${job}/${jobs.length}`, 26, 57, slate, 17);
    text(ctx, `HULL ${"โ—†".repeat(life)}${"โ—‡".repeat(3 - life)}`, 469, 51, coral, 19, "center");
    text(ctx, `${Math.max(0, Math.ceil(limit - elapsed))}s`, 934, 48, cyan, 34, "right");
    const activeDock = jobs[job]?.[cargo ? 1 : 0];
    docks.forEach((dock, i) => {
      const active = i === activeDock;
      const color = cargo ? amber : cyan;
      ctx.strokeStyle = active ? color : "#547181";
      ctx.lineWidth = active ? 3 : 1;
      ctx.beginPath();
      ctx.arc(dock.x, dock.y, 34 + (active ? Math.sin(time * 4) * 2 : 0), 0, Math.PI * 2);
      ctx.stroke();
      box(ctx, dock.x - 12, dock.y - 12, 24, 24, 4, active ? color : "#547181");
      text(ctx, String(i + 1), dock.x, dock.y, "#0c2033", 15, "center");
      text(ctx, dock.name, dock.x, dock.y + 52, active ? color : slate, 13, "center");
    });
    for (const current of currents()) {
      const { x: cx, y: cy, r } = current;
      ctx.beginPath();
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
      ctx.fillStyle = "#ed6f6024";
      ctx.fill();
      ctx.strokeStyle = coral;
      ctx.lineWidth = 2;
      ctx.stroke();
      for (let i = 0; i < 3; i++) {
        const angle = elapsed * 2 + i * Math.PI * 2 / 3;
        ctx.beginPath();
        ctx.arc(cx, cy, r * .56, angle, angle + 1);
        ctx.stroke();
      }
    }
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(heading);
    ctx.globalAlpha = invulnerable && Math.floor(elapsed * 12) % 2 ? .45 : 1;
    ctx.fillStyle = cyan;
    ctx.beginPath();
    ctx.moveTo(22, 0);
    ctx.lineTo(-14, -12);
    ctx.lineTo(-9, 0);
    ctx.lineTo(-14, 12);
    ctx.closePath();
    ctx.fill();
    if (cargo) box(ctx, -5, -5, 12, 10, 2, amber);
    ctx.restore();
    const next = docks[activeDock];
    const objective = next ? (cargo ? "DELIVER TO " : "PICK UP AT ") + next.name : "ROUTE COMPLETE";
    text(ctx, objective, 26, 513, cargo ? amber : cyan, 17);
    text(ctx, pulse > 0 ? "PARCEL DELIVERED" : "WATCH THE CORAL CURRENTS", 934, 513, slate, 14, "right");
  }

  reset();
  start({ reset, update, draw, pointer: point => { target = point; }, drag: point => { target = point; }, release: () => { target = null; } });
})();
</script>
</body>
</html>

Game Source: Tideglass Courier

Creator: NovaCaptain32

Libraries: none

Complexity: complex (437 lines, 15.6 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: tideglass-courier-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.