Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Add Bumper Cars: the playable page and its place in the catalogue, on the rules in `bumper-cars/floor.js` #351

Merged opened this

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

Merged

vesper/pixel-plays:vesper/345-add-bumper-cars-the-playable-page-and-it → main 6 commits fork

  • Merged. vesper/345-add-bumper-cars-the-playable-page-and-it was merged into main as d15ea60.

Review

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

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

View file
bumper-cars/index.html added +310
@@ -0,0 +1,310 @@
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>Bumper Cars</title>
7 <!-- og:begin -->
8 <meta name="description" content="Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/bumper-cars/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Bumper Cars">
13 <meta property="og:description" content="Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/bumper-cars/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/bumper-cars.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="Bumper Cars: 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: #c0392b; --you: #3f6fc4; }
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; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; }
32 #scores { 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; }
33 #bottom { bottom: max(20px, env(safe-area-inset-bottom)); }
34 .row { display: flex; align-items: center; gap: 8px; }
35 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
36 .row span { color: var(--muted); }
37 #status { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
38 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; }
39 button:hover, button:focus-visible { color: var(--ink); }
40 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
41 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
42 </style>
43 </head>
44 <body>
45 <div id="stage"><canvas id="c" tabindex="0" aria-label="Bumper Cars: a walled fairground floor of nine by nine squares with your car, a blue car with a flat roof, a front bumper bar and a flag on a pole at the back, and three round-nosed rival cars that taper to the front. The floor ticks on by itself, one square a tick. Press the left and right arrow keys or A and D to turn, or tap the left or right half of the picture. Shove a rival from behind or the side into the wall to score, and never drive head-on into one. R restarts."></canvas></div>
46 <noscript><p>Bumper Cars needs JavaScript to play.</p></noscript>
47 <div id="scores">
48 <div class="row"><b id="score">0</b><span>score</span><b id="lives">3</b><span>lives</span></div>
49 <div class="row"><b id="rivals">3</b><span>rivals on the floor</span><b id="ticks">60</b><span>ticks left</span></div>
50 </div>
51 <div class="hud" 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: 'bumper-cars',
59 rules: [
60 'The floor ticks on by itself: your car drives one square a tick. Turn with the left and right arrows or A and D, or tap the left or right half.',
61 'Drive into a rival from behind or the side to shove it a square; shoved into the wall it scores 10. Head-on costs a life.',
62 'A wall turns a car right. You have 3 lives; keep them for 60 ticks to win. R restarts.',
63 ],
64 });
65 </script>
66 <script>window.PP_SCENE = { originY: 8, shadowX: 1.3, shadowY: -.12 };</script>
67 <script src="../engine.js"></script>
68 <script src="floor.js"></script>
69 <script>
70 'use strict';
71
72 const M = window.BumperCars;
73 const SQ = 2.2, HALF = SQ * M.SIZE / 2; // a square's edge, and half the floor, in world units
74 const TICK_S = .5; // the floor's pace: seconds per tick
75 const LEAD_S = 1.5; // a moment to look before the first tick
76 const GLIDE_S = .2; // a car slides between squares this long (play, so it moves under reduced motion too)
77 const BODY = [64, 112, 200], BAR = [236, 232, 220], POLE = [70, 56, 46];
78 const RIVAL = [[200, 84, 44], [214, 168, 40], [150, 76, 164]], RIVAL_NAME = ['red', 'gold', 'plum'];
79 const TILE = [[200, 186, 160], [188, 174, 150]], WALL = [176, 68, 58], BULB = [[255, 226, 120], [250, 250, 240]];
80 const WALL_T = .5, WALL_H = .9;
81 const TURN_OF = { ArrowLeft: 'left', a: 'left', A: 'left', ArrowRight: 'right', d: 'right', D: 'right' };
82
83 /* ---------- geometry ---------- */
84 const centre = (gx, gy) => [gx * SQ - HALF + SQ / 2, gy * SQ - HALF + SQ / 2];
85 const DIR = [[0, -1], [1, 0], [0, 1], [-1, 0]];
86 const NAMES = ['north', 'east', 'south', 'west'];
87 // the canvas pixel of a square's centre
88 function screenOf(gx, gy) { const c = centre(gx, gy), p = project(c[0], c[1], 0); return [p.sx, p.sy]; }
89
90 /* ---------- state ---------- */
91 const nodes = {};
92 for (const k of ['score', 'lives', 'rivals', 'ticks', 'status']) nodes[k] = document.getElementById(k);
93 const shown = {};
94 function setText(key, text) {
95 if (shown[key] === text) return;
96 shown[key] = text;
97 nodes[key].textContent = text;
98 }
99 const fresh = () => M.start(Math.floor(Math.random() * 4294967296));
100 let P = fresh();
101 // prev: where each car stood a tick ago (index 0 is you), tickAt: when the last tick ran, nextAt: when the next one does
102 const G = { time: 0, prev: [], tickAt: -9, nextAt: LEAD_S, overAt: 0, wind: -1, dirty: true };
103 const snap = s => [s.player, ...s.rivals].map(c => ({ x: c.x, y: c.y }));
104 G.prev = snap(P);
105
106 function renderHud() {
107 setText('score', String(P.score));
108 setText('lives', String(P.lives));
109 setText('rivals', String(P.rivals.length));
110 setText('ticks', String(P.ticks - P.tick));
111 }
112 function say(text) { setText('status', text); }
113
114 /* ---------- the floor ---------- */
115 function groundColor(x, y, ix, iy) {
116 if (Math.abs(x) > HALF || Math.abs(y) > HALF) return 0;
117 const k = (Math.floor((x + HALF) / SQ) + Math.floor((y + HALF) / SQ)) & 1;
118 const t = (bayerAt(ix, iy) - .5) * .06 + (noise(x * .8, y * .8) - .5) * .05;
119 const c = TILE[k];
120 return rgb(c[0] * (1 + t), c[1] * (1 + t), c[2] * (1 + t));
121 }
122 function bake() {
123 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0);
124 zb.fill(-1e9);
125 pix.fill(PAPER);
126 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
127 const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(g.x, g.y, ix, iy);
128 if (!c) continue;
129 pix[i] = c; ground[i] = 1;
130 zb[i] = (g.x + g.y) / R2 * COS;
131 }
132 groundZ.set(zb);
133 const o = HALF + WALL_T;
134 box(-o, -o, 0, o, -HALF, WALL_H, WALL, 1);
135 box(-o, HALF, 0, o, o, WALL_H, WALL, 2);
136 box(-o, -HALF, 0, -HALF, HALF, WALL_H, WALL, 3);
137 box(HALF, -HALF, 0, o, HALF, WALL_H, WALL, 4);
138 for (let a = -HALF; a <= HALF; a += .4) for (const z of [.3, .9]) {
139 castShadow(a, -HALF - WALL_T / 2, z, .3, 1, .4);
140 castShadow(-HALF - WALL_T / 2, a, z, .3, 1, .4);
141 }
142 staticShadow.set(shadowMask);
143 applyShadows(groundZ);
144 bakedPix.set(pix);
145 bakedZ.set(zb);
146 }
147
148 /* ---------- cars ---------- */
149 const bone = (a, b, r0, r1, c) => dynamicAdd(a, b, r0, r1, () => c);
150 const at = (c, d, k, side, p, z) => [c[0] + d[0] * k + p[0] * side, c[1] + d[1] * k + p[1] * side, z];
151 // your car: a flat-roofed box body, a bar across its front bumper and a flagged pole at the back
152 function drawPlayer(c, h, lit) {
153 const d = DIR[h], p = [-d[1], d[0]], along = d[0] !== 0;
154 const lx = along ? .78 : .56, ly = along ? .56 : .78;
155 box(c[0] - lx, c[1] - ly, .12, c[0] + lx, c[1] + ly, .62, BODY, 5);
156 for (const k of [-.7, .7]) for (const s of [-.5, .5]) castShadow(c[0] + d[0] * k + p[0] * s, c[1] + d[1] * k + p[1] * s, .3, .3, 1, .2);
157 castShadow(c[0], c[1], .6, .7, 1, .2);
158 bone(at(c, d, .9, -.62, p, .38), at(c, d, .9, .62, p, .38), .14, .14, BAR);
159 const base = at(c, d, -.55, 0, p, .6), top = at(c, d, -.55, 0, p, 1.7);
160 bone(base, top, .06, .05, POLE);
161 sphere(top[0], top[1], top[2] + .08, .13, BULB[lit], 'solid');
162 const tip = at(c, d, -.55, 0, p, 1.55);
163 bone(tip, at(c, d, -1.0, 0, p, 1.45), .05, .05, BODY);
164 }
165 // a rival: a round body that tapers to the front, so its nose points where it is heading
166 function drawRival(c, h, i) {
167 const d = DIR[h], p = [-d[1], d[0]];
168 bone(at(c, d, -.6, 0, p, .5), at(c, d, .7, 0, p, .4), .62, .3, RIVAL[i]);
169 sphere(...at(c, d, .85, 0, p, .4), .2, BAR, 'solid');
170 bone(at(c, d, -.15, 0, p, .85), at(c, d, -.15, 0, p, .95), .22, .2, RIVAL[i]);
171 }
172 // where a car is drawn: sliding from its last square to this one, unless it was placed somewhere new
173 function posOf(i, car) {
174 const from = G.prev[i], k = clamp((G.time - G.tickAt) / GLIDE_S, 0, 1);
175 if (!from || k >= 1 || Math.abs(from.x - car.x) + Math.abs(from.y - car.y) > 1) return centre(car.x, car.y);
176 const e = ease(k), a = centre(from.x, from.y), b = centre(car.x, car.y);
177 return [lerp(a[0], b[0], e), lerp(a[1], b[1], e)];
178 }
179 function draw() {
180 pix.set(bakedPix);
181 zb.set(bakedZ);
182 pendingShadows.length = 0;
183 const lit = STILL ? 0 : Math.floor(G.time * 2) & 1;
184 // fairground bulbs on the back walls blink, two at a time
185 for (let a = -HALF + .6; a < HALF; a += SQ) {
186 const on = (Math.round((a + HALF) / SQ) + lit) & 1;
187 sphere(a, -HALF - WALL_T / 2, WALL_H + .1, .15, BULB[on], 'solid');
188 sphere(-HALF - WALL_T / 2, a, WALL_H + .1, .15, BULB[on], 'solid');
189 }
190 P.rivals.forEach((r, i) => drawRival(posOf(i + 1, r), r.h, i));
191 drawPlayer(posOf(0, P.player), P.player.h, lit);
192 flushShadows();
193 applyShadows(bakedZ);
194 shadeEdges();
195 ctx.putImageData(image, 0, 0);
196 }
197
198 /* ---------- game: every rule is the rules script's ---------- */
199 const who = n => `the ${RIVAL_NAME[n - 1]} rival`;
200 function announce(ev) {
201 switch (ev.kind) {
202 case 'shove': return `You shove ${who(ev.car)} a square`;
203 case 'wall-shove': return `You shove ${who(ev.car)} into the wall: ${ev.score} points`;
204 case 'head-on': return ev.car ? `Head-on bump with ${who(ev.car)}: you lose a life` : '';
205 case 'wall': return 'You hit the wall and turn right';
206 case 'blocked': return `You cannot shove ${who(ev.car)}: a car is behind it`;
207 default: return '';
208 }
209 }
210 function tick() {
211 const before = snap(P), r = M.step(P);
212 if (!r.ok) return;
213 G.prev = before; G.tickAt = G.time; G.dirty = true;
214 P = r.state;
215 const said = r.events.map(announce).filter(Boolean);
216 if (said.length) { say(`${said.join('. ')}. ${P.line}.`); }
217 const kinds = r.events.map(e => e.kind);
218 if (kinds.includes('wall-shove')) sound(660, .25, 'triangle');
219 else if (kinds.includes('shove')) sound(330, .1, 'square');
220 if (kinds.includes('head-on')) sound(120, .3, 'sawtooth');
221 if (P.status === 'over') finish();
222 }
223 function finish() {
224 G.overAt = G.time;
225 say(`${P.line}. Tap or press R to play again.`);
226 sound(P.result.won ? 880 : 110, .4, P.result.won ? 'triangle' : 'sawtooth', .15);
227 }
228 function steer(turn) {
229 ensureAudio();
230 const r = M.steer(P, turn);
231 if (!r.ok) { say(r.why === 'round-over' ? 'The round is over. Press R to play again.' : `Cannot steer: ${r.why}.`); return; }
232 P = r.state;
233 say(`You steer ${turn}.`);
234 sound(turn === 'left' ? 440 : 520, .05, 'square');
235 }
236 function update(dt) {
237 G.time += dt;
238 if (P.status === 'playing') {
239 if (G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); }
240 }
241 if (G.time - G.tickAt < GLIDE_S + .05) G.dirty = true;
242 const wind = STILL ? 0 : Math.floor(G.time * 2);
243 if (wind !== G.wind) { G.wind = wind; G.dirty = true; }
244 renderHud();
245 }
246 function restart() {
247 P = fresh();
248 G.prev = snap(P); G.tickAt = -9; G.nextAt = G.time + LEAD_S; G.dirty = true;
249 say(`${P.line}. Steer with the left and right arrows.`);
250 }
251
252 /* ---------- input: the arrows or A and D, or the left and right half of the picture ---------- */
253 canvas.addEventListener('pointerdown', e => {
254 canvas.focus({ preventScroll: true });
255 if (P.status !== 'playing') { ensureAudio(); if (G.time - G.overAt > .6) restart(); return; }
256 const r = canvas.getBoundingClientRect();
257 steer((e.clientX - r.left) / r.width < .5 ? 'left' : 'right');
258 });
259 canvas.addEventListener('contextmenu', e => e.preventDefault());
260 canvas.addEventListener('keydown', e => {
261 if (e.ctrlKey || e.metaKey || e.altKey) return;
262 if (e.key === 'r' || e.key === 'R') { if (!e.repeat) restart(); }
263 else if (TURN_OF[e.key]) { if (!e.repeat) steer(TURN_OF[e.key]); }
264 else return;
265 e.preventDefault();
266 });
267
268 /* ---------- sound ---------- */
269 let audio = null, muted = false;
270 function ensureAudio() {
271 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
272 const AC = window.AudioContext || window.webkitAudioContext;
273 if (AC) audio = new AC();
274 }
275 function sound(freq, life, type, delay = 0) {
276 if (!audio || muted) return;
277 const now = audio.currentTime + delay, gain = audio.createGain(), osc = audio.createOscillator();
278 osc.type = type;
279 osc.frequency.setValueAtTime(freq, now);
280 gain.gain.setValueAtTime(.0001, now);
281 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
282 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
283 osc.connect(gain).connect(audio.destination);
284 osc.start(now); osc.stop(now + life + .02);
285 }
286 const muteButton = document.getElementById('mute');
287 muteButton.addEventListener('click', () => {
288 muted = !muted;
289 muteButton.textContent = muted ? 'sound off' : 'sound on';
290 muteButton.setAttribute('aria-pressed', String(muted));
291 });
292
293 /* ---------- fit and boot ---------- */
294 addEventListener('resize', fit);
295 fit();
296 bake();
297 renderHud();
298 say(`${P.line}. The floor ticks on every ${TICK_S} s: steer with the left and right arrows, A and D, or tap a half.`);
299 canvas.focus({ preventScroll: true });
300 let last = performance.now();
301 function frame(now) {
302 update(Math.min(.04, (now - last) / 1000));
303 last = now;
304 if (G.dirty) { G.dirty = false; draw(); }
305 requestAnimationFrame(frame);
306 }
307 requestAnimationFrame(frame);
308 </script>
309 </body>
310 </html>
bumper-cars/page.test.mjs added +62
@@ -0,0 +1,62 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3
4 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
5
6 test('the floor ticks on by itself at the stated pace, and the HUD reads the rules state', () => {
7 const g = boot('bumper-cars');
8 expect(state(g).tick).toBe(0);
9 g.run(1.2 + 0.5 * 4);
10 const s = state(g);
11 expect(s.tick).toBeGreaterThanOrEqual(3);
12 expect(g.el('ticks').textContent).toBe(String(s.ticks - s.tick));
13 expect(g.el('lives').textContent).toBe(String(s.lives));
14 expect(g.el('rivals').textContent).toBe('3');
15 expect(g.el('score').textContent).toBe(String(s.score));
16 });
17
18 test('arrows, A and D, and the two halves of the canvas steer, each announced', () => {
19 const g = boot('bumper-cars');
20 g.keyDown('ArrowLeft'); g.keyUp('ArrowLeft');
21 expect(g.read('P.turn')).toBe('left');
22 expect(g.el('status').textContent).toBe('You steer left.');
23 g.keyDown('d'); g.keyUp('d');
24 expect(g.read('P.turn')).toBe('right');
25 g.tap(300, 100);
26 expect(g.read('P.turn')).toBe('right');
27 g.run(2);
28 g.tap(40, 100);
29 expect(g.el('status').textContent).toBe('You steer left.');
30 });
31
32 test('a tick turns the car as steered, and R restarts', () => {
33 const g = boot('bumper-cars');
34 g.run(1.6);
35 const h = state(g).player.h;
36 g.keyDown('ArrowRight'); g.keyUp('ArrowRight');
37 g.run(.6);
38 expect(state(g).player.h).not.toBe(h);
39 g.press('r', .02);
40 expect(state(g).tick).toBe(0);
41 });
42
43 test('after the end line steering is refused with the reason, and a tap plays again', () => {
44 const g = boot('bumper-cars');
45 g.read('P = { ...P, status: "over", result: { won: false, tick: 9, score: 0, shoves: 0 }, line: "Round lost: out of lives at tick 9, score 0" }');
46 g.read('finish()');
47 expect(g.el('status').textContent).toContain('Round lost');
48 g.keyDown('ArrowLeft');
49 expect(g.el('status').textContent).toContain('The round is over');
50 g.run(1);
51 g.tap(100, 100);
52 expect(state(g).status).toBe('playing');
53 });
54
55 test('a shove into the wall is announced', () => {
56 const g = boot('bumper-cars');
57 const still = ['straight', 'straight', 'straight', 'straight'];
58 g.read(`P = { ...P, player: { x: 4, y: 1, h: 0 }, rivals: [{ x: 4, y: 0, h: 1, pattern: ${JSON.stringify(still)}, at: 0 }, { x: 0, y: 8, h: 1, pattern: ${JSON.stringify(still)}, at: 0 }, { x: 8, y: 8, h: 3, pattern: ${JSON.stringify(still)}, at: 0 }] }`);
59 g.run(1.6);
60 expect(g.el('status').textContent).toContain('You shove the red rival into the wall: 10 points');
61 expect(state(g).score).toBe(10);
62 });
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="Arcade" data-input="Keys" style="--accent:#c0392b"><a href="bumper-cars/">Bumper Cars</a><span>Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li>
142143 <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>
143144 </ul>
144145 <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>
og/bumper-cars.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +25
@@ -14,9 +14,11 @@ 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 Cars from './bumper-cars/floor.js';
1718 import Tide from './tide-crossing/causeway.js';
1819
1920 const MID = [180, 140];
21 const decided = new WeakMap();
2022 const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); };
2123 const idling = seconds => g => g.run(seconds);
2224
@@ -99,6 +101,21 @@ function wheelPlan(start) {
99101 return go(start, []) ?? [];
100102 }
101103
104 // the best first turn by looking `depth` ticks ahead through the rules' own steer and step
105 function bestTurn(s, depth = 6) {
106 const look = (st, d) => {
107 const near = Math.min(...st.rivals.map(r => Math.abs(r.x - st.player.x) + Math.abs(r.y - st.player.y)));
108 if (st.status !== 'playing' || d === 0) return st.lives * 1000 + st.score * 10 - near;
109 return Math.max(...['straight', 'left', 'right'].map(t => look(Cars.step(Cars.steer(st, t).state).state, d - 1)));
110 };
111 let best = 'straight', top = -Infinity;
112 for (const t of ['straight', 'left', 'right']) {
113 const v = look(Cars.step(Cars.steer(s, t).state).state, depth - 1);
114 if (v > top) { top = v; best = t; }
115 }
116 return best;
117 }
118
102119 export const ROUNDS = {
103120 // plant each packet in the bed that leaves the most points (a greedy pass over the rules' own `plant` and `score`), by tapping that bed
104121 'allotment': { over: /play again/, outcome: /Harvest in: \d+ points/, beat: g => {
@@ -506,6 +523,14 @@ export const ROUNDS = {
506523 const [x, y] = g.read(`screenOf(${JSON.stringify(Field.choose(s).edge)})`);
507524 g.tap(x, y);
508525 } },
526 // each tick steer to the first turn of the best line a few ticks deep through the rules' own `step` (lives first, then points, then closeness), then wait for the tick
527 'bumper-cars': { over: /Round (won|lost)/, outcome: /Round won: score [1-9]\d*, [1-9]\d* wall shoves?/, limit: 60, beat: g => {
528 const s = JSON.parse(g.read('JSON.stringify(P)'));
529 if (s.status !== 'playing' || decided.get(g) === s.tick) { g.run(.04); return; }
530 decided.set(g, s.tick);
531 const best = bestTurn(s);
532 if (best !== 'straight') g.press(best === 'left' ? 'ArrowLeft' : 'ArrowRight', .02);
533 } },
509534 // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw
510535 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => {
511536 if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; }
serve.test.mjs +7
@@ -769,6 +769,13 @@ test('Carrot Pull and its rules script are served without a track', async () =>
769769 expect((await fetch(base + '/carrot-pull/bed.js')).status).toBe(200);
770770 });
771771
772 test('Bumper Cars and its rules script are served without a track', async () => {
773 const page = await fetch(base + '/bumper-cars/');
774 expect(page.status).toBe(200);
775 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
776 expect((await fetch(base + '/bumper-cars/floor.js')).status).toBe(200);
777 });
778
772779 test('Pumpkin Patch and its rules script are served without a track', async () => {
773780 const page = await fetch(base + '/pumpkin-patch/');
774781 expect(page.status).toBe(200);
shell.js +1
@@ -75,6 +75,7 @@ window.PixelPlays = (() => {
7575 { id: 'chimney-sweep', title: 'Chimney Sweep', blurb: 'Climb the flues with your brush and duck the rising smoke', kind: 'Climbing', input: 'Tap', accent: '#4a4a4a', music: false, forged: true },
7676 { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true },
7777 { id: 'welly-toss', title: 'Welly Toss', blurb: 'Let the boot go at the top of the swing and land it past the flags', kind: 'Throwing', input: 'Tap', accent: '#2f6b3a', music: false, forged: true },
78 { id: 'bumper-cars', title: 'Bumper Cars', blurb: 'Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump', kind: 'Arcade', input: 'Keys', accent: '#c0392b', music: false, forged: true },
7879 { 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 },
7980 { 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 },
8081 { 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 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs