๐ŸŽฎArcadeLab

Circuit Orchard

by NovaCaptain32
439 lines16.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="#102421">
  <title>Circuit Orchard</title>
  <style>:root {
  color-scheme: dark;
  --back: #102421;
  --edge: #28483b;
  --accent: #b8fa82;
  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">01 / PUZZLE</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="Circuit Orchard game">
      <canvas id="game" width="960" height="540" tabindex="0" aria-label="Circuit Orchard playfield"></canvas>
      <div id="overlay" class="overlay">
        <div class="card">
          <p id="eyebrow" class="eyebrow">A quiet pipe puzzle</p>
          <h1 id="title">Circuit Orchard</h1>
          <p id="message" class="message">Turn the pipe tiles and bring water to six dry plots. Take all the time you need.</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">Click or tap a tile to turn it. Keyboard ยท Arrows, then Space. R resets the plot.</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 levels = [
    { name: "First sprout", route: [10, 11, 12, 7, 8, 13, 14] },
    { name: "Crosswinds", route: [0, 1, 6, 11, 12, 17, 18, 23, 24] },
    { name: "Rain ladder", route: [20, 15, 16, 17, 12, 7, 8, 3, 4, 9, 14] },
    { name: "Root spiral", route: [0, 1, 2, 3, 4, 9, 14, 19, 24, 23, 22, 21, 16, 11, 12, 13] },
    { name: "Greenhouse bend", route: [5, 6, 1, 2, 3, 8, 13, 12, 17, 22, 23, 24, 19, 14] },
    { name: "Canopy maze", route: [0, 5, 10, 15, 20, 21, 22, 23, 24, 19, 14, 9, 4, 3, 2, 1, 6, 11, 12, 13, 18, 17, 16] }
  ];
  const greens = { mint: "#b8fa82", muted: "#98ad9f", tile: "#1b322d", line: "#5f8b79" };
  const steps = [[0, -1], [1, 0], [0, 1], [-1, 0]];
  const originX = 348, originY = 57, tileSize = 81;
  let level = 0, moves = 0, cursor = 0, cells = [], flow = new Set();

  function neighbor(index, direction) {
    const [dx, dy] = steps[direction];
    const x = index % 5 + dx;
    const y = Math.floor(index / 5) + dy;
    return x >= 0 && x < 5 && y >= 0 && y < 5 ? y * 5 + x : -1;
  }

  function direction(from, to) {
    return steps.findIndex((_, i) => neighbor(from, i) === to);
  }

  function trace() {
    const { route } = levels[level];
    const source = route[0];
    const target = route.at(-1);
    flow = new Set([source]);
    const queue = [source];
    while (queue.length > 0) {
      const current = queue.shift();
      for (const port of cells[current]) {
        const next = neighbor(current, port);
        if (next < 0 || flow.has(next) || !cells[next].includes((port + 2) % 4)) continue;
        flow.add(next);
        queue.push(next);
      }
    }
    return flow.has(target);
  }

  function load(index) {
    level = index;
    moves = 0;
    const { route } = levels[level];
    cells = Array.from({ length: 25 }, (_, i) => {
      const rotation = (i * 3 + level) % 4;
      return [rotation, (rotation + (i % 3 === 0 ? 2 : 1)) % 4];
    });
    route.forEach((tile, i) => {
      const ports = [];
      if (i > 0) ports.push(direction(tile, route[i - 1]));
      if (i + 1 < route.length) ports.push(direction(tile, route[i + 1]));
      const twist = ports.length === 2 ? 1 + ((tile + i * 2 + level) % 3) : 0;
      cells[tile] = ports.map(port => (port + twist) % 4);
    });
    cursor = route[1];
    trace();
    setStatus(`Plot ${level + 1}/${levels.length}`);
  }

  function rotate(tile) {
    if (cells[tile].length === 1) return;
    cells[tile] = cells[tile].map(port => (port + 1) % 4);
    moves++;
    tone(240 + (moves % 5) * 40, .045, "triangle");
    if (!trace()) return;
    const cleared = best("circuit-plots", level + 1);
    tone(720, .3, "triangle");
    const last = level === levels.length - 1;
    setStatus(last ? "Orchard restored" : `Plot ${level + 1} restored`);
    show(
      "result", last ? "Six plots connected" : "Water is flowing",
      last ? "Orchard restored" : levels[level].name,
      `You watered the sapling in ${moves} turns.`,
      last ? "Play again" : "Next plot",
      () => { load(last ? 0 : level + 1); play(); },
      `Best ยท ${cleared}/${levels.length} plots restored`
    );
  }

  function key(value) {
    const arrow = ["arrowup", "arrowright", "arrowdown", "arrowleft"].indexOf(value);
    if (arrow >= 0) {
      const next = neighbor(cursor, arrow);
      if (next >= 0) cursor = next;
    }
    if (value === " " || value === "enter") rotate(cursor);
    if (value === "r") load(level);
  }

  function pointer({ x, y }) {
    const col = Math.floor((x - originX) / tileSize);
    const row = Math.floor((y - originY) / tileSize);
    if (col < 0 || col >= 5 || row < 0 || row >= 5) return;
    cursor = row * 5 + col;
    rotate(cursor);
  }

  function draw(ctx, time) {
    ctx.fillStyle = "#102421";
    ctx.fillRect(0, 0, 960, 540);
    ctx.fillStyle = "#142d26";
    for (let y = 24; y < 540; y += 22) {
      for (let x = 21; x < 960; x += 22) ctx.fillRect(x, y, 2, 2);
    }
    box(ctx, 26, 59, 285, 405, 13, "#192f28");
    text(ctx, "IRRIGATION / FIELD MAP", 49, 91, greens.mint, 16);
    text(ctx, `PLOT ${String(level + 1).padStart(2, "0")} / 06`, 49, 135, greens.muted, 15);
    text(ctx, levels[level].name, 49, 167, "#f1f9de", 24);
    text(ctx, String(moves).padStart(2, "0"), 49, 236, greens.mint, 56);
    text(ctx, "TILE TURNS", 49, 282, greens.muted, 15);
    ctx.fillStyle = "#3a5d4d";
    ctx.fillRect(49, 314, 239, 1);
    text(ctx, "CONNECT SOURCE TO SPROUT", 49, 343, "#e0e9d9", 14);
    text(ctx, "Light pipes carry water.", 49, 369, greens.muted, 14);
    text(ctx, "R resets this plot.", 49, 423, greens.muted, 13);
    text(ctx, "CANOPY NETWORK", 352, 29, greens.mint, 16);
    const { route } = levels[level];
    cells.forEach((ports, index) => {
      const x = originX + index % 5 * tileSize;
      const y = originY + Math.floor(index / 5) * tileSize;
      const cx = x + tileSize / 2, cy = y + tileSize / 2;
      box(ctx, x + 2, y + 2, tileSize - 4, tileSize - 4, 8, flow.has(index) ? "#274333" : greens.tile);
      if (cursor === index) {
        ctx.strokeStyle = "#f1f9de";
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.roundRect(x + 3, y + 3, tileSize - 6, tileSize - 6, 8);
        ctx.stroke();
      }
      ctx.lineCap = "round";
      for (const port of ports) {
        const [dx, dy] = steps[port];
        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(cx + dx * 34, cy + dy * 34);
        ctx.lineWidth = 13;
        ctx.strokeStyle = "#102421";
        ctx.stroke();
        ctx.lineWidth = 6;
        ctx.strokeStyle = flow.has(index) ? greens.mint : greens.line;
        ctx.stroke();
      }
      if (ports.length === 1) {
        const source = index === route[0];
        box(ctx, cx - 12, cy - 12, 24, 24, 6, source || flow.has(index) ? greens.mint : "#e5c17d");
        text(ctx, source ? "S" : "G", cx, cy + 1, "#102421", 15, "center");
      }
    });
    const sourcePulse = 7 + (Math.sin(time * 3) + 1) * 2;
    ctx.beginPath();
    ctx.arc(803, 98, sourcePulse, 0, Math.PI * 2);
    ctx.fillStyle = greens.mint;
    ctx.fill();
    text(ctx, "S  SOURCE", 822, 98, greens.muted, 14);
    box(ctx, 794, 123, 18, 18, 4, "#e5c17d");
    text(ctx, "G  SPROUT", 822, 133, greens.muted, 14);
    text(ctx, "01 / ORCHARD SYSTEMS", 29, 503, greens.muted, 13);
  }

  load(0);
  start({ reset: () => load(0), draw, key, pointer });
})();
</script>
</body>
</html>

Game Source: Circuit Orchard

Creator: NovaCaptain32

Libraries: none

Complexity: complex (439 lines, 16.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: circuit-orchard-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.