fuzzyworld

basin / commit

Move target indicators to shore cells; add dev debug markers; fix water springs

644389f34b7cdb706f9b067b6574aa0cdf67cf8a · Pavel Safronov · 2026-07-01 22:17:30 -0700 · parents b9acd697966e

Claude Code usage: this commit ~160.7K out / ~10.3M cached · cost unknown (no per-model trailer) · session 1 tally 200 in / 191.9K out / 12.1M cached · usage by commit and session

Move target indicators to shore cells; add dev debug markers; fix water springs

Target (gold) outlines now appear on the first land cell adjacent to each
ocean edge ("shore") rather than on the blue ocean-edge tiles themselves,
which looked odd when water tiles separated land from the border.

Adds a dev-only (import.meta.env.DEV, stripped from production builds) black ×
overlay on each hidden spring so positions can be inspected during development.

Fixes a bug where springs could be placed on shallow coastal cells that render
as blue water (height < 0.1) and would be impossible for players to discover
by tapping. Spring candidates are now restricted to cells with height ≥ 0.1.

Usage: 0.2K in / 191.9K out / 12.1M cached (session tally)
rtk:   2.6M saved, 78.9% over 1423 cmds (global/lifetime)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/game.ts            |  1 +
 src/model/puzzle.ts    |  9 ++++--
 src/render/renderer.ts | 79 ++++++++++++++++++++++++++++++++++++++++----------
 3 files changed, 70 insertions(+), 19 deletions(-)

diff --git a/src/game.ts b/src/game.ts
index 37f150b..3319988 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -110,6 +110,7 @@ export class Game {
       target: this.puzzle.target,
       found: this.found,
       probe: this.probe,
+      solution: import.meta.env.DEV ? this.puzzle.solution : undefined,
     });
   }
 }
diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts
index e3230e6..6c800a4 100644
--- a/src/model/puzzle.ts
+++ b/src/model/puzzle.ts
@@ -76,12 +76,15 @@ export function generatePuzzle(
   const hm = generateDome(size, size, rng, edges, erosion);
   onProgress?.(0.1);
 
-  // Candidates: interior (non-edge) land cells. After drainage resolution every
-  // interior cell has height > 0 and a strictly-decreasing path to an ocean edge.
+  // Candidates: interior land cells with enough elevation to render visibly as
+  // land (not water-blue). After drainage resolution all interior cells have
+  // height > 0, but very shallow cells near ocean edges can still look like
+  // water in the terrain colours — a spring there would be unclickable.
+  // 0.1 sits comfortably in the water→land colour transition (0.02–0.15).
   const candidates: Cell[] = [];
   for (let y = 1; y < size - 1; y++) {
     for (let x = 1; x < size - 1; x++) {
-      if (heightAt(hm, x, y) > 0) candidates.push({ x, y });
+      if (heightAt(hm, x, y) >= 0.1) candidates.push({ x, y });
     }
   }
 
diff --git a/src/render/renderer.ts b/src/render/renderer.ts
index 63271fd..94989ff 100644
--- a/src/render/renderer.ts
+++ b/src/render/renderer.ts
@@ -10,6 +10,8 @@ export interface PuzzleView {
   found: Cell[];
   /** The current transient exploration tap, if any. */
   probe: Cell | null;
+  /** Dev-only: hidden spring positions to reveal as debug markers. */
+  solution?: Cell[];
 }
 
 /**
@@ -101,6 +103,23 @@ export class Renderer {
       for (const s of flow.springs) this.dot(s.x, s.y, this.cell * 0.28, 4);
     }
 
+    // Dev-only: reveal hidden spring positions with a black ×.
+    if (import.meta.env.DEV && view?.solution?.length) {
+      ctx.strokeStyle = 'rgba(0,0,0,0.85)';
+      ctx.lineWidth = Math.max(1.5, this.cell * 0.1);
+      const r = this.cell * 0.28;
+      for (const s of view.solution) {
+        const mx = (s.x + 0.5) * this.cell;
+        const my = (s.y + 0.5) * this.cell;
+        ctx.beginPath();
+        ctx.moveTo(mx - r, my - r);
+        ctx.lineTo(mx + r, my + r);
+        ctx.moveTo(mx + r, my - r);
+        ctx.lineTo(mx - r, my + r);
+        ctx.stroke();
+      }
+    }
+
     // Grid lines (small boards only).
     if (this.cell >= 16) {
       ctx.strokeStyle = 'rgba(0,0,0,0.15)';
@@ -121,9 +140,13 @@ export class Renderer {
   }
 
   /**
-   * Border feedback. Gold outline = a goal edge cell the solution wets. When
-   * the current flow reaches it the outline fills green (satisfied); a wet
-   * border cell that is *not* a goal fills red (overflow — a wrong spring).
+   * Shore feedback. Targets are shown on "shore" cells — the land cells
+   * (height > 0) immediately adjacent to ocean edges — rather than on the
+   * blue ocean-edge tiles themselves.
+   *
+   * Gold outline = a goal shore (the adjacent ocean edge is in the target).
+   * Green fill   = goal shore is satisfied (the player's flow wets it).
+   * Red fill     = shore wets a non-goal ocean edge (wrong spring / overflow).
    */
   private drawTarget(hm: Heightmap, target: Uint8Array, flow: FlowResult | null): void {
     const ctx = this.ctx;
@@ -131,28 +154,52 @@ export class Renderer {
     const inset = Math.max(1.5, c * 0.12);
     ctx.lineWidth = Math.max(2, c * 0.14);
 
+    // For each ocean edge cell, accumulate goal/overflow state onto its
+    // adjacent land ("shore") cells. A shore can adjoin multiple edges;
+    // goal state takes priority over overflow.
+    const shoreGoal = new Set<number>();
+    const shoreOverflow = new Set<number>();
+    const DIRS: ReadonlyArray<readonly [number, number]> = [[0, -1], [1, 0], [0, 1], [-1, 0]];
+
     for (let y = 0; y < hm.height; y++) {
       for (let x = 0; x < hm.width; x++) {
-        // Only ocean edges carry goal/overflow feedback — water pooling against
-        // a wall is expected, not a mistake.
         if (!isOceanEdge(hm.edges, x, y, hm.width, hm.height)) continue;
         const i = y * hm.width + x;
         const goal = target[i] === 1;
         const wet = flow ? flow.wet[i] === 1 : false;
-
-        if (goal) {
-          ctx.strokeStyle = '#ffd60a';
-          ctx.strokeRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
-          if (wet) {
-            ctx.fillStyle = 'rgba(52,199,89,0.55)';
-            ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
-          }
-        } else if (wet) {
-          ctx.fillStyle = 'rgba(255,59,48,0.5)';
-          ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+        if (!goal && !wet) continue;
+
+        for (const [dx, dy] of DIRS) {
+          const nx = x + dx;
+          const ny = y + dy;
+          if (nx < 0 || ny < 0 || nx >= hm.width || ny >= hm.height) continue;
+          if (isOceanEdge(hm.edges, nx, ny, hm.width, hm.height)) continue;
+          if (heightAt(hm, nx, ny) <= 0) continue; // skip sunken cells
+          const ni = ny * hm.width + nx;
+          if (goal) shoreGoal.add(ni);
+          else if (wet) shoreOverflow.add(ni);
         }
       }
     }
+
+    for (const ni of shoreGoal) {
+      const x = ni % hm.width;
+      const y = (ni / hm.width) | 0;
+      ctx.strokeStyle = '#ffd60a';
+      ctx.strokeRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+      if (flow && flow.wet[ni]) {
+        ctx.fillStyle = 'rgba(52,199,89,0.55)';
+        ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+      }
+    }
+
+    for (const ni of shoreOverflow) {
+      if (shoreGoal.has(ni)) continue; // goal takes priority
+      const x = ni % hm.width;
+      const y = (ni / hm.width) | 0;
+      ctx.fillStyle = 'rgba(255,59,48,0.5)';
+      ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+    }
   }
 
   private dot(cx: number, cy: number, r: number, min: number): void {