7
Mini Golf: 6 Holes
drag from the ball to aim, release to shoot
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.