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 "h": 1000
}, },
"level": 5, "level": 5,
"name": "Overhead", "name": "Watch Out",
"chapter": 1, "chapter": 1,
"tip": "The ceiling bites. Stop short of it.", "tip": "Lot's of danger to avoid!",
"terrain": [ "terrain": [
{ {
"kind": "solid", "kind": "solid",
"poly": [ "poly": [
[ [
0, 7,
850 832
],
[
1593,
839
],
[
1593,
997
],
[
4,
997
]
]
},
{
"kind": "solid",
"poly": [
[
1014,
546
],
[
1524,
546
], ],
[ [
1600, 1600,
850 463
], ],
[ [
1600, 1595,
1000 836
], ],
[ [
0, 1543,
1000 836
],
[
1537,
602
],
[
1034,
589
] ]
] ]
}, },
@ -33,46 +66,64 @@
"kind": "spike", "kind": "spike",
"poly": [ "poly": [
[ [
400, 204,
180 520
], ],
[ [
1200, 713,
180 460
], ],
[ [
1200, 760,
240 422
], ],
[ [
400, 702,
240 401
],
[
196,
469
],
[
128,
509
] ]
] ]
} }
], ],
"gears": [
{
"x": 1105,
"y": 570,
"r": 100,
"teeth": 9,
"omega": 1.4
}
],
"balls": [ "balls": [
{ {
"x": 760, "x": 423,
"y": 836, "y": 808,
"type": "common", "type": "common",
"pinned": true "pinned": false
}, },
{ {
"x": 822, "x": 525,
"y": 836, "y": 808,
"type": "common", "type": "common",
"pinned": true "pinned": false
}, },
{ {
"x": 791, "x": 475,
"y": 782, "y": 731,
"type": "common" "type": "common",
"pinned": false
} }
], ],
"strands": [ "strands": [
[ [
2, 1,
0 0
], ],
[ [
@ -80,107 +131,132 @@
1 1
], ],
[ [
0, 2,
1 0
] ]
], ],
"pipe": {
"x": 791,
"y": 340,
"r": 46
},
"pile": [ "pile": [
{ {
"x": 1000, "x": 680,
"y": 834, "y": 803,
"type": "common" "type": "common"
}, },
{ {
"x": 1030, "x": 717,
"y": 834, "y": 795,
"type": "common" "type": "common"
}, },
{ {
"x": 1060, "x": 766,
"y": 834, "y": 795,
"type": "common" "type": "common"
}, },
{ {
"x": 1090, "x": 815,
"y": 834, "y": 803,
"type": "common" "type": "common"
}, },
{ {
"x": 1120, "x": 870,
"y": 834, "y": 800,
"type": "common" "type": "common"
}, },
{ {
"x": 1150, "x": 842,
"y": 834, "y": 770,
"type": "common" "type": "common"
}, },
{ {
"x": 1180, "x": 793,
"y": 834, "y": 756,
"type": "common" "type": "common"
}, },
{ {
"x": 1210, "x": 743,
"y": 834, "y": 754,
"type": "common" "type": "common"
}, },
{ {
"x": 1240, "x": 684,
"y": 834, "y": 762,
"type": "common" "type": "common"
}, },
{ {
"x": 1000, "x": 716,
"y": 804, "y": 715,
"type": "common" "type": "common"
}, },
{ {
"x": 1030, "x": 768,
"y": 804, "y": 709,
"type": "common" "type": "common"
}, },
{ {
"x": 1060, "x": 804,
"y": 804, "y": 724,
"type": "common" "type": "common"
}, },
{ {
"x": 1090, "x": 839,
"y": 804, "y": 731,
"type": "common" "type": "common"
}, },
{ {
"x": 1120, "x": 873,
"y": 804, "y": 742,
"type": "common" "type": "common"
}, },
{ {
"x": 1150, "x": 875,
"y": 804, "y": 691,
"type": "common" "type": "common"
}, },
{ {
"x": 1180, "x": 825,
"y": 804, "y": 688,
"type": "common" "type": "common"
}, },
{ {
"x": 1210, "x": 791,
"y": 804, "y": 665,
"type": "common" "type": "common"
}, },
{ {
"x": 1240, "x": 747,
"y": 804, "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" "type": "common"
} }
], ],
"pipe": {
"x": 916,
"y": 354,
"r": 46
},
"required": 5, "required": 5,
"ocdTarget": 11 "ocdTarget": 11
} }

View File

