feat(rushhour): redesign level bank, procedural art, and tiered curriculum
- Generate 60 levels across 5 themed tiers (Downtown, Freight Yard, Airport Apron, Construction, Night City) with guaranteed minimal and unsolved board states - Rewrite generator (tools/genRushHour.js) with refine() pipeline that strips redundant vehicles and re-hardens boards, plus hill-climbing phase B - Add comprehensive verifier (tools/verifyRushHour.js) checking solver correctness, structural criteria, MINIMAL/UNSOLVED properties, and curriculum shape - Implement procedural vehicle art (RushHourArt.js) with baked textures, per-theme decals (hazard, hivis, freight, neon), and themed board surfaces - Move level bank to assets/gamedata/rushhour/levels.json and fetch on entry instead of preloading at boot - Restructure level JSON with tiers, level names, par, and difficulty metrics (decoyDensity, targetRetreats, firstMoveFanout, carsMoved) - Update level select to display tiers with themed swatches and color-coded labels - Show level name and theme in HUD during gameplay - Tighten hint solver with maxStates budget to prevent main-thread stalls - Update PreloadScene to remove rushhour.json from boot-time assets
This commit is contained in:
parent
dbc1c64d51
commit
052417aa9a
|
|
@ -278,9 +278,11 @@ from the repo root (Node.js 20+):
|
|||
|
||||
Runtime JSON fetched by the games:
|
||||
|
||||
- **Per-game banks & artwork maps** — e.g. `rushhour.json`, `zuma.json`,
|
||||
`katamino.json`, `dotlink.json`, `jumble.json`, `spireclimb-artwork.json`,
|
||||
`slots-artwork.json`, `shift-artwork.json`.
|
||||
- **Per-game banks & artwork maps** — e.g. `katamino.json`, `dotlink.json`,
|
||||
`jumble.json`, `spireclimb-artwork.json`, `slots-artwork.json`,
|
||||
`shift-artwork.json`. Newer games keep their level banks under
|
||||
`assets/gamedata/<game>/` and fetch them on entry instead (Rush Hour, Zuma,
|
||||
Peggle, Goo Tower).
|
||||
- **Shared UI options** — `playfields.json`, `colored-playfields.json`,
|
||||
`card-backs.json`, `opponents.json`, `music.json`.
|
||||
- **Word data** — `wordlists/enable1.txt` (main dictionary),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,400 +1,105 @@
|
|||
{
|
||||
"level": 3,
|
||||
"name": "Twin Pools",
|
||||
"name": "Dual Strike",
|
||||
"frog": [
|
||||
620,
|
||||
600
|
||||
966,
|
||||
579
|
||||
],
|
||||
"colors": 4,
|
||||
"background": "zuma-background-1",
|
||||
"seed": 24757,
|
||||
"seed": 12345,
|
||||
"starScores": [
|
||||
970,
|
||||
1460,
|
||||
1940
|
||||
640,
|
||||
960,
|
||||
1280
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"shape": "doubleLoop",
|
||||
"shape": "custom",
|
||||
"points": [
|
||||
[
|
||||
-80,
|
||||
300
|
||||
-49,
|
||||
235
|
||||
],
|
||||
[
|
||||
-10,
|
||||
300
|
||||
669,
|
||||
132
|
||||
],
|
||||
[
|
||||
60,
|
||||
300
|
||||
1515,
|
||||
190
|
||||
],
|
||||
[
|
||||
130,
|
||||
300
|
||||
1726,
|
||||
411
|
||||
],
|
||||
[
|
||||
200,
|
||||
300
|
||||
1710,
|
||||
667
|
||||
],
|
||||
[
|
||||
270,
|
||||
300
|
||||
],
|
||||
[
|
||||
340,
|
||||
300
|
||||
],
|
||||
[
|
||||
410,
|
||||
300
|
||||
],
|
||||
[
|
||||
480,
|
||||
300
|
||||
],
|
||||
[
|
||||
550,
|
||||
300
|
||||
],
|
||||
[
|
||||
620,
|
||||
300
|
||||
1210,
|
||||
817
|
||||
],
|
||||
[
|
||||
689,
|
||||
308
|
||||
813
|
||||
],
|
||||
[
|
||||
755,
|
||||
332
|
||||
],
|
||||
[
|
||||
813,
|
||||
370
|
||||
],
|
||||
[
|
||||
861,
|
||||
421
|
||||
],
|
||||
[
|
||||
895,
|
||||
481
|
||||
],
|
||||
[
|
||||
915,
|
||||
548
|
||||
],
|
||||
[
|
||||
919,
|
||||
617
|
||||
],
|
||||
[
|
||||
907,
|
||||
686
|
||||
],
|
||||
[
|
||||
880,
|
||||
750
|
||||
],
|
||||
[
|
||||
838,
|
||||
806
|
||||
],
|
||||
[
|
||||
785,
|
||||
851
|
||||
],
|
||||
[
|
||||
723,
|
||||
882
|
||||
],
|
||||
[
|
||||
655,
|
||||
898
|
||||
],
|
||||
[
|
||||
585,
|
||||
898
|
||||
],
|
||||
[
|
||||
517,
|
||||
882
|
||||
],
|
||||
[
|
||||
455,
|
||||
851
|
||||
],
|
||||
[
|
||||
402,
|
||||
806
|
||||
],
|
||||
[
|
||||
360,
|
||||
750
|
||||
],
|
||||
[
|
||||
333,
|
||||
686
|
||||
],
|
||||
[
|
||||
321,
|
||||
617
|
||||
],
|
||||
[
|
||||
325,
|
||||
548
|
||||
],
|
||||
[
|
||||
345,
|
||||
481
|
||||
],
|
||||
[
|
||||
379,
|
||||
421
|
||||
],
|
||||
[
|
||||
427,
|
||||
370
|
||||
],
|
||||
[
|
||||
485,
|
||||
332
|
||||
],
|
||||
[
|
||||
551,
|
||||
308
|
||||
],
|
||||
[
|
||||
620,
|
||||
300
|
||||
],
|
||||
[
|
||||
691,
|
||||
300
|
||||
],
|
||||
[
|
||||
762,
|
||||
300
|
||||
],
|
||||
[
|
||||
833,
|
||||
300
|
||||
],
|
||||
[
|
||||
904,
|
||||
300
|
||||
],
|
||||
[
|
||||
975,
|
||||
300
|
||||
],
|
||||
[
|
||||
1045,
|
||||
300
|
||||
],
|
||||
[
|
||||
1116,
|
||||
300
|
||||
],
|
||||
[
|
||||
1187,
|
||||
300
|
||||
],
|
||||
[
|
||||
1258,
|
||||
300
|
||||
],
|
||||
[
|
||||
1329,
|
||||
300
|
||||
],
|
||||
[
|
||||
1400,
|
||||
300
|
||||
],
|
||||
[
|
||||
1469,
|
||||
308
|
||||
],
|
||||
[
|
||||
1535,
|
||||
332
|
||||
],
|
||||
[
|
||||
1593,
|
||||
370
|
||||
],
|
||||
[
|
||||
1641,
|
||||
421
|
||||
],
|
||||
[
|
||||
1675,
|
||||
481
|
||||
],
|
||||
[
|
||||
1695,
|
||||
548
|
||||
],
|
||||
[
|
||||
1699,
|
||||
617
|
||||
],
|
||||
[
|
||||
1687,
|
||||
686
|
||||
],
|
||||
[
|
||||
1660,
|
||||
750
|
||||
],
|
||||
[
|
||||
1618,
|
||||
806
|
||||
],
|
||||
[
|
||||
1565,
|
||||
851
|
||||
],
|
||||
[
|
||||
1503,
|
||||
882
|
||||
],
|
||||
[
|
||||
1435,
|
||||
898
|
||||
],
|
||||
[
|
||||
1365,
|
||||
898
|
||||
],
|
||||
[
|
||||
1297,
|
||||
882
|
||||
],
|
||||
[
|
||||
1235,
|
||||
851
|
||||
],
|
||||
[
|
||||
1182,
|
||||
806
|
||||
],
|
||||
[
|
||||
1140,
|
||||
750
|
||||
],
|
||||
[
|
||||
1113,
|
||||
686
|
||||
],
|
||||
[
|
||||
1101,
|
||||
617
|
||||
],
|
||||
[
|
||||
1105,
|
||||
548
|
||||
],
|
||||
[
|
||||
1125,
|
||||
481
|
||||
],
|
||||
[
|
||||
1159,
|
||||
421
|
||||
],
|
||||
[
|
||||
1207,
|
||||
370
|
||||
],
|
||||
[
|
||||
1265,
|
||||
332
|
||||
],
|
||||
[
|
||||
1331,
|
||||
308
|
||||
],
|
||||
[
|
||||
1400,
|
||||
300
|
||||
],
|
||||
[
|
||||
1473,
|
||||
300
|
||||
],
|
||||
[
|
||||
1547,
|
||||
300
|
||||
],
|
||||
[
|
||||
1620,
|
||||
300
|
||||
],
|
||||
[
|
||||
1691,
|
||||
311
|
||||
],
|
||||
[
|
||||
1755,
|
||||
344
|
||||
],
|
||||
[
|
||||
1806,
|
||||
395
|
||||
],
|
||||
[
|
||||
1839,
|
||||
459
|
||||
],
|
||||
[
|
||||
1850,
|
||||
530
|
||||
],
|
||||
[
|
||||
1839,
|
||||
601
|
||||
],
|
||||
[
|
||||
1806,
|
||||
665
|
||||
],
|
||||
[
|
||||
1755,
|
||||
716
|
||||
],
|
||||
[
|
||||
1691,
|
||||
749
|
||||
],
|
||||
[
|
||||
1620,
|
||||
760
|
||||
],
|
||||
[
|
||||
1555,
|
||||
760
|
||||
],
|
||||
[
|
||||
1490,
|
||||
760
|
||||
],
|
||||
[
|
||||
1425,
|
||||
760
|
||||
],
|
||||
[
|
||||
1360,
|
||||
760
|
||||
482,
|
||||
604
|
||||
]
|
||||
],
|
||||
"tunnels": [],
|
||||
"quota": 30,
|
||||
"introBalls": 9,
|
||||
"pushSpeed": 84,
|
||||
"powerUpRate": 0.065
|
||||
"quota": 20,
|
||||
"introBalls": 8,
|
||||
"pushSpeed": 30,
|
||||
"powerUpRate": 0.06
|
||||
},
|
||||
{
|
||||
"shape": "custom",
|
||||
"points": [
|
||||
[
|
||||
1995,
|
||||
920
|
||||
],
|
||||
[
|
||||
1113,
|
||||
954
|
||||
],
|
||||
[
|
||||
265,
|
||||
940
|
||||
],
|
||||
[
|
||||
113,
|
||||
633
|
||||
],
|
||||
[
|
||||
330,
|
||||
341
|
||||
],
|
||||
[
|
||||
628,
|
||||
249
|
||||
],
|
||||
[
|
||||
1103,
|
||||
251
|
||||
],
|
||||
[
|
||||
1361,
|
||||
350
|
||||
],
|
||||
[
|
||||
1466,
|
||||
491
|
||||
]
|
||||
],
|
||||
"tunnels": [],
|
||||
"quota": 20,
|
||||
"introBalls": 8,
|
||||
"pushSpeed": 30,
|
||||
"powerUpRate": 0.06
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,364 +1,84 @@
|
|||
{
|
||||
"level": 4,
|
||||
"name": "Switchbacks",
|
||||
"level": 21,
|
||||
"name": "New Path",
|
||||
"frog": [
|
||||
1620,
|
||||
550
|
||||
1661,
|
||||
544
|
||||
],
|
||||
"colors": 4,
|
||||
"background": "zuma-background-2",
|
||||
"seed": 32676,
|
||||
"seed": 12345,
|
||||
"starScores": [
|
||||
1040,
|
||||
1550,
|
||||
2070
|
||||
640,
|
||||
960,
|
||||
1280
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"shape": "zigzag",
|
||||
"shape": "custom",
|
||||
"points": [
|
||||
[
|
||||
-80,
|
||||
130
|
||||
-65,
|
||||
93
|
||||
],
|
||||
[
|
||||
-11,
|
||||
130
|
||||
466,
|
||||
132
|
||||
],
|
||||
[
|
||||
58,
|
||||
130
|
||||
1034,
|
||||
374
|
||||
],
|
||||
[
|
||||
126,
|
||||
130
|
||||
1116,
|
||||
817
|
||||
],
|
||||
[
|
||||
195,
|
||||
130
|
||||
706,
|
||||
956
|
||||
],
|
||||
[
|
||||
264,
|
||||
130
|
||||
156,
|
||||
663
|
||||
],
|
||||
[
|
||||
333,
|
||||
130
|
||||
316,
|
||||
309
|
||||
],
|
||||
[
|
||||
401,
|
||||
130
|
||||
1095,
|
||||
143
|
||||
],
|
||||
[
|
||||
470,
|
||||
130
|
||||
1484,
|
||||
780
|
||||
],
|
||||
[
|
||||
539,
|
||||
130
|
||||
1210,
|
||||
979
|
||||
],
|
||||
[
|
||||
608,
|
||||
130
|
||||
853,
|
||||
719
|
||||
],
|
||||
[
|
||||
676,
|
||||
130
|
||||
],
|
||||
[
|
||||
745,
|
||||
130
|
||||
],
|
||||
[
|
||||
814,
|
||||
130
|
||||
],
|
||||
[
|
||||
882,
|
||||
130
|
||||
],
|
||||
[
|
||||
951,
|
||||
130
|
||||
],
|
||||
[
|
||||
1020,
|
||||
130
|
||||
],
|
||||
[
|
||||
1089,
|
||||
130
|
||||
],
|
||||
[
|
||||
1158,
|
||||
130
|
||||
],
|
||||
[
|
||||
1226,
|
||||
130
|
||||
],
|
||||
[
|
||||
1295,
|
||||
130
|
||||
],
|
||||
[
|
||||
1365,
|
||||
149
|
||||
],
|
||||
[
|
||||
1416,
|
||||
200
|
||||
],
|
||||
[
|
||||
1435,
|
||||
270
|
||||
],
|
||||
[
|
||||
1416,
|
||||
340
|
||||
],
|
||||
[
|
||||
1365,
|
||||
391
|
||||
],
|
||||
[
|
||||
1295,
|
||||
410
|
||||
],
|
||||
[
|
||||
1225,
|
||||
410
|
||||
],
|
||||
[
|
||||
1156,
|
||||
410
|
||||
],
|
||||
[
|
||||
1086,
|
||||
410
|
||||
],
|
||||
[
|
||||
1016,
|
||||
410
|
||||
],
|
||||
[
|
||||
947,
|
||||
410
|
||||
],
|
||||
[
|
||||
877,
|
||||
410
|
||||
],
|
||||
[
|
||||
807,
|
||||
410
|
||||
],
|
||||
[
|
||||
738,
|
||||
410
|
||||
],
|
||||
[
|
||||
668,
|
||||
410
|
||||
],
|
||||
[
|
||||
598,
|
||||
410
|
||||
],
|
||||
[
|
||||
529,
|
||||
410
|
||||
],
|
||||
[
|
||||
459,
|
||||
410
|
||||
],
|
||||
[
|
||||
389,
|
||||
410
|
||||
],
|
||||
[
|
||||
320,
|
||||
410
|
||||
],
|
||||
[
|
||||
250,
|
||||
410
|
||||
],
|
||||
[
|
||||
180,
|
||||
429
|
||||
],
|
||||
[
|
||||
129,
|
||||
480
|
||||
],
|
||||
[
|
||||
110,
|
||||
550
|
||||
],
|
||||
[
|
||||
129,
|
||||
620
|
||||
],
|
||||
[
|
||||
180,
|
||||
671
|
||||
],
|
||||
[
|
||||
250,
|
||||
690
|
||||
],
|
||||
[
|
||||
320,
|
||||
690
|
||||
],
|
||||
[
|
||||
389,
|
||||
690
|
||||
],
|
||||
[
|
||||
459,
|
||||
690
|
||||
],
|
||||
[
|
||||
529,
|
||||
690
|
||||
],
|
||||
[
|
||||
598,
|
||||
690
|
||||
],
|
||||
[
|
||||
668,
|
||||
690
|
||||
],
|
||||
[
|
||||
738,
|
||||
690
|
||||
],
|
||||
[
|
||||
807,
|
||||
690
|
||||
],
|
||||
[
|
||||
877,
|
||||
690
|
||||
],
|
||||
[
|
||||
947,
|
||||
690
|
||||
],
|
||||
[
|
||||
1016,
|
||||
690
|
||||
],
|
||||
[
|
||||
1086,
|
||||
690
|
||||
],
|
||||
[
|
||||
1156,
|
||||
690
|
||||
],
|
||||
[
|
||||
1225,
|
||||
690
|
||||
],
|
||||
[
|
||||
1295,
|
||||
690
|
||||
],
|
||||
[
|
||||
1365,
|
||||
709
|
||||
],
|
||||
[
|
||||
1416,
|
||||
760
|
||||
],
|
||||
[
|
||||
1435,
|
||||
830
|
||||
],
|
||||
[
|
||||
1416,
|
||||
900
|
||||
],
|
||||
[
|
||||
1365,
|
||||
951
|
||||
],
|
||||
[
|
||||
1295,
|
||||
970
|
||||
],
|
||||
[
|
||||
1225,
|
||||
970
|
||||
],
|
||||
[
|
||||
1156,
|
||||
970
|
||||
],
|
||||
[
|
||||
1086,
|
||||
970
|
||||
],
|
||||
[
|
||||
1016,
|
||||
970
|
||||
],
|
||||
[
|
||||
947,
|
||||
970
|
||||
],
|
||||
[
|
||||
877,
|
||||
970
|
||||
],
|
||||
[
|
||||
807,
|
||||
970
|
||||
],
|
||||
[
|
||||
738,
|
||||
970
|
||||
],
|
||||
[
|
||||
668,
|
||||
970
|
||||
],
|
||||
[
|
||||
598,
|
||||
970
|
||||
],
|
||||
[
|
||||
529,
|
||||
970
|
||||
],
|
||||
[
|
||||
459,
|
||||
970
|
||||
],
|
||||
[
|
||||
389,
|
||||
970
|
||||
],
|
||||
[
|
||||
320,
|
||||
970
|
||||
],
|
||||
[
|
||||
250,
|
||||
970
|
||||
552,
|
||||
397
|
||||
]
|
||||
],
|
||||
"tunnels": [],
|
||||
"quota": 32,
|
||||
"introBalls": 10,
|
||||
"pushSpeed": 84,
|
||||
"powerUpRate": 0.065
|
||||
"tunnels": [
|
||||
[
|
||||
3182,
|
||||
3834
|
||||
],
|
||||
[
|
||||
5104,
|
||||
5615
|
||||
]
|
||||
],
|
||||
"quota": 24,
|
||||
"introBalls": 8,
|
||||
"pushSpeed": 30,
|
||||
"powerUpRate": 0.06
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
|
|
@ -90,6 +90,11 @@ export const MANIFEST = {
|
|||
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
|
||||
],
|
||||
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
|
||||
rushhour: [
|
||||
// Carries its own title, so the level-select screen draws no heading.
|
||||
// In-play art is procedural (see games/rushhour/RushHourArt.js).
|
||||
image('bg-rushhour-menu', 'assets/images/background-rushhour.png'),
|
||||
],
|
||||
dominion: [
|
||||
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
|
||||
// the title/icon band is drawn at runtime). Optional — the scene falls
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.replayQueue = [];
|
||||
this.selected = null;
|
||||
this.dragFrom = null;
|
||||
this.rowsTween?.stop();
|
||||
this.rowsTween = null;
|
||||
this.layer.removeAll(true);
|
||||
if (this.calloutText) { this.calloutText.destroy(); this.calloutText = null; }
|
||||
this.cellSprites = null;
|
||||
|
|
@ -496,6 +498,11 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
const left = cx - gridW / 2 + TILE / 2;
|
||||
const top = 530;
|
||||
|
||||
// The grid starts invisible so the background art gets a beat on its own
|
||||
// before the level tiles fade in on top of it.
|
||||
const rows = this.add.container(0, 0).setAlpha(0);
|
||||
this.layer.add(rows);
|
||||
|
||||
this.bank.forEach((lv, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
|
|
@ -525,7 +532,7 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
objs.push(tag);
|
||||
this.layer.add(objs);
|
||||
rows.add(objs);
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
|
|
@ -535,6 +542,10 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
}
|
||||
});
|
||||
|
||||
this.rowsTween = this.tweens.add({
|
||||
targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear',
|
||||
});
|
||||
|
||||
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel),
|
||||
{ width: 280, height: 58, fontSize: 24 });
|
||||
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,461 @@
|
|||
// Rush Hour — procedural vehicle and board art.
|
||||
//
|
||||
// Every vehicle is painted once into a canvas texture and then rendered as a
|
||||
// plain Image, the same bake-then-blit approach used by Mini Motorways and
|
||||
// Peggle. Raw Canvas 2D rather than Phaser Graphics because gradients, curved
|
||||
// bodywork and rotation are what make a block read as a car; the closest
|
||||
// existing reference in the repo is drawKart() in superkart/SuperKartRaster.js.
|
||||
//
|
||||
// ── Orientation ──────────────────────────────────────────────────────────────
|
||||
// Everything is painted once nose-up and then rotated into place, so a
|
||||
// horizontal car is genuinely the same car turned ninety degrees rather than a
|
||||
// separately-drawn shape. Facing alternates by colour slot to keep the board
|
||||
// looking like traffic instead of a parade.
|
||||
//
|
||||
// ── The red car ──────────────────────────────────────────────────────────────
|
||||
// The target is deliberately exempt from theming. Same silhouette, same red,
|
||||
// same white roof stripe in all five themes, always nose-right toward the exit.
|
||||
// It is the one constant on the board, so it stays instantly identifiable as
|
||||
// the car that has to get out.
|
||||
|
||||
export const CELL = 132;
|
||||
export const PAD = 10; // inset of a vehicle body within its cells
|
||||
|
||||
export const TARGET_BODY = '#d92b2b';
|
||||
export const TARGET_DARK = '#8c1414';
|
||||
|
||||
// ── Colour helpers ───────────────────────────────────────────────────────────
|
||||
// Local by convention — each game in this repo carries its own shade helper
|
||||
// rather than sharing one.
|
||||
|
||||
function shade(hex, amt) {
|
||||
const v = parseInt(hex.slice(1), 16);
|
||||
const ch = (sh) => {
|
||||
let c = (v >> sh) & 0xff;
|
||||
c = Math.round(amt >= 0 ? c + (255 - c) * amt : c * (1 + amt));
|
||||
return Math.max(0, Math.min(255, c));
|
||||
};
|
||||
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
|
||||
}
|
||||
const lighten = (hex, a) => shade(hex, a);
|
||||
const darken = (hex, a) => shade(hex, -a);
|
||||
|
||||
function makeCanvas(w, h) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Themes ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A theme is data, not a code branch: a palette, a decal style applied over the
|
||||
// finished bodywork, and the board tones. Tiers map onto themes by id, so the
|
||||
// look is a visible reward for progressing rather than a menu setting.
|
||||
|
||||
export const THEMES = {
|
||||
downtown: {
|
||||
id: 'downtown',
|
||||
name: 'Downtown',
|
||||
decal: 'none',
|
||||
palette: ['#f2b705', '#2d7dd2', '#3fa34d', '#8367c7', '#e8833a', '#20a4a4', '#d94f8a', '#8a929c'],
|
||||
glass: '#bfe3f2',
|
||||
board: { felt: 0x101a26, frame: 0x1b2836, cell: 0x2b3947, grid: 0x3b4c5c, gridStyle: 'lane' },
|
||||
},
|
||||
freightyard: {
|
||||
id: 'freightyard',
|
||||
name: 'Freight Yard',
|
||||
decal: 'freight',
|
||||
palette: ['#c1553b', '#3d6e9c', '#6f7a86', '#b08a3e', '#4a7a5e', '#9c5b8a', '#5a6472', '#a8632f'],
|
||||
glass: '#a8c4d4',
|
||||
board: { felt: 0x14161a, frame: 0x24282e, cell: 0x33383f, grid: 0x464d56, gridStyle: 'plain' },
|
||||
},
|
||||
airport: {
|
||||
id: 'airport',
|
||||
name: 'Airport Apron',
|
||||
decal: 'hivis',
|
||||
palette: ['#e8c33a', '#e6e9ec', '#3f7fbf', '#d97a2b', '#5c9e6f', '#b9bec4', '#c9553f', '#4a5560'],
|
||||
glass: '#cfe6f2',
|
||||
board: { felt: 0x0e1418, frame: 0x1e262b, cell: 0x2f383e, grid: 0x47535b, gridStyle: 'runway' },
|
||||
},
|
||||
construction: {
|
||||
id: 'construction',
|
||||
name: 'Construction',
|
||||
decal: 'hazard',
|
||||
palette: ['#e09b1a', '#d4761f', '#b8b0a2', '#7a8b52', '#a85a32', '#6d747c', '#c8a94c', '#8a5a3c'],
|
||||
glass: '#c2d2c8',
|
||||
board: { felt: 0x1a160f, frame: 0x2c2517, cell: 0x3d3524, grid: 0x554a32, gridStyle: 'dirt' },
|
||||
},
|
||||
nightcity: {
|
||||
id: 'nightcity',
|
||||
name: 'Night City',
|
||||
decal: 'neon',
|
||||
palette: ['#25d0d8', '#c644d8', '#3f5ce0', '#1fbf6a', '#e0483f', '#d8a520', '#8a4fe0', '#4a5a6e'],
|
||||
glass: '#1e3a4a',
|
||||
board: { felt: 0x05070d, frame: 0x0d1220, cell: 0x141c2c, grid: 0x24314a, gridStyle: 'wet' },
|
||||
},
|
||||
};
|
||||
|
||||
export const THEME_ORDER = ['downtown', 'freightyard', 'airport', 'construction', 'nightcity'];
|
||||
|
||||
export function themeFor(id) {
|
||||
return THEMES[id] ?? THEMES.downtown;
|
||||
}
|
||||
|
||||
// ── Body painting (always nose-up, in a cell x len*cell box) ─────────────────
|
||||
|
||||
function roundRectPath(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, r);
|
||||
}
|
||||
|
||||
// Wheels sit outside the body on both flanks so the silhouette reads as a
|
||||
// vehicle rather than a rectangle.
|
||||
function drawWheels(ctx, bx, by, bw, bh, positions, wheelLen) {
|
||||
ctx.fillStyle = '#15171a';
|
||||
for (const t of positions) {
|
||||
const cy = by + bh * t - wheelLen / 2;
|
||||
roundRectPath(ctx, bx - 5, cy, 9, wheelLen, 4);
|
||||
ctx.fill();
|
||||
roundRectPath(ctx, bx + bw - 4, cy, 9, wheelLen, 4);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Tapered shell: narrower at the nose, full width through the cabin, slightly
|
||||
// drawn in at the tail. This is the shape that stops it looking like a block.
|
||||
function bodyPath(ctx, bx, by, bw, bh, noseInset, tailInset) {
|
||||
const l = bx + noseInset;
|
||||
const r = bx + bw - noseInset;
|
||||
const tl = bx + tailInset;
|
||||
const tr = bx + bw - tailInset;
|
||||
const shoulder = by + bh * 0.16;
|
||||
const hip = by + bh * 0.86;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + bw / 2, by);
|
||||
ctx.quadraticCurveTo(r, by + bh * 0.015, r, shoulder);
|
||||
ctx.lineTo(bx + bw, by + bh * 0.30);
|
||||
ctx.lineTo(bx + bw, hip);
|
||||
ctx.quadraticCurveTo(bx + bw, by + bh - 2, tr, by + bh);
|
||||
ctx.lineTo(tl, by + bh);
|
||||
ctx.quadraticCurveTo(bx, by + bh - 2, bx, hip);
|
||||
ctx.lineTo(bx, by + bh * 0.30);
|
||||
ctx.lineTo(l, shoulder);
|
||||
ctx.quadraticCurveTo(l, by + bh * 0.015, bx + bw / 2, by);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawCar(ctx, bx, by, bw, bh, color, theme, isTarget) {
|
||||
drawWheels(ctx, bx, by, bw, bh, [0.22, 0.78], Math.min(34, bh * 0.16));
|
||||
|
||||
// Shell with a cross-axis gradient so the roof looks crowned.
|
||||
bodyPath(ctx, bx, by, bw, bh, bw * 0.07, bw * 0.03);
|
||||
const grad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
grad.addColorStop(0, darken(color, 0.34));
|
||||
grad.addColorStop(0.34, lighten(color, 0.14));
|
||||
grad.addColorStop(0.62, color);
|
||||
grad.addColorStop(1, darken(color, 0.40));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Cabin: an inset darker panel carrying the two windows.
|
||||
const cx0 = bx + bw * 0.12;
|
||||
const cw = bw * 0.76;
|
||||
const cy0 = by + bh * 0.28;
|
||||
const chh = bh * 0.40;
|
||||
ctx.fillStyle = darken(color, 0.52);
|
||||
roundRectPath(ctx, cx0, cy0, cw, chh, Math.min(16, cw * 0.18));
|
||||
ctx.fill();
|
||||
|
||||
const glass = isTarget ? '#d8ecf5' : theme.glass;
|
||||
ctx.fillStyle = glass;
|
||||
// Windshield (nose side) — trapezoid, wider at the base.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx0 + cw * 0.17, cy0 + chh * 0.30);
|
||||
ctx.lineTo(cx0 + cw * 0.83, cy0 + chh * 0.30);
|
||||
ctx.lineTo(cx0 + cw * 0.92, cy0 + chh * 0.05);
|
||||
ctx.lineTo(cx0 + cw * 0.08, cy0 + chh * 0.05);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Rear window.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx0 + cw * 0.14, cy0 + chh * 0.70);
|
||||
ctx.lineTo(cx0 + cw * 0.86, cy0 + chh * 0.70);
|
||||
ctx.lineTo(cx0 + cw * 0.94, cy0 + chh * 0.95);
|
||||
ctx.lineTo(cx0 + cw * 0.06, cy0 + chh * 0.95);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Roof panel between the windows.
|
||||
ctx.fillStyle = lighten(color, 0.06);
|
||||
roundRectPath(ctx, cx0 + cw * 0.06, cy0 + chh * 0.34, cw * 0.88, chh * 0.32, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Mirrors.
|
||||
ctx.fillStyle = darken(color, 0.45);
|
||||
roundRectPath(ctx, bx - 3, by + bh * 0.30, 7, 12, 3);
|
||||
ctx.fill();
|
||||
roundRectPath(ctx, bx + bw - 4, by + bh * 0.30, 7, 12, 3);
|
||||
ctx.fill();
|
||||
|
||||
lamps(ctx, bx, by, bw, bh);
|
||||
specular(ctx, bx, by, bw, bh);
|
||||
}
|
||||
|
||||
function drawTruck(ctx, bx, by, bw, bh, color, theme) {
|
||||
const cabH = bh * 0.26;
|
||||
drawWheels(ctx, bx, by, bw, bh, [0.14, 0.66, 0.86], Math.min(32, bh * 0.11));
|
||||
|
||||
// Cab.
|
||||
bodyPath(ctx, bx, by, bw, cabH * 1.6, bw * 0.06, 0);
|
||||
const cabGrad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
cabGrad.addColorStop(0, darken(color, 0.36));
|
||||
cabGrad.addColorStop(0.36, lighten(color, 0.16));
|
||||
cabGrad.addColorStop(1, darken(color, 0.42));
|
||||
ctx.fillStyle = cabGrad;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Windshield across the cab.
|
||||
ctx.fillStyle = theme.glass;
|
||||
roundRectPath(ctx, bx + bw * 0.14, by + cabH * 0.42, bw * 0.72, cabH * 0.46, 7);
|
||||
ctx.fill();
|
||||
|
||||
// Cargo box — the structural tell that separates a truck from a long car.
|
||||
const boxY = by + cabH * 1.32;
|
||||
const boxH = by + bh - boxY;
|
||||
const boxCol = darken(color, 0.18);
|
||||
const boxGrad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
boxGrad.addColorStop(0, darken(boxCol, 0.34));
|
||||
boxGrad.addColorStop(0.36, lighten(boxCol, 0.12));
|
||||
boxGrad.addColorStop(1, darken(boxCol, 0.40));
|
||||
ctx.fillStyle = boxGrad;
|
||||
roundRectPath(ctx, bx, boxY, bw, boxH, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Seam where the cab meets the box, plus panel ribs along the box.
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.38)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + 2, boxY + 2);
|
||||
ctx.lineTo(bx + bw - 2, boxY + 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const ry = boxY + (boxH * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + 8, ry);
|
||||
ctx.lineTo(bx + bw - 8, ry);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
lamps(ctx, bx, by, bw, bh);
|
||||
specular(ctx, bx, by, bw, bh);
|
||||
}
|
||||
|
||||
function lamps(ctx, bx, by, bw, bh) {
|
||||
ctx.fillStyle = '#fdf3c8';
|
||||
roundRectPath(ctx, bx + bw * 0.10, by + 4, bw * 0.20, 9, 4);
|
||||
ctx.fill();
|
||||
roundRectPath(ctx, bx + bw * 0.70, by + 4, bw * 0.20, 9, 4);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#c9302c';
|
||||
roundRectPath(ctx, bx + bw * 0.08, by + bh - 12, bw * 0.22, 8, 3);
|
||||
ctx.fill();
|
||||
roundRectPath(ctx, bx + bw * 0.70, by + bh - 12, bw * 0.22, 8, 3);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function specular(ctx, bx, by, bw, bh) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.13)';
|
||||
roundRectPath(ctx, bx + bw * 0.16, by + bh * 0.06, bw * 0.10, bh * 0.86, bw * 0.05);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Theme decals, applied over finished bodywork ─────────────────────────────
|
||||
|
||||
function applyDecal(ctx, style, bx, by, bw, bh, color) {
|
||||
if (style === 'none') return;
|
||||
|
||||
ctx.save();
|
||||
roundRectPath(ctx, bx, by, bw, bh, 10);
|
||||
ctx.clip();
|
||||
|
||||
if (style === 'hazard') {
|
||||
// Diagonal chevrons across the tail.
|
||||
ctx.globalAlpha = 0.85;
|
||||
const band = by + bh * 0.74;
|
||||
ctx.beginPath();
|
||||
ctx.rect(bx, band, bw, bh * 0.26);
|
||||
ctx.clip();
|
||||
const step = 18;
|
||||
for (let i = -Math.ceil(bh / step); i * step < bw + bh; i++) {
|
||||
ctx.fillStyle = i % 2 === 0 ? '#1c1c1c' : '#f0b323';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + i * step, band + bh * 0.26);
|
||||
ctx.lineTo(bx + i * step + step, band + bh * 0.26);
|
||||
ctx.lineTo(bx + i * step + step + bh * 0.26, band);
|
||||
ctx.lineTo(bx + i * step + bh * 0.26, band);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (style === 'hivis') {
|
||||
// Reflective bands, the airside ground-vehicle look.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||
ctx.fillRect(bx, by + bh * 0.60, bw, 7);
|
||||
ctx.fillStyle = 'rgba(240,160,40,0.85)';
|
||||
ctx.fillRect(bx, by + bh * 0.67, bw, 5);
|
||||
} else if (style === 'freight') {
|
||||
// Blank livery panel on the cargo box.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)';
|
||||
roundRectPath(ctx, bx + bw * 0.16, by + bh * 0.52, bw * 0.68, bh * 0.22, 5);
|
||||
ctx.fill();
|
||||
} else if (style === 'neon') {
|
||||
// Underglow plus a bright light strip along the flanks.
|
||||
ctx.fillStyle = lighten(color, 0.55);
|
||||
ctx.fillRect(bx + 1, by + bh * 0.20, 4, bh * 0.60);
|
||||
ctx.fillRect(bx + bw - 5, by + bh * 0.20, 4, bh * 0.60);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.30)';
|
||||
ctx.fillRect(bx + bw * 0.20, by + bh - 7, bw * 0.60, 4);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// The red car's own marking — never themed.
|
||||
function targetStripe(ctx, bx, by, bw, bh) {
|
||||
ctx.save();
|
||||
roundRectPath(ctx, bx, by, bw, bh, 10);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
||||
ctx.fillRect(bx + bw * 0.42, by, bw * 0.06, bh);
|
||||
ctx.fillRect(bx + bw * 0.52, by, bw * 0.06, bh);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Baking ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function vehicleTextureKey(themeId, slot, len, orient, isTarget) {
|
||||
return isTarget ? `rh-veh-target-${len}${orient}` : `rh-veh-${themeId}-${slot}-${len}${orient}`;
|
||||
}
|
||||
|
||||
// Facing alternates by slot so a board reads as traffic. The target is always
|
||||
// nose-right, pointing at the exit it is trying to reach.
|
||||
function facingFor(slot, orient, isTarget) {
|
||||
if (orient === 'h') return isTarget || slot % 2 === 0 ? 'right' : 'left';
|
||||
return slot % 2 === 0 ? 'down' : 'up';
|
||||
}
|
||||
|
||||
function bakeVehicle(scene, key, { themeId, slot, len, orient, isTarget }) {
|
||||
if (scene.textures.exists(key)) return key;
|
||||
|
||||
const theme = themeFor(themeId);
|
||||
const longSide = len * CELL;
|
||||
const w = orient === 'h' ? longSide : CELL;
|
||||
const h = orient === 'h' ? CELL : longSide;
|
||||
const canvas = makeCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const facing = facingFor(slot, orient, isTarget);
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
if (orient === 'h') ctx.rotate(facing === 'right' ? Math.PI / 2 : -Math.PI / 2);
|
||||
else if (facing === 'down') ctx.rotate(Math.PI);
|
||||
// Paint in nose-up space: a CELL-wide, len*CELL-tall box.
|
||||
ctx.translate(-CELL / 2, -longSide / 2);
|
||||
|
||||
const bx = PAD;
|
||||
const by = PAD;
|
||||
const bw = CELL - PAD * 2;
|
||||
const bh = longSide - PAD * 2;
|
||||
|
||||
// Contact shadow first, offset down-right, so pieces sit on the board.
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.42)';
|
||||
ctx.filter = 'blur(4px)';
|
||||
roundRectPath(ctx, bx + 4, by + 6, bw, bh, 14);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
const color = isTarget ? TARGET_BODY : theme.palette[slot % theme.palette.length];
|
||||
if (len >= 3) drawTruck(ctx, bx, by, bw, bh, color, theme);
|
||||
else drawCar(ctx, bx, by, bw, bh, color, theme, isTarget);
|
||||
|
||||
if (isTarget) targetStripe(ctx, bx, by, bw, bh);
|
||||
else applyDecal(ctx, theme.decal, bx, by, bw, bh, color);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
scene.textures.addCanvas(key, canvas);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Bake every texture a level needs. Cheap to call per level — each key is
|
||||
// guarded by textures.exists(), so a repeat visit costs nothing.
|
||||
export function ensureVehicleTextures(scene, themeId, vehicles) {
|
||||
const keys = new Map();
|
||||
let slot = 0;
|
||||
for (const v of vehicles) {
|
||||
const s = v.isTarget ? 0 : slot++;
|
||||
const key = vehicleTextureKey(themeId, s, v.len, v.orient, v.isTarget);
|
||||
bakeVehicle(scene, key, { themeId, slot: s, len: v.len, orient: v.orient, isTarget: v.isTarget });
|
||||
keys.set(v.id, key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ── Board chrome ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Road markings drawn straight onto the playfield. Each theme gets a different
|
||||
// surface treatment so the tier is readable at a glance.
|
||||
export function drawBoardSurface(g, theme, left, top, sizePx, grid) {
|
||||
const cell = sizePx / grid;
|
||||
const b = theme.board;
|
||||
|
||||
g.fillStyle(b.cell, 1);
|
||||
g.fillRect(left, top, sizePx, sizePx);
|
||||
|
||||
if (b.gridStyle === 'lane') {
|
||||
// Dashed white lane markings.
|
||||
g.lineStyle(2, 0xffffff, 0.13);
|
||||
for (let i = 1; i < grid; i++) {
|
||||
const x = left + i * cell;
|
||||
for (let d = 0; d < grid * 2; d++) {
|
||||
const y = top + d * (cell / 2) + cell / 8;
|
||||
g.lineBetween(x, y, x, y + cell / 4);
|
||||
}
|
||||
}
|
||||
} else if (b.gridStyle === 'runway') {
|
||||
g.lineStyle(3, 0xe8c33a, 0.16);
|
||||
for (let i = 1; i < grid; i++) {
|
||||
g.lineBetween(left + i * cell, top, left + i * cell, top + sizePx);
|
||||
}
|
||||
} else if (b.gridStyle === 'dirt') {
|
||||
g.lineStyle(2, 0x000000, 0.16);
|
||||
for (let i = 1; i < grid; i++) {
|
||||
g.lineBetween(left, top + i * cell, left + sizePx, top + i * cell);
|
||||
}
|
||||
} else if (b.gridStyle === 'wet') {
|
||||
// Reflected light streaks on wet asphalt.
|
||||
g.lineStyle(3, 0x25d0d8, 0.10);
|
||||
for (let i = 0; i < grid * 2; i++) {
|
||||
const y = top + i * (cell / 2);
|
||||
g.lineBetween(left + cell * 0.2, y, left + sizePx - cell * 0.2, y);
|
||||
}
|
||||
}
|
||||
|
||||
g.lineStyle(2, b.grid, 0.55);
|
||||
for (let i = 0; i <= grid; i++) {
|
||||
g.lineBetween(left + i * cell, top, left + i * cell, top + sizePx);
|
||||
g.lineBetween(left, top + i * cell, left + sizePx, top + i * cell);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,23 +7,20 @@ import { api } from '../../services/api.js';
|
|||
import {
|
||||
GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve,
|
||||
} from './RushHourLogic.js';
|
||||
import {
|
||||
CELL, themeFor, ensureVehicleTextures, drawBoardSurface,
|
||||
} from './RushHourArt.js';
|
||||
|
||||
const BANK_URL = 'assets/gamedata/rushhour/levels.json';
|
||||
|
||||
const CELL = 132;
|
||||
const BOARD_PX = CELL * GRID;
|
||||
const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2);
|
||||
const BOARD_TOP = 188;
|
||||
const PAD = 10; // inset of a vehicle body within its cells
|
||||
|
||||
const FELT = 0x0e2233;
|
||||
const FRAME = 0x0a1722;
|
||||
const CELLBG = 0x14304a;
|
||||
const GRIDLN = 0x21466a;
|
||||
const TARGET_COLOR = 0xe03131;
|
||||
const CAR_COLORS = [
|
||||
0x4a90d9, 0x2ecc71, 0xf1c40f, 0x9b59b6, 0xe67e22, 0x1abc9c,
|
||||
0x3498db, 0xe84393, 0x16a085, 0xd35400, 0x27ae60, 0x8e44ad,
|
||||
0x7f8c8d, 0x2980b9, 0xf39c12, 0x6ab04c,
|
||||
];
|
||||
// Hints solve from wherever the player currently is, which on a tier-5 board
|
||||
// can be a large reachable space. Bound it so a press can never stall the
|
||||
// main thread; an unsolvable-within-budget hint just does nothing.
|
||||
const HINT_MAX_STATES = 120000;
|
||||
|
||||
const D = { felt: -2, frame: -1, grid: 0, exit: 1, vehicle: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
|
||||
|
||||
|
|
@ -33,6 +30,7 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' };
|
||||
this.bank = [];
|
||||
this.tiers = [];
|
||||
this.levelsCompleted = 0; // highest contiguous level cleared
|
||||
this.canPersist = true;
|
||||
this.view = 'select';
|
||||
|
|
@ -54,10 +52,20 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
|
||||
this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT,
|
||||
themeFor('downtown').board.felt).setDepth(D.felt);
|
||||
|
||||
const raw = this.cache.json.get('rushhour');
|
||||
this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
// Fetched on entry rather than preloaded at boot — the bank is dead weight
|
||||
// for every player who never opens this game.
|
||||
try {
|
||||
const res = await fetch(BANK_URL);
|
||||
const data = await res.json();
|
||||
this.bank = (data?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
this.tiers = data?.tiers ?? [];
|
||||
} catch (_) {
|
||||
this.bank = [];
|
||||
this.tiers = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.get('/puzzles/rushhour/progress');
|
||||
|
|
@ -74,6 +82,8 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
|
||||
clearLayer() {
|
||||
this.sprites.clear();
|
||||
this.rowsTween?.stop();
|
||||
this.rowsTween = null;
|
||||
this.layer.removeAll(true);
|
||||
// These were just destroyed; drop the stale references so updateMoves()
|
||||
// doesn't poke a dead Button (whose scene is now undefined) before drawHud
|
||||
|
|
@ -89,18 +99,33 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
this.overlayUp = false;
|
||||
this.busy = false;
|
||||
this.clearLayer();
|
||||
// Level select is theme-neutral; otherwise it keeps whatever tier colour
|
||||
// the last level painted the background.
|
||||
this.bg.setFillStyle(themeFor('downtown').board.felt);
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'RUSH HOUR', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 138, 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([title, sub]);
|
||||
// The artwork carries the wordmark, so no heading is drawn over it. A
|
||||
// scrim keeps the level grid legible against a busy illustration.
|
||||
const hasArt = this.textures.exists('bg-rushhour-menu');
|
||||
if (hasArt) {
|
||||
this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-rushhour-menu')
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
|
||||
this.layer.add(this.add.rectangle(cx, GAME_HEIGHT / 2 + 90, GAME_WIDTH, GAME_HEIGHT - 180,
|
||||
0x000000, 0.62));
|
||||
} else {
|
||||
const title = this.add.text(cx, 84, 'RUSH HOUR', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(title);
|
||||
}
|
||||
const sub = this.add.text(cx, hasArt ? 214 : 138,
|
||||
'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(sub);
|
||||
|
||||
if (!this.bank.length) {
|
||||
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genRushHour.js', {
|
||||
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node tools/genRushHour.js', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(msg);
|
||||
|
|
@ -110,46 +135,77 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
|
||||
const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||
const prog = this.add.text(cx, hasArt ? 252 : 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(prog);
|
||||
|
||||
const COLS = 12;
|
||||
const SIZE = 104;
|
||||
const GAP = 14;
|
||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const top = 268;
|
||||
// One row per tier, headed by the tier name in its own theme colour, so the
|
||||
// themes still to come are visible from the start.
|
||||
const SIZE = 88;
|
||||
const GAP = 12;
|
||||
const ROW_STRIDE = 128;
|
||||
const top = hasArt ? 330 : 262;
|
||||
|
||||
this.bank.forEach((p, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
const x = left + col * (SIZE + GAP);
|
||||
const y = top + row * (SIZE + GAP);
|
||||
const level = p.level;
|
||||
const cleared = level <= this.levelsCompleted;
|
||||
const playable = level <= nextLevel;
|
||||
const tierList = this.tiers.length
|
||||
? this.tiers
|
||||
: [{ id: 'downtown', name: 'Levels', from: 1, to: this.bank.length }];
|
||||
|
||||
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
|
||||
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
|
||||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||||
const num = this.add.text(x, y - 8, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '34px',
|
||||
color: playable || cleared ? COLORS.textHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
const tag = this.add.text(x, y + 28, cleared ? '✓ cleared' : playable ? `par ${p.minMoves}` : 'locked', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px',
|
||||
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([tile, num, tag]);
|
||||
// The rows start invisible so the background art gets a beat on its own
|
||||
// before the level grid fades in on top of it.
|
||||
const rows = this.add.container(0, 0).setAlpha(0);
|
||||
this.layer.add(rows);
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(cleared ? 3 : 3, stroke, 1));
|
||||
tile.on('pointerup', () => this.playLevel(level));
|
||||
}
|
||||
tierList.forEach((tier, ti) => {
|
||||
const theme = themeFor(tier.theme ?? tier.id);
|
||||
const levels = this.bank.filter((p) => p.level >= tier.from && p.level <= tier.to);
|
||||
if (!levels.length) return;
|
||||
|
||||
const gridW = levels.length * SIZE + (levels.length - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const rowY = top + ti * ROW_STRIDE;
|
||||
const unlockedHere = levels.some((p) => p.level <= nextLevel);
|
||||
|
||||
const label = this.add.text(left - SIZE / 2, rowY - SIZE / 2 - 22, tier.name.toUpperCase(), {
|
||||
fontFamily: 'Righteous', fontSize: '22px',
|
||||
color: unlockedHere ? COLORS.goldHex : '#54606b',
|
||||
}).setOrigin(0, 0.5);
|
||||
const swatch = this.add.rectangle(left - SIZE / 2 - 16, rowY - SIZE / 2 - 22, 8, 22,
|
||||
Phaser.Display.Color.HexStringToColor(theme.palette[0]).color)
|
||||
.setAlpha(unlockedHere ? 1 : 0.3);
|
||||
rows.add([label, swatch]);
|
||||
|
||||
levels.forEach((p, i) => {
|
||||
const x = left + i * (SIZE + GAP);
|
||||
const level = p.level;
|
||||
const cleared = level <= this.levelsCompleted;
|
||||
const playable = level <= nextLevel;
|
||||
|
||||
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
|
||||
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
|
||||
const tile = this.add.rectangle(x, rowY, SIZE, SIZE, fill)
|
||||
.setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||||
const num = this.add.text(x, rowY - 8, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '30px',
|
||||
color: playable || cleared ? COLORS.textHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
const tag = this.add.text(x, rowY + 24, cleared ? '✓' : playable ? `par ${p.par}` : 'locked', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px',
|
||||
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
rows.add([tile, num, tag]);
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
|
||||
tile.on('pointerup', () => this.playLevel(level));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.rowsTween = this.tweens.add({
|
||||
targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear',
|
||||
});
|
||||
|
||||
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
|
||||
|
|
@ -206,7 +262,10 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
this.level = level;
|
||||
this.startVehicles = cloneVehicles(puzzle.vehicles);
|
||||
this.vehicles = cloneVehicles(puzzle.vehicles);
|
||||
this.par = puzzle.minMoves;
|
||||
this.par = puzzle.par;
|
||||
this.levelName = puzzle.name ?? '';
|
||||
this.theme = themeFor(this.tierFor(level)?.theme ?? 'downtown');
|
||||
this.bg.setFillStyle(this.theme.board.felt);
|
||||
this.undoStack = [];
|
||||
this.moves = 0;
|
||||
this.overlayUp = false;
|
||||
|
|
@ -219,27 +278,22 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
drawBoardChrome() {
|
||||
const board = this.theme.board;
|
||||
const g = this.add.graphics().setDepth(D.frame);
|
||||
// Outer frame
|
||||
g.fillStyle(FRAME, 1);
|
||||
g.fillStyle(board.frame, 1);
|
||||
g.fillRoundedRect(BOARD_LEFT - 22, BOARD_TOP - 22, BOARD_PX + 44, BOARD_PX + 44, 18);
|
||||
// Playfield
|
||||
g.fillStyle(CELLBG, 1);
|
||||
g.fillRect(BOARD_LEFT, BOARD_TOP, BOARD_PX, BOARD_PX);
|
||||
this.layer.add(g);
|
||||
|
||||
// Playfield surface + per-theme road markings.
|
||||
const grid = this.add.graphics().setDepth(D.grid);
|
||||
grid.lineStyle(2, GRIDLN, 0.9);
|
||||
for (let i = 0; i <= GRID; i++) {
|
||||
grid.lineBetween(BOARD_LEFT + i * CELL, BOARD_TOP, BOARD_LEFT + i * CELL, BOARD_TOP + BOARD_PX);
|
||||
grid.lineBetween(BOARD_LEFT, BOARD_TOP + i * CELL, BOARD_LEFT + BOARD_PX, BOARD_TOP + i * CELL);
|
||||
}
|
||||
drawBoardSurface(grid, this.theme, BOARD_LEFT, BOARD_TOP, BOARD_PX, GRID);
|
||||
this.layer.add(grid);
|
||||
|
||||
// Exit gap on the right wall at EXIT_ROW
|
||||
const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL;
|
||||
const ex = this.add.graphics().setDepth(D.exit);
|
||||
ex.fillStyle(FELT, 1);
|
||||
ex.fillStyle(board.felt, 1);
|
||||
ex.fillRect(BOARD_LEFT + BOARD_PX - 1, BOARD_TOP + EXIT_ROW * CELL + 6, 46, CELL - 12);
|
||||
ex.fillStyle(COLORS.gold, 0.9);
|
||||
ex.fillTriangle(
|
||||
|
|
@ -255,10 +309,14 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
drawHud() {
|
||||
const title = this.add.text(BOARD_LEFT - 22, 92, `Level ${this.level}`, {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
|
||||
const title = this.add.text(BOARD_LEFT - 22, 84, `Level ${this.level}`, {
|
||||
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.layer.add(title);
|
||||
const sub = this.add.text(BOARD_LEFT - 20, 124,
|
||||
`${this.levelName}${this.levelName ? ' · ' : ''}${this.theme.name}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.layer.add([title, sub]);
|
||||
|
||||
this.movesText = this.add.text(BOARD_LEFT + BOARD_PX + 22, 92, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||
|
|
@ -310,36 +368,17 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
this.sprites.forEach((c) => c.destroy());
|
||||
this.sprites.clear();
|
||||
|
||||
let carIdx = 0;
|
||||
// Textures are baked once per theme+slot+shape and reused; a repeat visit
|
||||
// to a tier costs nothing.
|
||||
const keys = ensureVehicleTextures(this, this.theme.id, this.vehicles);
|
||||
|
||||
for (const v of this.vehicles) {
|
||||
const wCells = v.orient === 'h' ? v.len : 1;
|
||||
const hCells = v.orient === 'v' ? v.len : 1;
|
||||
const w = wCells * CELL - PAD * 2;
|
||||
const h = hCells * CELL - PAD * 2;
|
||||
const color = v.isTarget ? TARGET_COLOR : CAR_COLORS[carIdx++ % CAR_COLORS.length];
|
||||
|
||||
const { x, y } = this.centerFor(v);
|
||||
const c = this.add.container(x, y).setDepth(D.vehicle);
|
||||
|
||||
const body = this.add.graphics();
|
||||
body.fillStyle(color, 1);
|
||||
body.fillRoundedRect(-w / 2, -h / 2, w, h, 16);
|
||||
body.fillStyle(0xffffff, 0.14);
|
||||
body.fillRoundedRect(-w / 2 + 8, -h / 2 + 8, w - 16, Math.max(10, h * 0.22), 10);
|
||||
body.lineStyle(3, 0x000000, 0.28);
|
||||
body.strokeRoundedRect(-w / 2, -h / 2, w, h, 16);
|
||||
c.add(body);
|
||||
|
||||
if (v.isTarget) {
|
||||
c.add(this.add.text(0, 0, '★', { fontFamily: 'serif', fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setAlpha(0.9));
|
||||
}
|
||||
|
||||
// The body graphics are centered on the container origin, but setSize()
|
||||
// makes Phaser offset the input hit test by the display origin (w/2, h/2).
|
||||
// So the hit area must be specified top-left at (0,0) — NOT (-w/2,-h/2) —
|
||||
// to line up with the visible car (mirrors Battleship's draggable ships).
|
||||
c.setSize(w, h);
|
||||
c.setInteractive(new Phaser.Geom.Rectangle(0, 0, w, h), Phaser.Geom.Rectangle.Contains);
|
||||
// A plain Image rather than a Container: its origin is 0.5 and its hit
|
||||
// area is the texture bounds, which sidesteps the top-left-hit-rect quirk
|
||||
// the old Container version needed a comment to explain.
|
||||
const c = this.add.image(x, y, keys.get(v.id)).setDepth(D.vehicle);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.input.cursor = 'grab';
|
||||
this.input.setDraggable(c);
|
||||
|
||||
|
|
@ -350,6 +389,10 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
tierFor(level) {
|
||||
return this.tiers.find((t) => level >= t.from && level <= t.to) ?? null;
|
||||
}
|
||||
|
||||
attachDrag(c, _v) {
|
||||
c.on('dragstart', () => {
|
||||
if (this.busy || this.overlayUp) { c._noDrag = true; return; }
|
||||
|
|
@ -438,7 +481,7 @@ export default class RushHourGame extends Phaser.Scene {
|
|||
|
||||
showHint() {
|
||||
if (this.busy || this.overlayUp) return;
|
||||
const { path } = solve(this.vehicles);
|
||||
const { path } = solve(this.vehicles, { maxStates: HINT_MAX_STATES });
|
||||
if (!path || !path.length) return;
|
||||
const mv = path[0];
|
||||
const c = this.sprites.get(mv.id);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Rush Hour — pure board model + BFS solver. No Phaser, no DOM.
|
||||
// Shared by the client scene and the offline puzzle generator (both ESM).
|
||||
// Rush Hour — pure board model + solver. No Phaser, no DOM.
|
||||
// Shared by the client scene, the offline generator and the verifier (all ESM).
|
||||
//
|
||||
// Grid is 6x6. x = column (0 left .. 5 right), y = row (0 top .. 5 bottom).
|
||||
// The red target car is horizontal in EXIT_ROW and escapes through a gap in
|
||||
|
|
@ -9,12 +9,26 @@
|
|||
// horizontal -> occupies (x..x+len-1, y); vertical -> occupies (x, y..y+len-1)
|
||||
//
|
||||
// Move metric (matches published Rush Hour difficulty): sliding one car any
|
||||
// number of squares in a single direction counts as ONE move.
|
||||
// number of squares in a single direction counts as ONE move. Fogleman calls
|
||||
// this a "move" as opposed to a "step" (one square); the published ThinkFun
|
||||
// difficulties are quoted in moves.
|
||||
//
|
||||
// ── Representation ───────────────────────────────────────────────────────────
|
||||
// A vehicle's orientation, length and fixed axis never change, so a whole board
|
||||
// state is just the moving coordinate of each vehicle. Everything below packs a
|
||||
// state into a Uint8Array of those coordinates plus a single numeric key
|
||||
// (base-7, since each coordinate is 0..5 and 7^18 still lands inside the exact
|
||||
// integer range of a double). Occupancy is a reused Uint8Array(36) repainted
|
||||
// per expansion rather than an array of cloned objects — that is what makes a
|
||||
// full-cluster analysis affordable.
|
||||
|
||||
export const GRID = 6;
|
||||
export const EXIT_ROW = 2;
|
||||
export const TARGET_ID = 'X';
|
||||
|
||||
const CELLS = GRID * GRID;
|
||||
const MAX_PACKED_VEHICLES = 18; // 7^18 < 2^53, so keys stay exact
|
||||
|
||||
export function vehicleCells(v) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < v.len; i++) {
|
||||
|
|
@ -89,47 +103,246 @@ export function cloneVehicles(vehicles) {
|
|||
return vehicles.map((v) => ({ ...v }));
|
||||
}
|
||||
|
||||
// ── Packed engine ────────────────────────────────────────────────────────────
|
||||
|
||||
// Freezes the immutable facts about a vehicle set (length, orientation, fixed
|
||||
// axis, id) and exposes cheap operations over packed position vectors.
|
||||
export function packBoard(vehicles) {
|
||||
const n = vehicles.length;
|
||||
if (n > MAX_PACKED_VEHICLES) {
|
||||
throw new Error(`RushHour: ${n} vehicles exceeds the packed limit of ${MAX_PACKED_VEHICLES}`);
|
||||
}
|
||||
const len = new Uint8Array(n);
|
||||
const horiz = new Uint8Array(n);
|
||||
const fixed = new Uint8Array(n);
|
||||
const maxPos = new Uint8Array(n);
|
||||
const pow = new Float64Array(n);
|
||||
const ids = new Array(n);
|
||||
let targetIdx = -1;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = vehicles[i];
|
||||
len[i] = v.len;
|
||||
horiz[i] = v.orient === 'h' ? 1 : 0;
|
||||
fixed[i] = horiz[i] ? v.y : v.x;
|
||||
maxPos[i] = GRID - v.len;
|
||||
pow[i] = 7 ** i;
|
||||
ids[i] = v.id;
|
||||
if (v.isTarget) targetIdx = i;
|
||||
}
|
||||
|
||||
const start = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) start[i] = horiz[i] ? vehicles[i].x : vehicles[i].y;
|
||||
|
||||
// Cell index of the k-th square of vehicle i when its head sits at p.
|
||||
const cell = (i, p, k) => (horiz[i] ? fixed[i] * GRID + p + k : (p + k) * GRID + fixed[i]);
|
||||
|
||||
const goalPos = targetIdx >= 0 ? GRID - len[targetIdx] : -1;
|
||||
const targetEscapes = targetIdx >= 0 && horiz[targetIdx] === 1 && fixed[targetIdx] === EXIT_ROW;
|
||||
|
||||
function key(P) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < n; i++) s += P[i] * pow[i];
|
||||
return s;
|
||||
}
|
||||
function paint(P, occ) {
|
||||
occ.fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let k = 0; k < len[i]; k++) occ[cell(i, P[i], k)] = 1;
|
||||
}
|
||||
}
|
||||
function isGoal(P) {
|
||||
return targetEscapes && P[targetIdx] === goalPos;
|
||||
}
|
||||
function toVehicles(P) {
|
||||
return vehicles.map((v, i) => ({
|
||||
...v,
|
||||
x: horiz[i] ? P[i] : fixed[i],
|
||||
y: horiz[i] ? fixed[i] : P[i],
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
n, len, horiz, fixed, maxPos, pow, ids, targetIdx, goalPos, targetEscapes,
|
||||
start, cell, key, paint, isGoal, toVehicles,
|
||||
};
|
||||
}
|
||||
|
||||
// Every one-slide neighbour of P, given occupancy already painted into `occ`.
|
||||
// Calls visit(vehicleIdx, newPos) — no allocation per neighbour.
|
||||
function forEachSlide(B, P, occ, visit) {
|
||||
for (let i = 0; i < B.n; i++) {
|
||||
const head = P[i];
|
||||
// Toward 0: each step exposes exactly one new leading cell at `np`.
|
||||
for (let np = head - 1; np >= 0; np--) {
|
||||
if (occ[B.cell(i, np, 0)]) break;
|
||||
visit(i, np);
|
||||
}
|
||||
// Toward GRID: the new leading cell is the tail square, np + len - 1.
|
||||
const tail = B.len[i] - 1;
|
||||
for (let np = head + 1; np <= B.maxPos[i]; np++) {
|
||||
if (occ[B.cell(i, np, tail)]) break;
|
||||
visit(i, np);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Breadth-first shortest solution. Returns { moves, path }.
|
||||
// moves: minimum number of slides to solve (0 if already solved, -1 if none)
|
||||
// path: array of { id, x, y } optimal moves (null if unsolvable)
|
||||
// The 6x6 state space is small, but packed unsolvable layouts can still have a
|
||||
// large reachable space. `maxStates` bounds the search so a single solve stays
|
||||
// cheap (used heavily by the generator); exceeding it returns moves:-1.
|
||||
// `maxStates` bounds the search so a single solve stays cheap; exceeding it
|
||||
// returns moves:-1, which is indistinguishable from "unsolvable" by design —
|
||||
// callers treat both as "reject this board".
|
||||
export function solve(vehicles, { maxStates = 600000 } = {}) {
|
||||
const start = cloneVehicles(vehicles);
|
||||
const startKey = stateKey(start);
|
||||
if (isSolved(start)) return { moves: 0, path: [] };
|
||||
const B = packBoard(vehicles);
|
||||
if (B.isGoal(B.start)) return { moves: 0, path: [] };
|
||||
if (!B.targetEscapes) return { moves: -1, path: null };
|
||||
|
||||
const meta = new Map([[startKey, null]]); // key -> { parentKey, move }
|
||||
const stateByKey = new Map([[startKey, start]]);
|
||||
let frontier = [startKey];
|
||||
let depth = 0;
|
||||
const states = [B.start];
|
||||
const keys = [B.key(B.start)];
|
||||
const parent = [-1];
|
||||
const mvVeh = [-1];
|
||||
const mvPos = [-1];
|
||||
const depth = [0];
|
||||
const seen = new Map([[keys[0], 0]]);
|
||||
const occ = new Uint8Array(CELLS);
|
||||
|
||||
while (frontier.length) {
|
||||
depth++;
|
||||
const next = [];
|
||||
for (const key of frontier) {
|
||||
const state = stateByKey.get(key);
|
||||
for (const mv of legalMoves(state)) {
|
||||
const ns = cloneVehicles(state);
|
||||
ns[mv.idx].x = mv.x;
|
||||
ns[mv.idx].y = mv.y;
|
||||
const nk = stateKey(ns);
|
||||
if (meta.has(nk)) continue;
|
||||
meta.set(nk, { parentKey: key, move: { id: mv.id, x: mv.x, y: mv.y } });
|
||||
stateByKey.set(nk, ns);
|
||||
if (isSolved(ns)) {
|
||||
const path = [];
|
||||
let cur = nk;
|
||||
while (meta.get(cur)) { const e = meta.get(cur); path.unshift(e.move); cur = e.parentKey; }
|
||||
return { moves: depth, path };
|
||||
}
|
||||
next.push(nk);
|
||||
}
|
||||
if (meta.size > maxStates) return { moves: -1, path: null };
|
||||
const build = (endIdx) => {
|
||||
const path = [];
|
||||
for (let at = endIdx; at > 0; at = parent[at]) {
|
||||
const i = mvVeh[at];
|
||||
const p = mvPos[at];
|
||||
path.push({
|
||||
id: B.ids[i],
|
||||
x: B.horiz[i] ? p : B.fixed[i],
|
||||
y: B.horiz[i] ? B.fixed[i] : p,
|
||||
});
|
||||
}
|
||||
frontier = next;
|
||||
if (depth > 100) break;
|
||||
path.reverse();
|
||||
return path;
|
||||
};
|
||||
|
||||
for (let qi = 0; qi < states.length; qi++) {
|
||||
const P = states[qi];
|
||||
const baseKey = keys[qi];
|
||||
const d = depth[qi];
|
||||
B.paint(P, occ);
|
||||
|
||||
let done = -1;
|
||||
forEachSlide(B, P, occ, (i, np) => {
|
||||
if (done >= 0) return;
|
||||
const k = baseKey + (np - P[i]) * B.pow[i];
|
||||
if (seen.has(k)) return;
|
||||
const Q = Uint8Array.from(P);
|
||||
Q[i] = np;
|
||||
const idx = states.length;
|
||||
seen.set(k, idx);
|
||||
states.push(Q); keys.push(k); parent.push(qi); mvVeh.push(i); mvPos.push(np); depth.push(d + 1);
|
||||
if (B.isGoal(Q)) done = idx;
|
||||
});
|
||||
|
||||
if (done >= 0) return { moves: depth[done], path: build(done) };
|
||||
if (states.length > maxStates) return { moves: -1, path: null };
|
||||
}
|
||||
return { moves: -1, path: null };
|
||||
}
|
||||
|
||||
// Full reachable cluster of a board, plus each state's distance to the goal.
|
||||
//
|
||||
// Every slide is reversible, so the state graph is undirected: a multi-source
|
||||
// BFS from the goal states yields optimal distance-to-goal for the whole
|
||||
// cluster in one pass. Neighbours are recomputed rather than stored — clusters
|
||||
// run to hundreds of thousands of states and an explicit adjacency list costs
|
||||
// far more memory than the second enumeration costs time.
|
||||
//
|
||||
// Returns null if the cluster exceeds maxStates or contains no goal state.
|
||||
// { size, states, dist, startDist, maxDist, hardest, keyOf, indexOf, board }
|
||||
// `hardest` is a state at maximum distance from the goal — i.e. the hardest
|
||||
// arrangement of this piece set, which is what makes a puzzle "unsolved" in
|
||||
// Fogleman's sense: no rearrangement of these pieces is harder.
|
||||
export function analyzeCluster(vehicles, { maxStates = 400000 } = {}) {
|
||||
const B = packBoard(vehicles);
|
||||
if (!B.targetEscapes) return null;
|
||||
|
||||
const states = [B.start];
|
||||
const keys = [B.key(B.start)];
|
||||
const index = new Map([[keys[0], 0]]);
|
||||
const occ = new Uint8Array(CELLS);
|
||||
|
||||
for (let qi = 0; qi < states.length; qi++) {
|
||||
const P = states[qi];
|
||||
const baseKey = keys[qi];
|
||||
B.paint(P, occ);
|
||||
forEachSlide(B, P, occ, (i, np) => {
|
||||
const k = baseKey + (np - P[i]) * B.pow[i];
|
||||
if (index.has(k)) return;
|
||||
const Q = Uint8Array.from(P);
|
||||
Q[i] = np;
|
||||
index.set(k, states.length);
|
||||
states.push(Q);
|
||||
keys.push(k);
|
||||
});
|
||||
if (states.length > maxStates) return null;
|
||||
}
|
||||
|
||||
const size = states.length;
|
||||
const dist = new Int32Array(size).fill(-1);
|
||||
let frontier = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (B.isGoal(states[i])) { dist[i] = 0; frontier.push(i); }
|
||||
}
|
||||
if (!frontier.length) return null;
|
||||
|
||||
let d = 0;
|
||||
while (frontier.length) {
|
||||
d++;
|
||||
const next = [];
|
||||
for (const qi of frontier) {
|
||||
const P = states[qi];
|
||||
const baseKey = keys[qi];
|
||||
B.paint(P, occ);
|
||||
forEachSlide(B, P, occ, (i, np) => {
|
||||
const k = baseKey + (np - P[i]) * B.pow[i];
|
||||
const ni = index.get(k);
|
||||
if (ni === undefined || dist[ni] >= 0) return;
|
||||
dist[ni] = d;
|
||||
next.push(ni);
|
||||
});
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
let maxDist = 0;
|
||||
let hardestIdx = 0;
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (dist[i] > maxDist) { maxDist = dist[i]; hardestIdx = i; }
|
||||
}
|
||||
|
||||
// One-slide neighbours of a state, as { index, vehicleIdx, pos }. Used by the
|
||||
// generator to measure how many legal moves at each step are dead ends.
|
||||
const nbrOcc = new Uint8Array(CELLS);
|
||||
function neighbors(qi) {
|
||||
const P = states[qi];
|
||||
const baseKey = keys[qi];
|
||||
B.paint(P, nbrOcc);
|
||||
const out = [];
|
||||
forEachSlide(B, P, nbrOcc, (i, np) => {
|
||||
out.push({ index: index.get(baseKey + (np - P[i]) * B.pow[i]), vehicleIdx: i, pos: np });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
return {
|
||||
board: B,
|
||||
size,
|
||||
states,
|
||||
dist,
|
||||
startDist: dist[0],
|
||||
maxDist,
|
||||
hardest: states[hardestIdx],
|
||||
hardestIndex: hardestIdx,
|
||||
neighbors,
|
||||
indexOf: (P) => index.get(B.key(P)),
|
||||
toVehicles: (P) => B.toVehicles(P),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
|
||||
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
|
||||
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');
|
||||
this.load.json('rushhour', 'data/rushhour.json');
|
||||
this.load.json('bloxorz', 'data/bloxorz.json');
|
||||
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
|
||||
this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json');
|
||||
|
|
|
|||
|
|
@ -1,29 +1,101 @@
|
|||
// Offline generator for Rush Hour puzzles.
|
||||
//
|
||||
// Random-fills a 6x6 board with a red target car plus cars (len 2) and trucks
|
||||
// (len 3), runs the BFS solver to (a) reject unsolvable/trivial layouts and
|
||||
// (b) label each survivor with its minimum-moves difficulty, then selects a
|
||||
// smooth easy->expert curve and writes ordered levels to data/rushhour.json.
|
||||
// Offline curriculum generator for Rush Hour.
|
||||
//
|
||||
// Usage:
|
||||
// node server/scripts/genRushHour.js [seed] [outFile]
|
||||
// node tools/genRushHour.js [seed] [outFile]
|
||||
//
|
||||
// Deterministic: same seed -> same bank. Re-run after changing the curve.
|
||||
// Deterministic: same seed -> same bank.
|
||||
//
|
||||
// ── Why this is not just "random boards, keep the solvable ones" ─────────────
|
||||
// The previous generator reject-sampled random layouts and kept whatever the
|
||||
// solver reported. That produces padded puzzles: measured on the old 45-level
|
||||
// bank, 32 levels carried at least one vehicle that could be deleted without
|
||||
// changing the solution, and level 1 had 10 vehicles of which 8 were pure
|
||||
// decoration and solved in 2 moves. A board that *looks* busy but is trivially
|
||||
// empty is exactly what makes a sliding-block game feel unengaging.
|
||||
//
|
||||
// Michael Fogleman's exhaustive study of the 6x6 board
|
||||
// (https://www.michaelfogleman.com/rush/) names the properties that separate a
|
||||
// real puzzle from a padded one. We enforce four:
|
||||
//
|
||||
// 1. MINIMAL — removing any vehicle changes the solution.
|
||||
// 2. UNSOLVED — the start is the state farthest from the goal in its
|
||||
// reachable cluster, so no rearrangement of these pieces is
|
||||
// harder and there is no shortcut to stumble into.
|
||||
// 3. No complete row of horizontal pieces, no complete column of vertical
|
||||
// pieces (such lines can never move).
|
||||
// 4. Nothing but the red car on the exit row.
|
||||
//
|
||||
// ── Pipeline ─────────────────────────────────────────────────────────────────
|
||||
// The key move is `refine()`. Rather than *rejecting* a board that carries a
|
||||
// spare vehicle, it strips the spare out — removing a redundant piece leaves
|
||||
// par unchanged by definition — and then re-hardens: with fewer pieces the
|
||||
// cluster often reaches a farther state, so par goes UP. Stripping and
|
||||
// hardening feed each other, and the loop terminates at a board that is
|
||||
// simultaneously minimal and unsolved. Car count therefore falls out of the
|
||||
// difficulty rather than being dialled in, which is what gives the curriculum
|
||||
// its shape: easy levels are genuinely small boards where every car matters,
|
||||
// not big boards with a two-move answer.
|
||||
//
|
||||
// Phase A seeds a pool by random sampling. Phase B hill-climbs from the best
|
||||
// boards found (add / relocate / lengthen a piece, then refine again), which is
|
||||
// what reaches the top tiers — pure sampling plateaus around par 24.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
GRID, EXIT_ROW, TARGET_ID, isSolved, solve, vehicleCells,
|
||||
GRID, EXIT_ROW, TARGET_ID, analyzeCluster, solve,
|
||||
} from '../src/games/rushhour/RushHourLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_FILE = process.argv[3]
|
||||
? path.resolve(process.argv[3])
|
||||
: path.join(__dirname, '../data/rushhour.json');
|
||||
: path.join(__dirname, '../assets/gamedata/rushhour/levels.json');
|
||||
|
||||
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x9e3779b9;
|
||||
|
||||
// ── Tuning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Budgets are counted in iterations, not wall clock: a time-bounded loop makes
|
||||
// the output depend on how fast the machine happened to be, which would mean
|
||||
// the committed bank could not be reproduced from its seed. WALL_CLOCK_CAP is
|
||||
// only an emergency brake, and firing it is treated as a failure.
|
||||
const PHASE_A_ATTEMPTS = Number(process.env.RH_PHASE_A ?? 700);
|
||||
const PHASE_B_CLIMBS = Number(process.env.RH_PHASE_B ?? 3000);
|
||||
const WALL_CLOCK_CAP = Number(process.env.RH_MAX_SECONDS ?? 2400);
|
||||
const CLUSTER_MAX_STATES = 300000;
|
||||
const ELITE_POOL = 48;
|
||||
|
||||
// Difficulty tiers. `par` is the move band; vehicle count is an *outcome* of
|
||||
// refine(), not an input, so it is reported rather than constrained.
|
||||
const TIERS = [
|
||||
{
|
||||
id: 'downtown', name: 'Downtown', count: 12, par: [5, 9],
|
||||
names: ['First Gear', 'Fender Bender', 'One Way Out', 'Meter Maid', 'Corner Store', 'Crosswalk',
|
||||
'Double Park', 'Side Street', 'Red Light', 'Delivery Van', 'Taxi Stand', 'Grid Lock'],
|
||||
},
|
||||
{
|
||||
id: 'freightyard', name: 'Freight Yard', count: 12, par: [10, 14],
|
||||
names: ['Loading Dock', 'Long Haul', 'Container Row', 'Weigh Station', 'Flatbed', 'Coupling',
|
||||
'Yard Shunt', 'Box Car', 'Diesel Lane', 'The Straddle', 'Cargo Jam', 'Last Wagon'],
|
||||
},
|
||||
{
|
||||
id: 'airport', name: 'Airport Apron', count: 12, par: [15, 19],
|
||||
names: ['Pushback', 'Baggage Train', 'Fuel Bowser', 'Taxiway Bravo', 'Ground Hold', 'Jet Bridge',
|
||||
'De-icer', 'Catering Lift', 'Stand 21', 'Runway Cross', 'Apron Shuffle', 'Final Approach'],
|
||||
},
|
||||
{
|
||||
id: 'construction', name: 'Construction', count: 12, par: [20, 25],
|
||||
names: ['Ground Break', 'Skip Loader', 'Cement Mixer', 'Steel Beam', 'Backhoe', 'Scaffold',
|
||||
'Tipper Truck', 'Crane Base', 'Rebar', 'Site Gate', 'Dozer Line', 'Hard Hat'],
|
||||
},
|
||||
{
|
||||
id: 'nightcity', name: 'Night City', count: 12, par: [26, 99],
|
||||
names: ['Neon Mile', 'Last Call', 'Wet Asphalt', 'Midnight Run', 'Streetlight', 'After Hours',
|
||||
'Rain Check', 'Chrome', 'Blackout', 'Red Line', 'The Long Night', 'Dead of Night'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||
function makeRng(seed) {
|
||||
let a = seed >>> 0;
|
||||
|
|
@ -36,22 +108,30 @@ function makeRng(seed) {
|
|||
}
|
||||
const rng = makeRng(SEED);
|
||||
const randInt = (n) => Math.floor(rng() * n);
|
||||
const pick = (arr) => arr[randInt(arr.length)];
|
||||
|
||||
// Difficulty curve: count + [min,max] minMoves per tier. Sums to the bank size.
|
||||
const TIERS = [
|
||||
{ count: 8, min: 2, max: 4 },
|
||||
{ count: 10, min: 5, max: 7 },
|
||||
{ count: 10, min: 8, max: 11 },
|
||||
{ count: 8, min: 12, max: 15 },
|
||||
{ count: 7, min: 16, max: 20 },
|
||||
{ count: 5, min: 21, max: 40 },
|
||||
];
|
||||
const MAX_ATTEMPTS = 8000000;
|
||||
const MAX_SECONDS = 220; // wall-clock budget
|
||||
const SOLVE_MAX_STATES = 12000; // bound per-solve cost (rejects pathological layouts fast)
|
||||
// ── Board helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop';
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVW';
|
||||
|
||||
function cellsOf(v) {
|
||||
const out = [];
|
||||
for (let i = 0; i < v.len; i++) out.push(v.orient === 'h' ? [v.x + i, v.y] : [v.x, v.y + i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function occupancy(vehicles) {
|
||||
const occ = Array.from({ length: GRID }, () => Array(GRID).fill(null));
|
||||
for (const v of vehicles) for (const [x, y] of cellsOf(v)) occ[y][x] = v.id;
|
||||
return occ;
|
||||
}
|
||||
|
||||
function fits(occ, v) {
|
||||
for (const [x, y] of cellsOf(v)) {
|
||||
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
|
||||
if (occ[y][x] !== null) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Canonical, label-independent key for dedup.
|
||||
function canonKey(vehicles) {
|
||||
|
|
@ -61,117 +141,359 @@ function canonKey(vehicles) {
|
|||
.join('|');
|
||||
}
|
||||
|
||||
function fits(grid, v) {
|
||||
for (const [x, y] of vehicleCells(v)) {
|
||||
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
|
||||
if (grid[y][x] !== null) return false;
|
||||
// Criteria 3 and 4. Orientation and fixed axis never change as pieces slide, so
|
||||
// the exit-row rule is invariant once placed; the frozen-line rule is not, and
|
||||
// must be checked on the final arrangement.
|
||||
function structurallyOk(vehicles) {
|
||||
for (const v of vehicles) {
|
||||
if (!v.isTarget && v.orient === 'h' && v.y === EXIT_ROW) return false;
|
||||
}
|
||||
const occ = occupancy(vehicles);
|
||||
const byId = new Map(vehicles.map((v) => [v.id, v]));
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
let full = true;
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
const id = occ[y][x];
|
||||
if (id === null || byId.get(id).orient !== 'h') { full = false; break; }
|
||||
}
|
||||
if (full) return false;
|
||||
}
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
let full = true;
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
const id = occ[y][x];
|
||||
if (id === null || byId.get(id).orient !== 'v') { full = false; break; }
|
||||
}
|
||||
if (full) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function stamp(grid, v) {
|
||||
for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id;
|
||||
|
||||
// Relabel deterministically: target is X, the rest A.. in reading order. Keeps
|
||||
// the shipped JSON (and the colour slots the scene derives from it) stable.
|
||||
function normalize(vehicles) {
|
||||
const target = vehicles.find((v) => v.isTarget);
|
||||
const rest = vehicles.filter((v) => !v.isTarget)
|
||||
.sort((a, b) => (a.y - b.y) || (a.x - b.x) || (a.orient < b.orient ? -1 : 1));
|
||||
return [
|
||||
{ id: TARGET_ID, x: target.x, y: target.y, len: target.len, orient: 'h', isTarget: true },
|
||||
...rest.map((v, i) => ({ id: LETTERS[i], x: v.x, y: v.y, len: v.len, orient: v.orient, isTarget: false })),
|
||||
];
|
||||
}
|
||||
|
||||
// Build one random candidate layout (target + blockers), or null if it failed
|
||||
// to place the target.
|
||||
function randomLayout() {
|
||||
const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null));
|
||||
const vehicles = [];
|
||||
function randomBlocker(id) {
|
||||
const orient = rng() < 0.55 ? 'v' : 'h';
|
||||
const len = rng() < 0.35 ? 3 : 2;
|
||||
const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID);
|
||||
const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1);
|
||||
return { id, x, y, len, orient, isTarget: false };
|
||||
}
|
||||
|
||||
// Target car: horizontal, length 2, starting far left so its escape path is
|
||||
// long and likely blocked -> richer puzzles.
|
||||
const target = { id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true };
|
||||
stamp(grid, target);
|
||||
vehicles.push(target);
|
||||
|
||||
const count = 6 + randInt(5); // 6..10 blockers
|
||||
function randomLayout(nBlockers) {
|
||||
const vehicles = [{ id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }];
|
||||
const occ = occupancy(vehicles);
|
||||
let letterIdx = 0;
|
||||
let tries = 0;
|
||||
while (vehicles.length < count + 1 && tries < 200) {
|
||||
while (vehicles.length < nBlockers + 1 && tries < 400) {
|
||||
tries++;
|
||||
// Favour vertical cars: they cross the exit row and create real blockades.
|
||||
const orient = rng() < 0.6 ? 'v' : 'h';
|
||||
const len = rng() < 0.3 ? 3 : 2;
|
||||
const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID);
|
||||
const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1);
|
||||
const v = { id: LETTERS[letterIdx], x, y, len, orient, isTarget: false };
|
||||
if (!fits(grid, v)) continue;
|
||||
stamp(grid, v);
|
||||
const v = randomBlocker(LETTERS[letterIdx]);
|
||||
if (v.orient === 'h' && v.y === EXIT_ROW) continue; // would block the exit forever
|
||||
if (!fits(occ, v)) continue;
|
||||
for (const [x, y] of cellsOf(v)) occ[y][x] = v.id;
|
||||
vehicles.push(v);
|
||||
letterIdx++;
|
||||
}
|
||||
return vehicles;
|
||||
}
|
||||
|
||||
// ── Generate pool ────────────────────────────────────────────────────────────
|
||||
console.log(`[rushhour] generating with seed ${SEED}…`);
|
||||
const target = TIERS.reduce((t, x) => t + x.count, 0);
|
||||
// ── The core: strip to minimal, harden to the cluster's farthest state ───────
|
||||
//
|
||||
// Removing a redundant piece cannot change par (that is what redundant means),
|
||||
// but it can enlarge the cluster, so the re-harden on the next pass may find a
|
||||
// strictly harder start. Terminates when a full sweep strips nothing.
|
||||
function refine(vehicles) {
|
||||
let cur = vehicles;
|
||||
for (let round = 0; round < 24; round++) {
|
||||
const C = analyzeCluster(cur, { maxStates: CLUSTER_MAX_STATES });
|
||||
if (!C || C.maxDist < 2) return null;
|
||||
cur = C.toVehicles(C.hardest);
|
||||
const par = C.maxDist;
|
||||
|
||||
let stripped = false;
|
||||
for (const v of cur) {
|
||||
if (v.isTarget) continue;
|
||||
const reduced = cur.filter((w) => w.id !== v.id);
|
||||
if (reduced.length < 2) continue;
|
||||
if (solve(reduced, { maxStates: CLUSTER_MAX_STATES }).moves === par) {
|
||||
cur = reduced;
|
||||
stripped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stripped) return { vehicles: normalize(cur), par };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Difficulty metrics ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Par alone is a poor proxy for how hard a puzzle *feels*: a 20-move solution
|
||||
// made of forced moves is easier than a 12-move one with three retreats. All of
|
||||
// these fall out of the distance-to-goal map the cluster analysis already
|
||||
// computed, so they cost nothing extra.
|
||||
function measure(vehicles, par) {
|
||||
const C = analyzeCluster(vehicles, { maxStates: CLUSTER_MAX_STATES });
|
||||
if (!C || C.startDist !== par) return null;
|
||||
|
||||
const targetIdx = C.board.targetIdx;
|
||||
const movedBy = new Set();
|
||||
let targetRetreats = 0;
|
||||
let decoySum = 0;
|
||||
let decoySteps = 0;
|
||||
|
||||
let at = 0;
|
||||
let guard = 0;
|
||||
while (C.dist[at] > 0 && guard++ < 200) {
|
||||
const nbrs = C.neighbors(at);
|
||||
const here = C.dist[at];
|
||||
const dead = nbrs.filter((nb) => C.dist[nb.index] >= here).length;
|
||||
if (nbrs.length) { decoySum += dead / nbrs.length; decoySteps++; }
|
||||
|
||||
const step = nbrs.find((nb) => C.dist[nb.index] === here - 1);
|
||||
if (!step) return null;
|
||||
movedBy.add(step.vehicleIdx);
|
||||
if (step.vehicleIdx === targetIdx && step.pos < C.states[at][targetIdx]) targetRetreats++;
|
||||
at = step.index;
|
||||
}
|
||||
|
||||
const firstMoveFanout = C.neighbors(0).filter((nb) => C.dist[nb.index] === par - 1).length;
|
||||
|
||||
return {
|
||||
carsMoved: movedBy.size,
|
||||
decoyDensity: decoySteps ? decoySum / decoySteps : 0,
|
||||
targetRetreats,
|
||||
firstMoveFanout,
|
||||
clusterSize: C.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Composite ranking. Par dominates (it is the player-visible number), with the
|
||||
// "how easy is it to go wrong" terms breaking ties within a band.
|
||||
function composite(par, m) {
|
||||
return par
|
||||
+ 0.8 * m.carsMoved
|
||||
+ 10 * m.decoyDensity
|
||||
+ 2.5 * m.targetRetreats
|
||||
- 0.4 * m.firstMoveFanout;
|
||||
}
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const buckets = TIERS.map(() => []);
|
||||
const seen = new Set();
|
||||
const elites = [];
|
||||
let refined = 0;
|
||||
let attempts = 0;
|
||||
let solved = 0;
|
||||
|
||||
function tierIndexFor(moves) {
|
||||
function tierFor(par) {
|
||||
for (let i = 0; i < TIERS.length; i++) {
|
||||
if (moves >= TIERS[i].min && moves <= TIERS[i].max) return i;
|
||||
if (par >= TIERS[i].par[0] && par <= TIERS[i].par[1]) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
const tierFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (attempts < MAX_ATTEMPTS && !tierFull()) {
|
||||
attempts++;
|
||||
if ((attempts & 0x3ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) {
|
||||
console.log('\n[rushhour] time budget reached, stopping early');
|
||||
break;
|
||||
}
|
||||
const vehicles = randomLayout();
|
||||
if (isSolved(vehicles)) continue; // already at exit -> trivial
|
||||
function consider(result) {
|
||||
if (!result) return null;
|
||||
const { vehicles, par } = result;
|
||||
if (!structurallyOk(vehicles)) return null;
|
||||
const key = canonKey(vehicles);
|
||||
if (seen.has(key)) continue;
|
||||
if (seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
refined++;
|
||||
|
||||
const { moves } = solve(vehicles, { maxStates: SOLVE_MAX_STATES });
|
||||
if (moves < 2) continue; // unsolvable or 1-move (no blockers)
|
||||
solved++;
|
||||
const ti = tierIndexFor(moves);
|
||||
if (ti === -1) continue;
|
||||
if (buckets[ti].length >= TIERS[ti].count) continue;
|
||||
buckets[ti].push({ minMoves: moves, vehicles });
|
||||
// Elite pool drives phase B regardless of whether this board lands in a tier.
|
||||
elites.push({ vehicles, par });
|
||||
elites.sort((a, b) => b.par - a.par);
|
||||
if (elites.length > ELITE_POOL) elites.length = ELITE_POOL;
|
||||
|
||||
if (solved % 2000 === 0) {
|
||||
process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `);
|
||||
const ti = tierFor(par);
|
||||
if (ti === -1) return { par };
|
||||
const m = measure(vehicles, par);
|
||||
if (!m) return { par };
|
||||
buckets[ti].push({ vehicles, par, ...m, score: composite(par, m) });
|
||||
return { par, tier: ti };
|
||||
}
|
||||
|
||||
// `densify` biases toward adding and lengthening pieces. Dropping a piece
|
||||
// usually lowers par, so when the hard tiers are the ones still short there is
|
||||
// little point spending climbs on it.
|
||||
function mutate(vehicles, densify) {
|
||||
const out = vehicles.map((v) => ({ ...v }));
|
||||
const blockers = out.filter((v) => !v.isTarget);
|
||||
const roll = densify ? 0.55 + rng() * 0.45 : rng();
|
||||
|
||||
if (roll < 0.30 && blockers.length > 2) {
|
||||
// Relocate: drop one piece, place a fresh one somewhere it fits.
|
||||
const victim = blockers[randInt(blockers.length)];
|
||||
const kept = out.filter((v) => v.id !== victim.id);
|
||||
const occ = occupancy(kept);
|
||||
for (let t = 0; t < 60; t++) {
|
||||
const v = randomBlocker('tmp');
|
||||
if (v.orient === 'h' && v.y === EXIT_ROW) continue;
|
||||
if (!fits(occ, v)) continue;
|
||||
return normalize([...kept, v]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (roll < 0.55 && blockers.length > 2) {
|
||||
// Drop a piece outright and let refine() re-harden the sparser board.
|
||||
const victim = blockers[randInt(blockers.length)];
|
||||
return normalize(out.filter((v) => v.id !== victim.id));
|
||||
}
|
||||
|
||||
if (roll < 0.80) {
|
||||
// Lengthen a car into a truck where there is room.
|
||||
const shorts = out.filter((v) => !v.isTarget && v.len === 2);
|
||||
if (!shorts.length) return null;
|
||||
const v = shorts[randInt(shorts.length)];
|
||||
const kept = out.filter((w) => w.id !== v.id);
|
||||
const occ = occupancy(kept);
|
||||
for (const cand of [{ ...v, len: 3 }, { ...v, len: 3, x: v.orient === 'h' ? v.x - 1 : v.x, y: v.orient === 'v' ? v.y - 1 : v.y }]) {
|
||||
if (fits(occ, cand)) return normalize([...kept, cand]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add a piece: denser boards give refine() more to strip and harden against.
|
||||
const occ = occupancy(out);
|
||||
for (let t = 0; t < 60; t++) {
|
||||
const v = randomBlocker('tmp');
|
||||
if (v.orient === 'h' && v.y === EXIT_ROW) continue;
|
||||
if (!fits(occ, v)) continue;
|
||||
return normalize([...out, v]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
|
||||
// Count toward the goal only what a tier can actually use, so the progress
|
||||
// line reads as "levels we can ship", not "candidates collected".
|
||||
const kept = () => buckets.reduce((t, b, i) => t + Math.min(b.length, TIERS[i].count), 0);
|
||||
const wanted = TIERS.reduce((t, x) => t + x.count, 0);
|
||||
|
||||
console.log(`[rushhour] generating with seed ${SEED}…`);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let timedOut = false;
|
||||
const overCap = () => {
|
||||
if ((Date.now() - startedAt) / 1000 < WALL_CLOCK_CAP) return false;
|
||||
timedOut = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Phase A — random seeding.
|
||||
while (attempts < PHASE_A_ATTEMPTS && !tiersFull() && !overCap()) {
|
||||
attempts++;
|
||||
consider(refine(randomLayout(6 + randInt(8))));
|
||||
if (attempts % 25 === 0) {
|
||||
process.stdout.write(`\r[rushhour] phase A attempts=${attempts} refined=${refined} kept=${kept()}/${wanted} `);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
console.log(`[rushhour] phase A done: ${kept()}/${wanted} kept, elite par max ${elites[0]?.par ?? 0}`);
|
||||
|
||||
// Phase B — hill climb from the elite pool toward the tiers still short.
|
||||
let climbs = 0;
|
||||
while (climbs < PHASE_B_CLIMBS && !tiersFull() && !overCap()) {
|
||||
climbs++;
|
||||
// Bias seed choice toward the hardest boards when the top tiers are short.
|
||||
const shortHigh = buckets.some((b, i) => i >= 3 && b.length < TIERS[i].count);
|
||||
const pool = shortHigh ? elites.slice(0, Math.max(8, elites.length >> 1)) : elites;
|
||||
if (!pool.length) break;
|
||||
const seedBoard = pool[randInt(pool.length)];
|
||||
const mutated = mutate(seedBoard.vehicles, shortHigh);
|
||||
if (!mutated) continue;
|
||||
consider(refine(mutated));
|
||||
if (climbs % 25 === 0) {
|
||||
process.stdout.write(`\r[rushhour] phase B climbs=${climbs} refined=${refined} kept=${kept()}/${wanted} best par=${elites[0]?.par ?? 0} `);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// ── Assemble ordered levels ──────────────────────────────────────────────────
|
||||
const chosen = [];
|
||||
buckets.forEach((b) => {
|
||||
b.sort((p, q) => p.minMoves - q.minMoves);
|
||||
chosen.push(...b);
|
||||
});
|
||||
// Overall ascending so levels ramp smoothly even across tier boundaries.
|
||||
chosen.sort((p, q) => p.minMoves - q.minMoves);
|
||||
// ── Assemble ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const puzzles = chosen.map((p, i) => ({
|
||||
level: i + 1,
|
||||
minMoves: p.minMoves,
|
||||
vehicles: p.vehicles,
|
||||
}));
|
||||
const shortfall = [];
|
||||
const levels = [];
|
||||
const tiersOut = [];
|
||||
let levelNo = 1;
|
||||
|
||||
TIERS.forEach((tier, ti) => {
|
||||
const pool = buckets[ti].slice().sort((a, b) => a.score - b.score);
|
||||
if (pool.length < tier.count) shortfall.push(`${tier.name}: ${pool.length}/${tier.count}`);
|
||||
|
||||
// Spread the picks across the band rather than taking the 12 easiest.
|
||||
const picks = [];
|
||||
if (pool.length <= tier.count) {
|
||||
picks.push(...pool);
|
||||
} else {
|
||||
for (let i = 0; i < tier.count; i++) {
|
||||
picks.push(pool[Math.round((i * (pool.length - 1)) / (tier.count - 1))]);
|
||||
}
|
||||
}
|
||||
|
||||
// Selection spreads across the composite score so a tier samples its whole
|
||||
// band, but presentation order is by par: par is the number printed on the
|
||||
// level tile, and a tile reading "par 22" after one reading "par 25" looks
|
||||
// like the ramp went backwards even when the later puzzle is genuinely
|
||||
// trickier. Composite breaks ties.
|
||||
picks.sort((a, b) => (a.par - b.par) || (a.score - b.score));
|
||||
|
||||
const from = levelNo;
|
||||
picks.forEach((p, i) => {
|
||||
levels.push({
|
||||
level: levelNo++,
|
||||
name: tier.names[i] ?? `${tier.name} ${i + 1}`,
|
||||
tier: tier.id,
|
||||
par: p.par,
|
||||
carsMoved: p.carsMoved,
|
||||
decoyDensity: Number(p.decoyDensity.toFixed(3)),
|
||||
targetRetreats: p.targetRetreats,
|
||||
firstMoveFanout: p.firstMoveFanout,
|
||||
difficulty: Number(p.score.toFixed(2)),
|
||||
vehicles: p.vehicles,
|
||||
});
|
||||
});
|
||||
tiersOut.push({ id: tier.id, name: tier.name, theme: tier.id, from, to: levelNo - 1 });
|
||||
});
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
seed: SEED,
|
||||
count: puzzles.length,
|
||||
puzzles,
|
||||
generatedAt: new Date().toISOString(),
|
||||
count: levels.length,
|
||||
tiers: tiersOut,
|
||||
levels,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||
|
||||
const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' ');
|
||||
console.log(`[rushhour] attempts=${attempts} solvable=${solved}`);
|
||||
console.log(`[rushhour] tiers filled: ${perTier}`);
|
||||
console.log(`[rushhour] wrote ${puzzles.length} levels (minMoves ${puzzles[0]?.minMoves}..${puzzles[puzzles.length - 1]?.minMoves}) -> ${OUT_FILE}`);
|
||||
console.log(`[rushhour] attempts=${attempts} climbs=${climbs} distinct minimal boards=${refined}`);
|
||||
TIERS.forEach((t, i) => {
|
||||
const pool = buckets[i];
|
||||
const pars = pool.map((p) => p.par);
|
||||
console.log(`[rushhour] ${t.name.padEnd(14)} ${String(pool.length).padStart(3)}/${t.count} candidates` +
|
||||
(pars.length ? ` par ${Math.min(...pars)}..${Math.max(...pars)}` : ''));
|
||||
});
|
||||
console.log(`[rushhour] wrote ${levels.length} levels -> ${OUT_FILE}`);
|
||||
|
||||
if (timedOut) {
|
||||
console.error(`\n[rushhour] WALL CLOCK CAP (${WALL_CLOCK_CAP}s) HIT — this bank is not reproducible from its seed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (shortfall.length) {
|
||||
console.error(`\n[rushhour] TIERS SHORT: ${shortfall.join(', ')}`);
|
||||
console.error('[rushhour] raise RH_PHASE_B, or widen the par bands, and re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
// Headless verification for Rush Hour.
|
||||
// node tools/verifyRushHour.js
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Solver correctness against an independent reference BFS.
|
||||
// 2. Board model invariants (slideRange, legalMoves, isSolved).
|
||||
// 3. Cluster analysis (distance-to-goal, hardest-state extraction).
|
||||
// 4. Level bank schema and geometry.
|
||||
// 5. Structural criteria (exit row, no frozen lines).
|
||||
// 6. Par matches the solver, and the shipped optimal path replays cleanly.
|
||||
// 7. MINIMAL — removing any vehicle changes the solution.
|
||||
// 8. UNSOLVED — the start is the farthest state from the goal in its cluster.
|
||||
// 9. Curriculum shape (tiers, monotonic par, difficulty metrics).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
GRID, EXIT_ROW, TARGET_ID,
|
||||
vehicleCells, buildGrid, isSolved, stateKey, legalMoves, slideRange,
|
||||
cloneVehicles, solve, analyzeCluster, packBoard,
|
||||
} from '../src/games/rushhour/RushHourLogic.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
function check(name, cond, detail = '') {
|
||||
checks += 1;
|
||||
if (cond) return;
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
function section(title) {
|
||||
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 62 - title.length))}`);
|
||||
}
|
||||
|
||||
// ── Reference solver ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Deliberately naive: clones an array of plain objects per state and keys it
|
||||
// with a string join, exactly the way the shipped solver used to. It exists
|
||||
// only to cross-check the packed rewrite. If these two ever disagree, the fast
|
||||
// path is wrong — this is the whole reason it lives in the verifier rather
|
||||
// than being deleted.
|
||||
function referenceSolve(vehicles, maxStates = 400000) {
|
||||
const start = cloneVehicles(vehicles);
|
||||
if (isSolved(start)) return 0;
|
||||
const seen = new Set([stateKey(start)]);
|
||||
let frontier = [start];
|
||||
let depth = 0;
|
||||
while (frontier.length) {
|
||||
depth += 1;
|
||||
const next = [];
|
||||
for (const state of frontier) {
|
||||
for (const mv of legalMoves(state)) {
|
||||
const ns = cloneVehicles(state);
|
||||
ns[mv.idx].x = mv.x;
|
||||
ns[mv.idx].y = mv.y;
|
||||
const k = stateKey(ns);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
if (isSolved(ns)) return depth;
|
||||
next.push(ns);
|
||||
}
|
||||
if (seen.size > maxStates) return -1;
|
||||
}
|
||||
frontier = next;
|
||||
if (depth > 120) break;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// The canonical ThinkFun "card 1" shape: target boxed in behind one truck.
|
||||
const FIXTURE = [
|
||||
{ id: TARGET_ID, x: 1, y: 2, len: 2, orient: 'h', isTarget: true },
|
||||
{ id: 'A', x: 3, y: 1, len: 3, orient: 'v', isTarget: false },
|
||||
{ id: 'B', x: 0, y: 0, len: 2, orient: 'h', isTarget: false },
|
||||
];
|
||||
|
||||
// ── 1. Solver vs reference ───────────────────────────────────────────────────
|
||||
|
||||
section('1. Solver correctness');
|
||||
{
|
||||
const trivial = [{ id: TARGET_ID, x: 4, y: 2, len: 2, orient: 'h', isTarget: true }];
|
||||
check('already-solved board reports 0 moves', solve(trivial).moves === 0);
|
||||
check('already-solved board returns an empty path', solve(trivial).path.length === 0);
|
||||
|
||||
const clear = [{ id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true }];
|
||||
check('unobstructed target solves in one move', solve(clear).moves === 1);
|
||||
|
||||
// A vertical wall spanning the exit row with no way past is unsolvable.
|
||||
const walled = [
|
||||
{ id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true },
|
||||
{ id: 'A', x: 5, y: 0, len: 3, orient: 'v', isTarget: false },
|
||||
{ id: 'B', x: 5, y: 3, len: 3, orient: 'v', isTarget: false },
|
||||
];
|
||||
check('permanently blocked board is unsolvable', solve(walled).moves === -1);
|
||||
|
||||
check('fixture par matches the reference solver',
|
||||
solve(FIXTURE).moves === referenceSolve(FIXTURE),
|
||||
`fast=${solve(FIXTURE).moves} ref=${referenceSolve(FIXTURE)}`);
|
||||
|
||||
// A target not on the exit row can never escape.
|
||||
const offRow = [{ id: TARGET_ID, x: 0, y: 3, len: 2, orient: 'h', isTarget: true }];
|
||||
check('target off the exit row is unsolvable', solve(offRow).moves === -1);
|
||||
|
||||
// maxStates is a budget, not a claim of unsolvability — but it must not lie
|
||||
// in the other direction by reporting a solution it did not find.
|
||||
const budgeted = solve(FIXTURE, { maxStates: 1 });
|
||||
check('exhausted budget reports -1 rather than a wrong answer',
|
||||
budgeted.moves === -1 || budgeted.moves === solve(FIXTURE).moves);
|
||||
}
|
||||
|
||||
// ── 2. Board model invariants ────────────────────────────────────────────────
|
||||
|
||||
section('2. Board model');
|
||||
{
|
||||
const cells = vehicleCells({ x: 2, y: 3, len: 3, orient: 'v' });
|
||||
check('vertical vehicleCells runs down the column',
|
||||
JSON.stringify(cells) === JSON.stringify([[2, 3], [2, 4], [2, 5]]));
|
||||
const hcells = vehicleCells({ x: 2, y: 3, len: 2, orient: 'h' });
|
||||
check('horizontal vehicleCells runs along the row',
|
||||
JSON.stringify(hcells) === JSON.stringify([[2, 3], [3, 3]]));
|
||||
|
||||
const grid = buildGrid(FIXTURE);
|
||||
check('buildGrid marks every occupied square',
|
||||
grid.flat().filter(Boolean).length === FIXTURE.reduce((s, v) => s + v.len, 0));
|
||||
|
||||
const r = slideRange(FIXTURE, 0);
|
||||
check('slideRange bounds the target by the blocking truck', r.min === 0 && r.max === 1,
|
||||
`got ${r.min}..${r.max}`);
|
||||
|
||||
// Every legal move must land somewhere different and stay on the board.
|
||||
const moves = legalMoves(FIXTURE);
|
||||
check('legal moves stay on the board',
|
||||
moves.every((m) => m.x >= 0 && m.y >= 0 && m.x < GRID && m.y < GRID));
|
||||
check('legal moves actually move a piece',
|
||||
moves.every((m) => {
|
||||
const v = FIXTURE[m.idx];
|
||||
return m.x !== v.x || m.y !== v.y;
|
||||
}));
|
||||
|
||||
// packBoard must round-trip positions unchanged.
|
||||
const B = packBoard(FIXTURE);
|
||||
const round = B.toVehicles(B.start);
|
||||
check('packBoard round-trips vehicle positions',
|
||||
round.every((v, i) => v.x === FIXTURE[i].x && v.y === FIXTURE[i].y && v.id === FIXTURE[i].id));
|
||||
}
|
||||
|
||||
// ── 3. Cluster analysis ──────────────────────────────────────────────────────
|
||||
|
||||
section('3. Cluster analysis');
|
||||
{
|
||||
const C = analyzeCluster(FIXTURE);
|
||||
check('cluster analysis succeeds on a solvable board', !!C);
|
||||
check('start distance equals the solver par', C.startDist === solve(FIXTURE).moves,
|
||||
`dist=${C.startDist} par=${solve(FIXTURE).moves}`);
|
||||
check('hardest state is at least as far as the start', C.maxDist >= C.startDist);
|
||||
check('every state in the cluster can reach the goal',
|
||||
Array.from(C.dist).every((d) => d >= 0));
|
||||
|
||||
// The hardest state must really solve in maxDist moves.
|
||||
const hardVehicles = C.toVehicles(C.hardest);
|
||||
check('hardest state solves in exactly maxDist moves',
|
||||
solve(hardVehicles).moves === C.maxDist,
|
||||
`solve=${solve(hardVehicles).moves} maxDist=${C.maxDist}`);
|
||||
|
||||
// Neighbour distances must differ by at most one — the defining property of
|
||||
// a BFS layering, and the thing the decoy metric relies on.
|
||||
let layered = true;
|
||||
for (let i = 0; i < C.size && layered; i++) {
|
||||
for (const nb of C.neighbors(i)) {
|
||||
if (Math.abs(C.dist[nb.index] - C.dist[i]) > 1) { layered = false; break; }
|
||||
}
|
||||
}
|
||||
check('neighbouring states differ by at most one in distance', layered);
|
||||
}
|
||||
|
||||
// ── 4-9. The shipped bank ────────────────────────────────────────────────────
|
||||
|
||||
const BANK_PATH = join(__dirname, '../assets/gamedata/rushhour/levels.json');
|
||||
let bank;
|
||||
try {
|
||||
bank = JSON.parse(readFileSync(BANK_PATH, 'utf8'));
|
||||
} catch (err) {
|
||||
console.error(`FAIL level bank is missing or unreadable — ${err.message}`);
|
||||
console.error(' run: node tools/genRushHour.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
section('4. Bank schema and geometry');
|
||||
{
|
||||
check('bank declares a version', bank.version === 1);
|
||||
check('bank declares tiers', Array.isArray(bank.tiers) && bank.tiers.length > 0);
|
||||
check('count matches the level array', bank.count === bank.levels.length);
|
||||
check('levels are numbered 1..N contiguously',
|
||||
bank.levels.every((p, i) => p.level === i + 1));
|
||||
|
||||
const names = new Set(bank.levels.map((p) => p.name));
|
||||
check('every level has a distinct name', names.size === bank.levels.length);
|
||||
|
||||
for (const p of bank.levels) {
|
||||
const tag = `level ${p.level}`;
|
||||
const targets = p.vehicles.filter((v) => v.isTarget);
|
||||
check(`${tag}: exactly one target`, targets.length === 1);
|
||||
check(`${tag}: target is horizontal in the exit row`,
|
||||
targets[0]?.orient === 'h' && targets[0]?.y === EXIT_ROW);
|
||||
|
||||
let ok = true;
|
||||
for (const v of p.vehicles) {
|
||||
if (v.len !== 2 && v.len !== 3) ok = false;
|
||||
if (v.orient !== 'h' && v.orient !== 'v') ok = false;
|
||||
for (const [x, y] of vehicleCells(v)) {
|
||||
if (x < 0 || y < 0 || x >= GRID || y >= GRID) ok = false;
|
||||
}
|
||||
}
|
||||
check(`${tag}: all vehicles are well-formed and on the board`, ok);
|
||||
|
||||
const occupied = buildGrid(p.vehicles).flat().filter(Boolean).length;
|
||||
check(`${tag}: no two vehicles overlap`,
|
||||
occupied === p.vehicles.reduce((s, v) => s + v.len, 0));
|
||||
|
||||
const ids = new Set(p.vehicles.map((v) => v.id));
|
||||
check(`${tag}: vehicle ids are unique`, ids.size === p.vehicles.length);
|
||||
check(`${tag}: does not start already solved`, !isSolved(p.vehicles));
|
||||
}
|
||||
}
|
||||
|
||||
section('5. Structural criteria');
|
||||
{
|
||||
for (const p of bank.levels) {
|
||||
const tag = `level ${p.level}`;
|
||||
|
||||
// A horizontal piece sharing the exit row either blocks the exit forever
|
||||
// or is pure decoration; either way it has no place on the board.
|
||||
check(`${tag}: nothing but the target on the exit row`,
|
||||
!p.vehicles.some((v) => !v.isTarget && v.orient === 'h' && v.y === EXIT_ROW));
|
||||
|
||||
// A row filled entirely with horizontal pieces (or a column with vertical
|
||||
// ones) can never move.
|
||||
const grid = buildGrid(p.vehicles);
|
||||
const byId = new Map(p.vehicles.map((v) => [v.id, v]));
|
||||
let frozenRow = -1;
|
||||
let frozenCol = -1;
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
if (grid[y].every((id) => id !== null && byId.get(id).orient === 'h')) frozenRow = y;
|
||||
}
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
let all = true;
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
const id = grid[y][x];
|
||||
if (id === null || byId.get(id).orient !== 'v') { all = false; break; }
|
||||
}
|
||||
if (all) frozenCol = x;
|
||||
}
|
||||
check(`${tag}: no frozen row of horizontal pieces`, frozenRow === -1, `row ${frozenRow}`);
|
||||
check(`${tag}: no frozen column of vertical pieces`, frozenCol === -1, `col ${frozenCol}`);
|
||||
}
|
||||
}
|
||||
|
||||
section('6. Par is honest');
|
||||
{
|
||||
for (const p of bank.levels) {
|
||||
const tag = `level ${p.level}`;
|
||||
const { moves, path } = solve(p.vehicles);
|
||||
check(`${tag}: shipped par matches the solver`, moves === p.par, `shipped=${p.par} solver=${moves}`);
|
||||
|
||||
// Replay the optimal path and confirm it both stays legal and finishes.
|
||||
const state = cloneVehicles(p.vehicles);
|
||||
let legal = true;
|
||||
for (const mv of path ?? []) {
|
||||
const idx = state.findIndex((v) => v.id === mv.id);
|
||||
const range = slideRange(state, idx);
|
||||
const axis = state[idx].orient === 'h' ? mv.x : mv.y;
|
||||
if (axis < range.min || axis > range.max) { legal = false; break; }
|
||||
state[idx].x = mv.x;
|
||||
state[idx].y = mv.y;
|
||||
}
|
||||
check(`${tag}: optimal path is a legal sequence of slides`, legal);
|
||||
check(`${tag}: optimal path ends solved`, legal && isSolved(state));
|
||||
}
|
||||
}
|
||||
|
||||
section('7. Every vehicle is load-bearing (MINIMAL)');
|
||||
{
|
||||
let offenders = 0;
|
||||
for (const p of bank.levels) {
|
||||
const spare = [];
|
||||
for (const v of p.vehicles) {
|
||||
if (v.isTarget) continue;
|
||||
const reduced = p.vehicles.filter((w) => w.id !== v.id);
|
||||
if (solve(reduced).moves === p.par) spare.push(v.id);
|
||||
}
|
||||
if (spare.length) offenders += 1;
|
||||
check(`level ${p.level}: removing any vehicle changes the solution`,
|
||||
spare.length === 0, `redundant: ${spare.join(',')}`);
|
||||
}
|
||||
check('no level in the bank carries a decorative vehicle', offenders === 0,
|
||||
`${offenders} level(s) affected`);
|
||||
}
|
||||
|
||||
section('8. Start is the hardest arrangement (UNSOLVED)');
|
||||
{
|
||||
for (const p of bank.levels) {
|
||||
const C = analyzeCluster(p.vehicles);
|
||||
check(`level ${p.level}: cluster analysis succeeds`, !!C);
|
||||
if (!C) continue;
|
||||
check(`level ${p.level}: start is at maximum distance from the goal`,
|
||||
C.startDist === C.maxDist, `start=${C.startDist} max=${C.maxDist}`);
|
||||
}
|
||||
}
|
||||
|
||||
section('9. Curriculum shape');
|
||||
{
|
||||
const pars = bank.levels.map((p) => p.par);
|
||||
check('par never decreases as levels advance',
|
||||
pars.every((v, i) => i === 0 || v >= pars[i - 1]));
|
||||
check('the curriculum actually ramps', pars[pars.length - 1] > pars[0] * 2,
|
||||
`${pars[0]} -> ${pars[pars.length - 1]}`);
|
||||
|
||||
// Tier ranges must tile the bank exactly, in order, with no gaps.
|
||||
let cursor = 1;
|
||||
let tiled = true;
|
||||
for (const t of bank.tiers) {
|
||||
if (t.from !== cursor || t.to < t.from) tiled = false;
|
||||
cursor = t.to + 1;
|
||||
}
|
||||
check('tier ranges tile the bank contiguously', tiled);
|
||||
check('tiers cover every level', cursor - 1 === bank.levels.length);
|
||||
check('every level carries the tier id that contains it',
|
||||
bank.levels.every((p) => {
|
||||
const t = bank.tiers.find((q) => p.level >= q.from && p.level <= q.to);
|
||||
return t && t.id === p.tier;
|
||||
}));
|
||||
|
||||
// Difficulty must rise between tiers, not just within them.
|
||||
const tierMedian = bank.tiers.map((t) => {
|
||||
const d = bank.levels.filter((p) => p.tier === t.id).map((p) => p.difficulty).sort((a, b) => a - b);
|
||||
return d[Math.floor(d.length / 2)];
|
||||
});
|
||||
check('median difficulty rises with every tier',
|
||||
tierMedian.every((v, i) => i === 0 || v > tierMedian[i - 1]),
|
||||
tierMedian.join(' -> '));
|
||||
|
||||
// Metrics must be self-consistent with the board they describe.
|
||||
for (const p of bank.levels) {
|
||||
check(`level ${p.level}: carsMoved does not exceed the vehicles present`,
|
||||
p.carsMoved <= p.vehicles.length && p.carsMoved > 0);
|
||||
check(`level ${p.level}: decoyDensity is a fraction`,
|
||||
p.decoyDensity >= 0 && p.decoyDensity <= 1);
|
||||
}
|
||||
|
||||
const avgShare = bank.levels.reduce((s, p) => s + p.carsMoved / p.vehicles.length, 0) / bank.levels.length;
|
||||
check('on average almost every vehicle has to move', avgShare > 0.85, avgShare.toFixed(3));
|
||||
}
|
||||
|
||||
section('10. Fast solver agrees with the reference on every level');
|
||||
{
|
||||
// The expensive one, so it runs last: an independent BFS over the shipped
|
||||
// bank. Capped to the levels the naive solver can reach in reasonable time.
|
||||
let compared = 0;
|
||||
for (const p of bank.levels) {
|
||||
const ref = referenceSolve(p.vehicles, 300000);
|
||||
if (ref === -1) continue; // reference ran out of budget
|
||||
compared += 1;
|
||||
check(`level ${p.level}: packed solver matches reference BFS`, ref === p.par,
|
||||
`ref=${ref} shipped=${p.par}`);
|
||||
}
|
||||
check('the reference cross-check covered the whole bank', compared === bank.levels.length,
|
||||
`covered ${compared}/${bank.levels.length}`);
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\n${'─'.repeat(68)}`);
|
||||
const pars = bank.levels.map((p) => p.par);
|
||||
const cars = bank.levels.map((p) => p.vehicles.length);
|
||||
console.log(`levels ${bank.levels.length} par ${Math.min(...pars)}..${Math.max(...pars)} ` +
|
||||
`vehicles ${Math.min(...cars)}..${Math.max(...cars)}`);
|
||||
for (const t of bank.tiers) {
|
||||
const ps = bank.levels.filter((p) => p.tier === t.id).map((p) => p.par);
|
||||
console.log(` ${t.name.padEnd(15)} levels ${String(t.from).padStart(2)}-${String(t.to).padEnd(2)} par ${Math.min(...ps)}..${Math.max(...ps)}`);
|
||||
}
|
||||
console.log(`${'─'.repeat(68)}`);
|
||||
if (failures) {
|
||||
console.error(`\n${failures} FAILED of ${checks} checks`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nall ${checks} checks passed`);
|
||||
Loading…
Reference in New Issue