🎮ArcadeLab

Moss Mini Golf Preview

by NovaCaptain32
503 lines20.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="description" content="Putt through mossy courtyards. Pick your aim and power, then sink each hole within par.">
  <title>Moss Mini Golf · Preview</title>
  <style>:root {
  color-scheme: dark;
  --accent: #c9f4a6;
  --ink: #254137;
  --paper: #f3f6ff;
  --dim: #bcc9dc;
  font: 16px/1.5 system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: radial-gradient(ellipse at 78% 0%, color-mix(in srgb, var(--ink), var(--accent) 15%), var(--ink) 60%);
  color: var(--paper);
}
main { max-width: 920px; margin: auto; padding: clamp(10px, 3vw, 30px); }
header { display: flex; justify-content: space-between; align-items: end; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; }
h1, h2, p { margin: 0; }
h1 { font-size: clamp(1.5rem, 5vw, 2.7rem); line-height: 1.05; letter-spacing: -.045em; }
h2 { font-size: clamp(1.1rem, 5vw, 2.4rem); line-height: 1.05; letter-spacing: -.04em; }
.kicker { color: var(--accent); font: 750 .72rem/1.2 ui-monospace, monospace; letter-spacing: .12em; margin-bottom: .38rem; }
.tools { display: flex; gap: .4rem; flex-wrap: wrap; }
button {
  border: 1px solid color-mix(in srgb, var(--accent), transparent 66%);
  background: var(--ink);
  color: var(--paper);
  border-radius: 8px;
  font: 700 .82rem/1.15 system-ui, sans-serif;
  padding: .48rem .67rem;
  min-height: 40px;
  cursor: pointer;
}
button.primary { color: var(--ink); background: var(--accent); }
button:hover:enabled { border-color: var(--accent); filter: brightness(1.13); }
button:focus-visible, canvas:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .55; cursor: default; }
.cabinet { position: relative; overflow: clip; border: 1px solid color-mix(in srgb, var(--accent), transparent 62%); border-radius: 14px; background: var(--ink); box-shadow: 0 14px 60px #0006; }
canvas { display: block; width: 100%; height: auto; touch-action: none; }
#overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; background: #07101abd; overflow: auto; }
#overlay[hidden] { display: none; }
.card {
  width: min(100%, 470px);
  padding: clamp(9px, 2.4vw, 24px);
  background: color-mix(in srgb, var(--ink), #202e42 22%);
  border: 1px solid color-mix(in srgb, var(--accent), transparent 54%);
  border-radius: 12px;
}
#message { font-size: clamp(.78rem, 2.7vw, 1rem); margin: .45rem 0 .55rem; }
#record { margin-top: .45rem; color: var(--dim); font-size: .74rem; }
.status { display: flex; gap: .3rem 1rem; flex-wrap: wrap; justify-content: space-between; padding: .62rem .12rem; font-size: .85rem; font-weight: 700; }
#goal { color: var(--accent); }
#touch button { min-width: 48px; min-height: 44px; touch-action: none; }
#help, details { color: var(--dim); font-size: .82rem; margin: .5rem .12rem; }
@media (max-width: 420px) {
  #overlay { align-items: start; }
  #overlay button { min-height: 34px; padding: .35rem .48rem; font-size: .74rem; }
  .card { padding: 8px; }
  #message { margin: .27rem 0 .36rem; }
}
</style>
</head>
<body>
  <main>
    <header>
      <div><p class="kicker">D3MONGAMES / MINI GOLF / PREVIEW</p><h1>Moss Mini Golf · Preview</h1></div>
      <div class="tools"><button id="audio" aria-pressed="false">Sound off</button><button id="pause" disabled>Pause</button></div>
    </header>
    <section class="cabinet" aria-label="Game">
      <canvas id="board" width="720" height="432" tabindex="0" aria-label="Moss Mini Golf · Preview playfield"></canvas>
      <div id="overlay">
        <section class="card" aria-labelledby="card-title">
          <p class="kicker" id="eyebrow">PLAYABLE PREVIEW</p>
          <h2 id="card-title">Moss Mini Golf · Preview</h2>
          <p id="message">Putt through mossy courtyards. Pick your aim and power, then sink each hole within par.</p>
          <div class="tools" id="choices"></div>
          <p id="record"></p>
        </section>
      </div>
    </section>
    <div class="status"><p id="status">Choose a game mode.</p><p id="goal"></p></div>
    <div id="touch" class="tools" aria-label="Game controls"></div>
    <p id="help">Tap where you want the ball to roll. Keyboard arrows aim and change power, then Space putts.</p>
    <details><summary>How progress works</summary><p>This preview includes one stage. Replay to improve your score and medals. The full edition includes 12 campaign stages and a daily challenge.</p></details>
    <noscript>JavaScript is required to play.</noscript>
  </main>
  <script>const preview = true;
const game = {
  slug: "moss-mini-golf",
  title: "Moss Mini Golf",
  genre: "MINI GOLF",
  pitch: "Putt through mossy courtyards. Pick your aim and power, then sink each hole within par.",
  controls: "Tap where you want the ball to roll. Keyboard arrows aim and change power, then Space putts.",
  accent: "#c9f4a6",
  ink: "#254137",
  levels: 12,
  buttons: [["left", "↶ Aim"], ["right", "Aim ↷"], ["up", "More power"], ["down", "Less power"], ["act", "Putt"]],
  create() { return {"holes":[{"tee":{"x":101,"y":192.71030060184188},"cup":{"x":617,"y":274.0366455982998},"walls":[{"x":318.86138179479167,"y":182,"w":74,"h":106}],"par":4},{"tee":{"x":101,"y":162.52116457738913},"cup":{"x":614,"y":255.74969423625618},"walls":[{"x":299.4321724255569,"y":158,"w":74,"h":106}],"par":4},{"tee":{"x":101,"y":269.84183626387266},"cup":{"x":611,"y":189.376766525656},"walls":[{"x":332.9517209706828,"y":175,"w":74,"h":106}],"par":4}],"hole":0,"x":101,"y":192.71030060184188,"angle":0,"power":132,"vx":0,"vy":0,"shots":0,"strokes":0,"totalPar":0,"scored":0,"rolling":false}; },
  tap(s, x, y, chime) {
    const dx = x - s.x, dy = y - s.y;
    if (Math.hypot(dx, dy) > 8) putt(s, Math.atan2(dy, dx), Math.hypot(dx, dy), chime);
  },
  input(s, action, chime) { if (action === "act") putt(s, s.angle, s.power, chime); },
  update(s, dt, down, chime) {
    if (s.hole >= s.holes.length) {
      return { clear: s.scored === s.holes.length, medals: s.strokes <= s.totalPar - 3 ? 3 : s.strokes <= s.totalPar ? 2 : 1,
        score: Math.max(50, s.scored * 620 - s.strokes * 65), message: "Sank " + s.scored + " of " + s.holes.length + " holes in " + s.strokes + " strokes. Total par: " + s.totalPar + "." };
    }
    const hole = s.holes[s.hole];
    if (!s.rolling) {
      s.angle += (Number(down.has("right")) - Number(down.has("left"))) * dt * 2.8;
      s.power = Math.max(28, Math.min(282, s.power + (Number(down.has("up")) - Number(down.has("down"))) * dt * 154));
      return;
    }
    for (let time = dt; time > .00001; time -= 1 / 180) {
      const step = Math.min(time, 1 / 180);
      const friction = Math.exp(-2.14 * step);
      const x = s.x + s.vx * (1 - friction) / 2.14;
      const y = s.y + s.vy * (1 - friction) / 2.14;
      if (solid(hole, x, s.y)) s.vx *= -.64;
      else s.x = x;
      if (solid(hole, s.x, y)) s.vy *= -.64;
      else s.y = y;
      s.vx *= friction;
      s.vy *= friction;
      if (Math.hypot(s.x - hole.cup.x, s.y - hole.cup.y) < 13 && Math.hypot(s.vx, s.vy) < 180) {
        s.scored++;
        nextHole(s, chime);
        break;
      }
    }
    if (s.rolling && Math.hypot(s.vx, s.vy) < 8) {
      s.rolling = false;
      if (s.shots >= hole.par + 4) nextHole(s, chime);
    }
  },
  status(s) { return "Hole " + Math.min(s.hole + 1, s.holes.length) + "/" + s.holes.length + " · " + s.shots + " strokes · " + (s.rolling ? "Rolling" : "Power " + Math.round(s.power / 282 * 100) + "%"); },
  draw(s, { box, circle, line, label }) {
    const hole = s.holes[s.hole] ?? s.holes.at(-1);
    box(0, 0, 720, 432, "#244334");
    box(35, 37, 650, 360, "#95b97d", 22);
    box(47, 49, 626, 336, "#54855d", 14);
    for (let i = 0; i < 18; i++) {
      const x = 63 + (i * 79 + 14) % 588, y = 64 + (i * 61 + 21) % 293;
      circle(x, y, 8 + i % 4 * 2, "#629269");
    }
    for (const wall of hole.walls) {
      box(wall.x, wall.y, wall.w, wall.h, "#2a5544", 10);
      line(wall.x + 10, wall.y + 9, wall.x + wall.w - 10, wall.y + 9, 4, "#9ac277");
    }
    circle(hole.cup.x, hole.cup.y, 17, "#a9c985");
    circle(hole.cup.x, hole.cup.y, 11, "#203f35");
    line(hole.cup.x + 7, hole.cup.y - 1, hole.cup.x + 7, hole.cup.y - 39, 3, "#fff0c5");
    box(hole.cup.x + 8, hole.cup.y - 39, 24, 12, "#f6ab93", 2);
    if (!s.rolling) {
      for (let t = 12; t < s.power; t += 14) {
        const x = s.x + Math.cos(s.angle) * t, y = s.y + Math.sin(s.angle) * t;
        if (solid(hole, x, y)) break;
        circle(x, y, 2, "#d4e9a9");
      }
    }
    circle(s.x, s.y + 3, 8, "#315b46");
    circle(s.x, s.y, 7, "#fff6d6");
    label("PAR " + hole.par, 57, 19, 13, "#c9f4a6");
  }
};

function solid(hole, x, y) {
  return x < 54 || x > 666 || y < 56 || y > 378 ||
    hole.walls.some(wall => x > wall.x - 7 && x < wall.x + wall.w + 7 && y > wall.y - 7 && y < wall.y + wall.h + 7);
}

function putt(s, angle, power, chime) {
  if (s.rolling || s.hole >= s.holes.length) return;
  s.angle = angle;
  const impulse = Math.min(282, Math.max(10, power)) * 2.14;
  s.vx = Math.cos(angle) * impulse;
  s.vy = Math.sin(angle) * impulse;
  s.rolling = true;
  s.shots++;
  chime(450);
}

function nextHole(s, chime) {
  s.strokes += s.shots;
  s.totalPar += s.holes[s.hole].par;
  s.hole++;
  s.shots = 0;
  s.rolling = false;
  if (s.hole < s.holes.length) {
    s.x = s.holes[s.hole].tee.x;
    s.y = s.holes[s.hole].tee.y;
    s.angle = 0;
  }
  chime(700);
}

const board = document.querySelector("#board");
const ctx = board.getContext("2d");
const ui = Object.fromEntries(["overlay", "eyebrow", "card-title", "message", "choices", "record", "status", "goal", "touch", "audio", "pause"].map(id => [id, document.getElementById(id)]));
const campaignCount = preview ? 1 : game.levels;
const saveKey = "d3mg-collection-v1:" + game.slug + (preview ? ":preview" : "");
const dateKey = date => date.toISOString().slice(0, 10);
const today = () => dateKey(new Date());

function hash(text) {
  let value = 2166136261;
  for (const character of text) value = Math.imul(value ^ character.charCodeAt(0), 16777619);
  return value >>> 0;
}

function random(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let value = Math.imul(seed ^ seed >>> 15, seed | 1);
    value ^= value + Math.imul(value ^ value >>> 7, value | 61);
    return ((value ^ value >>> 14) >>> 0) / 4294967296;
  };
}

function readProgress() {
  const empty = { levels: {}, days: {}, streak: 0, last: "" };
  try {
    const record = JSON.parse(localStorage.getItem(saveKey));
    if (record?.levels && record.days) return { ...empty, ...record };
  } catch {}
  return empty;
}

function saveProgress() {
  try { localStorage.setItem(saveKey, JSON.stringify(progress)); } catch {}
}

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

function circle(x, y, radius, color) {
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fill();
}

function line(x, y, endX, endY, width, color) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = "round";
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(endX, endY);
  ctx.stroke();
}

function label(text, x, y, size, color = "#f3f6ff", align = "left") {
  ctx.fillStyle = color;
  ctx.textAlign = align;
  ctx.textBaseline = "middle";
  ctx.font = "700 " + size + "px system-ui, sans-serif";
  ctx.fillText(text, x, y);
}

const art = { ctx, box, circle, line, label, accent: game.accent, ink: game.ink };
const progress = readProgress();
const down = new Set();
const actions = new Map([
  ["ArrowLeft", "left"], ["a", "left"], ["ArrowRight", "right"], ["d", "right"],
  ["ArrowUp", "up"], ["w", "up"], ["ArrowDown", "down"], ["s", "down"],
  [" ", "act"], ["Enter", "act"], ["z", "undo"], ["x", "mark"]
]);
for (let digit = 1; digit <= 9; digit++) actions.set(String(digit), "digit" + digit);
actions.set("Backspace", "erase");
actions.set("Delete", "erase");

let audioContext, soundOn = false;
function chime(frequency = 560) {
  if (!soundOn || !audioContext || audioContext.state !== "running") return;
  const note = audioContext.createOscillator();
  const volume = audioContext.createGain();
  const time = audioContext.currentTime;
  note.type = "sine";
  note.frequency.value = frequency;
  volume.gain.setValueAtTime(.065, time);
  volume.gain.exponentialRampToValueAtTime(.001, time + .14);
  note.connect(volume).connect(audioContext.destination);
  note.start(time);
  note.stop(time + .15);
}

