7

Mini Golf: 6 Holes

drag from the ball to aim, release to shoot

A top-down 2D putting course with six hand-built holes — straight away, dog-leg, U-turn around a long wall, a sand-trap shortcut, a narrow water bridge, and a bank-shot finisher. Drag from the ball to set direction and power (the aim line goes red as you approach max power), then release to launch. The ball rolls with kinetic friction on grass, slows dramatically inside sand traps, bounces elastically off interior walls and the rough border, and resets to the tee with a one-stroke penalty if it touches water. Stroke count and par are shown per hole, with a running total against par across the round; after the sixth pin, the final score is displayed and a Play again button restarts the course. The drag-aim works identically on mouse and touch, so the same controls play on phone, tablet, and desktop.

idle
428 lines · vanilla
view source
// Mini Golf: 6 Holes
// Top-down 2D putting green. Drag from the ball to aim and set power,
// release to launch. Ball rolls with kinetic friction, bounces elastically
// off walls, drags hard in sand, and water resets you with a +1 stroke.
// Works identically on mouse and touch (input.mouseDown + mouseX/Y).

let W = 0, H = 0;

// ---- Level data --------------------------------------------------------
// Coords are in a normalized 100x100 design space; mapped to pixels at runtime.
// Each level: walls (interior obstacles), sand boxes, water boxes,
// start (ball spawn), hole (goal), par.
//
// The play area is always the full canvas, with a 4px border wall.
// All rectangles are axis-aligned: { x, y, w, h } in design units.

const LEVELS = [
  // 1 — Straight shot, gentle warmup.
  {
    name: "Straight away",
    par: 2,
    start: { x: 20, y: 50 },
    hole:  { x: 80, y: 50 },
    walls: [],
    sand:  [],
    water: [],
  },
  // 2 — L-shape: corridor turns 90 degrees, wall in the middle.
  {
    name: "Dog-leg",
    par: 3,
    start: { x: 15, y: 80 },
    hole:  { x: 85, y: 20 },
    walls: [
      { x: 30, y: 30, w: 45, h: 25 }, // big block forces a turn
    ],
    sand:  [],
    water: [],
  },
  // 3 — U-turn around a long wall.
  {
    name: "U-turn",
    par: 4,
    start: { x: 15, y: 20 },
    hole:  { x: 15, y: 80 },
    walls: [
      { x: 30, y: 15, w: 8, h: 70 },  // long vertical wall splits the green
    ],
    sand:  [],
    water: [],
  },
  // 4 — Sand trap risk: shortcut through sand, or go around.
  {
    name: "Sand risk",
    par: 3,
    start: { x: 15, y: 50 },
    hole:  { x: 85, y: 50 },
    walls: [],
    sand:  [
      { x: 35, y: 35, w: 30, h: 30 }, // big sand patch right in the middle
    ],
    water: [],
  },
  // 5 — Water bridge: a thin channel of green between two ponds.
  {
    name: "Water bridge",
    par: 3,
    start: { x: 12, y: 50 },
    hole:  { x: 88, y: 50 },
    walls: [],
    sand:  [],
    water: [
      { x: 35, y: 10, w: 30, h: 33 }, // top pond
      { x: 35, y: 57, w: 30, h: 33 }, // bottom pond — leaves a strip ~14u wide
    ],
  },
  // 6 — Tricky angle: bounce off a wall to reach a tucked pin.
  {
    name: "Bank shot",
    par: 4,
    start: { x: 15, y: 80 },
    hole:  { x: 80, y: 25 },
    walls: [
      { x: 45, y: 50, w: 35, h: 8 },  // middle horizontal wall
      { x: 60, y: 10, w: 8, h: 20 },  // little vertical near the hole
    ],
    sand:  [{ x: 25, y: 25, w: 18, h: 18 }],
    water: [],
  },
];