@ -12,46 +12,233 @@
"kind": "solid", "kind": "solid",
"poly": [ "poly": [
[ [
0, 5,
850 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, 1600,
850 997
], ],
[ [
1600, 1600,
1000 869
]
]
},
{
"kind": "solid",
"poly": [
[
1023,
435
], ],
[ [
0, 1056,
1000 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": [ "balls": [
{ {
"x": 760, "x": 453,
"y": 836, "y": 343,
"type": "common", "type": "common",
"pinned": true "pinned": false
}, },
{ {
"x": 822, "x": 533,
"y": 836, "y": 345,
"type": "common", "type": "common",
"pinned": true "pinned": false
}, },
{ {
"x": 791, "x": 495,
"y": 782, "y": 285,
"type": "common" "type": "common",
"pinned": false
} }
], ],
"strands": [ "strands": [
[ [
2, 1,
0 0
], ],
[ [
@ -59,147 +246,142 @@
1 1
], ],
[ [
0, 2,
1 0
] ]
], ],
"pipe": {
"x": 791,
"y": 180,
"r": 46
},
"pile": [ "pile": [
{ {
"x": 1020, "x": 94,
"y": 834, "y": 294,
"type": "common" "type": "common"
}, },
{ {
"x": 1050, "x": 48,
"y": 834, "y": 291,
"type": "common" "type": "common"
}, },
{ {
"x": 1080, "x": 284,
"y": 834, "y": 225,
"type": "common" "type": "common"
}, },
{ {
"x": 1110, "x": 228,
"y": 834, "y": 219,
"type": "common" "type": "common"
}, },
{ {
"x": 1140, "x": 174,
"y": 834, "y": 217,
"type": "common" "type": "common"
}, },
{ {
"x": 1170, "x": 127,
"y": 834, "y": 211,
"type": "common" "type": "common"
}, },
{ {
"x": 1200, "x": 78,
"y": 834, "y": 212,
"type": "common" "type": "common"
}, },
{ {
"x": 1230, "x": 158,
"y": 834, "y": 261,
"type": "common" "type": "common"
}, },
{ {
"x": 1260, "x": 141,
"y": 834, "y": 290,
"type": "common" "type": "common"
}, },
{ {
"x": 1020, "x": 207,
"y": 804, "y": 294,
"type": "common" "type": "common"
}, },
{ {
"x": 1050, "x": 306,
"y": 804, "y": 301,
"type": "common" "type": "common"
}, },
{ {
"x": 1080, "x": 257,
"y": 804, "y": 261,
"type": "common" "type": "common"
}, },
{ {
"x": 1110, "x": 240,
"y": 804, "y": 310,
"type": "common" "type": "common"
}, },
{ {
"x": 1140, "x": 34,
"y": 804, "y": 249,
"type": "common" "type": "common"
}, },
{ {
"x": 1170, "x": 327,
"y": 804, "y": 249,
"type": "common" "type": "common"
}, },
{ {
"x": 1200, "x": 357,
"y": 804, "y": 291,
"type": "common" "type": "common"
}, },
{ {
"x": 1230, "x": 328,
"y": 804, "y": 200,
"type": "common" "type": "common"
}, },
{ {
"x": 1260, "x": 275,
"y": 804, "y": 184,
"type": "common" "type": "common"
}, },
{ {
"x": 1020, "x": 174,
"y": 774, "y": 335,
"type": "common" "type": "common"
}, },
{ {
"x": 1050, "x": 35,
"y": 774, "y": 338,
"type": "common" "type": "common"
}, },
{ {
"x": 1080, "x": 387,
"y": 774, "y": 230,
"type": "common" "type": "common"
}, },
{ {
"x": 1110, "x": 352,
"y": 774, "y": 142,
"type": "common" "type": "common"
}, },
{ {
"x": 1140, "x": 262,
"y": 774, "y": 131,
"type": "common" "type": "common"
}, },
{ {
"x": 1170, "x": 385,
"y": 774, "y": 179,
"type": "common" "type": "common"
}, },
{ {
"x": 1200, "x": 406,
"y": 774, "y": 274,
"type": "common"
},
{
"x": 1230,
"y": 774,
"type": "common" "type": "common"
} }
], ],
"pipe": {
"x": 1127,
"y": 657,
"r": 46
},
"required": 6, "required": 6,
"ocdTarget": 14 "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", "name": "Fierce Warrior",
"key": "warrior", "key": "warrior",
"path": "assets/images/shift/warrior.png" "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 // Gears are ROTATING SOLID TERRAIN. They are stored as ordinary terrain
// entries carrying a `gear` record, so the existing collision path handles // 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. // 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) { function buildGearPoly(gear) {
const { cx, cy, r, teeth, angle } = gear; const { cx, cy, r, teeth, angle } = gear;
@ -695,6 +696,12 @@ function resolveTerrain(state, events, finalPass = true) {
} }
continue; 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; if (t.kind !== 'solid') continue;
let nx, ny; let nx, ny;
@ -717,21 +724,8 @@ function resolveTerrain(state, events, finalPass = true) {
const ty = vy - vn * ny; const ty = vy - vn * ny;
const nvn = vn < 0 ? -vn * TUNING.RESTITUTION : vn; const nvn = vn < 0 ? -vn * TUNING.RESTITUTION : vn;
// A spinning gear drags whatever touches it. Friction pulls the ball's vx = tx * (1 - TUNING.FRICTION) + nx * nvn;
// tangential motion toward the RIM's surface velocity rather than vy = ty * (1 - TUNING.FRICTION) + ny * nvn;
// 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;
b.px = b.x - vx; b.px = b.x - vx;
b.py = b.y - vy; b.py = b.y - vy;
} }
@ -921,20 +915,22 @@ export function requiredStrands(state, x, y, type) {
return gooType(type).minStrands; return gooType(type).minStrands;
} }
// Would goo of this type dropped here die on contact? Spikes and fire are // Would goo of this type dropped here die on contact? Spikes, fire and gears
// lethal terrain, and a placement that lands on them is legal but suicidal -- // are lethal terrain, and a placement that lands on them is legal but
// the reference player must not walk into it, and the drag preview warns. // suicidal -- the reference player must not walk into it, and the drag
// preview warns.
export function hazardAt(state, x, y, type = 'common') { export function hazardAt(state, x, y, type = 'common') {
const def = gooType(type); const def = gooType(type);
const r = def.r + 2; const r = def.r + 2;
for (const t of state.terrain) { 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; const bb = t.bounds;
if (x + r < bb.minX || x - r > bb.maxX || y + r < bb.minY || y - r > bb.maxY) continue; 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 (!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 === 'spike' && def.spikeProof) continue;
if (t.kind === 'fire' && def.explodes) return 'fire'; // lights it, not lethal per se if (t.kind === 'fire' && def.explodes) return 'fire'; // lights it, not lethal per se
if (t.kind === 'fire' && def.spikeProof) continue; if (t.kind === 'fire' && def.spikeProof) continue;
if (t.gear) return 'gear'; // no exceptions -- gears grind up everything
return t.kind; return t.kind;
} }
return null; return null;

View File

@ -49,6 +49,7 @@ export default class ShiftGame extends Phaser.Scene {
this.boardSize = 0; this.boardSize = 0;
this.cols = 0; this.cols = 0;
this.rows = 0; this.rows = 0;
this.artworkPage = 0;
} }
create() { create() {
@ -216,7 +217,7 @@ export default class ShiftGame extends Phaser.Scene {
} }
} }
showArtworkSelect(difficulty) { showArtworkSelect(difficulty, page = 0) {
this.view = 'artwork'; this.view = 'artwork';
this.overlayUp = false; this.overlayUp = false;
this.clearLayer(); this.clearLayer();
@ -234,48 +235,56 @@ export default class ShiftGame extends Phaser.Scene {
this.layer.add([titleBg, title]); this.layer.add([titleBg, title]);
const CARD_W = 310; const CARD_W = 310;
const CARD_H = 360; const CARD_H = 320;
const GAP = 50; const GAP = 50;
const THUMB = 240; const THUMB = 200;
const MAX_ROW = 5; const MAX_ROW = 5;
const perRow = Math.min(this.artworkList.length, MAX_ROW); 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 totalW = perRow * CARD_W + (perRow - 1) * GAP;
const startX = cx - totalW / 2 + CARD_W / 2; 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 row = Math.floor(i / MAX_ROW);
const col = i % MAX_ROW; const col = i % MAX_ROW;
const x = startX + col * (CARD_W + GAP); 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) const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x182430)
.setStrokeStyle(2, COLORS.accent, 0.5); .setStrokeStyle(2, COLORS.accent, 0.5);
this.layer.add(card); this.layer.add(card);
if (art.key && this.textures.exists(art.key)) { 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); .setDisplaySize(THUMB, THUMB);
this.layer.add(thumb); this.layer.add(thumb);
} else { } else {
const ph = this.add.graphics(); const ph = this.add.graphics();
ph.fillStyle(0x223344, 1); ph.fillStyle(0x223344, 1);
ph.fillRoundedRect(x - THUMB / 2, y - 40 - THUMB / 2, THUMB, THUMB, 8); ph.fillRoundedRect(x - THUMB / 2, y - 35 - THUMB / 2, THUMB, THUMB, 8);
const icon = this.add.text(x, y - 40, '▦', { const icon = this.add.text(x, y - 35, '▦', {
fontFamily: 'Righteous', fontSize: '80px', color: '#5b9bff', fontFamily: 'Righteous', fontSize: '70px', color: '#5b9bff',
}).setOrigin(0.5); }).setOrigin(0.5);
this.layer.add([ph, icon]); this.layer.add([ph, icon]);
} }
const label = this.add.text(x, y + CARD_H / 2 - 80, art.name, { const label = this.add.text(x, y + CARD_H / 2 - 68, art.name, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
align: 'center', wordWrap: { width: CARD_W - 20 }, align: 'center', wordWrap: { width: CARD_W - 20 },
}).setOrigin(0.5, 0); }).setOrigin(0.5, 0);
const lsKey = `shift-best-${difficulty}-${art.id}`; const lsKey = `shift-best-${difficulty}-${art.id}`;
const bestV = parseInt(localStorage.getItem(lsKey), 10); const bestV = parseInt(localStorage.getItem(lsKey), 10);
const bestLb = this.add.text(x, y + CARD_H / 2 - 44, !isNaN(bestV) ? `Best: ${bestV}` : '', { const bestLb = this.add.text(x, y + CARD_H / 2 - 36, !isNaN(bestV) ? `Best: ${bestV}` : '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(0.5, 0); }).setOrigin(0.5, 0);
this.layer.add([label, bestLb]); this.layer.add([label, bestLb]);
@ -285,7 +294,26 @@ export default class ShiftGame extends Phaser.Scene {
card.on('pointerup', () => this.startGame(difficulty, art.id)); 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(), () => this.showDifficultySelect(),
{ variant: 'ghost', width: 240, height: 60, fontSize: 24 }); { variant: 'ghost', width: 240, height: 60, fontSize: 24 });
this.layer.add(back); 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); { variant: 'ghost', width: btnW, height: btnH, fontSize: 26 }).setDepth(D.overlayUI);
this.layer.add([again, goBack]); 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 // Animate: fade out the panel background, move text/buttons to upper-left
const offsetX = 230; const offsetX = 230;
const offsetY = 230; const offsetY = 230;
@ -577,6 +620,16 @@ export default class ShiftGame extends Phaser.Scene {
ease: 'Cubic.easeInOut', 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 // Move text elements to upper-left, preserving their relative vertical spacing
this.tweens.add({ this.tweens.add({
targets: [winTitle, movesLbl, bestLbl, again, goBack], 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({ const mkGear = (omega) => createState(baseLevel({
terrain: [ground()], terrain: [ground()],
gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega }], 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); const gearT = spun.terrain.find((t) => t.gear);
check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid'); check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid');
const a0 = gearT.gear.angle; 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, check('a gear rotates', Math.abs(gearT.gear.angle - a0) > 1,
`angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`); `angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`);
check('a spinning gear stays solid (goo never sinks into it)', check('touching a gear kills the ball', spun.balls[0].dead);
!pointInPoly(spun.balls[0].x, spun.balls[0].y, gearT.poly)); 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). // A ball that never comes near the gear is unaffected.
const right = mkGear(4); const safe = createState(baseLevel({
const left = mkGear(-4); terrain: [ground()],
const rx = right.balls[0].x; gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega: 3 }],
const lx = left.balls[0].x; pile: [{ x: 1200, y: 460, type: 'common' }],
for (let i = 0; i < 150; i += 1) { stepSim(right, 1 / 60); stepSim(left, 1 / 60); } }));
check('a gear drags goo along its rim', for (let i = 0; i < 90; i += 1) stepSim(safe, 1 / 60);
right.balls[0].x > rx + 5 && left.balls[0].x < lx - 5, check('a ball far from the gear survives', !safe.balls[0].dead);
`cw ${(right.balls[0].x - rx).toFixed(1)}px, ccw ${(left.balls[0].x - lx).toFixed(1)}px`);
// Time-varying terrain must not cost determinism. // Time-varying terrain must not cost determinism.
const d1 = mkGear(3); const d1 = mkGear(3);