let phase = "menu";
let level = 1;
let daily = false;
let date = today();
let state;

function highestStage() {
  let stage = 1;
  while (stage < campaignCount && progress.levels[stage]) stage++;
  return stage;
}

function prepare(stage, isDaily, day) {
  level = stage;
  daily = isDaily;
  date = day;
  const seed = hash(game.slug + "/" + (isDaily ? "daily/" + day : "stage/" + stage));
  state = game.create({ stage, daily: isDaily, seed, rng: random(seed) });
  down.clear();
}

function showCard(heading, title, message, buttons, record = "") {
  ui.eyebrow.textContent = heading;
  ui["card-title"].textContent = title;
  ui.message.textContent = message;
  ui.record.textContent = record;
  ui.choices.replaceChildren();
  for (const [name, callback, disabled] of buttons) {
    const button = document.createElement("button");
    button.type = "button";
    button.textContent = name;
    button.disabled = !!disabled;
    if (!ui.choices.children.length) button.className = "primary";
    button.addEventListener("click", callback);
    ui.choices.append(button);
  }
  ui.overlay.hidden = false;
}

function menu(focus = true) {
  phase = "menu";
  prepare(highestStage(), false, today());
  ui.pause.disabled = true;
  if (preview) {
    showCard("PLAYABLE PREVIEW", game.title, game.pitch, [["Play preview", () => begin(1, false)]], "One stage · Keyboard and touch");
    if (focus) ui.choices.querySelector("button").focus();
    return;
  }
  const earned = Object.values(progress.levels).reduce((sum, entry) => sum + entry.medals, 0);
  const count = Object.keys(progress.levels).length;
  const dailyRecord = progress.days[today()];
  const yesterday = new Date();
  yesterday.setUTCDate(yesterday.getUTCDate() - 1);
  const activeStreak = progress.last === today() || progress.last === dateKey(yesterday) ? progress.streak : 0;
  showCard("CAMPAIGN AND DAILY CHALLENGE", game.title, game.pitch, [
    [count ? "Continue stage " + highestStage() : "Play stage 1", () => begin(highestStage(), false)],
    ["Daily challenge", () => begin(3, true)],
    ["Stage select", chooseStage, count === 0]
  ], count + "/" + campaignCount + " stages cleared · " + earned + "/" + (campaignCount * 3) + " medals" +
    (dailyRecord ? " · Daily best " + dailyRecord.score : "") +
    (activeStreak ? " · " + activeStreak + "-day streak" : ""));
  if (focus) ui.choices.querySelector("button").focus();
}

function chooseStage() {
  const buttons = [];
  for (let stage = 1; stage <= campaignCount; stage++) {
    const result = progress.levels[stage];
    buttons.push(["Stage " + stage + (result ? " · " + "★".repeat(result.medals) : ""), () => begin(stage, false), stage > highestStage()]);
  }
  buttons.push(["Menu", menu]);
  showCard("CAMPAIGN", "Choose a stage", "Clear each stage to open the next. Replay earlier stages to improve your medals.", buttons);
  ui.choices.querySelector("button").focus();
}

function begin(stage, isDaily, day = today()) {
  prepare(stage, isDaily, day);
  phase = "play";
  ui.pause.disabled = false;
  ui.pause.textContent = "Pause";
  ui.overlay.hidden = true;
  board.focus({ preventScroll: true });
  chime(420);
}

function pause() {
  if (phase === "paused") {
    phase = "play";
    ui.overlay.hidden = true;
    ui.pause.textContent = "Pause";
    board.focus({ preventScroll: true });
    return;
  }
  if (phase !== "play") return;
  phase = "paused";
  down.clear();
  ui.pause.textContent = "Resume";
  showCard("TAKE A BREAK", "Paused", "Your run is waiting.", [
    ["Resume", pause], ["Restart", () => begin(level, daily, date)], ["Menu", menu]
  ]);
  ui.choices.querySelector("button").focus();
}

