Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Add Tide Crossing: the playable page and its place in the catalogue, on the rules in `tide-crossing/causeway.js` #352
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/349-add-tide-crossing-the-playable-page-and → main 2 commits fork
9 files
changed +418
−2
README.md +3 −2
Diff not shown — a line 3,837 characters wide (limit 2,000). +3 −2
View fileindex.html +1
| @@ -139,6 +139,7 @@ footer a::after { content: none; } | ||
| 139 | 139 | <li data-kind="Puzzle" data-input="Tap" style="--accent:#2b7a8c"><a href="ticket-booth/">Ticket Booth</a><span>Hand each pier visitor exact change from a limited till</span><span>Game forged by the crew</span><span class="tags">Puzzle · Tap</span></li> |
| 140 | 140 | <li data-kind="Sport" data-input="Tap" style="--accent:#b5412f"><a href="village-darts/">Village Darts</a><span>Count down from 301 and finish on a double before the village rival does</span><span>Game forged by the crew</span><span class="tags">Sport · Tap</span></li> |
| 141 | 141 | <li data-kind="Arcade" data-input="Keys" style="--accent:#d9731f"><a href="carrot-pull/">Carrot Pull</a><span>Pull each carrot while it is ripe, before the rabbit nibbles it</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li> |
| 142 | <li data-kind="Puzzle" data-input="Keys" style="--accent:#2a6f97"><a href="tide-crossing/">Tide Crossing</a><span>Step across the stones to the island before the tide covers them</span><span>Game forged by the crew</span><span class="tags">Puzzle · Keys</span></li> | |
| 142 | 143 | </ul> |
| 143 | 144 | <footer>Concept by <a href="https://x.com/awwbhi2">abhi</a>. Game by <a href="https://x.com/AmirMushich">AmirMushich</a>. Visual reference for the style: shawn.'s <a href="https://x.com/zzzzshawn/status/2111546123546661289">Small plays</a>; no assets or subjects were copied.<br><a href="stats/">Most played</a></footer> |
| 144 | 145 | </main> |
og/home.png
Binary file not shown.
og/tide-crossing.png added
Binary file not shown.
rounds.mjs +7
| @@ -14,6 +14,7 @@ import Darts from './village-darts/board.js'; | ||
| 14 | 14 | import Sweep from './chimney-sweep/roofs.js'; |
| 15 | 15 | import Field from './fence-posts/field.js'; |
| 16 | 16 | import Carrot from './carrot-pull/bed.js'; |
| 17 | import Tide from './tide-crossing/causeway.js'; | |
| 17 | 18 | |
| 18 | 19 | const MID = [180, 140]; |
| 19 | 20 | const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); }; |
| @@ -514,6 +515,12 @@ export const ROUNDS = { | ||
| 514 | 515 | while (g.read('P.tick') < best) g.step(1); |
| 515 | 516 | g.keyDown(' '); g.run(.05); |
| 516 | 517 | } }, |
| 518 | // walk the first level's stated solution, one move or wait per tick, keyed by the tick the rules report | |
| 519 | 'tide-crossing': { over: /Crossed in \d+ ticks against par \d+/, outcome: /Crossed in 3 ticks against par 3: on par/, limit: 20, beat: g => { | |
| 520 | if (g.read('P.status') !== 'playing') { g.run(.3); return; } | |
| 521 | const move = Tide.LEVELS[0].solution[g.read('P.tick')]; | |
| 522 | g.press({ up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', wait: ' ' }[move], .1); | |
| 523 | } }, | |
| 517 | 524 | // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight |
| 518 | 525 | 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => { |
| 519 | 526 | if (g.read('P.status') !== 'swinging') { g.run(.3); return; } |
serve.test.mjs +7
| @@ -775,3 +775,10 @@ test('Pumpkin Patch and its rules script are served without a track', async () = | ||
| 775 | 775 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); |
| 776 | 776 | expect((await fetch(base + '/pumpkin-patch/plots.js')).status).toBe(200); |
| 777 | 777 | }); |
| 778 | ||
| 779 | test('Tide Crossing and its rules script are served without a track', async () => { | |
| 780 | const page = await fetch(base + '/tide-crossing/'); | |
| 781 | expect(page.status).toBe(200); | |
| 782 | expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8'); | |
| 783 | expect((await fetch(base + '/tide-crossing/causeway.js')).status).toBe(200); | |
| 784 | }); |
shell.js +1
| @@ -78,6 +78,7 @@ window.PixelPlays = (() => { | ||
| 78 | 78 | { id: 'ticket-booth', title: 'Ticket Booth', blurb: 'Hand each pier visitor exact change from a limited till', kind: 'Puzzle', input: 'Tap', accent: '#2b7a8c', music: false, forged: true }, |
| 79 | 79 | { id: 'village-darts', title: 'Village Darts', blurb: 'Count down from 301 and finish on a double before the village rival does', kind: 'Sport', input: 'Tap', accent: '#b5412f', music: false, forged: true }, |
| 80 | 80 | { id: 'carrot-pull', title: 'Carrot Pull', blurb: 'Pull each carrot while it is ripe, before the rabbit nibbles it', kind: 'Arcade', input: 'Keys', accent: '#d9731f', music: false, forged: true }, |
| 81 | { id: 'tide-crossing', title: 'Tide Crossing', blurb: 'Step across the stones to the island before the tide covers them', kind: 'Puzzle', input: 'Keys', accent: '#2a6f97', music: false, forged: true }, | |
| 81 | 82 | { id: 'pumpkin-patch', title: 'Pumpkin Patch', blurb: 'Water each plot and its neighbours until every pumpkin has exactly what it needs', kind: 'Puzzle', input: 'Tap', accent: '#d9822b', music: false, forged: true }, |
| 82 | 83 | ]; |
| 83 | 84 | const MUSIC_VOLUME = .4; |
tide-crossing/index.html added +326
| @@ -0,0 +1,326 @@ | ||
| 1 | ||
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="utf-8"> | |
| 5 | <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> | |
| 6 | <title>Tide Crossing</title> | |
| 7 | <!-- og:begin --> | |
| 8 | <meta name="description" content="Step across the stones to the island before the tide covers them"> | |
| 9 | <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/tide-crossing/"> | |
| 10 | <meta property="og:type" content="website"> | |
| 11 | <meta property="og:site_name" content="Pixel Plays"> | |
| 12 | <meta property="og:title" content="Tide Crossing"> | |
| 13 | <meta property="og:description" content="Step across the stones to the island before the tide covers them"> | |
| 14 | <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/tide-crossing/"> | |
| 15 | <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/tide-crossing.png"> | |
| 16 | <meta property="og:image:type" content="image/png"> | |
| 17 | <meta property="og:image:width" content="1200"> | |
| 18 | <meta property="og:image:height" content="630"> | |
| 19 | <meta property="og:image:alt" content="Tide Crossing: the opening scene of the game"> | |
| 20 | <meta name="twitter:card" content="summary_large_image"> | |
| 21 | <!-- og:end --> | |
| 22 | <style> | |
| 23 | :root { : #1c1b19; : #6b675f; : #fff; : #2a6f97; } | |
| 24 | * { box-sizing: border-box; } | |
| 25 | html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; } | |
| 26 | html, body { height: 100%; margin: 0; background: var(); color: var(); } | |
| 27 | body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; } | |
| 28 | #stage { position: fixed; inset: 0; display: grid; place-items: center; } | |
| 29 | canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; } | |
| 30 | canvas:focus-visible { outline: 2px solid var(); outline-offset: -2px; } | |
| 31 | #hud { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; } | |
| 32 | #hud b { font-size: 16px; font-weight: 600; } | |
| 33 | #hud span { color: var(); font-variant-numeric: tabular-nums; min-height: 1.35em; } | |
| 34 | #bottom { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); bottom: max(20px, env(safe-area-inset-bottom)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; } | |
| 35 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 36 | button { pointer-events: auto; font: inherit; color: var(); background: none; border: 0; padding: 13px 10px; margin: -13px -10px; min-height: 44px; min-width: 44px; cursor: pointer; } | |
| 37 | button:hover, button:focus-visible { color: var(); } | |
| 38 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 39 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 40 | </style> | |
| 41 | </head> | |
| 42 | <body> | |
| 43 | <div id="stage"><canvas id="c" tabindex="0" aria-label="Tide Crossing: a causeway of stepping stones leads out to an island while the tide rises. Press the arrow keys or W, A, S, D to step to a neighbouring stone and Space to wait a tick; on touch, tap a neighbouring stone to step there and tap the walker to wait. Reach the island before the water covers your stone. R restarts the level and N goes to the next after a win."></canvas></div> | |
| 44 | <noscript><p>Tide Crossing needs JavaScript to play.</p></noscript> | |
| 45 | <div id="hud"> | |
| 46 | <b id="name"></b> | |
| 47 | <span id="tick"></span> | |
| 48 | <span id="par"></span> | |
| 49 | <span id="next"></span> | |
| 50 | </div> | |
| 51 | <div id="bottom"> | |
| 52 | <div id="status" role="status" aria-live="polite"></div> | |
| 53 | <button id="mute" type="button" aria-pressed="false">sound on</button> | |
| 54 | </div> | |
| 55 | <script src="../shell.js"></script> | |
| 56 | <script> | |
| 57 | PixelPlays.mount({ | |
| 58 | id: 'tide-crossing', | |
| 59 | rules: [ | |
| 60 | 'Arrow keys or WASD step to a neighbouring stone, Space waits a tick; on touch tap a stone to step, the walker to wait.', | |
| 61 | 'Each tick the tide moves on and any stone at or below the water is covered: reach the island before yours is.', | |
| 62 | 'A stone with a rippling edge goes under next tick; the gauge on the left shows the water.', | |
| 63 | 'Beat par by crossing in fewer ticks; R restarts, N goes to the next level after a win.', | |
| 64 | ], | |
| 65 | }); | |
| 66 | </script> | |
| 67 | <script>window.PP_SCENE = { originY: 14, shadowX: 1.3, shadowY: -.12 };</script> | |
| 68 | <script src="../engine.js"></script> | |
| 69 | <script src="causeway.js"></script> | |
| 70 | <script> | |
| 71 | 'use strict'; | |
| 72 | ||
| 73 | const M = window.TideCrossing; | |
| 74 | const PITCH = 3.6, SIZE = 3, BASE = .5, RISE = .12; // world units between stones, a stone's width, its foot, and height per tide-unit | |
| 75 | const DIR_KEYS = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right' }; | |
| 76 | const ROCK = [112, 116, 124], SAND = [214, 190, 130], GRASS_TOP = [96, 150, 70], FLAG = [214, 70, 56], COAT = [226, 150, 44], SKIN = [238, 200, 160], POST = [120, 90, 60]; | |
| 77 | const SEA_TOP = [58, 128, 168], SEA_LOW = [34, 82, 128], FOAM = rgb(214, 236, 246), WET = [40, 96, 150]; | |
| 78 | const topOf = h => BASE + h * RISE; | |
| 79 | let P = M.start(0); | |
| 80 | const G = { time: 0, dirty: true, frame: -1, sea: [] }; | |
| 81 | ||
| 82 | const nodes = {}; | |
| 83 | for (const k of ['name', 'tick', 'par', 'next', 'status']) nodes[k] = document.getElementById(k); | |
| 84 | const shown = {}; | |
| 85 | function setText(key, text) { | |
| 86 | if (shown[key] === text) return; | |
| 87 | shown[key] = text; | |
| 88 | nodes[key].textContent = text; | |
| 89 | } | |
| 90 | function say(text) { shown.status = null; setText('status', text); } | |
| 91 | ||
| 92 | /* ---------- reading the rules state ---------- */ | |
| 93 | const squareName = (row, col) => `row ${row + 1} column ${col + 1}`; | |
| 94 | const covered = (s, row, col, t) => s.grid[row][col] !== null && M.tideAt(s.tide, t) >= s.grid[row][col]; | |
| 95 | function goingUnder(s) { // the stones dry now that the rules' own tide table covers next tick | |
| 96 | const out = []; | |
| 97 | for (let r = 0; r < s.rows; r++) for (let c = 0; c < s.cols; c++) { | |
| 98 | if (s.grid[r][c] !== null && !covered(s, r, c, s.tick) && covered(s, r, c, s.tick + 1)) out.push({ row: r, col: c }); | |
| 99 | } | |
| 100 | return out; | |
| 101 | } | |
| 102 | function renderHud() { | |
| 103 | setText('name', `Level ${P.level + 1}: ${P.name}`); | |
| 104 | setText('tick', `Tick ${P.tick}`); | |
| 105 | setText('par', `Moves ${P.tick} of par ${P.par}`); | |
| 106 | const next = goingUnder(P); | |
| 107 | setText('next', P.status !== 'playing' ? '' : next.length ? `Under next tick: ${next.map(n => squareName(n.row, n.col)).join(', ')}` : 'Under next tick: none'); | |
| 108 | } | |
| 109 | ||
| 110 | /* ---------- geometry ---------- */ | |
| 111 | const wx = col => (col - (P.cols - 1) / 2) * PITCH, wy = row => (row - (P.rows - 1) / 2) * PITCH; | |
| 112 | const stoneTop = (r, c) => topOf(P.grid[r][c]); | |
| 113 | const centre = (r, c) => project(wx(c), wy(r), stoneTop(r, c)); | |
| 114 | function stoneAt(px, py) { | |
| 115 | let best = null, bd = 1.15; | |
| 116 | for (let r = 0; r < P.rows; r++) for (let c = 0; c < P.cols; c++) { | |
| 117 | if (P.grid[r][c] === null) continue; | |
| 118 | const p = centre(r, c), d = Math.abs(px - p.sx) / (SIZE / R2 * S) + Math.abs(py - p.sy) / (SIZE / R2 * S * SIN); | |
| 119 | if (d < bd) { bd = d; best = { row: r, col: c }; } | |
| 120 | } | |
| 121 | return best; | |
| 122 | } | |
| 123 | ||
| 124 | /* ---------- the sea ---------- */ | |
| 125 | const put = (x, y, c) => { x |= 0; y |= 0; if (x >= 0 && y >= 0 && x < W && y < H) pix[y * W + x] = c; }; | |
| 126 | function darken(x, y, k) { | |
| 127 | x |= 0; y |= 0; | |
| 128 | if (x < 0 || y < 0 || x >= W || y >= H) return; | |
| 129 | const c = pix[y * W + x]; | |
| 130 | pix[y * W + x] = rgb((c & 255) * k, (c >> 8 & 255) * k, (c >> 16 & 255) * k); | |
| 131 | } | |
| 132 | function bake() { | |
| 133 | for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) { | |
| 134 | const k = iy / H, n = noise(ix * .05, iy * .09) * .5; | |
| 135 | bakedPix[iy * W + ix] = rgb(...SEA_TOP.map((v, i) => lerp(v, SEA_LOW[i], clamp(k + n - .15, 0, 1)))); | |
| 136 | } | |
| 137 | for (let k = 0; k < 46; k++) G.sea.push([hash(k, 1) * W, hash(k, 2) * H, 5 + Math.floor(hash(k, 3) * 6)]); | |
| 138 | } | |
| 139 | // lapping water: short foam dashes that slide a pixel at a time at 8 fps, and hold still under reduced motion | |
| 140 | function drawLap(beat) { | |
| 141 | for (const [x, y, len] of G.sea) { | |
| 142 | const off = STILL ? 0 : Math.round(Math.sin(beat * .5 + x * .1) * 2); | |
| 143 | for (let i = 0; i < len; i++) put(x + off + i, y, FOAM); | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | /* ---------- stones, goal, walker ---------- */ | |
| 148 | function shadowOf(r, c) { | |
| 149 | const x0 = wx(c) - SIZE / 2, y0 = wy(r) - SIZE / 2, h = stoneTop(r, c); | |
| 150 | for (let a = 0; a <= SIZE; a += .09) for (let b = 0; b <= SIZE; b += .09) { | |
| 151 | const p = project(x0 + a + SHADOW_X * h, y0 + b + SHADOW_Y * h, 0); | |
| 152 | darken(p.sx, p.sy, .7); | |
| 153 | } | |
| 154 | } | |
| 155 | const colourOf = (r, c) => r === P.start.row && c === P.start.col ? SAND : r === P.goal.row && c === P.goal.col ? GRASS_TOP : ROCK; | |
| 156 | function drawStone(r, c) { | |
| 157 | const x = wx(c), y = wy(r), h = stoneTop(r, c), half = SIZE / 2; | |
| 158 | box(x - half, y - half, 0, x + half, y + half, h, colourOf(r, c), r * 7 + c); | |
| 159 | } | |
| 160 | function wet(r, c) { // the water over a stone: a bluish veil with a wave line, over its top face | |
| 161 | const x = wx(c), y = wy(r), h = stoneTop(r, c) + .03, half = SIZE / 2; | |
| 162 | for (let a = -half; a <= half; a += .05) for (let b = -half; b <= half; b += .05) { | |
| 163 | const p = project(x + a, y + b, h), ix = p.sx | 0, iy = p.sy | 0; | |
| 164 | if (ix < 0 || iy < 0 || ix >= W || iy >= H || p.d <= zb[iy * W + ix]) continue; | |
| 165 | pix[iy * W + ix] = (iy + (STILL ? 0 : G.frame >> 1)) % 4 === 0 ? FOAM : rgb(...WET.map((v, i) => lerp(v, pix[iy * W + ix] >> 8 * i & 255, .25))); | |
| 166 | } | |
| 167 | } | |
| 168 | // a stone about to go under: a dashed ripple ring around its top edge, so it is not told from a dry stone by colour alone | |
| 169 | function ripple(r, c) { | |
| 170 | const x = wx(c), y = wy(r), h = stoneTop(r, c) + .02, e = SIZE / 2 + .3; | |
| 171 | let n = 0; | |
| 172 | for (let t = -e; t <= e; t += .08, n++) { | |
| 173 | if (n % 3 === 2) continue; | |
| 174 | for (const [a, b] of [[t, -e], [t, e], [-e, t], [e, t]]) { const p = project(x + a, y + b, h); put(p.sx, p.sy, FOAM); } | |
| 175 | } | |
| 176 | } | |
| 177 | function drawGoal(r, c) { // a flag on a post on the island | |
| 178 | const x = wx(c), y = wy(r), h = stoneTop(r, c); | |
| 179 | box(x - .1, y - .1, h, x + .1, y + .1, h + 2.6, POST, 3); | |
| 180 | box(x + .1, y - .1, h + 1.7, x + 1.3, y + .1, h + 2.5, FLAG, 5); | |
| 181 | } | |
| 182 | function drawWalker() { | |
| 183 | const { row, col } = P.pos, x = wx(col), y = wy(row), h = stoneTop(row, col); | |
| 184 | const lost = P.status === 'lost', lift = lost ? -.5 : 0; | |
| 185 | const s = project(x + SHADOW_X * .6, y + SHADOW_Y * .6, h); | |
| 186 | for (let dy = -2; dy <= 2; dy++) for (let dx = -5; dx <= 5; dx++) if (dx * dx / 25 + dy * dy / 4 <= 1) darken(s.sx + dx, s.sy + dy, .6); | |
| 187 | sphere(x, y, h + .75 + lift, .62, COAT, 'solid'); | |
| 188 | sphere(x, y, h + 1.75 + lift, .42, SKIN, 'solid'); | |
| 189 | } | |
| 190 | // the tide gauge: a post on the left marked at every stone height, the water level and next tick's level | |
| 191 | function drawGauge() { | |
| 192 | const bottom = 236, unit = 11, left = 22, top = bottom - 10 * unit; | |
| 193 | for (let y = top; y <= bottom; y++) { put(left, y, rgb(60, 58, 54)); put(left + 1, y, rgb(60, 58, 54)); } | |
| 194 | const lvl = M.tideAt(P.tide, P.tick), nxt = M.tideAt(P.tide, P.tick + 1); | |
| 195 | for (let h = 0; h <= 10; h++) for (let x = 0; x < (h % 5 === 0 ? 8 : 5); x++) put(left + 2 + x, bottom - h * unit, rgb(60, 58, 54)); | |
| 196 | for (let y = bottom - lvl * unit; y <= bottom; y++) for (let x = 0; x < 10; x++) put(left - 8 + x, y, rgb(...WET)); | |
| 197 | for (let x = 0; x < 10; x++) put(left - 8 + x, bottom - lvl * unit, FOAM); | |
| 198 | if (nxt !== lvl) for (let x = 0; x < 10; x += 2) put(left - 8 + x, bottom - nxt * unit, FOAM); | |
| 199 | } | |
| 200 | function draw() { | |
| 201 | pix.set(bakedPix); | |
| 202 | zb.fill(-1e9); | |
| 203 | drawLap(STILL ? 0 : G.frame); | |
| 204 | const stones = []; | |
| 205 | for (let r = 0; r < P.rows; r++) for (let c = 0; c < P.cols; c++) if (P.grid[r][c] !== null) stones.push([r, c]); | |
| 206 | for (const [r, c] of stones) shadowOf(r, c); | |
| 207 | for (const [r, c] of stones) drawStone(r, c); | |
| 208 | drawGoal(P.goal.row, P.goal.col); | |
| 209 | for (const [r, c] of stones) if (covered(P, r, c, P.tick)) wet(r, c); | |
| 210 | drawWalker(); | |
| 211 | if (P.status === 'playing') for (const n of goingUnder(P)) ripple(n.row, n.col); | |
| 212 | drawGauge(); | |
| 213 | ctx.putImageData(image, 0, 0); | |
| 214 | } | |
| 215 | ||
| 216 | /* ---------- game: every rule is the rules script's ---------- */ | |
| 217 | const REFUSALS = { | |
| 218 | 'unknown-direction': 'that is not a direction the walker knows', | |
| 219 | 'no-stone': 'there is no stone there, only open sea off the causeway', | |
| 220 | 'under-water': 'that stone is under water by next tick', | |
| 221 | 'game-over': 'the crossing is over', | |
| 222 | }; | |
| 223 | function act(label, result) { | |
| 224 | G.dirty = true; | |
| 225 | if (!result.ok) { | |
| 226 | sound(130, .1, 'sawtooth'); | |
| 227 | say(`Cannot ${label}: ${REFUSALS[result.why] ?? result.why}. ${P.line}.`); | |
| 228 | return; | |
| 229 | } | |
| 230 | P = result.state; | |
| 231 | sound(P.status === 'lost' ? 110 : P.status === 'won' ? 880 : 360 + P.tick * 30, P.status === 'playing' ? .08 : .3, P.status === 'lost' ? 'sawtooth' : 'triangle'); | |
| 232 | const done = P.status === 'won' ? (P.level + 1 < M.LEVELS.length ? ' Press N for the next level, or R to replay.' : ' That was the last level. Press R to replay.') | |
| 233 | : P.status === 'lost' ? ' Press R to try again.' : ''; | |
| 234 | say(`${label[0].toUpperCase()}${label.slice(1)}. ${P.line}.${done}`); | |
| 235 | } | |
| 236 | const step = dir => act(`move ${dir}`, M.move(P, dir)); | |
| 237 | const hold = () => act('wait', M.wait(P)); | |
| 238 | function restart() { | |
| 239 | P = M.restart(P); | |
| 240 | G.dirty = true; | |
| 241 | say(`Level ${P.level + 1}, ${P.name}. ${P.line}.`); | |
| 242 | } | |
| 243 | function nextLevel() { | |
| 244 | if (P.status !== 'won') { say(`Cross first: ${P.line}.`); return; } | |
| 245 | if (P.level + 1 >= M.LEVELS.length) { say('That was the last level. Press R to replay it.'); return; } | |
| 246 | P = M.start(P.level + 1); | |
| 247 | G.dirty = true; | |
| 248 | say(`Level ${P.level + 1}, ${P.name}. ${P.line}.`); | |
| 249 | } | |
| 250 | ||
| 251 | /* ---------- input ---------- */ | |
| 252 | const at = e => { | |
| 253 | const box = canvas.getBoundingClientRect(); | |
| 254 | return { x: (e.clientX - box.left) * W / box.width, y: (e.clientY - box.top) * H / box.height }; | |
| 255 | }; | |
| 256 | canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true })); | |
| 257 | canvas.addEventListener('pointerup', e => { | |
| 258 | ensureAudio(); | |
| 259 | if (P.status === 'lost') { restart(); return; } | |
| 260 | if (P.status === 'won') { if (P.level + 1 < M.LEVELS.length) nextLevel(); else restart(); return; } | |
| 261 | const p = at(e), hit = stoneAt(p.x, p.y); | |
| 262 | if (!hit) return; | |
| 263 | const dr = hit.row - P.pos.row, dc = hit.col - P.pos.col; | |
| 264 | if (dr === 0 && dc === 0) { hold(); return; } | |
| 265 | const dir = Object.keys(M.DIRS).find(k => M.DIRS[k][0] === dr && M.DIRS[k][1] === dc); | |
| 266 | if (dir) step(dir); | |
| 267 | else say(`Tap a stone next to the walker to step there, or the walker to wait. ${P.line}.`); | |
| 268 | }); | |
| 269 | canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| 270 | canvas.addEventListener('keydown', e => { | |
| 271 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 272 | ensureAudio(); | |
| 273 | if (Object.hasOwn(DIR_KEYS, e.key)) step(DIR_KEYS[e.key]); | |
| 274 | else if (e.key === ' ') hold(); | |
| 275 | else if (e.key === 'r' || e.key === 'R') restart(); | |
| 276 | else if (e.key === 'n' || e.key === 'N') nextLevel(); | |
| 277 | else return; | |
| 278 | e.preventDefault(); | |
| 279 | }); | |
| 280 | ||
| 281 | /* ---------- sound ---------- */ | |
| 282 | let audio = null, muted = false; | |
| 283 | function ensureAudio() { | |
| 284 | if (audio) { if (audio.state === 'suspended') audio.resume(); return; } | |
| 285 | const AC = window.AudioContext || window.webkitAudioContext; | |
| 286 | if (AC) audio = new AC(); | |
| 287 | } | |
| 288 | function sound(freq, life, type) { | |
| 289 | if (!audio || muted) return; | |
| 290 | const now = audio.currentTime, gain = audio.createGain(), osc = audio.createOscillator(); | |
| 291 | osc.type = type; | |
| 292 | osc.frequency.setValueAtTime(freq, now); | |
| 293 | gain.gain.setValueAtTime(.0001, now); | |
| 294 | gain.gain.exponentialRampToValueAtTime(.06, now + .01); | |
| 295 | gain.gain.exponentialRampToValueAtTime(.0005, now + life); | |
| 296 | osc.connect(gain).connect(audio.destination); | |
| 297 | osc.start(now); osc.stop(now + life + .02); | |
| 298 | } | |
| 299 | const muteButton = document.getElementById('mute'); | |
| 300 | muteButton.addEventListener('click', () => { | |
| 301 | muted = !muted; | |
| 302 | muteButton.textContent = muted ? 'sound off' : 'sound on'; | |
| 303 | muteButton.setAttribute('aria-pressed', String(muted)); | |
| 304 | }); | |
| 305 | ||
| 306 | /* ---------- fit and boot ---------- */ | |
| 307 | addEventListener('resize', fit); | |
| 308 | fit(); | |
| 309 | bake(); | |
| 310 | renderHud(); | |
| 311 | say(`Level ${P.level + 1}, ${P.name}. ${P.line}. Arrow keys or WASD to step, Space to wait.`); | |
| 312 | canvas.focus({ preventScroll: true }); | |
| 313 | let last = performance.now(); | |
| 314 | function frame(now) { | |
| 315 | G.time += Math.min(.04, (now - last) / 1000); | |
| 316 | last = now; | |
| 317 | const beat = STILL ? 0 : Math.floor(G.time * 8); | |
| 318 | if (beat !== G.frame) { G.frame = beat; G.dirty = true; } | |
| 319 | renderHud(); | |
| 320 | if (G.dirty) { G.dirty = false; draw(); } | |
| 321 | requestAnimationFrame(frame); | |
| 322 | } | |
| 323 | requestAnimationFrame(frame); | |
| 324 | </script> | |
| 325 | </body> | |
| 326 | </html> |
tide-crossing/page.test.mjs added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { boot } from '../playtest.mjs'; | |
| 3 | import M from './causeway.js'; | |
| 4 | ||
| 5 | test('arrow keys, WASD and Space drive the rules; the HUD and the live line read their state', () => { | |
| 6 | const g = boot('tide-crossing'); | |
| 7 | expect(g.el('name').textContent).toBe('Level 1: Short hop'); | |
| 8 | g.press('ArrowRight'); | |
| 9 | expect(g.read('P.pos.col')).toBe(1); | |
| 10 | expect(g.el('tick').textContent).toBe('Tick 1'); | |
| 11 | expect(g.el('par').textContent).toBe('Moves 1 of par 3'); | |
| 12 | expect(g.el('status').textContent).toContain('Move right'); | |
| 13 | g.press(' '); | |
| 14 | expect(g.read('P.tick')).toBe(2); | |
| 15 | g.press('a'); | |
| 16 | expect(g.read('P.pos.col')).toBe(0); | |
| 17 | }); | |
| 18 | ||
| 19 | test('every refusal is announced with its reason and leaves the state alone', () => { | |
| 20 | const g = boot('tide-crossing'); | |
| 21 | g.press('ArrowUp'); | |
| 22 | expect(g.el('status').textContent).toContain('open sea'); | |
| 23 | expect(g.read('P.tick')).toBe(0); | |
| 24 | g.press('ArrowRight'); g.press('ArrowRight'); g.press('ArrowRight'); | |
| 25 | expect(g.read('P.status')).toBe('won'); | |
| 26 | g.press('ArrowLeft'); | |
| 27 | expect(g.el('status').textContent).toContain('the crossing is over'); | |
| 28 | const h = boot('tide-crossing'); | |
| 29 | h.keyDown('n'); // N before a win | |
| 30 | expect(h.read('P.level')).toBe(0); | |
| 31 | }); | |
| 32 | ||
| 33 | test('a stone covered next tick is refused, and the next-tick list names it', () => { | |
| 34 | const g = boot('tide-crossing'); | |
| 35 | g.press('n'); | |
| 36 | // level 4 ("Wait for the ebb"): jump levels through N after winning each by its stated solution | |
| 37 | for (let lvl = 0; lvl < 3; lvl++) { | |
| 38 | for (const m of M.LEVELS[lvl].solution) g.press({ up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', wait: ' ' }[m]); | |
| 39 | expect(g.read('P.status')).toBe('won'); | |
| 40 | g.press('n'); | |
| 41 | } | |
| 42 | expect(g.read('P.name')).toBe('Wait for the ebb'); | |
| 43 | expect(g.el('next').textContent).toBe('Under next tick: row 1 column 2, row 1 column 3'); | |
| 44 | g.press('ArrowRight'); // the stone at height 2 is under at tick 1 (tide 2) | |
| 45 | expect(g.read('P.tick')).toBe(0); | |
| 46 | expect(g.el('status').textContent).toContain('under water'); | |
| 47 | }); | |
| 48 | ||
| 49 | test('waiting under a rising tide loses, R restarts, and a tap on a neighbouring stone steps', () => { | |
| 50 | const g = boot('tide-crossing'); | |
| 51 | g.press('ArrowRight'); g.press('ArrowRight'); g.press('ArrowRight'); | |
| 52 | g.press('n'); // Low stones: heights 2, 3, 4 against a tide of 0, 1, 2 ... | |
| 53 | g.press('ArrowRight'); | |
| 54 | g.press(' '); // tick 2: the tide is 2 and covers the stone of height 2 | |
| 55 | expect(g.read('P.status')).toBe('lost'); | |
| 56 | expect(g.el('status').textContent).toContain('Lost'); | |
| 57 | g.press('r'); | |
| 58 | expect(g.read('P.tick')).toBe(0); | |
| 59 | expect(g.read('P.level')).toBe(1); | |
| 60 | expect(g.read('P.status')).toBe('playing'); | |
| 61 | const p = g.read('(() => { const c = centre(0, 1); return [c.sx, c.sy]; })()'); | |
| 62 | g.tap(p[0], p[1]); | |
| 63 | expect(g.read('P.pos.col')).toBe(1); | |
| 64 | const q = g.read('(() => { const c = centre(0, 1); return [c.sx, c.sy]; })()'); | |
| 65 | g.tap(q[0], q[1]); // the walker's own stone: wait | |
| 66 | expect(g.read('P.tick')).toBe(2); | |
| 67 | }); | |
| 68 | ||
| 69 | test('reduced motion stops the lapping foam', () => { | |
| 70 | const a = boot('tide-crossing', { reduced: true }), b = boot('tide-crossing', { reduced: true }); | |
| 71 | a.run(1); | |
| 72 | expect(a.picture()).toBe(b.picture()); | |
| 73 | }); |
new above
offline