// ---- State -------------------------------------------------------------
let levelIdx = 0;
let strokes = 0;       // strokes on the current hole
let totalStrokes = 0;  // sum across completed holes
let totalPar = 0;      // par sum across completed holes
let ball = { x: 0, y: 0, vx: 0, vy: 0, r: 1.4 }; // in design units; r in design u
let dragging = false;
let dragStart = { x: 0, y: 0 };   // pixel coords of drag origin (= ball center)
let dragNow = { x: 0, y: 0 };
let lastMouseDown = false;
let holeComplete = false;
let holeFlash = 0;     // celebration ms remaining
let finished = false;  // all 6 holes done

// ---- Geometry helpers --------------------------------------------------
// Convert design (0..100) to pixels. We use the smaller dim so the green
// is square and centered; the rest is dark surround.
function geom() {
  const size = Math.min(W, H);
  const ox = (W - size) / 2;
  const oy = (H - size) / 2;
  const s = size / 100;
  return { ox, oy, s, size };
}
function d2p(p) { const g = geom(); return { x: g.ox + p.x * g.s, y: g.oy + p.y * g.s }; }
function du2px(u) { return geom().s * u; }
function p2d(px) { const g = geom(); return { x: (px.x - g.ox) / g.s, y: (px.y - g.oy) / g.s }; }

// AABB list for a level (in design units): walls + the four border walls.
function levelWalls(lv) {
  const T = 3; // border thickness in design units
  return [
    { x: 0, y: 0, w: 100, h: T },        // top
    { x: 0, y: 100 - T, w: 100, h: T },  // bottom
    { x: 0, y: 0, w: T, h: 100 },        // left
    { x: 100 - T, y: 0, w: T, h: 100 },  // right
    ...lv.walls,
  ];
}

