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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/345-add-bumper-cars-the-playable-page-and-it → main 6 commits fork
9 files
changed +409
−2
README.md +3 −2
Diff not shown — a line 3,914 characters wide (limit 2,000). +3 −2
View filebumper-cars/index.html added +310
| @@ -0,0 +1,310 @@ | ||
| 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>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 { : #1c1b19; : #6b675f; : #fff; : #c0392b; : #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(); 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; 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(); } | |
| 37 | #status { color: var(); min-height: 2.7em; max-width: 60ch; } | |
| 38 | 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; } | |
| 39 | button:hover, button:focus-visible { color: var(); } | |
| 40 | button:focus-visible { outline: 2px solid var(); outline-offset: 2px; } | |
| 41 | noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(); } | |
| 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; } | ||
| 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="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> | |
| 142 | 143 | <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> |
| 143 | 144 | </ul> |
| 144 | 145 | <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'; | ||
| 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 Cars from './bumper-cars/floor.js'; | |
| 17 | 18 | import Tide from './tide-crossing/causeway.js'; |
| 18 | 19 | |
| 19 | 20 | const MID = [180, 140]; |
| 21 | const decided = new WeakMap(); | |
| 20 | 22 | const tapping = (wait = .3) => g => { g.tap(...MID); g.run(wait); }; |
| 21 | 23 | const idling = seconds => g => g.run(seconds); |
| 22 | 24 | |
| @@ -99,6 +101,21 @@ function wheelPlan(start) { | ||
| 99 | 101 | return go(start, []) ?? []; |
| 100 | 102 | } |
| 101 | 103 | |
| 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 | ||
| 102 | 119 | export const ROUNDS = { |
| 103 | 120 | // 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 |
| 104 | 121 | 'allotment': { over: /play again/, outcome: /Harvest in: \d+ points/, beat: g => { |
| @@ -506,6 +523,14 @@ export const ROUNDS = { | ||
| 506 | 523 | const [x, y] = g.read(`screenOf(${JSON.stringify(Field.choose(s).edge)})`); |
| 507 | 524 | g.tap(x, y); |
| 508 | 525 | } }, |
| 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 | } }, | |
| 509 | 534 | // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw |
| 510 | 535 | 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => { |
| 511 | 536 | 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 () => | ||
| 769 | 769 | expect((await fetch(base + '/carrot-pull/bed.js')).status).toBe(200); |
| 770 | 770 | }); |
| 771 | 771 | |
| 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 | ||
| 772 | 779 | test('Pumpkin Patch and its rules script are served without a track', async () => { |
| 773 | 780 | const page = await fetch(base + '/pumpkin-patch/'); |
| 774 | 781 | expect(page.status).toBe(200); |
shell.js +1
| @@ -75,6 +75,7 @@ window.PixelPlays = (() => { | ||
| 75 | 75 | { 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 }, |
| 76 | 76 | { 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 }, |
| 77 | 77 | { 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 }, | |
| 78 | 79 | { 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 | 80 | { 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 | 81 | { 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 }, |
new above
offline