feat(gootower): make gears lethal hazards; redesign levels 5-6

Replace the "ride the rim" gear mechanic with instant ball destruction on
contact. Update hazardAt(), resolveTerrain(), and all related tests.
Redesign level 5 ("Watch Out") and level 6 with new terrain layouts
featuring gear hazards, updated ball/pipe/pile positions.

feat(shift): paginate artwork selection and reveal on completion

Add page navigation to artwork cards when there are more than one page,
and fade in the completed artwork over the puzzle after winning.

chore: add 9 new Shift artwork entries
This commit is contained in:
Brian Fertig 2026-07-30 23:09:59 -06:00
parent 221f1d428a
commit 78c61c1cd5
15 changed files with 562 additions and 200 deletions

View File

@ -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
}

View File

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -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"
}
]
}

View File

@ -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;

View File

@ -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],

View File

@ -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);