diff --git a/assets/gamedata/gootower/level-005.json b/assets/gamedata/gootower/level-005.json index 858f4f1..1319f28 100644 --- a/assets/gamedata/gootower/level-005.json +++ b/assets/gamedata/gootower/level-005.json @@ -4,28 +4,61 @@ "h": 1000 }, "level": 5, - "name": "Overhead", + "name": "Watch Out", "chapter": 1, - "tip": "The ceiling bites. Stop short of it.", + "tip": "Lot's of danger to avoid!", "terrain": [ { "kind": "solid", "poly": [ [ - 0, - 850 + 7, + 832 + ], + [ + 1593, + 839 + ], + [ + 1593, + 997 + ], + [ + 4, + 997 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 1014, + 546 + ], + [ + 1524, + 546 ], [ 1600, - 850 + 463 ], [ - 1600, - 1000 + 1595, + 836 ], [ - 0, - 1000 + 1543, + 836 + ], + [ + 1537, + 602 + ], + [ + 1034, + 589 ] ] }, @@ -33,46 +66,64 @@ "kind": "spike", "poly": [ [ - 400, - 180 + 204, + 520 ], [ - 1200, - 180 + 713, + 460 ], [ - 1200, - 240 + 760, + 422 ], [ - 400, - 240 + 702, + 401 + ], + [ + 196, + 469 + ], + [ + 128, + 509 ] ] } ], + "gears": [ + { + "x": 1105, + "y": 570, + "r": 100, + "teeth": 9, + "omega": 1.4 + } + ], "balls": [ { - "x": 760, - "y": 836, + "x": 423, + "y": 808, "type": "common", - "pinned": true + "pinned": false }, { - "x": 822, - "y": 836, + "x": 525, + "y": 808, "type": "common", - "pinned": true + "pinned": false }, { - "x": 791, - "y": 782, - "type": "common" + "x": 475, + "y": 731, + "type": "common", + "pinned": false } ], "strands": [ [ - 2, + 1, 0 ], [ @@ -80,107 +131,132 @@ 1 ], [ - 0, - 1 + 2, + 0 ] ], - "pipe": { - "x": 791, - "y": 340, - "r": 46 - }, "pile": [ { - "x": 1000, - "y": 834, + "x": 680, + "y": 803, "type": "common" }, { - "x": 1030, - "y": 834, + "x": 717, + "y": 795, "type": "common" }, { - "x": 1060, - "y": 834, + "x": 766, + "y": 795, "type": "common" }, { - "x": 1090, - "y": 834, + "x": 815, + "y": 803, "type": "common" }, { - "x": 1120, - "y": 834, + "x": 870, + "y": 800, "type": "common" }, { - "x": 1150, - "y": 834, + "x": 842, + "y": 770, "type": "common" }, { - "x": 1180, - "y": 834, + "x": 793, + "y": 756, "type": "common" }, { - "x": 1210, - "y": 834, + "x": 743, + "y": 754, "type": "common" }, { - "x": 1240, - "y": 834, + "x": 684, + "y": 762, "type": "common" }, { - "x": 1000, - "y": 804, + "x": 716, + "y": 715, "type": "common" }, { - "x": 1030, - "y": 804, + "x": 768, + "y": 709, "type": "common" }, { - "x": 1060, - "y": 804, + "x": 804, + "y": 724, "type": "common" }, { - "x": 1090, - "y": 804, + "x": 839, + "y": 731, "type": "common" }, { - "x": 1120, - "y": 804, + "x": 873, + "y": 742, "type": "common" }, { - "x": 1150, - "y": 804, + "x": 875, + "y": 691, "type": "common" }, { - "x": 1180, - "y": 804, + "x": 825, + "y": 688, "type": "common" }, { - "x": 1210, - "y": 804, + "x": 791, + "y": 665, "type": "common" }, { - "x": 1240, - "y": 804, + "x": 747, + "y": 661, + "type": "common" + }, + { + "x": 780, + "y": 611, + "type": "common" + }, + { + "x": 823, + "y": 625, + "type": "common" + }, + { + "x": 862, + "y": 642, + "type": "common" + }, + { + "x": 910, + "y": 658, + "type": "common" + }, + { + "x": 862, + "y": 600, "type": "common" } ], + "pipe": { + "x": 916, + "y": 354, + "r": 46 + }, "required": 5, "ocdTarget": 11 } diff --git a/assets/gamedata/gootower/level-006.json b/assets/gamedata/gootower/level-006.json index e906cf7..66d14d6 100644 --- a/assets/gamedata/gootower/level-006.json +++ b/assets/gamedata/gootower/level-006.json @@ -12,46 +12,233 @@ "kind": "solid", "poly": [ [ - 0, - 850 + 5, + 372 + ], + [ + 681, + 370 + ], + [ + 681, + 501 + ], + [ + 607, + 572 + ], + [ + 565, + 642 + ], + [ + 558, + 696 + ], + [ + 573, + 806 + ], + [ + 617, + 887 + ], + [ + 639, + 943 + ], + [ + 650, + 998 + ], + [ + 7, + 997 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 669, + 255 + ], + [ + 692, + 258 + ], + [ + 700, + 5 + ], + [ + 2, + 9 + ], + [ + 2, + 58 + ], + [ + 470, + 64 + ], + [ + 541, + 74 + ], + [ + 588, + 104 + ], + [ + 639, + 156 + ], + [ + 665, + 253 + ] + ] + }, + { + "kind": "spike", + "poly": [ + [ + 933, + 6 + ], + [ + 932, + 433 + ], + [ + 1595, + 433 + ], + [ + 1596, + 8 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 932, + 869 + ], + [ + 936, + 997 ], [ 1600, - 850 + 997 ], [ 1600, - 1000 + 869 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 1023, + 435 ], [ - 0, - 1000 + 1056, + 494 + ], + [ + 1044, + 539 + ], + [ + 1034, + 567 + ], + [ + 1051, + 589 + ], + [ + 1600, + 586 + ], + [ + 1598, + 438 + ] + ] + }, + { + "kind": "fire", + "poly": [ + [ + 933, + 869 + ], + [ + 602, + 860 + ], + [ + 631, + 915 + ], + [ + 650, + 976 + ], + [ + 650, + 998 + ], + [ + 933, + 997 ] ] } ], + "gears": [ + { + "x": 958, + "y": 411, + "r": 100, + "teeth": 9, + "omega": 1.4 + } + ], "balls": [ { - "x": 760, - "y": 836, + "x": 453, + "y": 343, "type": "common", - "pinned": true + "pinned": false }, { - "x": 822, - "y": 836, + "x": 533, + "y": 345, "type": "common", - "pinned": true + "pinned": false }, { - "x": 791, - "y": 782, - "type": "common" + "x": 495, + "y": 285, + "type": "common", + "pinned": false } ], "strands": [ [ - 2, + 1, 0 ], [ @@ -59,147 +246,142 @@ 1 ], [ - 0, - 1 + 2, + 0 ] ], - "pipe": { - "x": 791, - "y": 180, - "r": 46 - }, "pile": [ { - "x": 1020, - "y": 834, + "x": 94, + "y": 294, "type": "common" }, { - "x": 1050, - "y": 834, + "x": 48, + "y": 291, "type": "common" }, { - "x": 1080, - "y": 834, + "x": 284, + "y": 225, "type": "common" }, { - "x": 1110, - "y": 834, + "x": 228, + "y": 219, "type": "common" }, { - "x": 1140, - "y": 834, + "x": 174, + "y": 217, "type": "common" }, { - "x": 1170, - "y": 834, + "x": 127, + "y": 211, "type": "common" }, { - "x": 1200, - "y": 834, + "x": 78, + "y": 212, "type": "common" }, { - "x": 1230, - "y": 834, + "x": 158, + "y": 261, "type": "common" }, { - "x": 1260, - "y": 834, + "x": 141, + "y": 290, "type": "common" }, { - "x": 1020, - "y": 804, + "x": 207, + "y": 294, "type": "common" }, { - "x": 1050, - "y": 804, + "x": 306, + "y": 301, "type": "common" }, { - "x": 1080, - "y": 804, + "x": 257, + "y": 261, "type": "common" }, { - "x": 1110, - "y": 804, + "x": 240, + "y": 310, "type": "common" }, { - "x": 1140, - "y": 804, + "x": 34, + "y": 249, "type": "common" }, { - "x": 1170, - "y": 804, + "x": 327, + "y": 249, "type": "common" }, { - "x": 1200, - "y": 804, + "x": 357, + "y": 291, "type": "common" }, { - "x": 1230, - "y": 804, + "x": 328, + "y": 200, "type": "common" }, { - "x": 1260, - "y": 804, + "x": 275, + "y": 184, "type": "common" }, { - "x": 1020, - "y": 774, + "x": 174, + "y": 335, "type": "common" }, { - "x": 1050, - "y": 774, + "x": 35, + "y": 338, "type": "common" }, { - "x": 1080, - "y": 774, + "x": 387, + "y": 230, "type": "common" }, { - "x": 1110, - "y": 774, + "x": 352, + "y": 142, "type": "common" }, { - "x": 1140, - "y": 774, + "x": 262, + "y": 131, "type": "common" }, { - "x": 1170, - "y": 774, + "x": 385, + "y": 179, "type": "common" }, { - "x": 1200, - "y": 774, - "type": "common" - }, - { - "x": 1230, - "y": 774, + "x": 406, + "y": 274, "type": "common" } ], + "pipe": { + "x": 1127, + "y": 657, + "r": 46 + }, "required": 6, "ocdTarget": 14 } diff --git a/assets/images/shift/bounty-hunter.png b/assets/images/shift/bounty-hunter.png new file mode 100644 index 0000000..258581d Binary files /dev/null and b/assets/images/shift/bounty-hunter.png differ diff --git a/assets/images/shift/cybertrain.png b/assets/images/shift/cybertrain.png new file mode 100644 index 0000000..d39df07 Binary files /dev/null and b/assets/images/shift/cybertrain.png differ diff --git a/assets/images/shift/future-city.png b/assets/images/shift/future-city.png new file mode 100644 index 0000000..d981836 Binary files /dev/null and b/assets/images/shift/future-city.png differ diff --git a/assets/images/shift/japanese-poster.png b/assets/images/shift/japanese-poster.png new file mode 100644 index 0000000..cac2a2c Binary files /dev/null and b/assets/images/shift/japanese-poster.png differ diff --git a/assets/images/shift/leaving-gas-giant.png b/assets/images/shift/leaving-gas-giant.png new file mode 100644 index 0000000..75f3ac4 Binary files /dev/null and b/assets/images/shift/leaving-gas-giant.png differ diff --git a/assets/images/shift/night-city.png b/assets/images/shift/night-city.png new file mode 100644 index 0000000..4dc8c01 Binary files /dev/null and b/assets/images/shift/night-city.png differ diff --git a/assets/images/shift/research-station.png b/assets/images/shift/research-station.png new file mode 100644 index 0000000..65458e5 Binary files /dev/null and b/assets/images/shift/research-station.png differ diff --git a/assets/images/shift/sky-city.png b/assets/images/shift/sky-city.png new file mode 100644 index 0000000..d0a6afc Binary files /dev/null and b/assets/images/shift/sky-city.png differ diff --git a/assets/images/shift/steampunk-future-city.png b/assets/images/shift/steampunk-future-city.png new file mode 100644 index 0000000..70e449f Binary files /dev/null and b/assets/images/shift/steampunk-future-city.png differ diff --git a/data/shift-artwork.json b/data/shift-artwork.json index a0ca3ef..8b754e0 100644 --- a/data/shift-artwork.json +++ b/data/shift-artwork.json @@ -47,6 +47,60 @@ "name": "Fierce Warrior", "key": "warrior", "path": "assets/images/shift/warrior.png" + }, + { + "id": "cybertrain", + "name": "Cybercity Train", + "key": "cybertrain", + "path": "assets/images/shift/cybertrain.png" + }, + { + "id": "bounty-hunter", + "name": "Bounty Hunter", + "key": "bounty-hunter", + "path": "assets/images/shift/bounty-hunter.png" + }, + { + "id": "future-city", + "name": "Future City", + "key": "future-city", + "path": "assets/images/shift/future-city.png" + }, + { + "id": "japanese-poster", + "name": "Japanese Poster", + "key": "japanese-poster", + "path": "assets/images/shift/japanese-poster.png" + }, + { + "id": "leaving-gas-giant", + "name": "Leaving the Gas Giant", + "key": "leaving-gas-giant", + "path": "assets/images/shift/leaving-gas-giant.png" + }, + { + "id": "night-city", + "name": "Night City", + "key": "night-city", + "path": "assets/images/shift/night-city.png" + }, + { + "id": "research-station", + "name": "Research Station", + "key": "research-station", + "path": "assets/images/shift/research-station.png" + }, + { + "id": "sky-city", + "name": "Sky City", + "key": "sky-city", + "path": "assets/images/shift/sky-city.png" + }, + { + "id": "steampunk-future-city", + "name": "Steampunk Future City", + "key": "steampunk-future-city", + "path": "assets/images/shift/steampunk-future-city.png" } ] } \ No newline at end of file diff --git a/src/games/gootower/GooTowerLogic.js b/src/games/gootower/GooTowerLogic.js index 9aea939..5ea4a16 100644 --- a/src/games/gootower/GooTowerLogic.js +++ b/src/games/gootower/GooTowerLogic.js @@ -568,7 +568,8 @@ function relaxStrands(state, dt) { // Gears are ROTATING SOLID TERRAIN. They are stored as ordinary terrain // entries carrying a `gear` record, so the existing collision path handles // them for free; all this has to do is spin the polygon and recompute bounds. -// Contact then drags goo along at the rim's surface speed. +// Contact is lethal (see resolveTerrain) -- a gear grinds up anything that +// touches it, structure and loose goo alike. function buildGearPoly(gear) { const { cx, cy, r, teeth, angle } = gear; @@ -695,6 +696,12 @@ function resolveTerrain(state, events, finalPass = true) { } continue; } + // A gear grinds up anything that touches it -- structure and loose goo + // alike, no exceptions -- rather than the old "ride the rim" mechanic. + // Positional pushout still runs every relax pass for stability, same as + // any other solid; only the kill (like spike/fire) is once-per-substep. + if (t.gear && checkHazards) { killBall(state, b, 'gear', events); break; } + if (t.kind !== 'solid') continue; let nx, ny; @@ -717,21 +724,8 @@ function resolveTerrain(state, events, finalPass = true) { const ty = vy - vn * ny; const nvn = vn < 0 ? -vn * TUNING.RESTITUTION : vn; - // A spinning gear drags whatever touches it. Friction pulls the ball's - // tangential motion toward the RIM's surface velocity rather than - // toward zero -- that difference is the whole mechanic. - let stx = 0; - let sty = 0; - if (t.gear) { - const sdt = TUNING.SUBSTEP_DT; - const svx = -t.gear.omega * (cp.y - t.gear.cy) * sdt; - const svy = t.gear.omega * (cp.x - t.gear.cx) * sdt; - const svn = svx * nx + svy * ny; - stx = svx - svn * nx; - sty = svy - svn * ny; - } - vx = tx + (stx - tx) * TUNING.FRICTION + nx * nvn; - vy = ty + (sty - ty) * TUNING.FRICTION + ny * nvn; + vx = tx * (1 - TUNING.FRICTION) + nx * nvn; + vy = ty * (1 - TUNING.FRICTION) + ny * nvn; b.px = b.x - vx; b.py = b.y - vy; } @@ -921,20 +915,22 @@ export function requiredStrands(state, x, y, type) { return gooType(type).minStrands; } -// Would goo of this type dropped here die on contact? Spikes and fire are -// lethal terrain, and a placement that lands on them is legal but suicidal -- -// the reference player must not walk into it, and the drag preview warns. +// Would goo of this type dropped here die on contact? Spikes, fire and gears +// are lethal terrain, and a placement that lands on them is legal but +// suicidal -- the reference player must not walk into it, and the drag +// preview warns. export function hazardAt(state, x, y, type = 'common') { const def = gooType(type); const r = def.r + 2; for (const t of state.terrain) { - if (t.kind !== 'spike' && t.kind !== 'fire') continue; + if (t.kind !== 'spike' && t.kind !== 'fire' && !t.gear) continue; const bb = t.bounds; if (x + r < bb.minX || x - r > bb.maxX || y + r < bb.minY || y - r > bb.maxY) continue; if (!pointInPoly(x, y, t.poly) && Math.sqrt(closestPointOnPoly(x, y, t.poly).d2) > r) continue; if (t.kind === 'spike' && def.spikeProof) continue; if (t.kind === 'fire' && def.explodes) return 'fire'; // lights it, not lethal per se if (t.kind === 'fire' && def.spikeProof) continue; + if (t.gear) return 'gear'; // no exceptions -- gears grind up everything return t.kind; } return null; diff --git a/src/games/shift/ShiftGame.js b/src/games/shift/ShiftGame.js index 10444a3..8f4de67 100644 --- a/src/games/shift/ShiftGame.js +++ b/src/games/shift/ShiftGame.js @@ -49,6 +49,7 @@ export default class ShiftGame extends Phaser.Scene { this.boardSize = 0; this.cols = 0; this.rows = 0; + this.artworkPage = 0; } create() { @@ -216,7 +217,7 @@ export default class ShiftGame extends Phaser.Scene { } } - showArtworkSelect(difficulty) { + showArtworkSelect(difficulty, page = 0) { this.view = 'artwork'; this.overlayUp = false; this.clearLayer(); @@ -233,49 +234,57 @@ export default class ShiftGame extends Phaser.Scene { }).setOrigin(0.5); this.layer.add([titleBg, title]); - const CARD_W = 310; - const CARD_H = 360; - const GAP = 50; - const THUMB = 240; - const MAX_ROW = 5; - const perRow = Math.min(this.artworkList.length, MAX_ROW); + const CARD_W = 310; + const CARD_H = 320; + const GAP = 50; + const THUMB = 200; + const MAX_ROW = 5; + const ROWS_PER_PAGE = 2; + const PAGE_SIZE = MAX_ROW * ROWS_PER_PAGE; + + const totalPages = Math.max(1, Math.ceil(this.artworkList.length / PAGE_SIZE)); + page = Phaser.Math.Clamp(page, 0, totalPages - 1); + this.artworkPage = page; + + const pageItems = this.artworkList.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + const perRow = Math.min(pageItems.length, MAX_ROW); const totalW = perRow * CARD_W + (perRow - 1) * GAP; const startX = cx - totalW / 2 + CARD_W / 2; - const startY = 400; + const startY = 330; - this.artworkList.forEach((art, i) => { + pageItems.forEach((art, i) => { const row = Math.floor(i / MAX_ROW); const col = i % MAX_ROW; const x = startX + col * (CARD_W + GAP); - const y = startY + row * (CARD_H + 50); + const y = startY + row * (CARD_H + 40); const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x182430) .setStrokeStyle(2, COLORS.accent, 0.5); this.layer.add(card); if (art.key && this.textures.exists(art.key)) { - const thumb = this.add.image(x, y - 40, art.key, `${art.key}-sq`) + const thumb = this.add.image(x, y - 35, art.key, `${art.key}-sq`) .setDisplaySize(THUMB, THUMB); this.layer.add(thumb); } else { const ph = this.add.graphics(); ph.fillStyle(0x223344, 1); - ph.fillRoundedRect(x - THUMB / 2, y - 40 - THUMB / 2, THUMB, THUMB, 8); - const icon = this.add.text(x, y - 40, '▦', { - fontFamily: 'Righteous', fontSize: '80px', color: '#5b9bff', + ph.fillRoundedRect(x - THUMB / 2, y - 35 - THUMB / 2, THUMB, THUMB, 8); + const icon = this.add.text(x, y - 35, '▦', { + fontFamily: 'Righteous', fontSize: '70px', color: '#5b9bff', }).setOrigin(0.5); this.layer.add([ph, icon]); } - const label = this.add.text(x, y + CARD_H / 2 - 80, art.name, { - fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, + const label = this.add.text(x, y + CARD_H / 2 - 68, art.name, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, align: 'center', wordWrap: { width: CARD_W - 20 }, }).setOrigin(0.5, 0); const lsKey = `shift-best-${difficulty}-${art.id}`; const bestV = parseInt(localStorage.getItem(lsKey), 10); - const bestLb = this.add.text(x, y + CARD_H / 2 - 44, !isNaN(bestV) ? `Best: ${bestV}` : '', { - fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + const bestLb = this.add.text(x, y + CARD_H / 2 - 36, !isNaN(bestV) ? `Best: ${bestV}` : '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex, }).setOrigin(0.5, 0); this.layer.add([label, bestLb]); @@ -285,7 +294,26 @@ export default class ShiftGame extends Phaser.Scene { card.on('pointerup', () => this.startGame(difficulty, art.id)); }); - const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', + // ── Pagination controls ───────────────────────────────────────────────── + if (totalPages > 1) { + const pagY = 920; + const prevBtn = new Button(this, cx - 180, pagY, '< Prev', + () => this.showArtworkSelect(difficulty, page - 1), + { variant: 'ghost', width: 160, height: 52, fontSize: 22 }); + const nextBtn = new Button(this, cx + 180, pagY, 'Next >', + () => this.showArtworkSelect(difficulty, page + 1), + { variant: 'ghost', width: 160, height: 52, fontSize: 22 }); + prevBtn.setEnabled(page > 0); + nextBtn.setEnabled(page < totalPages - 1); + + const pageLabel = this.add.text(cx, pagY, `Page ${page + 1} of ${totalPages}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5); + + this.layer.add([prevBtn, nextBtn, pageLabel]); + } + + const back = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.showDifficultySelect(), { variant: 'ghost', width: 240, height: 60, fontSize: 24 }); this.layer.add(back); @@ -563,6 +591,21 @@ export default class ShiftGame extends Phaser.Scene { { variant: 'ghost', width: btnW, height: btnH, fontSize: 26 }).setDepth(D.overlayUI); this.layer.add([again, goBack]); + // Reveal the completed artwork over the puzzle + dimmed background once the + // panel clears out of the way. + const hasArt = !!(this.artDef?.key && this.textures.exists(this.artDef.key)); + let reveal = null; + if (hasArt) { + reveal = this.add.image( + this.boardLeft + this.boardSize / 2, + this.boardTop + this.boardSize / 2, + this.artDef.key, `${this.artDef.key}-sq`, + ).setDisplaySize(this.boardSize, this.boardSize) + .setAlpha(0) + .setDepth(D.overlay + 1); + this.layer.add(reveal); + } + // Animate: fade out the panel background, move text/buttons to upper-left const offsetX = 230; const offsetY = 230; @@ -577,6 +620,16 @@ export default class ShiftGame extends Phaser.Scene { ease: 'Cubic.easeInOut', }); + // Fade in the completed artwork as the panel clears + if (reveal) { + this.tweens.add({ + targets: reveal, + alpha: 1, + duration: 800, + ease: 'Cubic.easeInOut', + }); + } + // Move text elements to upper-left, preserving their relative vertical spacing this.tweens.add({ targets: [winTitle, movesLbl, bestLbl, again, goBack], diff --git a/tools/verifyGooTower.js b/tools/verifyGooTower.js index 2c597fe..6f64fb2 100644 --- a/tools/verifyGooTower.js +++ b/tools/verifyGooTower.js @@ -772,7 +772,7 @@ section('4f. Fans and gears'); })()); } { - // A gear is solid, spins, and drags what touches it. + // A gear is solid, spins, and grinds up anything that touches it. const mkGear = (omega) => createState(baseLevel({ terrain: [ground()], gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega }], @@ -783,21 +783,22 @@ section('4f. Fans and gears'); const gearT = spun.terrain.find((t) => t.gear); check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid'); const a0 = gearT.gear.angle; - for (let i = 0; i < 60; i += 1) stepSim(spun, 1 / 60); + let events = []; + for (let i = 0; i < 60; i += 1) events = events.concat(stepSim(spun, 1 / 60)); check('a gear rotates', Math.abs(gearT.gear.angle - a0) > 1, `angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`); - check('a spinning gear stays solid (goo never sinks into it)', - !pointInPoly(spun.balls[0].x, spun.balls[0].y, gearT.poly)); + check('touching a gear kills the ball', spun.balls[0].dead); + check('a gear kill fires a ballDie event with cause "gear"', + events.some((e) => e.type === 'ballDie' && e.cause === 'gear')); - // Positive omega drags the top of the wheel to the right (screen y is down). - const right = mkGear(4); - const left = mkGear(-4); - const rx = right.balls[0].x; - const lx = left.balls[0].x; - for (let i = 0; i < 150; i += 1) { stepSim(right, 1 / 60); stepSim(left, 1 / 60); } - check('a gear drags goo along its rim', - right.balls[0].x > rx + 5 && left.balls[0].x < lx - 5, - `cw ${(right.balls[0].x - rx).toFixed(1)}px, ccw ${(left.balls[0].x - lx).toFixed(1)}px`); + // A ball that never comes near the gear is unaffected. + const safe = createState(baseLevel({ + terrain: [ground()], + gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega: 3 }], + pile: [{ x: 1200, y: 460, type: 'common' }], + })); + for (let i = 0; i < 90; i += 1) stepSim(safe, 1 / 60); + check('a ball far from the gear survives', !safe.balls[0].dead); // Time-varying terrain must not cost determinism. const d1 = mkGear(3);