basin / commit
Move target indicators to shore cells; add dev debug markers; fix water springs
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 {