function pointInRect(px, py, r) {
  return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
function circleRectOverlap(cx, cy, cr, r) {
  const nx = Math.max(r.x, Math.min(cx, r.x + r.w));
  const ny = Math.max(r.y, Math.min(cy, r.y + r.h));
  const dx = cx - nx, dy = cy - ny;
  return dx * dx + dy * dy < cr * cr;
}

// ---- Level lifecycle ---------------------------------------------------
function loadLevel(i) {
  const lv = LEVELS[i];
  ball.x = lv.start.x;
  ball.y = lv.start.y;
  ball.vx = 0;
  ball.vy = 0;
  strokes = 0;
  holeComplete = false;
  holeFlash = 0;
  dragging = false;
}

function reset() {
  levelIdx = 0;
  totalStrokes = 0;
  totalPar = 0;
  finished = false;
  loadLevel(0);
}

function init({ width, height }) {
  W = width; H = height;
  reset();
}

// ---- Physics -----------------------------------------------------------
// Tunables in design units (per second).
const MAX_LAUNCH_SPEED = 110;   // d.u./s on full-power drag
const MAX_DRAG_PX_FRAC = 0.30;  // drag length capped to this fraction of canvas
const STOP_SPEED = 1.5;         // d.u./s; below this we snap to rest
const FRICTION_GREEN = 0.55;    // linear damping coefficient (1/s)
const FRICTION_SAND  = 4.5;     // much higher drag in sand
const HOLE_RADIUS    = 2.4;     // design units; ball center must enter this
const HOLE_CAPTURE_SPEED = 70;  // d.u./s; faster than this and we roll over

function step(dt, lv) {
  if (holeComplete || finished) return;

  // Apply friction.
  const inSand = lv.sand.some((r) => pointInRect(ball.x, ball.y, r));
  const k = inSand ? FRICTION_SAND : FRICTION_GREEN;
  const damp = Math.exp(-k * dt);
  ball.vx *= damp;
  ball.vy *= damp;

  // Integrate, with collision substeps so we don't tunnel walls at high speed.
  const speed = Math.hypot(ball.vx, ball.vy);
  const maxStep = ball.r * 0.6; // d.u. per substep
  const dist = speed * dt;
  const subs = Math.max(1, Math.ceil(dist / maxStep));
  const sdt = dt / subs;
  const walls = levelWalls(lv);

  for (let s = 0; s < subs; s++) {
    ball.x += ball.vx * sdt;
    ball.y += ball.vy * sdt;

    // Wall collisions (axis-aligned). Resolve by finding the smallest push-out
    // and reflecting the velocity component normal to that axis.
    for (const r of walls) {
      if (!circleRectOverlap(ball.x, ball.y, ball.r, r)) continue;
      // Find closest point on rect.
      const nx = Math.max(r.x, Math.min(ball.x, r.x + r.w));
      const ny = Math.max(r.y, Math.min(ball.y, r.y + r.h));
      let dx = ball.x - nx, dy = ball.y - ny;
      let len = Math.hypot(dx, dy);
      if (len === 0) {
        // Ball center inside the rect — push out along the shallowest axis.
        const leftPen   = ball.x - r.x;
        const rightPen  = r.x + r.w - ball.x;
        const topPen    = ball.y - r.y;
        const bottomPen = r.y + r.h - ball.y;
        const m = Math.min(leftPen, rightPen, topPen, bottomPen);
        if (m === leftPen)        { dx = -1; dy = 0; len = 1; ball.x = r.x - ball.r - 0.01; }
        else if (m === rightPen)  { dx = 1;  dy = 0; len = 1; ball.x = r.x + r.w + ball.r + 0.01; }
        else if (m === topPen)    { dx = 0; dy = -1; len = 1; ball.y = r.y - ball.r - 0.01; }
        else                      { dx = 0; dy = 1;  len = 1; ball.y = r.y + r.h + ball.r + 0.01; }
      } else {
        // Push out along (dx,dy)/len so the circle just touches.
        const overlap = ball.r - len + 0.001;
        ball.x += (dx / len) * overlap;
        ball.y += (dy / len) * overlap;
      }
      // Reflect velocity along normal (dx, dy)/len. Slight energy loss.
      const nrmx = dx / len, nrmy = dy / len;
      const vDotN = ball.vx * nrmx + ball.vy * nrmy;
      if (vDotN < 0) {
        const RESTITUTION = 0.86;
        ball.vx -= (1 + RESTITUTION) * vDotN * nrmx;
        ball.vy -= (1 + RESTITUTION) * vDotN * nrmy;
      }
    }

    // Water — reset with +1 stroke. Use the ball center inside any pond.
    if (lv.water.some((r) => pointInRect(ball.x, ball.y, r))) {
      strokes += 1; // penalty
      ball.x = lv.start.x;
      ball.y = lv.start.y;
      ball.vx = 0;
      ball.vy = 0;
      return;
    }

    // Hole — capture if center is close enough AND we're not screaming through.
    const dxh = ball.x - lv.hole.x, dyh = ball.y - lv.hole.y;
    if (dxh * dxh + dyh * dyh < HOLE_RADIUS * HOLE_RADIUS) {
      const sp = Math.hypot(ball.vx, ball.vy);
      if (sp < HOLE_CAPTURE_SPEED) {
        ball.x = lv.hole.x;
        ball.y = lv.hole.y;
        ball.vx = 0;
        ball.vy = 0;
        holeComplete = true;
        holeFlash = 1.4;
        return;
      }
      // Otherwise the ball lips out — no special handling, just rolls on.
    }
  }

  // Snap to rest below threshold so the player can re-aim.
  if (Math.hypot(ball.vx, ball.vy) < STOP_SPEED) {
    ball.vx = 0;
    ball.vy = 0;
  }
}

// ---- Input -------------------------------------------------------------
function ballAtRest() {
  return ball.vx === 0 && ball.vy === 0;
}

function handleInput(input) {
  const md = input.mouseDown;
  const mp = { x: input.mouseX, y: input.mouseY };

  if (finished) {
    // Click the restart button (or anywhere — forgiving on small screens).
    if (md && !lastMouseDown) {
      // If a button rect exists, prefer it as the primary affordance, but
      // any click still works so the user can't get stuck.
      reset();
    }
    lastMouseDown = md;
    return;
  }

  if (holeComplete) {
    // Tap to advance to the next hole.
    if (md && !lastMouseDown) {
      totalStrokes += strokes;
      totalPar += LEVELS[levelIdx].par;
      if (levelIdx >= LEVELS.length - 1) {
        finished = true;
      } else {
        levelIdx++;
        loadLevel(levelIdx);
      }
    }
    lastMouseDown = md;
    return;
  }

  // Drag-to-aim: start drag only if the ball is at rest AND the press
  // begins on (or near) the ball. That way a stray click in a corner
  // doesn't immediately shoot.
  if (md && !lastMouseDown && ballAtRest()) {
    const bp = d2p(ball);
    const grab = Math.max(du2px(6), 28); // generous touch target
    if (Math.hypot(mp.x - bp.x, mp.y - bp.y) <= grab) {
      dragging = true;
      dragStart = { x: bp.x, y: bp.y };
      dragNow = mp;
    }
  } else if (md && dragging) {
    dragNow = mp;
  } else if (!md && lastMouseDown && dragging) {
    // Release — convert pull-back into a launch (opposite direction).
    const dxPx = dragStart.x - dragNow.x;
    const dyPx = dragStart.y - dragNow.y;
    const lenPx = Math.hypot(dxPx, dyPx);
    const cap = Math.min(W, H) * MAX_DRAG_PX_FRAC;
    const power = Math.min(1, lenPx / cap);
    if (power > 0.04) {
      // Convert pixel direction to design-space velocity.
      const g = geom();
      const dxd = dxPx / g.s, dyd = dyPx / g.s;
      const dlen = Math.hypot(dxd, dyd) || 1;
      const sp = MAX_LAUNCH_SPEED * power;
      ball.vx = (dxd / dlen) * sp;
      ball.vy = (dyd / dlen) * sp;
      strokes++;
    }
    dragging = false;
  }
  lastMouseDown = md;
}

// ---- Drawing -----------------------------------------------------------
function drawRectD(ctx, r, fill) {
  const tl = d2p({ x: r.x, y: r.y });
  ctx.fillStyle = fill;
  ctx.fillRect(tl.x, tl.y, du2px(r.w), du2px(r.h));
}

function drawScene(ctx, dt) {
  const lv = LEVELS[levelIdx];
  const g = geom();

  // Surround.
  ctx.fillStyle = "#1a1a1f";
  ctx.fillRect(0, 0, W, H);

  // Green.
  ctx.fillStyle = "#2f8a3e";
  ctx.fillRect(g.ox, g.oy, g.size, g.size);

  // Subtle grass texture stripes (very cheap).
  ctx.fillStyle = "rgba(255,255,255,0.025)";
  const stripeH = du2px(4);
  for (let y = g.oy; y < g.oy + g.size; y += stripeH * 2) {
    ctx.fillRect(g.ox, y, g.size, stripeH);
  }

  // Sand traps.
  for (const r of lv.sand)  drawRectD(ctx, r, "#d6b66a");
  // Water hazards.
  for (const r of lv.water) drawRectD(ctx, r, "#3a6db0");
  // Water ripples (single subtle highlight strip per pond).
  ctx.fillStyle = "rgba(255,255,255,0.08)";
  for (const r of lv.water) {
    const tl = d2p({ x: r.x + 1, y: r.y + r.h * 0.35 });
    ctx.fillRect(tl.x, tl.y, du2px(r.w - 2), Math.max(2, du2px(0.6)));
  }

  // Interior walls (dark brown — they're the rough boundary).
  for (const r of lv.walls) drawRectD(ctx, r, "#5b3a22");

  // Border walls drawn as a thick outline so they look like the cup rim.
  ctx.strokeStyle = "#5b3a22";
  ctx.lineWidth = Math.max(2, du2px(3));
  ctx.strokeRect(g.ox + ctx.lineWidth / 2, g.oy + ctx.lineWidth / 2,
                 g.size - ctx.lineWidth, g.size - ctx.lineWidth);

  // Hole.
  const hp = d2p(lv.hole);
  ctx.fillStyle = "#0d0d12";
  ctx.beginPath();
  ctx.arc(hp.x, hp.y, du2px(HOLE_RADIUS), 0, Math.PI * 2);
  ctx.fill();

  // Flag.
  const poleH = du2px(8);
  const flagW = du2px(5);
  const flagH = du2px(2.6);
  ctx.strokeStyle = "#222";
  ctx.lineWidth = Math.max(1, du2px(0.4));
  ctx.beginPath();
  ctx.moveTo(hp.x, hp.y);
  ctx.lineTo(hp.x, hp.y - poleH);
  ctx.stroke();
  ctx.fillStyle = "#d63a3a";
  ctx.beginPath();
  ctx.moveTo(hp.x, hp.y - poleH);
  ctx.lineTo(hp.x + flagW, hp.y - poleH + flagH / 2);
  ctx.lineTo(hp.x, hp.y - poleH + flagH);
  ctx.closePath();
  ctx.fill();

  // Ball.
  const bp = d2p(ball);
  const br = du2px(ball.r);
  ctx.fillStyle = "#fff";
  ctx.beginPath();
  ctx.arc(bp.x, bp.y, br, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = "rgba(0,0,0,0.35)";
  ctx.lineWidth = 1;
  ctx.stroke();

  // Aim indicator.
  if (dragging) {
    // Vector from current cursor back to ball = launch direction.
    const dxPx = bp.x - dragNow.x;
    const dyPx = bp.y - dragNow.y;
    const lenPx = Math.hypot(dxPx, dyPx);
    const cap = Math.min(W, H) * MAX_DRAG_PX_FRAC;
    const power = Math.min(1, lenPx / cap);
    if (power > 0.02) {
      const dirx = dxPx / (lenPx || 1);
      const diry = dyPx / (lenPx || 1);
      const aimLen = cap * power;
      ctx.strokeStyle = `rgba(255,${(255 * (1 - power)) | 0},${(255 * (1 - power)) | 0},0.9)`;
      ctx.lineWidth = Math.max(2, du2px(0.6));
      ctx.beginPath();
      ctx.moveTo(bp.x, bp.y);
      ctx.lineTo(bp.x + dirx * aimLen, bp.y + diry * aimLen);
      ctx.stroke();
      // Arrowhead.
      const tipx = bp.x + dirx * aimLen, tipy = bp.y + diry * aimLen;
      const ah = Math.max(6, du2px(2));
      const px1 = tipx - dirx * ah - diry * ah * 0.6;
      const py1 = tipy - diry * ah + dirx * ah * 0.6;
      const px2 = tipx - dirx * ah + diry * ah * 0.6;
      const py2 = tipy - diry * ah - dirx * ah * 0.6;
      ctx.fillStyle = ctx.strokeStyle;
      ctx.beginPath();
      ctx.moveTo(tipx, tipy); ctx.lineTo(px1, py1); ctx.lineTo(px2, py2);
      ctx.closePath();
      ctx.fill();

      // Power bar (small, anchored top-left of the green so it doesn't
      // crowd the ball).
      const barW = 90, barH = 8;
      const bx = g.ox + 12, by = g.oy + g.size - 22;
      ctx.fillStyle = "rgba(0,0,0,0.45)";
      ctx.fillRect(bx - 2, by - 2, barW + 4, barH + 4);
      ctx.fillStyle = "#444";
      ctx.fillRect(bx, by, barW, barH);
      ctx.fillStyle = power > 0.85 ? "#ff5252" : power > 0.5 ? "#ffd24a" : "#7ddc7d";
      ctx.fillRect(bx, by, barW * power, barH);
    }
  }

  // HUD (top-left of canvas, in the dark surround so it doesn't sit on grass).
  ctx.fillStyle = "#eaeaea";
  ctx.font = "bold 14px sans-serif";
  ctx.textBaseline = "top";
  ctx.textAlign = "left";
  ctx.fillText(`Hole ${levelIdx + 1} / ${LEVELS.length}`, 8, 8);
  ctx.font = "12px sans-serif";
  ctx.fillText(`Par ${lv.par}   Strokes ${strokes}`, 8, 26);
  ctx.fillText(lv.name, 8, 42);
  // Running total (top-right of canvas).
  ctx.textAlign = "right";
  const partialStrokes = totalStrokes + strokes;
  const partialPar = totalPar + lv.par;
  const diff = partialStrokes - partialPar;
  const diffStr = diff === 0 ? "E" : (diff > 0 ? `+${diff}` : `${diff}`);
  ctx.fillText(`Total ${partialStrokes}  (${diffStr})`, W - 8, 8);

  // Hole-complete overlay.
  if (holeComplete && !finished) {
    holeFlash -= dt;
    const lv2 = LEVELS[levelIdx];
    const d = strokes - lv2.par;
    let label = "";
    if (strokes === 1) label = "Hole in one!";
    else if (d <= -2) label = "Eagle!";
    else if (d === -1) label = "Birdie";
    else if (d === 0) label = "Par";
    else if (d === 1) label = "Bogey";
    else label = `+${d}`;
    ctx.textAlign = "center";
    ctx.fillStyle = "rgba(0,0,0,0.55)";
    ctx.fillRect(g.ox, g.oy + g.size / 2 - 36, g.size, 72);
    ctx.fillStyle = "#fff";
    ctx.font = "bold 22px sans-serif";
    ctx.fillText(label, g.ox + g.size / 2, g.oy + g.size / 2 - 24);
    ctx.font = "13px sans-serif";
    ctx.fillText(`Strokes ${strokes}  ·  tap for next hole`, g.ox + g.size / 2, g.oy + g.size / 2 + 6);
  }

  // Finished screen.
  if (finished) {
    ctx.fillStyle = "rgba(0,0,0,0.7)";
    ctx.fillRect(g.ox, g.oy, g.size, g.size);
    ctx.textAlign = "center";
    ctx.fillStyle = "#fff";
    ctx.font = "bold 26px sans-serif";
    ctx.fillText("Round complete", g.ox + g.size / 2, g.oy + g.size / 2 - 60);
    const diff2 = totalStrokes - totalPar;
    const diffStr2 = diff2 === 0 ? "even par" : (diff2 > 0 ? `+${diff2} over` : `${-diff2} under`);
    ctx.font = "16px sans-serif";
    ctx.fillText(`Total strokes ${totalStrokes}  ·  par ${totalPar}  ·  ${diffStr2}`,
                 g.ox + g.size / 2, g.oy + g.size / 2 - 22);

    // Restart button. Visual affordance only — clicks anywhere on the
    // overlay restart so phones with imprecise touch don't get stuck.
    const btnW = 160, btnH = 40;
    const bx = g.ox + g.size / 2 - btnW / 2;
    const by = g.oy + g.size / 2 + 16;
    ctx.fillStyle = "#3a8e4d";
    ctx.fillRect(bx, by, btnW, btnH);
    ctx.fillStyle = "#fff";
    ctx.font = "bold 16px sans-serif";
    ctx.textBaseline = "middle";
    ctx.fillText("Play again", bx + btnW / 2, by + btnH / 2);
    ctx.textBaseline = "top";
  }
}

// ---- Main loop ---------------------------------------------------------
function tick({ dt, ctx, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  // Clamp dt so a long tab-switch can't teleport the ball through walls.
  const cdt = Math.min(dt, 1 / 30);

  handleInput(input);
  step(cdt, LEVELS[levelIdx]);
  drawScene(ctx, cdt);
}

Comments (0)

Log in to comment.