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:
|
Runtime JSON fetched by the games:
|
||||||
|
|
||||||
- **Per-game banks & artwork maps** — e.g. `rushhour.json`, `zuma.json`,
|
- **Per-game banks & artwork maps** — e.g. `katamino.json`, `dotlink.json`,
|
||||||
`katamino.json`, `dotlink.json`, `jumble.json`, `spireclimb-artwork.json`,
|
`jumble.json`, `spireclimb-artwork.json`, `slots-artwork.json`,
|
||||||
`slots-artwork.json`, `shift-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`,
|
- **Shared UI options** — `playfields.json`, `colored-playfields.json`,
|
||||||
`card-backs.json`, `opponents.json`, `music.json`.
|
`card-backs.json`, `opponents.json`, `music.json`.
|
||||||
- **Word data** — `wordlists/enable1.txt` (main dictionary),
|
- **Word data** — `wordlists/enable1.txt` (main dictionary),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,400 +1,105 @@
|
||||||
{
|
{
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "Twin Pools",
|
"name": "Dual Strike",
|
||||||
"frog": [
|
"frog": [
|
||||||
620,
|
966,
|
||||||
600
|
579
|
||||||
],
|
],
|
||||||
"colors": 4,
|
"colors": 4,
|
||||||
"background": "zuma-background-1",
|
"seed": 12345,
|
||||||
"seed": 24757,
|
|
||||||
"starScores": [
|
"starScores": [
|
||||||
970,
|
640,
|
||||||
1460,
|
960,
|
||||||
1940
|
1280
|
||||||
],
|
],
|
||||||
"paths": [
|
"paths": [
|
||||||
{
|
{
|
||||||
"shape": "doubleLoop",
|
"shape": "custom",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
-80,
|
-49,
|
||||||
300
|
235
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
-10,
|
669,
|
||||||
300
|
132
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
60,
|
1515,
|
||||||
300
|
190
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
130,
|
1726,
|
||||||
300
|
411
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
200,
|
1710,
|
||||||
300
|
667
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
270,
|
1210,
|
||||||
300
|
817
|
||||||
],
|
|
||||||
[
|
|
||||||
340,
|
|
||||||
300
|
|
||||||
],
|
|
||||||
[
|
|
||||||
410,
|
|
||||||
300
|
|
||||||
],
|
|
||||||
[
|
|
||||||
480,
|
|
||||||
300
|
|
||||||
],
|
|
||||||
[
|
|
||||||
550,
|
|
||||||
300
|
|
||||||
],
|
|
||||||
[
|
|
||||||
620,
|
|
||||||
300
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
689,
|
689,
|
||||||
308
|
813
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
755,
|
482,
|
||||||
332
|
604
|
||||||
],
|
|
||||||
[
|
|
||||||
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
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"tunnels": [],
|
"tunnels": [],
|
||||||
"quota": 30,
|
"quota": 20,
|
||||||
"introBalls": 9,
|
"introBalls": 8,
|
||||||
"pushSpeed": 84,
|
"pushSpeed": 30,
|
||||||
"powerUpRate": 0.065
|
"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,
|
"level": 21,
|
||||||
"name": "Switchbacks",
|
"name": "New Path",
|
||||||
"frog": [
|
"frog": [
|
||||||
1620,
|
1661,
|
||||||
550
|
544
|
||||||
],
|
],
|
||||||
"colors": 4,
|
"colors": 4,
|
||||||
"background": "zuma-background-2",
|
"seed": 12345,
|
||||||
"seed": 32676,
|
|
||||||
"starScores": [
|
"starScores": [
|
||||||
1040,
|
640,
|
||||||
1550,
|
960,
|
||||||
2070
|
1280
|
||||||
],
|
],
|
||||||
"paths": [
|
"paths": [
|
||||||
{
|
{
|
||||||
"shape": "zigzag",
|
"shape": "custom",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
-80,
|
-65,
|
||||||
130
|
93
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
-11,
|
466,
|
||||||
130
|
132
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
58,
|
1034,
|
||||||
130
|
374
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
126,
|
1116,
|
||||||
130
|
817
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
195,
|
706,
|
||||||
130
|
956
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
264,
|
156,
|
||||||
130
|
663
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
333,
|
316,
|
||||||
130
|
309
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
401,
|
1095,
|
||||||
130
|
143
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
470,
|
1484,
|
||||||
130
|
780
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
539,
|
1210,
|
||||||
130
|
979
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
608,
|
853,
|
||||||
130
|
719
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
676,
|
552,
|
||||||
130
|
397
|
||||||
],
|
|
||||||
[
|
|
||||||
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
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"tunnels": [],
|
"tunnels": [
|
||||||
"quota": 32,
|
[
|
||||||
"introBalls": 10,
|
3182,
|
||||||
"pushSpeed": 84,
|
3834
|
||||||
"powerUpRate": 0.065
|
],
|
||||||
|
[
|
||||||
|
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'),
|
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
|
||||||
],
|
],
|
||||||
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.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: [
|
||||||
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
|
// 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
|
// 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.replayQueue = [];
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
this.dragFrom = null;
|
this.dragFrom = null;
|
||||||
|
this.rowsTween?.stop();
|
||||||
|
this.rowsTween = null;
|
||||||
this.layer.removeAll(true);
|
this.layer.removeAll(true);
|
||||||
if (this.calloutText) { this.calloutText.destroy(); this.calloutText = null; }
|
if (this.calloutText) { this.calloutText.destroy(); this.calloutText = null; }
|
||||||
this.cellSprites = null;
|
this.cellSprites = null;
|
||||||
|
|
@ -496,6 +498,11 @@ export default class JewelQuestGame extends Phaser.Scene {
|
||||||
const left = cx - gridW / 2 + TILE / 2;
|
const left = cx - gridW / 2 + TILE / 2;
|
||||||
const top = 530;
|
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) => {
|
this.bank.forEach((lv, i) => {
|
||||||
const col = i % COLS;
|
const col = i % COLS;
|
||||||
const row = Math.floor(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',
|
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
objs.push(tag);
|
objs.push(tag);
|
||||||
this.layer.add(objs);
|
rows.add(objs);
|
||||||
|
|
||||||
if (playable) {
|
if (playable) {
|
||||||
tile.setInteractive({ useHandCursor: true });
|
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),
|
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel),
|
||||||
{ width: 280, height: 58, fontSize: 24 });
|
{ width: 280, height: 58, fontSize: 24 });
|
||||||
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
|
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 {
|
import {
|
||||||
GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve,
|
GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve,
|
||||||
} from './RushHourLogic.js';
|
} 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_PX = CELL * GRID;
|
||||||
const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2);
|
const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2);
|
||||||
const BOARD_TOP = 188;
|
const BOARD_TOP = 188;
|
||||||
const PAD = 10; // inset of a vehicle body within its cells
|
|
||||||
|
|
||||||
const FELT = 0x0e2233;
|
// Hints solve from wherever the player currently is, which on a tier-5 board
|
||||||
const FRAME = 0x0a1722;
|
// can be a large reachable space. Bound it so a press can never stall the
|
||||||
const CELLBG = 0x14304a;
|
// main thread; an unsolvable-within-budget hint just does nothing.
|
||||||
const GRIDLN = 0x21466a;
|
const HINT_MAX_STATES = 120000;
|
||||||
const TARGET_COLOR = 0xe03131;
|
|
||||||
const CAR_COLORS = [
|
|
||||||
0x4a90d9, 0x2ecc71, 0xf1c40f, 0x9b59b6, 0xe67e22, 0x1abc9c,
|
|
||||||
0x3498db, 0xe84393, 0x16a085, 0xd35400, 0x27ae60, 0x8e44ad,
|
|
||||||
0x7f8c8d, 0x2980b9, 0xf39c12, 0x6ab04c,
|
|
||||||
];
|
|
||||||
|
|
||||||
const D = { felt: -2, frame: -1, grid: 0, exit: 1, vehicle: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
|
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) {
|
init(data) {
|
||||||
this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' };
|
this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' };
|
||||||
this.bank = [];
|
this.bank = [];
|
||||||
|
this.tiers = [];
|
||||||
this.levelsCompleted = 0; // highest contiguous level cleared
|
this.levelsCompleted = 0; // highest contiguous level cleared
|
||||||
this.canPersist = true;
|
this.canPersist = true;
|
||||||
this.view = 'select';
|
this.view = 'select';
|
||||||
|
|
@ -54,10 +52,20 @@ export default class RushHourGame extends Phaser.Scene {
|
||||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||||
} catch (_) { /* optional */ }
|
} 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');
|
// Fetched on entry rather than preloaded at boot — the bank is dead weight
|
||||||
this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level);
|
// 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 {
|
try {
|
||||||
const res = await api.get('/puzzles/rushhour/progress');
|
const res = await api.get('/puzzles/rushhour/progress');
|
||||||
|
|
@ -74,6 +82,8 @@ export default class RushHourGame extends Phaser.Scene {
|
||||||
|
|
||||||
clearLayer() {
|
clearLayer() {
|
||||||
this.sprites.clear();
|
this.sprites.clear();
|
||||||
|
this.rowsTween?.stop();
|
||||||
|
this.rowsTween = null;
|
||||||
this.layer.removeAll(true);
|
this.layer.removeAll(true);
|
||||||
// These were just destroyed; drop the stale references so updateMoves()
|
// These were just destroyed; drop the stale references so updateMoves()
|
||||||
// doesn't poke a dead Button (whose scene is now undefined) before drawHud
|
// 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.overlayUp = false;
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.clearLayer();
|
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 cx = GAME_WIDTH / 2;
|
||||||
|
|
||||||
const title = this.add.text(cx, 84, 'RUSH HOUR', {
|
// The artwork carries the wordmark, so no heading is drawn over it. A
|
||||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
// scrim keeps the level grid legible against a busy illustration.
|
||||||
}).setOrigin(0.5);
|
const hasArt = this.textures.exists('bg-rushhour-menu');
|
||||||
const sub = this.add.text(cx, 138, 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
|
if (hasArt) {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-rushhour-menu')
|
||||||
}).setOrigin(0.5);
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
|
||||||
this.layer.add([title, sub]);
|
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) {
|
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',
|
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
this.layer.add(msg);
|
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 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,
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
this.layer.add(prog);
|
this.layer.add(prog);
|
||||||
|
|
||||||
const COLS = 12;
|
// One row per tier, headed by the tier name in its own theme colour, so the
|
||||||
const SIZE = 104;
|
// themes still to come are visible from the start.
|
||||||
const GAP = 14;
|
const SIZE = 88;
|
||||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
const GAP = 12;
|
||||||
const left = cx - gridW / 2 + SIZE / 2;
|
const ROW_STRIDE = 128;
|
||||||
const top = 268;
|
const top = hasArt ? 330 : 262;
|
||||||
|
|
||||||
this.bank.forEach((p, i) => {
|
const tierList = this.tiers.length
|
||||||
const col = i % COLS;
|
? this.tiers
|
||||||
const row = Math.floor(i / COLS);
|
: [{ id: 'downtown', name: 'Levels', from: 1, to: this.bank.length }];
|
||||||
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 fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
|
// The rows start invisible so the background art gets a beat on its own
|
||||||
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
|
// before the level grid fades in on top of it.
|
||||||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
const rows = this.add.container(0, 0).setAlpha(0);
|
||||||
const num = this.add.text(x, y - 8, String(level), {
|
this.layer.add(rows);
|
||||||
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]);
|
|
||||||
|
|
||||||
if (playable) {
|
tierList.forEach((tier, ti) => {
|
||||||
tile.setInteractive({ useHandCursor: true });
|
const theme = themeFor(tier.theme ?? tier.id);
|
||||||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
const levels = this.bank.filter((p) => p.level >= tier.from && p.level <= tier.to);
|
||||||
tile.on('pointerout', () => tile.setStrokeStyle(cleared ? 3 : 3, stroke, 1));
|
if (!levels.length) return;
|
||||||
tile.on('pointerup', () => this.playLevel(level));
|
|
||||||
}
|
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),
|
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.level = level;
|
||||||
this.startVehicles = cloneVehicles(puzzle.vehicles);
|
this.startVehicles = cloneVehicles(puzzle.vehicles);
|
||||||
this.vehicles = 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.undoStack = [];
|
||||||
this.moves = 0;
|
this.moves = 0;
|
||||||
this.overlayUp = false;
|
this.overlayUp = false;
|
||||||
|
|
@ -219,27 +278,22 @@ export default class RushHourGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
drawBoardChrome() {
|
drawBoardChrome() {
|
||||||
|
const board = this.theme.board;
|
||||||
const g = this.add.graphics().setDepth(D.frame);
|
const g = this.add.graphics().setDepth(D.frame);
|
||||||
// Outer 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);
|
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);
|
this.layer.add(g);
|
||||||
|
|
||||||
|
// Playfield surface + per-theme road markings.
|
||||||
const grid = this.add.graphics().setDepth(D.grid);
|
const grid = this.add.graphics().setDepth(D.grid);
|
||||||
grid.lineStyle(2, GRIDLN, 0.9);
|
drawBoardSurface(grid, this.theme, BOARD_LEFT, BOARD_TOP, BOARD_PX, GRID);
|
||||||
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);
|
|
||||||
}
|
|
||||||
this.layer.add(grid);
|
this.layer.add(grid);
|
||||||
|
|
||||||
// Exit gap on the right wall at EXIT_ROW
|
// Exit gap on the right wall at EXIT_ROW
|
||||||
const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL;
|
const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL;
|
||||||
const ex = this.add.graphics().setDepth(D.exit);
|
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.fillRect(BOARD_LEFT + BOARD_PX - 1, BOARD_TOP + EXIT_ROW * CELL + 6, 46, CELL - 12);
|
||||||
ex.fillStyle(COLORS.gold, 0.9);
|
ex.fillStyle(COLORS.gold, 0.9);
|
||||||
ex.fillTriangle(
|
ex.fillTriangle(
|
||||||
|
|
@ -255,10 +309,14 @@ export default class RushHourGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
drawHud() {
|
drawHud() {
|
||||||
const title = this.add.text(BOARD_LEFT - 22, 92, `Level ${this.level}`, {
|
const title = this.add.text(BOARD_LEFT - 22, 84, `Level ${this.level}`, {
|
||||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
|
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
|
||||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
}).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, '', {
|
this.movesText = this.add.text(BOARD_LEFT + BOARD_PX + 22, 92, '', {
|
||||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
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.forEach((c) => c.destroy());
|
||||||
this.sprites.clear();
|
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) {
|
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 { x, y } = this.centerFor(v);
|
||||||
const c = this.add.container(x, y).setDepth(D.vehicle);
|
// 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
|
||||||
const body = this.add.graphics();
|
// the old Container version needed a comment to explain.
|
||||||
body.fillStyle(color, 1);
|
const c = this.add.image(x, y, keys.get(v.id)).setDepth(D.vehicle);
|
||||||
body.fillRoundedRect(-w / 2, -h / 2, w, h, 16);
|
c.setInteractive({ useHandCursor: true });
|
||||||
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);
|
|
||||||
c.input.cursor = 'grab';
|
c.input.cursor = 'grab';
|
||||||
this.input.setDraggable(c);
|
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) {
|
attachDrag(c, _v) {
|
||||||
c.on('dragstart', () => {
|
c.on('dragstart', () => {
|
||||||
if (this.busy || this.overlayUp) { c._noDrag = true; return; }
|
if (this.busy || this.overlayUp) { c._noDrag = true; return; }
|
||||||
|
|
@ -438,7 +481,7 @@ export default class RushHourGame extends Phaser.Scene {
|
||||||
|
|
||||||
showHint() {
|
showHint() {
|
||||||
if (this.busy || this.overlayUp) return;
|
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;
|
if (!path || !path.length) return;
|
||||||
const mv = path[0];
|
const mv = path[0];
|
||||||
const c = this.sprites.get(mv.id);
|
const c = this.sprites.get(mv.id);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rush Hour — pure board model + BFS solver. No Phaser, no DOM.
|
// Rush Hour — pure board model + solver. No Phaser, no DOM.
|
||||||
// Shared by the client scene and the offline puzzle generator (both ESM).
|
// 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).
|
// 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
|
// 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)
|
// 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
|
// 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 GRID = 6;
|
||||||
export const EXIT_ROW = 2;
|
export const EXIT_ROW = 2;
|
||||||
export const TARGET_ID = 'X';
|
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) {
|
export function vehicleCells(v) {
|
||||||
const cells = [];
|
const cells = [];
|
||||||
for (let i = 0; i < v.len; i++) {
|
for (let i = 0; i < v.len; i++) {
|
||||||
|
|
@ -89,47 +103,246 @@ export function cloneVehicles(vehicles) {
|
||||||
return vehicles.map((v) => ({ ...v }));
|
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 }.
|
// Breadth-first shortest solution. Returns { moves, path }.
|
||||||
// moves: minimum number of slides to solve (0 if already solved, -1 if none)
|
// 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)
|
// 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
|
// `maxStates` bounds the search so a single solve stays cheap; exceeding it
|
||||||
// large reachable space. `maxStates` bounds the search so a single solve stays
|
// returns moves:-1, which is indistinguishable from "unsolvable" by design —
|
||||||
// cheap (used heavily by the generator); exceeding it returns moves:-1.
|
// callers treat both as "reject this board".
|
||||||
export function solve(vehicles, { maxStates = 600000 } = {}) {
|
export function solve(vehicles, { maxStates = 600000 } = {}) {
|
||||||
const start = cloneVehicles(vehicles);
|
const B = packBoard(vehicles);
|
||||||
const startKey = stateKey(start);
|
if (B.isGoal(B.start)) return { moves: 0, path: [] };
|
||||||
if (isSolved(start)) return { moves: 0, path: [] };
|
if (!B.targetEscapes) return { moves: -1, path: null };
|
||||||
|
|
||||||
const meta = new Map([[startKey, null]]); // key -> { parentKey, move }
|
const states = [B.start];
|
||||||
const stateByKey = new Map([[startKey, start]]);
|
const keys = [B.key(B.start)];
|
||||||
let frontier = [startKey];
|
const parent = [-1];
|
||||||
let depth = 0;
|
const mvVeh = [-1];
|
||||||
|
const mvPos = [-1];
|
||||||
|
const depth = [0];
|
||||||
|
const seen = new Map([[keys[0], 0]]);
|
||||||
|
const occ = new Uint8Array(CELLS);
|
||||||
|
|
||||||
while (frontier.length) {
|
const build = (endIdx) => {
|
||||||
depth++;
|
const path = [];
|
||||||
const next = [];
|
for (let at = endIdx; at > 0; at = parent[at]) {
|
||||||
for (const key of frontier) {
|
const i = mvVeh[at];
|
||||||
const state = stateByKey.get(key);
|
const p = mvPos[at];
|
||||||
for (const mv of legalMoves(state)) {
|
path.push({
|
||||||
const ns = cloneVehicles(state);
|
id: B.ids[i],
|
||||||
ns[mv.idx].x = mv.x;
|
x: B.horiz[i] ? p : B.fixed[i],
|
||||||
ns[mv.idx].y = mv.y;
|
y: B.horiz[i] ? B.fixed[i] : p,
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
frontier = next;
|
path.reverse();
|
||||||
if (depth > 100) break;
|
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 };
|
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('advancewars-artwork', 'data/advancewars-artwork.json');
|
||||||
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
|
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
|
||||||
this.load.json('totalannihilation-artwork', 'data/totalannihilation-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('bloxorz', 'data/bloxorz.json');
|
||||||
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
|
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
|
||||||
this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json');
|
this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json');
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,101 @@
|
||||||
// Offline generator for Rush Hour puzzles.
|
// Offline curriculum generator for Rush Hour.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Usage:
|
// 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
GRID, EXIT_ROW, TARGET_ID, isSolved, solve, vehicleCells,
|
GRID, EXIT_ROW, TARGET_ID, analyzeCluster, solve,
|
||||||
} from '../src/games/rushhour/RushHourLogic.js';
|
} from '../src/games/rushhour/RushHourLogic.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const OUT_FILE = process.argv[3]
|
const OUT_FILE = process.argv[3]
|
||||||
? path.resolve(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;
|
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) ──────────────────────────────────────────────────
|
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||||
function makeRng(seed) {
|
function makeRng(seed) {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
|
|
@ -36,22 +108,30 @@ function makeRng(seed) {
|
||||||
}
|
}
|
||||||
const rng = makeRng(SEED);
|
const rng = makeRng(SEED);
|
||||||
const randInt = (n) => Math.floor(rng() * n);
|
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.
|
// ── Board helpers ────────────────────────────────────────────────────────────
|
||||||
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)
|
|
||||||
|
|
||||||
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.
|
// Canonical, label-independent key for dedup.
|
||||||
function canonKey(vehicles) {
|
function canonKey(vehicles) {
|
||||||
|
|
@ -61,117 +141,359 @@ function canonKey(vehicles) {
|
||||||
.join('|');
|
.join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fits(grid, v) {
|
// Criteria 3 and 4. Orientation and fixed axis never change as pieces slide, so
|
||||||
for (const [x, y] of vehicleCells(v)) {
|
// the exit-row rule is invariant once placed; the frozen-line rule is not, and
|
||||||
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
|
// must be checked on the final arrangement.
|
||||||
if (grid[y][x] !== null) return false;
|
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;
|
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
|
function randomBlocker(id) {
|
||||||
// to place the target.
|
const orient = rng() < 0.55 ? 'v' : 'h';
|
||||||
function randomLayout() {
|
const len = rng() < 0.35 ? 3 : 2;
|
||||||
const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null));
|
const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID);
|
||||||
const vehicles = [];
|
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
|
function randomLayout(nBlockers) {
|
||||||
// long and likely blocked -> richer puzzles.
|
const vehicles = [{ id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }];
|
||||||
const target = { id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true };
|
const occ = occupancy(vehicles);
|
||||||
stamp(grid, target);
|
|
||||||
vehicles.push(target);
|
|
||||||
|
|
||||||
const count = 6 + randInt(5); // 6..10 blockers
|
|
||||||
let letterIdx = 0;
|
let letterIdx = 0;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
while (vehicles.length < count + 1 && tries < 200) {
|
while (vehicles.length < nBlockers + 1 && tries < 400) {
|
||||||
tries++;
|
tries++;
|
||||||
// Favour vertical cars: they cross the exit row and create real blockades.
|
const v = randomBlocker(LETTERS[letterIdx]);
|
||||||
const orient = rng() < 0.6 ? 'v' : 'h';
|
if (v.orient === 'h' && v.y === EXIT_ROW) continue; // would block the exit forever
|
||||||
const len = rng() < 0.3 ? 3 : 2;
|
if (!fits(occ, v)) continue;
|
||||||
const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID);
|
for (const [x, y] of cellsOf(v)) occ[y][x] = v.id;
|
||||||
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);
|
|
||||||
vehicles.push(v);
|
vehicles.push(v);
|
||||||
letterIdx++;
|
letterIdx++;
|
||||||
}
|
}
|
||||||
return vehicles;
|
return vehicles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate pool ────────────────────────────────────────────────────────────
|
// ── The core: strip to minimal, harden to the cluster's farthest state ───────
|
||||||
console.log(`[rushhour] generating with seed ${SEED}…`);
|
//
|
||||||
const target = TIERS.reduce((t, x) => t + x.count, 0);
|
// 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 buckets = TIERS.map(() => []);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
const elites = [];
|
||||||
|
let refined = 0;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let solved = 0;
|
|
||||||
|
|
||||||
function tierIndexFor(moves) {
|
function tierFor(par) {
|
||||||
for (let i = 0; i < TIERS.length; i++) {
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
const tierFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
while (attempts < MAX_ATTEMPTS && !tierFull()) {
|
function consider(result) {
|
||||||
attempts++;
|
if (!result) return null;
|
||||||
if ((attempts & 0x3ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) {
|
const { vehicles, par } = result;
|
||||||
console.log('\n[rushhour] time budget reached, stopping early');
|
if (!structurallyOk(vehicles)) return null;
|
||||||
break;
|
|
||||||
}
|
|
||||||
const vehicles = randomLayout();
|
|
||||||
if (isSolved(vehicles)) continue; // already at exit -> trivial
|
|
||||||
const key = canonKey(vehicles);
|
const key = canonKey(vehicles);
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) return null;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
refined++;
|
||||||
|
|
||||||
const { moves } = solve(vehicles, { maxStates: SOLVE_MAX_STATES });
|
// Elite pool drives phase B regardless of whether this board lands in a tier.
|
||||||
if (moves < 2) continue; // unsolvable or 1-move (no blockers)
|
elites.push({ vehicles, par });
|
||||||
solved++;
|
elites.sort((a, b) => b.par - a.par);
|
||||||
const ti = tierIndexFor(moves);
|
if (elites.length > ELITE_POOL) elites.length = ELITE_POOL;
|
||||||
if (ti === -1) continue;
|
|
||||||
if (buckets[ti].length >= TIERS[ti].count) continue;
|
|
||||||
buckets[ti].push({ minMoves: moves, vehicles });
|
|
||||||
|
|
||||||
if (solved % 2000 === 0) {
|
const ti = tierFor(par);
|
||||||
process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `);
|
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');
|
process.stdout.write('\n');
|
||||||
|
|
||||||
// ── Assemble ordered levels ──────────────────────────────────────────────────
|
// ── Assemble ─────────────────────────────────────────────────────────────────
|
||||||
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);
|
|
||||||
|
|
||||||
const puzzles = chosen.map((p, i) => ({
|
const shortfall = [];
|
||||||
level: i + 1,
|
const levels = [];
|
||||||
minMoves: p.minMoves,
|
const tiersOut = [];
|
||||||
vehicles: p.vehicles,
|
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 = {
|
const payload = {
|
||||||
generatedAt: new Date().toISOString(),
|
version: 1,
|
||||||
seed: SEED,
|
seed: SEED,
|
||||||
count: puzzles.length,
|
generatedAt: new Date().toISOString(),
|
||||||
puzzles,
|
count: levels.length,
|
||||||
|
tiers: tiersOut,
|
||||||
|
levels,
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
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} climbs=${climbs} distinct minimal boards=${refined}`);
|
||||||
console.log(`[rushhour] attempts=${attempts} solvable=${solved}`);
|
TIERS.forEach((t, i) => {
|
||||||
console.log(`[rushhour] tiers filled: ${perTier}`);
|
const pool = buckets[i];
|
||||||
console.log(`[rushhour] wrote ${puzzles.length} levels (minMoves ${puzzles[0]?.minMoves}..${puzzles[puzzles.length - 1]?.minMoves}) -> ${OUT_FILE}`);
|
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