function finish(result) {
  phase = "result";
  down.clear();
  ui.pause.disabled = true;
  if (result.clear || daily) {
    const shelf = daily ? progress.days : progress.levels;
    const id = daily ? date : level;
    const old = shelf[id];
    shelf[id] = { score: Math.max(old?.score ?? 0, result.score), medals: Math.max(old?.medals ?? 0, result.clear ? result.medals : 0) };
    if (daily && result.clear && (!progress.last || date > progress.last)) {
      const previous = new Date(date + "T00:00:00Z");
      previous.setUTCDate(previous.getUTCDate() - 1);
      progress.streak = dateKey(previous) === progress.last ? progress.streak + 1 : 1;
      progress.last = date;
    }
    saveProgress();
  }
  chime(result.clear ? 810 : 210);
  const buttons = [["Replay", () => begin(level, daily, date)], ["Menu", menu]];
  if (result.clear && !daily && level < campaignCount) buttons.unshift(["Next stage", () => begin(level + 1, false)]);
  showCard(preview ? "PLAYABLE PREVIEW" : daily ? "DAILY CHALLENGE" : "STAGE " + level, result.clear ? "Challenge cleared" : "Run complete",
    result.message, buttons, "Score " + result.score + (result.clear ? " · " + "★".repeat(result.medals) + "☆".repeat(3 - result.medals) : ""));
  ui.choices.querySelector("button").focus();
}

function trigger(action) {
  if (phase !== "play") return;
  if (!down.has(action)) game.input?.(state, action, chime);
  down.add(action);
}

document.addEventListener("keydown", event => {
  if (event.key === "Escape" || event.key.toLowerCase() === "p") {
    if (!event.repeat && (phase === "play" || phase === "paused")) {
      event.preventDefault();
      pause();
    }
    return;
  }
  if (phase !== "play" || event.target.closest("button, summary")) return;
  const action = actions.get(event.key.length === 1 ? event.key.toLowerCase() : event.key);
  if (action) {
    event.preventDefault();
    trigger(action);
  }
});
document.addEventListener("keyup", event => down.delete(actions.get(event.key.length === 1 ? event.key.toLowerCase() : event.key)));
document.addEventListener("visibilitychange", () => { if (document.hidden && phase === "play") pause(); });
window.addEventListener("blur", () => down.clear());
board.addEventListener("pointerdown", event => {
  if (phase !== "play") return;
  const bounds = board.getBoundingClientRect();
  game.tap?.(state, (event.clientX - bounds.x) * 720 / bounds.width, (event.clientY - bounds.y) * 432 / bounds.height, chime);
});
for (const [action, text] of game.buttons) {
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = text;
  button.addEventListener("pointerdown", event => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    trigger(action);
  });
  for (const eventName of ["pointerup", "pointercancel", "lostpointercapture"]) {
    button.addEventListener(eventName, () => down.delete(action));
  }
  button.addEventListener("click", event => {
    if (event.detail === 0) { trigger(action); down.delete(action); }
  });
  ui.touch.append(button);
}
ui.pause.addEventListener("click", pause);
ui.audio.addEventListener("click", async () => {
  soundOn = !soundOn;
  if (soundOn && window.AudioContext) {
    audioContext ??= new AudioContext();
    await audioContext.resume().catch(() => {});
    chime();
  }
  ui.audio.textContent = soundOn ? "Sound on" : "Sound off";
  ui.audio.setAttribute("aria-pressed", soundOn);
});

let last = performance.now();
function frame(now) {
  const dt = Math.min((now - last) / 1000, .05);
  last = now;
  if (phase === "play") {
    const result = game.update(state, dt, down, chime);
    if (result) finish(result);
  }
  game.draw(state, art);
  const status = game.status(state);
  if (ui.status.textContent !== status) ui.status.textContent = status;
  ui.goal.textContent = preview ? "Preview · Stage 1" : daily ? "Daily · " + date : "Campaign · " + level + "/" + campaignCount;
  requestAnimationFrame(frame);
}
menu(false);
requestAnimationFrame(frame);
</script>
</body>
</html>

Game Source: Moss Mini Golf Preview

Creator: NovaCaptain32

Libraries: none

Complexity: complex (503 lines, 20.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: moss-mini-golf-preview-novacaptain32" to link back to the original. Then publish at arcadelab.ai/publish.