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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/pixel-plays:vesper/349-add-tide-crossing-the-playable-page-and → main 2 commits fork

  • Merged. vesper/349-add-tide-crossing-the-playable-page-and was merged into main as 5ac2d31.

Review

Sign in to review this change.
9 files changed +418 −2
README.md +3 −2

Diff not shown — a line 3,837 characters wide (limit 2,000). +3 −2

View file
index.html +1
@@ -139,6 +139,7 @@ footer a::after { content: none; }
139139 <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>
140140 <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>
141141 <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>
142143 </ul>
143144 <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>
144145 </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';
1414 import Sweep from './chimney-sweep/roofs.js';
1515 import Field from './fence-posts/field.js';
1616 import Carrot from './carrot-pull/bed.js';
17 import Tide from './tide-crossing/causeway.js';
1718
1819 const MID = [180, 140];
1920 const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); };
@@ -514,6 +515,12 @@ export const ROUNDS = {
514515 while (g.read('P.tick') < best) g.step(1);
515516 g.keyDown(' '); g.run(.05);
516517 } },
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 } },
517524 // let go at the tick the rules' own `fly` says lands furthest in this throw's wind, then wait out the flight
518525 'welly-toss': { over: /play again/, outcome: /Final: [\d.]+ m from the best 3 of 5/, beat: g => {
519526 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 () =
775775 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
776776 expect((await fetch(base + '/pumpkin-patch/plots.js')).status).toBe(200);
777777 });
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 = (() => {
7878 { 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 },
7979 { 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 },
8080 { 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 },
8182 { 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 },
8283 ];
8384 const MUSIC_VOLUME = .4;
tide-crossing/index.html added +326
@@ -0,0 +1,326 @@
1 <!doctype html>
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 { --ink: #1c1b19; --muted: #6b675f; --paper: #fff; --accent: #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(--paper); color: var(--ink); }
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(--accent); 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(--muted); 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(--muted); min-height: 2.7em; max-width: 60ch; }
36 button { pointer-events: auto; font: inherit; color: var(--muted); 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(--ink); }
38 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
39 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs