diff --git a/README.md b/README.md index 679def1..6d035b8 100644 --- a/README.md +++ b/README.md @@ -278,9 +278,11 @@ from the repo root (Node.js 20+): Runtime JSON fetched by the games: -- **Per-game banks & artwork maps** — e.g. `rushhour.json`, `zuma.json`, - `katamino.json`, `dotlink.json`, `jumble.json`, `spireclimb-artwork.json`, - `slots-artwork.json`, `shift-artwork.json`. +- **Per-game banks & artwork maps** — e.g. `katamino.json`, `dotlink.json`, + `jumble.json`, `spireclimb-artwork.json`, `slots-artwork.json`, + `shift-artwork.json`. Newer games keep their level banks under + `assets/gamedata//` and fetch them on entry instead (Rush Hour, Zuma, + Peggle, Goo Tower). - **Shared UI options** — `playfields.json`, `colored-playfields.json`, `card-backs.json`, `opponents.json`, `music.json`. - **Word data** — `wordlists/enable1.txt` (main dictionary), diff --git a/data/rushhour.json b/assets/gamedata/rushhour/levels.json similarity index 61% rename from data/rushhour.json rename to assets/gamedata/rushhour/levels.json index d10c612..abff1d2 100644 --- a/data/rushhour.json +++ b/assets/gamedata/rushhour/levels.json @@ -1,15 +1,60 @@ { - "generatedAt": "2026-06-08T23:20:08.270Z", + "version": 1, "seed": 2654435769, - "count": 45, - "puzzles": [ + "generatedAt": "2026-07-29T03:51:45.847Z", + "count": 60, + "tiers": [ + { + "id": "downtown", + "name": "Downtown", + "theme": "downtown", + "from": 1, + "to": 12 + }, + { + "id": "freightyard", + "name": "Freight Yard", + "theme": "freightyard", + "from": 13, + "to": 24 + }, + { + "id": "airport", + "name": "Airport Apron", + "theme": "airport", + "from": 25, + "to": 36 + }, + { + "id": "construction", + "name": "Construction", + "theme": "construction", + "from": 37, + "to": 48 + }, + { + "id": "nightcity", + "name": "Night City", + "theme": "nightcity", + "from": 49, + "to": 60 + } + ], + "levels": [ { "level": 1, - "minMoves": 2, + "name": "First Gear", + "tier": "downtown", + "par": 5, + "carsMoved": 5, + "decoyDensity": 0.628, + "targetRetreats": 0, + "firstMoveFanout": 12, + "difficulty": 10.48, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -17,8 +62,8 @@ }, { "id": "A", - "x": 5, - "y": 3, + "x": 2, + "y": 1, "len": 2, "orient": "v", "isTarget": false @@ -26,76 +71,43 @@ { "id": "B", "x": 4, - "y": 4, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "C", - "x": 4, - "y": 0, + "x": 5, + "y": 1, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "D", - "x": 0, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 1, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", "x": 3, - "y": 1, + "y": 2, "len": 2, "orient": "v", "isTarget": false - }, - { - "id": "G", - "x": 4, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "H", - "x": 1, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "I", - "x": 1, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false } ] }, { "level": 2, - "minMoves": 2, + "name": "Fender Bender", + "tier": "downtown", + "par": 5, + "carsMoved": 5, + "decoyDensity": 0.684, + "targetRetreats": 0, + "firstMoveFanout": 9, + "difficulty": 12.24, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -103,56 +115,40 @@ }, { "id": "A", + "x": 3, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "C", "x": 4, "y": 2, "len": 2, "orient": "v", "isTarget": false }, - { - "id": "B", - "x": 4, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "C", - "x": 1, - "y": 1, - "len": 3, - "orient": "h", - "isTarget": false - }, { "id": "D", - "x": 0, - "y": 1, + "x": 5, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { "id": "E", - "x": 1, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", - "x": 4, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "G", "x": 2, - "y": 0, + "y": 4, "len": 2, "orient": "h", "isTarget": false @@ -161,7 +157,14 @@ }, { "level": 3, - "minMoves": 2, + "name": "One Way Out", + "tier": "downtown", + "par": 5, + "carsMoved": 5, + "decoyDensity": 0.746, + "targetRetreats": 0, + "firstMoveFanout": 9, + "difficulty": 12.86, "vehicles": [ { "id": "X", @@ -176,23 +179,23 @@ "x": 2, "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 4, - "y": 5, - "len": 2, + "x": 2, + "y": 1, + "len": 3, "orient": "h", "isTarget": false }, { "id": "C", - "x": 0, - "y": 5, + "x": 3, + "y": 2, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { @@ -205,7 +208,7 @@ }, { "id": "E", - "x": 2, + "x": 0, "y": 3, "len": 2, "orient": "v", @@ -213,37 +216,28 @@ }, { "id": "F", - "x": 0, - "y": 1, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 4, - "y": 0, - "len": 2, + "x": 1, + "y": 4, + "len": 3, "orient": "h", "isTarget": false - }, - { - "id": "H", - "x": 5, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false } ] }, { "level": 4, - "minMoves": 2, + "name": "Meter Maid", + "tier": "downtown", + "par": 5, + "carsMoved": 5, + "decoyDensity": 0.719, + "targetRetreats": 0, + "firstMoveFanout": 6, + "difficulty": 13.79, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -251,9 +245,9 @@ }, { "id": "A", - "x": 1, + "x": 3, "y": 0, - "len": 3, + "len": 2, "orient": "h", "isTarget": false }, @@ -267,15 +261,15 @@ }, { "id": "C", - "x": 2, - "y": 5, - "len": 2, - "orient": "h", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", "isTarget": false }, { "id": "D", - "x": 4, + "x": 3, "y": 3, "len": 2, "orient": "v", @@ -283,25 +277,24 @@ }, { "id": "E", - "x": 1, - "y": 1, - "len": 2, + "x": 3, + "y": 5, + "len": 3, "orient": "h", "isTarget": false - }, - { - "id": "F", - "x": 0, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false } ] }, { "level": 5, - "minMoves": 3, + "name": "Corner Store", + "tier": "downtown", + "par": 6, + "carsMoved": 6, + "decoyDensity": 0.714, + "targetRetreats": 0, + "firstMoveFanout": 8, + "difficulty": 14.74, "vehicles": [ { "id": "X", @@ -313,61 +306,68 @@ }, { "id": "A", - "x": 0, - "y": 1, + "x": 4, + "y": 0, "len": 2, "orient": "h", "isTarget": false }, { "id": "B", - "x": 2, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "C", "x": 5, "y": 1, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "C", + "x": 2, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, { "id": "D", - "x": 1, - "y": 3, + "x": 3, + "y": 2, "len": 3, "orient": "v", "isTarget": false }, { "id": "E", - "x": 5, - "y": 3, + "x": 4, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 2, - "y": 0, - "len": 2, - "orient": "h", + "x": 5, + "y": 3, + "len": 3, + "orient": "v", "isTarget": false } ] }, { "level": 6, - "minMoves": 3, + "name": "Crosswalk", + "tier": "downtown", + "par": 7, + "carsMoved": 7, + "decoyDensity": 0.691, + "targetRetreats": 0, + "firstMoveFanout": 10, + "difficulty": 15.51, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -375,89 +375,72 @@ }, { "id": "A", - "x": 1, + "x": 3, "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 4, - "y": 3, - "len": 3, + "x": 3, + "y": 1, + "len": 2, "orient": "v", "isTarget": false }, { "id": "C", - "x": 2, - "y": 0, - "len": 2, + "x": 4, + "y": 1, + "len": 3, "orient": "v", "isTarget": false }, { "id": "D", - "x": 1, - "y": 5, - "len": 2, - "orient": "h", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", "isTarget": false }, { "id": "E", "x": 1, "y": 3, - "len": 2, - "orient": "v", + "len": 3, + "orient": "h", "isTarget": false }, { "id": "F", - "x": 3, - "y": 1, + "x": 2, + "y": 4, "len": 3, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "G", - "x": 5, - "y": 1, + "x": 3, + "y": 5, "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "H", - "x": 4, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 2, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "J", - "x": 5, - "y": 3, - "len": 2, - "orient": "v", + "orient": "h", "isTarget": false } ] }, { "level": 7, - "minMoves": 4, + "name": "Double Park", + "tier": "downtown", + "par": 7, + "carsMoved": 7, + "decoyDensity": 0.792, + "targetRetreats": 0, + "firstMoveFanout": 8, + "difficulty": 17.32, "vehicles": [ { "id": "X", @@ -470,142 +453,63 @@ { "id": "A", "x": 2, - "y": 5, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "B", - "x": 1, - "y": 3, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { - "id": "C", - "x": 3, + "id": "B", + "x": 4, "y": 1, "len": 3, - "orient": "h", + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 1, + "len": 2, + "orient": "v", "isTarget": false }, { "id": "D", - "x": 0, - "y": 1, - "len": 3, - "orient": "h", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", "isTarget": false }, { "id": "E", - "x": 2, - "y": 3, + "x": 1, + "y": 4, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 1, - "y": 0, - "len": 2, + "x": 2, + "y": 5, + "len": 3, "orient": "h", "isTarget": false - }, - { - "id": "G", - "x": 4, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "H", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false } ] }, { "level": 8, - "minMoves": 4, - "vehicles": [ - { - "id": "X", - "x": 1, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "B", - "x": 3, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 0, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 1, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 4, - "y": 3, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", - "x": 3, - "y": 5, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "G", - "x": 2, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - } - ] - }, - { - "level": 9, - "minMoves": 5, + "name": "Side Street", + "tier": "downtown", + "par": 7, + "carsMoved": 7, + "decoyDensity": 0.797, + "targetRetreats": 0, + "firstMoveFanout": 5, + "difficulty": 18.57, "vehicles": [ { "id": "X", @@ -617,38 +521,30 @@ }, { "id": "A", - "x": 3, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "B", - "x": 0, - "y": 3, + "x": 2, + "y": 0, "len": 2, "orient": "h", "isTarget": false }, { - "id": "C", + "id": "B", "x": 2, - "y": 2, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "C", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, { "id": "D", - "x": 0, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", "x": 5, "y": 1, "len": 2, @@ -656,112 +552,49 @@ "isTarget": false }, { - "id": "F", - "x": 4, - "y": 1, + "id": "E", + "x": 3, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { - "id": "G", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - } - ] - }, - { - "level": 10, - "minMoves": 5, - "vehicles": [ - { - "id": "X", - "x": 1, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 3, + "id": "F", + "x": 0, "y": 3, "len": 2, "orient": "v", "isTarget": false }, - { - "id": "B", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "C", - "x": 1, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "D", - "x": 2, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 5, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "F", - "x": 2, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, { "id": "G", - "x": 0, - "y": 0, + "x": 4, + "y": 3, "len": 2, "orient": "h", "isTarget": false }, { "id": "H", - "x": 3, - "y": 0, - "len": 2, + "x": 2, + "y": 4, + "len": 3, "orient": "h", "isTarget": false - }, - { - "id": "I", - "x": 0, - "y": 1, - "len": 2, - "orient": "v", - "isTarget": false } ] }, { - "level": 11, - "minMoves": 5, + "level": 9, + "name": "Red Light", + "tier": "downtown", + "par": 8, + "carsMoved": 7, + "decoyDensity": 0.693, + "targetRetreats": 0, + "firstMoveFanout": 2, + "difficulty": 19.73, "vehicles": [ { "id": "X", @@ -773,38 +606,91 @@ }, { "id": "A", + "x": 1, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", "x": 2, - "y": 5, + "y": 1, "len": 2, "orient": "h", "isTarget": false }, - { - "id": "B", - "x": 5, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, { "id": "C", - "x": 3, - "y": 0, - "len": 3, + "x": 4, + "y": 1, + "len": 2, "orient": "h", "isTarget": false }, { "id": "D", "x": 2, - "y": 0, - "len": 3, + "y": 2, + "len": 2, "orient": "v", "isTarget": false }, { "id": "E", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 2, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "G", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + } + ] + }, + { + "level": 10, + "name": "Delivery Van", + "tier": "downtown", + "par": 8, + "carsMoved": 8, + "decoyDensity": 0.815, + "targetRetreats": 0, + "firstMoveFanout": 5, + "difficulty": 20.55, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 3, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", "x": 4, "y": 1, "len": 2, @@ -812,26 +698,142 @@ "isTarget": false }, { - "id": "F", + "id": "C", + "x": 2, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "E", "x": 0, "y": 3, "len": 3, "orient": "v", "isTarget": false }, + { + "id": "F", + "x": 3, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, { "id": "G", - "x": 0, + "x": 4, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 11, + "name": "Taxi Stand", + "tier": "downtown", + "par": 9, + "carsMoved": 8, + "decoyDensity": 0.7, + "targetRetreats": 1, + "firstMoveFanout": 8, + "difficulty": 21.7, + "vehicles": [ + { + "id": "X", + "x": 2, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, "y": 1, "len": 2, "orient": "h", "isTarget": false + }, + { + "id": "B", + "x": 4, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 1, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 12, - "minMoves": 5, + "name": "Grid Lock", + "tier": "downtown", + "par": 9, + "carsMoved": 8, + "decoyDensity": 0.726, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 24.36, "vehicles": [ { "id": "X", @@ -843,122 +845,28 @@ }, { "id": "A", - "x": 4, - "y": 5, + "x": 2, + "y": 0, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "B", - "x": 4, - "y": 1, - "len": 2, - "orient": "v", + "x": 3, + "y": 0, + "len": 3, + "orient": "h", "isTarget": false }, { "id": "C", - "x": 0, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 2, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 0, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", - "x": 2, + "x": 3, "y": 1, "len": 2, "orient": "h", "isTarget": false }, - { - "id": "G", - "x": 4, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "H", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 0, - "y": 3, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "J", - "x": 1, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - } - ] - }, - { - "level": 13, - "minMoves": 5, - "vehicles": [ - { - "id": "X", - "x": 0, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 1, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "B", - "x": 0, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 2, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, { "id": "D", "x": 3, @@ -970,21 +878,114 @@ { "id": "E", "x": 4, - "y": 1, - "len": 2, + "y": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "F", - "x": 5, - "y": 1, + "x": 3, + "y": 4, "len": 2, "orient": "v", "isTarget": false }, { "id": "G", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 13, + "name": "Loading Dock", + "tier": "freightyard", + "par": 10, + "carsMoved": 9, + "decoyDensity": 0.549, + "targetRetreats": 0, + "firstMoveFanout": 6, + "difficulty": 20.29, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 1, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 4, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 2, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 4, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 4, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 3, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", "x": 5, "y": 4, "len": 2, @@ -995,7 +996,14 @@ }, { "level": 14, - "minMoves": 6, + "name": "Long Haul", + "tier": "freightyard", + "par": 10, + "carsMoved": 9, + "decoyDensity": 0.794, + "targetRetreats": 0, + "firstMoveFanout": 6, + "difficulty": 22.74, "vehicles": [ { "id": "X", @@ -1007,56 +1015,64 @@ }, { "id": "A", - "x": 0, - "y": 3, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "B", "x": 4, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 4, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 5, "y": 0, "len": 3, "orient": "v", "isTarget": false }, { - "id": "F", - "x": 0, + "id": "B", + "x": 2, "y": 1, "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 3, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 2, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 3, + "y": 3, + "len": 2, "orient": "h", "isTarget": false }, { "id": "G", - "x": 4, - "y": 4, + "x": 1, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 3, + "y": 5, "len": 2, "orient": "h", "isTarget": false @@ -1065,11 +1081,18 @@ }, { "level": 15, - "minMoves": 6, + "name": "Container Row", + "tier": "freightyard", + "par": 10, + "carsMoved": 8, + "decoyDensity": 0.749, + "targetRetreats": 1, + "firstMoveFanout": 8, + "difficulty": 23.19, "vehicles": [ { "id": "X", - "x": 0, + "x": 1, "y": 2, "len": 2, "orient": "h", @@ -1077,73 +1100,80 @@ }, { "id": "A", - "x": 3, - "y": 1, + "x": 1, + "y": 0, "len": 3, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", "x": 4, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 1, "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "D", - "x": 4, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", - "x": 2, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "G", - "x": 0, - "y": 3, "len": 3, "orient": "v", "isTarget": false }, { - "id": "H", - "x": 2, + "id": "C", + "x": 3, "y": 1, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "D", + "x": 5, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 2, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false } ] }, { "level": 16, - "minMoves": 7, + "name": "Weigh Station", + "tier": "freightyard", + "par": 10, + "carsMoved": 8, + "decoyDensity": 0.77, + "targetRetreats": 1, + "firstMoveFanout": 6, + "difficulty": 24.2, "vehicles": [ { "id": "X", @@ -1155,33 +1185,33 @@ }, { "id": "A", - "x": 3, - "y": 5, + "x": 1, + "y": 0, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "B", - "x": 1, - "y": 1, + "x": 2, + "y": 0, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "C", - "x": 1, - "y": 4, + "x": 3, + "y": 0, "len": 3, "orient": "h", "isTarget": false }, { "id": "D", - "x": 3, + "x": 4, "y": 1, - "len": 3, + "len": 2, "orient": "v", "isTarget": false }, @@ -1189,28 +1219,20 @@ "id": "E", "x": 0, "y": 2, - "len": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "F", - "x": 0, - "y": 4, + "x": 3, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { "id": "G", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "H", "x": 4, "y": 3, "len": 2, @@ -1218,18 +1240,25 @@ "isTarget": false }, { - "id": "I", - "x": 4, - "y": 0, + "id": "H", + "x": 0, + "y": 5, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false } ] }, { "level": 17, - "minMoves": 7, + "name": "Flatbed", + "tier": "freightyard", + "par": 10, + "carsMoved": 9, + "decoyDensity": 0.825, + "targetRetreats": 0, + "firstMoveFanout": 2, + "difficulty": 24.65, "vehicles": [ { "id": "X", @@ -1241,23 +1270,23 @@ }, { "id": "A", - "x": 4, - "y": 2, + "x": 0, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "B", - "x": 3, - "y": 5, - "len": 2, + "x": 1, + "y": 0, + "len": 3, "orient": "h", "isTarget": false }, { "id": "C", - "x": 2, + "x": 4, "y": 1, "len": 3, "orient": "v", @@ -1265,32 +1294,40 @@ }, { "id": "D", - "x": 3, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 3, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "F", "x": 1, "y": 3, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "E", + "x": 2, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 3, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, { "id": "G", "x": 4, - "y": 0, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 4, + "y": 5, "len": 2, "orient": "h", "isTarget": false @@ -1299,11 +1336,18 @@ }, { "level": 18, - "minMoves": 7, + "name": "Coupling", + "tier": "freightyard", + "par": 11, + "carsMoved": 8, + "decoyDensity": 0.793, + "targetRetreats": 0, + "firstMoveFanout": 4, + "difficulty": 23.73, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -1312,33 +1356,33 @@ { "id": "A", "x": 1, - "y": 3, - "len": 3, - "orient": "h", + "y": 0, + "len": 2, + "orient": "v", "isTarget": false }, { "id": "B", - "x": 3, + "x": 2, "y": 0, - "len": 3, + "len": 2, "orient": "h", "isTarget": false }, { "id": "C", "x": 4, - "y": 2, + "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "D", - "x": 0, - "y": 5, + "x": 2, + "y": 2, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { @@ -1352,15 +1396,15 @@ { "id": "F", "x": 3, - "y": 4, + "y": 3, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "G", "x": 5, - "y": 1, + "y": 3, "len": 2, "orient": "v", "isTarget": false @@ -1368,36 +1412,27 @@ { "id": "H", "x": 1, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 2, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "J", - "x": 5, "y": 4, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false } ] }, { "level": 19, - "minMoves": 8, + "name": "Yard Shunt", + "tier": "freightyard", + "par": 11, + "carsMoved": 8, + "decoyDensity": 0.777, + "targetRetreats": 1, + "firstMoveFanout": 5, + "difficulty": 25.67, "vehicles": [ { "id": "X", - "x": 0, + "x": 1, "y": 2, "len": 2, "orient": "h", @@ -1405,64 +1440,64 @@ }, { "id": "A", - "x": 2, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "B", - "x": 4, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 1, - "y": 4, + "x": 0, + "y": 0, "len": 2, "orient": "h", "isTarget": false }, { - "id": "D", - "x": 5, - "y": 2, + "id": "B", + "x": 2, + "y": 0, "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 2, "orient": "v", "isTarget": false }, { "id": "E", "x": 0, - "y": 3, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 1, - "y": 0, - "len": 2, - "orient": "h", + "x": 3, + "y": 2, + "len": 3, + "orient": "v", "isTarget": false }, { "id": "G", - "x": 3, - "y": 0, + "x": 2, + "y": 4, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "H", - "x": 2, - "y": 5, + "x": 4, + "y": 4, "len": 2, "orient": "h", "isTarget": false @@ -1471,11 +1506,18 @@ }, { "level": 20, - "minMoves": 8, + "name": "Box Car", + "tier": "freightyard", + "par": 12, + "carsMoved": 8, + "decoyDensity": 0.819, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 28.29, "vehicles": [ { "id": "X", - "x": 0, + "x": 2, "y": 2, "len": 2, "orient": "h", @@ -1483,15 +1525,15 @@ }, { "id": "A", - "x": 0, - "y": 0, + "x": 4, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "B", - "x": 4, + "x": 5, "y": 1, "len": 3, "orient": "v", @@ -1499,55 +1541,39 @@ }, { "id": "C", - "x": 2, - "y": 4, - "len": 2, + "x": 1, + "y": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "D", - "x": 3, - "y": 4, - "len": 2, - "orient": "h", + "x": 2, + "y": 3, + "len": 3, + "orient": "v", "isTarget": false }, { "id": "E", "x": 3, - "y": 2, + "y": 4, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 1, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 1, - "y": 1, + "x": 4, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { - "id": "H", + "id": "G", "x": 0, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 4, "y": 5, "len": 2, "orient": "h", @@ -1557,7 +1583,14 @@ }, { "level": 21, - "minMoves": 8, + "name": "Diesel Lane", + "tier": "freightyard", + "par": 13, + "carsMoved": 10, + "decoyDensity": 0.608, + "targetRetreats": 0, + "firstMoveFanout": 5, + "difficulty": 25.08, "vehicles": [ { "id": "X", @@ -1569,46 +1602,14 @@ }, { "id": "A", - "x": 4, - "y": 3, + "x": 0, + "y": 0, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "B", - "x": 0, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "C", - "x": 2, - "y": 1, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", "x": 5, "y": 0, "len": 2, @@ -1616,10 +1617,74 @@ "isTarget": false }, { - "id": "G", + "id": "C", "x": 1, - "y": 0, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 4, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 2, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 5, + "y": 2, "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 2, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 2, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "K", + "x": 4, + "y": 5, + "len": 2, "orient": "h", "isTarget": false } @@ -1627,11 +1692,18 @@ }, { "level": 22, - "minMoves": 8, + "name": "The Straddle", + "tier": "freightyard", + "par": 13, + "carsMoved": 10, + "decoyDensity": 0.626, + "targetRetreats": 1, + "firstMoveFanout": 8, + "difficulty": 26.56, "vehicles": [ { "id": "X", - "x": 1, + "x": 2, "y": 2, "len": 2, "orient": "h", @@ -1639,54 +1711,78 @@ }, { "id": "A", - "x": 3, - "y": 4, + "x": 0, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "B", - "x": 0, - "y": 0, - "len": 3, + "x": 1, + "y": 1, + "len": 2, "orient": "v", "isTarget": false }, { "id": "C", + "x": 2, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "D", "x": 5, "y": 1, "len": 3, "orient": "v", "isTarget": false }, - { - "id": "D", - "x": 0, - "y": 3, - "len": 2, - "orient": "h", - "isTarget": false - }, { "id": "E", - "x": 2, - "y": 3, + "x": 4, + "y": 2, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "F", - "x": 3, - "y": 1, + "x": 1, + "y": 3, "len": 2, "orient": "h", "isTarget": false }, { "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", "x": 4, "y": 5, "len": 2, @@ -1697,7 +1793,14 @@ }, { "level": 23, - "minMoves": 8, + "name": "Cargo Jam", + "tier": "freightyard", + "par": 13, + "carsMoved": 10, + "decoyDensity": 0.816, + "targetRetreats": 1, + "firstMoveFanout": 5, + "difficulty": 29.66, "vehicles": [ { "id": "X", @@ -1710,45 +1813,29 @@ { "id": "A", "x": 3, - "y": 2, + "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 1, - "y": 5, + "x": 0, + "y": 1, "len": 2, "orient": "h", "isTarget": false }, { "id": "C", - "x": 4, - "y": 0, + "x": 2, + "y": 1, "len": 2, "orient": "h", "isTarget": false }, { "id": "D", - "x": 1, - "y": 0, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 0, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "F", "x": 4, "y": 1, "len": 3, @@ -1756,42 +1843,73 @@ "isTarget": false }, { - "id": "G", - "x": 3, - "y": 4, - "len": 2, + "id": "E", + "x": 5, + "y": 1, + "len": 3, "orient": "v", "isTarget": false }, { - "id": "H", - "x": 5, + "id": "F", + "x": 2, "y": 3, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, { "id": "I", "x": 4, - "y": 5, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { "id": "J", - "x": 0, - "y": 0, + "x": 1, + "y": 5, "len": 2, - "orient": "v", + "orient": "h", + "isTarget": false + }, + { + "id": "K", + "x": 3, + "y": 5, + "len": 2, + "orient": "h", "isTarget": false } ] }, { "level": 24, - "minMoves": 9, + "name": "Last Wagon", + "tier": "freightyard", + "par": 14, + "carsMoved": 10, + "decoyDensity": 0.768, + "targetRetreats": 0, + "firstMoveFanout": 6, + "difficulty": 27.28, "vehicles": [ { "id": "X", @@ -1803,8 +1921,8 @@ }, { "id": "A", - "x": 2, - "y": 4, + "x": 0, + "y": 0, "len": 3, "orient": "h", "isTarget": false @@ -1812,23 +1930,23 @@ { "id": "B", "x": 1, - "y": 0, - "len": 2, - "orient": "v", + "y": 1, + "len": 3, + "orient": "h", "isTarget": false }, { "id": "C", - "x": 2, - "y": 2, + "x": 4, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "D", - "x": 3, - "y": 0, + "x": 5, + "y": 1, "len": 3, "orient": "v", "isTarget": false @@ -1836,52 +1954,59 @@ { "id": "E", "x": 2, - "y": 5, - "len": 3, - "orient": "h", + "y": 2, + "len": 2, + "orient": "v", "isTarget": false }, { "id": "F", - "x": 4, - "y": 1, + "x": 0, + "y": 3, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "G", - "x": 3, - "y": 3, + "x": 2, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { "id": "H", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", "isTarget": false }, { "id": "I", - "x": 0, - "y": 3, - "len": 2, - "orient": "v", + "x": 3, + "y": 5, + "len": 3, + "orient": "h", "isTarget": false } ] }, { "level": 25, - "minMoves": 9, + "name": "Pushback", + "tier": "airport", + "par": 15, + "carsMoved": 9, + "decoyDensity": 0.777, + "targetRetreats": 0, + "firstMoveFanout": 5, + "difficulty": 27.97, "vehicles": [ { "id": "X", - "x": 1, + "x": 0, "y": 2, "len": 2, "orient": "h", @@ -1889,81 +2014,65 @@ }, { "id": "A", - "x": 5, - "y": 1, - "len": 3, + "x": 0, + "y": 0, + "len": 2, "orient": "v", "isTarget": false }, { "id": "B", - "x": 4, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "C", "x": 1, - "y": 4, + "y": 0, "len": 3, "orient": "h", "isTarget": false }, { - "id": "D", - "x": 3, + "id": "C", + "x": 2, "y": 1, "len": 3, "orient": "v", "isTarget": false }, + { + "id": "D", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, { "id": "E", "x": 3, - "y": 0, + "y": 3, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "F", "x": 0, "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 3, - "y": 5, "len": 3, "orient": "h", "isTarget": false }, { - "id": "H", - "x": 0, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", + "id": "G", "x": 1, - "y": 1, + "y": 5, "len": 2, "orient": "h", "isTarget": false }, { - "id": "J", - "x": 0, - "y": 0, - "len": 2, + "id": "H", + "x": 3, + "y": 5, + "len": 3, "orient": "h", "isTarget": false } @@ -1971,11 +2080,18 @@ }, { "level": 26, - "minMoves": 9, + "name": "Baggage Train", + "tier": "airport", + "par": 15, + "carsMoved": 10, + "decoyDensity": 0.758, + "targetRetreats": 0, + "firstMoveFanout": 3, + "difficulty": 29.38, "vehicles": [ { "id": "X", - "x": 0, + "x": 1, "y": 2, "len": 2, "orient": "h", @@ -1985,63 +2101,86 @@ "id": "A", "x": 4, "y": 1, - "len": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "B", - "x": 0, - "y": 0, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "C", "x": 5, - "y": 3, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 2, "y": 1, "len": 3, "orient": "v", "isTarget": false }, { - "id": "F", - "x": 1, - "y": 5, - "len": 3, + "id": "C", + "x": 0, + "y": 3, + "len": 2, "orient": "h", "isTarget": false }, { - "id": "G", - "x": 0, + "id": "D", + "x": 2, "y": 3, - "len": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 0, + "y": 4, + "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "F", + "x": 1, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 2, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 2, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false } ] }, { "level": 27, - "minMoves": 10, + "name": "Fuel Bowser", + "tier": "airport", + "par": 15, + "carsMoved": 11, + "decoyDensity": 0.715, + "targetRetreats": 0, + "firstMoveFanout": 3, + "difficulty": 29.75, "vehicles": [ { "id": "X", @@ -2053,85 +2192,100 @@ }, { "id": "A", - "x": 1, - "y": 3, + "x": 2, + "y": 0, "len": 3, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 3, - "y": 5, - "len": 3, - "orient": "h", + "x": 5, + "y": 0, + "len": 2, + "orient": "v", "isTarget": false }, { "id": "C", - "x": 3, - "y": 4, + "x": 2, + "y": 1, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "D", - "x": 3, - "y": 1, - "len": 2, + "x": 4, + "y": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "E", - "x": 3, - "y": 3, - "len": 2, - "orient": "h", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", "isTarget": false }, { "id": "F", "x": 0, - "y": 1, + "y": 3, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "G", - "x": 5, - "y": 1, + "x": 1, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { "id": "H", - "x": 4, - "y": 0, - "len": 3, - "orient": "v", + "x": 2, + "y": 3, + "len": 2, + "orient": "h", "isTarget": false }, { "id": "I", "x": 2, - "y": 3, + "y": 4, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "J", + "x": 3, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 28, - "minMoves": 11, + "name": "Taxiway Bravo", + "tier": "airport", + "par": 15, + "carsMoved": 9, + "decoyDensity": 0.829, + "targetRetreats": 1, + "firstMoveFanout": 4, + "difficulty": 31.39, "vehicles": [ { "id": "X", - "x": 1, + "x": 3, "y": 2, "len": 2, "orient": "h", @@ -2139,31 +2293,31 @@ }, { "id": "A", - "x": 2, - "y": 3, + "x": 0, + "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 0, - "y": 2, - "len": 2, + "x": 2, + "y": 0, + "len": 3, "orient": "v", "isTarget": false }, { "id": "C", - "x": 0, - "y": 4, + "x": 3, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "D", - "x": 1, + "x": 4, "y": 0, "len": 2, "orient": "h", @@ -2171,31 +2325,31 @@ }, { "id": "E", - "x": 3, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", "x": 5, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 3, "y": 2, "len": 2, "orient": "v", "isTarget": false }, { - "id": "H", + "id": "F", + "x": 1, + "y": 3, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "G", "x": 4, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 1, "y": 4, "len": 2, "orient": "h", @@ -2203,17 +2357,32 @@ }, { "id": "I", - "x": 3, + "x": 5, "y": 4, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "J", + "x": 1, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 29, - "minMoves": 12, + "name": "Ground Hold", + "tier": "airport", + "par": 15, + "carsMoved": 10, + "decoyDensity": 0.835, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 33.05, "vehicles": [ { "id": "X", @@ -2225,6 +2394,14 @@ }, { "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", "x": 3, "y": 0, "len": 2, @@ -2232,32 +2409,24 @@ "isTarget": false }, { - "id": "B", + "id": "C", + "x": 4, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", "x": 5, "y": 1, "len": 3, "orient": "v", "isTarget": false }, - { - "id": "C", - "x": 4, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - }, { "id": "E", - "x": 4, + "x": 3, "y": 2, "len": 2, "orient": "v", @@ -2273,17 +2442,48 @@ }, { "id": "G", + "x": 1, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", "x": 2, - "y": 1, - "len": 3, + "y": 4, + "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 30, - "minMoves": 13, + "name": "Jet Bridge", + "tier": "airport", + "par": 16, + "carsMoved": 9, + "decoyDensity": 0.773, + "targetRetreats": 0, + "firstMoveFanout": 4, + "difficulty": 29.33, "vehicles": [ { "id": "X", @@ -2295,41 +2495,41 @@ }, { "id": "A", - "x": 0, - "y": 3, + "x": 2, + "y": 1, "len": 3, "orient": "v", "isTarget": false }, { "id": "B", - "x": 2, - "y": 5, - "len": 3, + "x": 3, + "y": 1, + "len": 2, "orient": "h", "isTarget": false }, { "id": "C", - "x": 5, - "y": 4, + "x": 1, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { "id": "D", - "x": 4, - "y": 0, - "len": 3, - "orient": "v", + "x": 3, + "y": 3, + "len": 2, + "orient": "h", "isTarget": false }, { "id": "E", - "x": 0, - "y": 0, - "len": 2, + "x": 5, + "y": 3, + "len": 3, "orient": "v", "isTarget": false }, @@ -2337,39 +2537,38 @@ "id": "F", "x": 2, "y": 4, - "len": 3, + "len": 2, "orient": "h", "isTarget": false }, { "id": "G", - "x": 2, - "y": 2, + "x": 4, + "y": 4, "len": 2, "orient": "v", "isTarget": false }, { "id": "H", - "x": 2, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "I", "x": 1, - "y": 4, - "len": 2, - "orient": "v", + "y": 5, + "len": 3, + "orient": "h", "isTarget": false } ] }, { "level": 31, - "minMoves": 14, + "name": "De-icer", + "tier": "airport", + "par": 16, + "carsMoved": 12, + "decoyDensity": 0.706, + "targetRetreats": 0, + "firstMoveFanout": 7, + "difficulty": 29.86, "vehicles": [ { "id": "X", @@ -2381,48 +2580,48 @@ }, { "id": "A", - "x": 0, - "y": 2, - "len": 3, - "orient": "v", + "x": 2, + "y": 0, + "len": 2, + "orient": "h", "isTarget": false }, { "id": "B", - "x": 1, - "y": 5, + "x": 4, + "y": 0, "len": 2, "orient": "h", "isTarget": false }, { "id": "C", - "x": 3, - "y": 0, - "len": 2, + "x": 0, + "y": 1, + "len": 3, "orient": "v", "isTarget": false }, { "id": "D", - "x": 5, + "x": 1, "y": 1, - "len": 2, - "orient": "v", + "len": 3, + "orient": "h", "isTarget": false }, { "id": "E", - "x": 2, - "y": 3, + "x": 4, + "y": 1, "len": 2, "orient": "h", "isTarget": false }, { "id": "F", - "x": 4, - "y": 0, + "x": 5, + "y": 2, "len": 3, "orient": "v", "isTarget": false @@ -2431,24 +2630,24 @@ "id": "G", "x": 1, "y": 3, - "len": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "H", - "x": 4, - "y": 4, + "x": 3, + "y": 3, "len": 2, "orient": "h", "isTarget": false }, { "id": "I", - "x": 0, - "y": 0, + "x": 2, + "y": 4, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { @@ -2458,12 +2657,27 @@ "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "K", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 32, - "minMoves": 14, + "name": "Catering Lift", + "tier": "airport", + "par": 17, + "carsMoved": 13, + "decoyDensity": 0.698, + "targetRetreats": 0, + "firstMoveFanout": 6, + "difficulty": 31.98, "vehicles": [ { "id": "X", @@ -2475,40 +2689,40 @@ }, { "id": "A", - "x": 0, + "x": 2, "y": 0, - "len": 3, + "len": 2, "orient": "h", "isTarget": false }, { "id": "B", - "x": 0, - "y": 3, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 3, - "y": 4, - "len": 3, + "x": 4, + "y": 0, + "len": 2, "orient": "h", "isTarget": false }, + { + "id": "C", + "x": 0, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, { "id": "D", - "x": 3, - "y": 3, + "x": 1, + "y": 1, "len": 3, "orient": "h", "isTarget": false }, { "id": "E", - "x": 1, - "y": 5, + "x": 4, + "y": 1, "len": 2, "orient": "h", "isTarget": false @@ -2516,44 +2730,75 @@ { "id": "F", "x": 5, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 4, - "y": 0, + "y": 2, "len": 3, "orient": "v", "isTarget": false }, { - "id": "H", - "x": 2, + "id": "G", + "x": 0, "y": 3, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "H", + "x": 1, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, { "id": "I", "x": 3, - "y": 1, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 2, + "y": 4, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "K", + "x": 3, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "L", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 33, - "minMoves": 14, + "name": "Stand 21", + "tier": "airport", + "par": 18, + "carsMoved": 9, + "decoyDensity": 0.778, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 34.28, "vehicles": [ { "id": "X", - "x": 0, + "x": 2, "y": 2, "len": 2, "orient": "h", @@ -2561,10 +2806,10 @@ }, { "id": "A", - "x": 3, - "y": 3, + "x": 1, + "y": 0, "len": 3, - "orient": "h", + "orient": "v", "isTarget": false }, { @@ -2572,74 +2817,73 @@ "x": 2, "y": 1, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "C", - "x": 2, - "y": 4, + "x": 4, + "y": 1, "len": 3, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "D", - "x": 4, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 1, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "F", "x": 5, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 3, "y": 1, "len": 2, "orient": "v", "isTarget": false }, { - "id": "H", - "x": 0, - "y": 0, + "id": "E", + "x": 1, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 3, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { - "id": "I", - "x": 1, - "y": 3, + "id": "G", + "x": 4, + "y": 4, "len": 2, - "orient": "v", + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 5, + "len": 3, + "orient": "h", "isTarget": false } ] }, { "level": 34, - "minMoves": 15, + "name": "Runway Cross", + "tier": "airport", + "par": 18, + "carsMoved": 10, + "decoyDensity": 0.808, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 35.78, "vehicles": [ { "id": "X", - "x": 0, + "x": 3, "y": 2, "len": 2, "orient": "h", @@ -2647,23 +2891,23 @@ }, { "id": "A", - "x": 1, + "x": 0, "y": 0, - "len": 3, + "len": 2, "orient": "h", "isTarget": false }, { "id": "B", "x": 2, - "y": 3, + "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "C", - "x": 0, + "x": 4, "y": 0, "len": 2, "orient": "v", @@ -2672,54 +2916,62 @@ { "id": "D", "x": 5, - "y": 4, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "E", - "x": 3, - "y": 3, - "len": 2, + "x": 1, + "y": 1, + "len": 3, "orient": "h", "isTarget": false }, { "id": "F", - "x": 5, - "y": 1, - "len": 2, + "x": 2, + "y": 2, + "len": 3, "orient": "v", "isTarget": false }, { "id": "G", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 1, "y": 5, "len": 2, "orient": "h", "isTarget": false }, { - "id": "H", + "id": "K", "x": 4, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 0, - "y": 3, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "J", - "x": 1, "y": 5, "len": 2, "orient": "h", @@ -2729,11 +2981,18 @@ }, { "level": 35, - "minMoves": 15, + "name": "Apron Shuffle", + "tier": "airport", + "par": 19, + "carsMoved": 10, + "decoyDensity": 0.786, + "targetRetreats": 1, + "firstMoveFanout": 5, + "difficulty": 35.36, "vehicles": [ { "id": "X", - "x": 0, + "x": 2, "y": 2, "len": 2, "orient": "h", @@ -2741,93 +3000,92 @@ }, { "id": "A", - "x": 0, - "y": 4, - "len": 2, + "x": 1, + "y": 0, + "len": 3, "orient": "v", "isTarget": false }, { "id": "B", - "x": 5, + "x": 2, "y": 1, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "C", - "x": 3, - "y": 2, + "x": 4, + "y": 1, "len": 3, "orient": "v", "isTarget": false }, { "id": "D", - "x": 1, + "x": 5, "y": 1, - "len": 3, - "orient": "h", + "len": 2, + "orient": "v", "isTarget": false }, { "id": "E", - "x": 4, - "y": 1, - "len": 2, + "x": 0, + "y": 3, + "len": 3, "orient": "v", "isTarget": false }, { "id": "F", - "x": 2, - "y": 2, + "x": 1, + "y": 3, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "G", - "x": 5, - "y": 4, + "x": 3, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { "id": "H", - "x": 3, - "y": 5, + "x": 4, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { "id": "I", - "x": 2, - "y": 0, - "len": 2, + "x": 1, + "y": 5, + "len": 3, "orient": "h", "isTarget": false - }, - { - "id": "J", - "x": 2, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false } ] }, { "level": 36, - "minMoves": 15, + "name": "Final Approach", + "tier": "airport", + "par": 19, + "carsMoved": 10, + "decoyDensity": 0.831, + "targetRetreats": 2, + "firstMoveFanout": 2, + "difficulty": 39.51, "vehicles": [ { "id": "X", - "x": 0, + "x": 3, "y": 2, "len": 2, "orient": "h", @@ -2835,65 +3093,96 @@ }, { "id": "A", - "x": 3, + "x": 1, "y": 0, - "len": 3, - "orient": "v", + "len": 2, + "orient": "h", "isTarget": false }, { "id": "B", - "x": 1, - "y": 4, + "x": 4, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "C", - "x": 3, - "y": 4, + "x": 1, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 2, + "y": 1, "len": 2, "orient": "h", "isTarget": false }, - { - "id": "D", - "x": 4, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, { "id": "E", "x": 2, - "y": 4, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 2, + "x": 5, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { - "id": "G", + "id": "I", "x": 2, - "y": 0, + "y": 4, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "J", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false } ] }, { "level": 37, - "minMoves": 16, + "name": "Ground Break", + "tier": "construction", + "par": 20, + "carsMoved": 12, + "decoyDensity": 0.809, + "targetRetreats": 0, + "firstMoveFanout": 4, + "difficulty": 36.09, "vehicles": [ { "id": "X", @@ -2905,39 +3194,39 @@ }, { "id": "A", - "x": 0, - "y": 1, + "x": 2, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, "len": 3, "orient": "h", "isTarget": false }, - { - "id": "B", - "x": 0, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - }, { "id": "C", "x": 3, - "y": 2, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "D", - "x": 5, + "x": 4, "y": 1, - "len": 3, - "orient": "v", + "len": 2, + "orient": "h", "isTarget": false }, { "id": "E", - "x": 2, + "x": 4, "y": 2, "len": 2, "orient": "v", @@ -2945,40 +3234,48 @@ }, { "id": "F", - "x": 4, - "y": 4, + "x": 5, + "y": 2, "len": 2, - "orient": "h", + "orient": "v", "isTarget": false }, { "id": "G", - "x": 0, + "x": 1, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 2, "y": 3, "len": 2, "orient": "h", "isTarget": false }, { - "id": "H", - "x": 2, + "id": "I", + "x": 0, "y": 4, "len": 2, "orient": "v", "isTarget": false }, { - "id": "I", - "x": 4, + "id": "J", + "x": 1, "y": 5, "len": 2, "orient": "h", "isTarget": false }, { - "id": "J", - "x": 1, - "y": 0, + "id": "K", + "x": 3, + "y": 5, "len": 2, "orient": "h", "isTarget": false @@ -2987,11 +3284,18 @@ }, { "level": 38, - "minMoves": 17, + "name": "Skip Loader", + "tier": "construction", + "par": 20, + "carsMoved": 11, + "decoyDensity": 0.841, + "targetRetreats": 2, + "firstMoveFanout": 2, + "difficulty": 41.41, "vehicles": [ { "id": "X", - "x": 0, + "x": 3, "y": 2, "len": 2, "orient": "h", @@ -2999,71 +3303,87 @@ }, { "id": "A", - "x": 4, - "y": 3, + "x": 0, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 1, + "y": 0, "len": 2, "orient": "h", "isTarget": false }, - { - "id": "B", - "x": 5, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, { "id": "C", - "x": 3, - "y": 3, + "x": 4, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "D", - "x": 3, - "y": 0, + "x": 1, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "E", - "x": 4, - "y": 0, + "x": 2, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 2, + "y": 2, "len": 2, "orient": "v", "isTarget": false }, - { - "id": "F", - "x": 0, - "y": 0, - "len": 3, - "orient": "h", - "isTarget": false - }, { "id": "G", - "x": 2, - "y": 3, + "x": 5, + "y": 2, "len": 3, "orient": "v", "isTarget": false }, { "id": "H", + "x": 3, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "I", "x": 0, - "y": 5, + "y": 4, "len": 2, "orient": "h", "isTarget": false }, { - "id": "I", - "x": 3, + "id": "J", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 4, "y": 5, "len": 2, "orient": "h", @@ -3073,11 +3393,18 @@ }, { "level": 39, - "minMoves": 17, + "name": "Cement Mixer", + "tier": "construction", + "par": 21, + "carsMoved": 9, + "decoyDensity": 0.77, + "targetRetreats": 2, + "firstMoveFanout": 3, + "difficulty": 39.7, "vehicles": [ { "id": "X", - "x": 0, + "x": 1, "y": 2, "len": 2, "orient": "h", @@ -3085,40 +3412,40 @@ }, { "id": "A", + "x": 2, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", "x": 3, "y": 0, "len": 3, "orient": "h", "isTarget": false }, - { - "id": "B", - "x": 3, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, { "id": "C", - "x": 2, + "x": 0, "y": 1, - "len": 3, - "orient": "v", + "len": 2, + "orient": "h", "isTarget": false }, { "id": "D", - "x": 1, - "y": 4, + "x": 3, + "y": 1, "len": 2, "orient": "v", "isTarget": false }, { "id": "E", - "x": 4, - "y": 3, + "x": 0, + "y": 2, "len": 2, "orient": "v", "isTarget": false @@ -3127,23 +3454,23 @@ "id": "F", "x": 5, "y": 3, - "len": 3, + "len": 2, "orient": "v", "isTarget": false }, { "id": "G", "x": 0, - "y": 3, + "y": 4, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "H", "x": 2, "y": 4, - "len": 2, + "len": 3, "orient": "h", "isTarget": false } @@ -3151,11 +3478,18 @@ }, { "level": 40, - "minMoves": 17, + "name": "Steel Beam", + "tier": "construction", + "par": 22, + "carsMoved": 11, + "decoyDensity": 0.82, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 40.3, "vehicles": [ { "id": "X", - "x": 0, + "x": 1, "y": 2, "len": 2, "orient": "h", @@ -3163,18 +3497,18 @@ }, { "id": "A", - "x": 3, + "x": 0, "y": 0, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", - "x": 0, + "x": 3, "y": 0, "len": 3, - "orient": "h", + "orient": "v", "isTarget": false }, { @@ -3187,208 +3521,36 @@ }, { "id": "D", - "x": 2, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 4, - "y": 1, + "x": 0, + "y": 2, "len": 3, "orient": "v", "isTarget": false }, { - "id": "F", + "id": "E", "x": 1, "y": 3, "len": 2, "orient": "v", "isTarget": false }, + { + "id": "F", + "x": 2, + "y": 3, + "len": 3, + "orient": "h", + "isTarget": false + }, { "id": "G", "x": 5, - "y": 4, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "H", - "x": 2, - "y": 1, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 0, - "y": 5, - "len": 3, - "orient": "h", - "isTarget": false - } - ] - }, - { - "level": 41, - "minMoves": 17, - "vehicles": [ - { - "id": "X", - "x": 0, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 0, "y": 3, "len": 2, "orient": "v", "isTarget": false }, - { - "id": "B", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "C", - "x": 2, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 1, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 3, - "y": 1, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "F", - "x": 3, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "G", - "x": 4, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "H", - "x": 3, - "y": 5, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "I", - "x": 1, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - } - ] - }, - { - "level": 42, - "minMoves": 17, - "vehicles": [ - { - "id": "X", - "x": 1, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 3, - "y": 4, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "B", - "x": 3, - "y": 5, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "C", - "x": 1, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "E", - "x": 3, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "F", - "x": 4, - "y": 1, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "G", - "x": 0, - "y": 0, - "len": 2, - "orient": "h", - "isTarget": false - }, { "id": "H", "x": 2, @@ -3399,94 +3561,8 @@ }, { "id": "I", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - } - ] - }, - { - "level": 43, - "minMoves": 18, - "vehicles": [ - { - "id": "X", - "x": 0, - "y": 2, - "len": 2, - "orient": "h", - "isTarget": true - }, - { - "id": "A", - "x": 2, - "y": 2, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "B", - "x": 2, - "y": 4, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "C", "x": 4, - "y": 0, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "D", - "x": 3, - "y": 5, - "len": 3, - "orient": "h", - "isTarget": false - }, - { - "id": "E", - "x": 5, - "y": 2, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "F", - "x": 1, - "y": 1, - "len": 2, - "orient": "h", - "isTarget": false - }, - { - "id": "G", - "x": 3, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "H", - "x": 0, - "y": 3, - "len": 3, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 5, - "y": 0, + "y": 4, "len": 2, "orient": "v", "isTarget": false @@ -3494,8 +3570,295 @@ { "id": "J", "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 41, + "name": "Backhoe", + "tier": "construction", + "par": 23, + "carsMoved": 11, + "decoyDensity": 0.781, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 40.91, + "vehicles": [ + { + "id": "X", + "x": 2, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 1, "y": 0, "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 1, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 42, + "name": "Scaffold", + "tier": "construction", + "par": 23, + "carsMoved": 11, + "decoyDensity": 0.825, + "targetRetreats": 1, + "firstMoveFanout": 1, + "difficulty": 42.15, + "vehicles": [ + { + "id": "X", + "x": 2, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 1, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 1, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 43, + "name": "Tipper Truck", + "tier": "construction", + "par": 24, + "carsMoved": 9, + "decoyDensity": 0.751, + "targetRetreats": 2, + "firstMoveFanout": 2, + "difficulty": 42.91, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 0, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 0, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 5, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 0, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 4, + "len": 3, "orient": "h", "isTarget": false } @@ -3503,7 +3866,14 @@ }, { "level": 44, - "minMoves": 21, + "name": "Crane Base", + "tier": "construction", + "par": 24, + "carsMoved": 10, + "decoyDensity": 0.697, + "targetRetreats": 2, + "firstMoveFanout": 1, + "difficulty": 43.57, "vehicles": [ { "id": "X", @@ -3515,8 +3885,8 @@ }, { "id": "A", - "x": 0, - "y": 1, + "x": 2, + "y": 0, "len": 2, "orient": "v", "isTarget": false @@ -3524,47 +3894,47 @@ { "id": "B", "x": 3, - "y": 4, - "len": 2, - "orient": "v", + "y": 0, + "len": 3, + "orient": "h", "isTarget": false }, { "id": "C", - "x": 3, + "x": 0, "y": 1, "len": 2, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "D", - "x": 4, - "y": 0, - "len": 3, + "x": 0, + "y": 2, + "len": 2, "orient": "v", "isTarget": false }, { "id": "E", "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 4, "y": 3, "len": 2, "orient": "h", "isTarget": false }, - { - "id": "F", - "x": 2, - "y": 3, - "len": 2, - "orient": "v", - "isTarget": false - }, { "id": "G", "x": 0, - "y": 0, + "y": 4, "len": 2, "orient": "h", "isTarget": false @@ -3572,22 +3942,14 @@ { "id": "H", "x": 2, - "y": 0, - "len": 2, - "orient": "v", - "isTarget": false - }, - { - "id": "I", - "x": 4, "y": 4, - "len": 2, + "len": 3, "orient": "h", "isTarget": false }, { - "id": "J", - "x": 1, + "id": "I", + "x": 5, "y": 4, "len": 2, "orient": "v", @@ -3597,7 +3959,809 @@ }, { "level": 45, - "minMoves": 25, + "name": "Rebar", + "tier": "construction", + "par": 24, + "carsMoved": 12, + "decoyDensity": 0.789, + "targetRetreats": 2, + "firstMoveFanout": 4, + "difficulty": 44.89, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 2, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 3, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 5, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 1, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 3, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "K", + "x": 3, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 46, + "name": "Site Gate", + "tier": "construction", + "par": 25, + "carsMoved": 12, + "decoyDensity": 0.737, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 43.27, + "vehicles": [ + { + "id": "X", + "x": 2, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 1, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 4, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 0, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 4, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 1, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 5, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 3, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 47, + "name": "Dozer Line", + "tier": "construction", + "par": 25, + "carsMoved": 12, + "decoyDensity": 0.766, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 43.96, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 3, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "B", + "x": 4, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 1, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 0, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 1, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 2, + "y": 3, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 48, + "name": "Hard Hat", + "tier": "construction", + "par": 25, + "carsMoved": 11, + "decoyDensity": 0.821, + "targetRetreats": 2, + "firstMoveFanout": 2, + "difficulty": 46.21, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 0, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 3, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 4, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 5, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 1, + "y": 3, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 0, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "K", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 49, + "name": "Neon Mile", + "tier": "nightcity", + "par": 26, + "carsMoved": 13, + "decoyDensity": 0.787, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 45.57, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 4, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 1, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 0, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 1, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 3, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 2, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 4, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "L", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 50, + "name": "Last Call", + "tier": "nightcity", + "par": 26, + "carsMoved": 13, + "decoyDensity": 0.759, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 45.69, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 0, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 5, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 1, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "L", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 51, + "name": "Wet Asphalt", + "tier": "nightcity", + "par": 27, + "carsMoved": 14, + "decoyDensity": 0.793, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 47.43, + "vehicles": [ + { + "id": "X", + "x": 1, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 0, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 4, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 2, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "L", + "x": 1, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "M", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 52, + "name": "Midnight Run", + "tier": "nightcity", + "par": 28, + "carsMoved": 12, + "decoyDensity": 0.741, + "targetRetreats": 0, + "firstMoveFanout": 2, + "difficulty": 44.21, "vehicles": [ { "id": "X", @@ -3609,22 +4773,78 @@ }, { "id": "A", - "x": 4, + "x": 0, "y": 0, "len": 3, - "orient": "v", + "orient": "h", "isTarget": false }, { "id": "B", "x": 3, - "y": 2, + "y": 0, "len": 2, "orient": "v", "isTarget": false }, { "id": "C", + "x": 2, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 4, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 1, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 5, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", "x": 0, "y": 5, "len": 2, @@ -3632,40 +4852,93 @@ "isTarget": false }, { - "id": "D", - "x": 1, - "y": 1, + "id": "K", + "x": 2, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 53, + "name": "Streetlight", + "tier": "nightcity", + "par": 28, + "carsMoved": 13, + "decoyDensity": 0.782, + "targetRetreats": 0, + "firstMoveFanout": 2, + "difficulty": 45.42, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, "len": 3, "orient": "h", "isTarget": false }, + { + "id": "B", + "x": 2, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, { "id": "E", "x": 0, - "y": 0, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { "id": "F", - "x": 3, - "y": 4, + "x": 1, + "y": 3, "len": 2, "orient": "v", "isTarget": false }, { "id": "G", - "x": 5, - "y": 0, - "len": 3, - "orient": "v", + "x": 4, + "y": 3, + "len": 2, + "orient": "h", "isTarget": false }, { "id": "H", - "x": 4, + "x": 2, "y": 4, "len": 2, "orient": "h", @@ -3673,11 +4946,782 @@ }, { "id": "I", + "x": 4, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "L", "x": 2, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 54, + "name": "After Hours", + "tier": "nightcity", + "par": 29, + "carsMoved": 10, + "decoyDensity": 0.74, + "targetRetreats": 1, + "firstMoveFanout": 2, + "difficulty": 46.1, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 5, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, "y": 2, "len": 2, "orient": "v", "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 55, + "name": "Rain Check", + "tier": "nightcity", + "par": 29, + "carsMoved": 14, + "decoyDensity": 0.718, + "targetRetreats": 2, + "firstMoveFanout": 2, + "difficulty": 51.58, + "vehicles": [ + { + "id": "X", + "x": 2, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 3, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 5, + "y": 0, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 0, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 2, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 2, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 3, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 0, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "L", + "x": 1, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "M", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 56, + "name": "Chrome", + "tier": "nightcity", + "par": 30, + "carsMoved": 11, + "decoyDensity": 0.52, + "targetRetreats": 1, + "firstMoveFanout": 5, + "difficulty": 44.5, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 5, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 57, + "name": "Blackout", + "tier": "nightcity", + "par": 30, + "carsMoved": 11, + "decoyDensity": 0.804, + "targetRetreats": 1, + "firstMoveFanout": 4, + "difficulty": 47.74, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 5, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 0, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 58, + "name": "Red Line", + "tier": "nightcity", + "par": 30, + "carsMoved": 12, + "decoyDensity": 0.8, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 48.9, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 2, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 4, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 2, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 59, + "name": "The Long Night", + "tier": "nightcity", + "par": 31, + "carsMoved": 12, + "decoyDensity": 0.626, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 48.16, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 2, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 5, + "y": 0, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 3, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 4, + "y": 3, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "H", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "I", + "x": 3, + "y": 4, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 2, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "K", + "x": 4, + "y": 5, + "len": 2, + "orient": "h", + "isTarget": false + } + ] + }, + { + "level": 60, + "name": "Dead of Night", + "tier": "nightcity", + "par": 35, + "carsMoved": 12, + "decoyDensity": 0.76, + "targetRetreats": 1, + "firstMoveFanout": 3, + "difficulty": 53.5, + "vehicles": [ + { + "id": "X", + "x": 0, + "y": 2, + "len": 2, + "orient": "h", + "isTarget": true + }, + { + "id": "A", + "x": 0, + "y": 0, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "B", + "x": 2, + "y": 0, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "C", + "x": 2, + "y": 1, + "len": 3, + "orient": "v", + "isTarget": false + }, + { + "id": "D", + "x": 3, + "y": 1, + "len": 3, + "orient": "h", + "isTarget": false + }, + { + "id": "E", + "x": 5, + "y": 2, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "F", + "x": 0, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "G", + "x": 3, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "H", + "x": 4, + "y": 3, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "I", + "x": 1, + "y": 4, + "len": 2, + "orient": "h", + "isTarget": false + }, + { + "id": "J", + "x": 5, + "y": 4, + "len": 2, + "orient": "v", + "isTarget": false + }, + { + "id": "K", + "x": 2, + "y": 5, + "len": 3, + "orient": "h", + "isTarget": false } ] } diff --git a/assets/gamedata/zuma/level-003.json b/assets/gamedata/zuma/level-003.json index 25e8192..d048e0a 100644 --- a/assets/gamedata/zuma/level-003.json +++ b/assets/gamedata/zuma/level-003.json @@ -1,400 +1,105 @@ { "level": 3, - "name": "Twin Pools", + "name": "Dual Strike", "frog": [ - 620, - 600 + 966, + 579 ], "colors": 4, - "background": "zuma-background-1", - "seed": 24757, + "seed": 12345, "starScores": [ - 970, - 1460, - 1940 + 640, + 960, + 1280 ], "paths": [ { - "shape": "doubleLoop", + "shape": "custom", "points": [ [ - -80, - 300 + -49, + 235 ], [ - -10, - 300 + 669, + 132 ], [ - 60, - 300 + 1515, + 190 ], [ - 130, - 300 + 1726, + 411 ], [ - 200, - 300 + 1710, + 667 ], [ - 270, - 300 - ], - [ - 340, - 300 - ], - [ - 410, - 300 - ], - [ - 480, - 300 - ], - [ - 550, - 300 - ], - [ - 620, - 300 + 1210, + 817 ], [ 689, - 308 + 813 ], [ - 755, - 332 - ], - [ - 813, - 370 - ], - [ - 861, - 421 - ], - [ - 895, - 481 - ], - [ - 915, - 548 - ], - [ - 919, - 617 - ], - [ - 907, - 686 - ], - [ - 880, - 750 - ], - [ - 838, - 806 - ], - [ - 785, - 851 - ], - [ - 723, - 882 - ], - [ - 655, - 898 - ], - [ - 585, - 898 - ], - [ - 517, - 882 - ], - [ - 455, - 851 - ], - [ - 402, - 806 - ], - [ - 360, - 750 - ], - [ - 333, - 686 - ], - [ - 321, - 617 - ], - [ - 325, - 548 - ], - [ - 345, - 481 - ], - [ - 379, - 421 - ], - [ - 427, - 370 - ], - [ - 485, - 332 - ], - [ - 551, - 308 - ], - [ - 620, - 300 - ], - [ - 691, - 300 - ], - [ - 762, - 300 - ], - [ - 833, - 300 - ], - [ - 904, - 300 - ], - [ - 975, - 300 - ], - [ - 1045, - 300 - ], - [ - 1116, - 300 - ], - [ - 1187, - 300 - ], - [ - 1258, - 300 - ], - [ - 1329, - 300 - ], - [ - 1400, - 300 - ], - [ - 1469, - 308 - ], - [ - 1535, - 332 - ], - [ - 1593, - 370 - ], - [ - 1641, - 421 - ], - [ - 1675, - 481 - ], - [ - 1695, - 548 - ], - [ - 1699, - 617 - ], - [ - 1687, - 686 - ], - [ - 1660, - 750 - ], - [ - 1618, - 806 - ], - [ - 1565, - 851 - ], - [ - 1503, - 882 - ], - [ - 1435, - 898 - ], - [ - 1365, - 898 - ], - [ - 1297, - 882 - ], - [ - 1235, - 851 - ], - [ - 1182, - 806 - ], - [ - 1140, - 750 - ], - [ - 1113, - 686 - ], - [ - 1101, - 617 - ], - [ - 1105, - 548 - ], - [ - 1125, - 481 - ], - [ - 1159, - 421 - ], - [ - 1207, - 370 - ], - [ - 1265, - 332 - ], - [ - 1331, - 308 - ], - [ - 1400, - 300 - ], - [ - 1473, - 300 - ], - [ - 1547, - 300 - ], - [ - 1620, - 300 - ], - [ - 1691, - 311 - ], - [ - 1755, - 344 - ], - [ - 1806, - 395 - ], - [ - 1839, - 459 - ], - [ - 1850, - 530 - ], - [ - 1839, - 601 - ], - [ - 1806, - 665 - ], - [ - 1755, - 716 - ], - [ - 1691, - 749 - ], - [ - 1620, - 760 - ], - [ - 1555, - 760 - ], - [ - 1490, - 760 - ], - [ - 1425, - 760 - ], - [ - 1360, - 760 + 482, + 604 ] ], "tunnels": [], - "quota": 30, - "introBalls": 9, - "pushSpeed": 84, - "powerUpRate": 0.065 + "quota": 20, + "introBalls": 8, + "pushSpeed": 30, + "powerUpRate": 0.06 + }, + { + "shape": "custom", + "points": [ + [ + 1995, + 920 + ], + [ + 1113, + 954 + ], + [ + 265, + 940 + ], + [ + 113, + 633 + ], + [ + 330, + 341 + ], + [ + 628, + 249 + ], + [ + 1103, + 251 + ], + [ + 1361, + 350 + ], + [ + 1466, + 491 + ] + ], + "tunnels": [], + "quota": 20, + "introBalls": 8, + "pushSpeed": 30, + "powerUpRate": 0.06 } ] } diff --git a/assets/gamedata/zuma/level-004.json b/assets/gamedata/zuma/level-004.json index 000babf..b06411a 100644 --- a/assets/gamedata/zuma/level-004.json +++ b/assets/gamedata/zuma/level-004.json @@ -1,364 +1,84 @@ { - "level": 4, - "name": "Switchbacks", + "level": 21, + "name": "New Path", "frog": [ - 1620, - 550 + 1661, + 544 ], "colors": 4, - "background": "zuma-background-2", - "seed": 32676, + "seed": 12345, "starScores": [ - 1040, - 1550, - 2070 + 640, + 960, + 1280 ], "paths": [ { - "shape": "zigzag", + "shape": "custom", "points": [ [ - -80, - 130 + -65, + 93 ], [ - -11, - 130 + 466, + 132 ], [ - 58, - 130 + 1034, + 374 ], [ - 126, - 130 + 1116, + 817 ], [ - 195, - 130 + 706, + 956 ], [ - 264, - 130 + 156, + 663 ], [ - 333, - 130 + 316, + 309 ], [ - 401, - 130 + 1095, + 143 ], [ - 470, - 130 + 1484, + 780 ], [ - 539, - 130 + 1210, + 979 ], [ - 608, - 130 + 853, + 719 ], [ - 676, - 130 - ], - [ - 745, - 130 - ], - [ - 814, - 130 - ], - [ - 882, - 130 - ], - [ - 951, - 130 - ], - [ - 1020, - 130 - ], - [ - 1089, - 130 - ], - [ - 1158, - 130 - ], - [ - 1226, - 130 - ], - [ - 1295, - 130 - ], - [ - 1365, - 149 - ], - [ - 1416, - 200 - ], - [ - 1435, - 270 - ], - [ - 1416, - 340 - ], - [ - 1365, - 391 - ], - [ - 1295, - 410 - ], - [ - 1225, - 410 - ], - [ - 1156, - 410 - ], - [ - 1086, - 410 - ], - [ - 1016, - 410 - ], - [ - 947, - 410 - ], - [ - 877, - 410 - ], - [ - 807, - 410 - ], - [ - 738, - 410 - ], - [ - 668, - 410 - ], - [ - 598, - 410 - ], - [ - 529, - 410 - ], - [ - 459, - 410 - ], - [ - 389, - 410 - ], - [ - 320, - 410 - ], - [ - 250, - 410 - ], - [ - 180, - 429 - ], - [ - 129, - 480 - ], - [ - 110, - 550 - ], - [ - 129, - 620 - ], - [ - 180, - 671 - ], - [ - 250, - 690 - ], - [ - 320, - 690 - ], - [ - 389, - 690 - ], - [ - 459, - 690 - ], - [ - 529, - 690 - ], - [ - 598, - 690 - ], - [ - 668, - 690 - ], - [ - 738, - 690 - ], - [ - 807, - 690 - ], - [ - 877, - 690 - ], - [ - 947, - 690 - ], - [ - 1016, - 690 - ], - [ - 1086, - 690 - ], - [ - 1156, - 690 - ], - [ - 1225, - 690 - ], - [ - 1295, - 690 - ], - [ - 1365, - 709 - ], - [ - 1416, - 760 - ], - [ - 1435, - 830 - ], - [ - 1416, - 900 - ], - [ - 1365, - 951 - ], - [ - 1295, - 970 - ], - [ - 1225, - 970 - ], - [ - 1156, - 970 - ], - [ - 1086, - 970 - ], - [ - 1016, - 970 - ], - [ - 947, - 970 - ], - [ - 877, - 970 - ], - [ - 807, - 970 - ], - [ - 738, - 970 - ], - [ - 668, - 970 - ], - [ - 598, - 970 - ], - [ - 529, - 970 - ], - [ - 459, - 970 - ], - [ - 389, - 970 - ], - [ - 320, - 970 - ], - [ - 250, - 970 + 552, + 397 ] ], - "tunnels": [], - "quota": 32, - "introBalls": 10, - "pushSpeed": 84, - "powerUpRate": 0.065 + "tunnels": [ + [ + 3182, + 3834 + ], + [ + 5104, + 5615 + ] + ], + "quota": 24, + "introBalls": 8, + "pushSpeed": 30, + "powerUpRate": 0.06 } ] } diff --git a/assets/images/background-rushhour.png b/assets/images/background-rushhour.png new file mode 100644 index 0000000..2d2a618 Binary files /dev/null and b/assets/images/background-rushhour.png differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index e037b8c..b9fc9ad 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -90,6 +90,11 @@ export const MANIFEST = { image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'), ], bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')], + rushhour: [ + // Carries its own title, so the level-select screen draws no heading. + // In-play art is procedural (see games/rushhour/RushHourArt.js). + image('bg-rushhour-menu', 'assets/images/background-rushhour.png'), + ], dominion: [ // Dominion card art. One 270×390 cell per card (art fills the top ~60%; // the title/icon band is drawn at runtime). Optional — the scene falls diff --git a/src/games/jewelquest/JewelQuestGame.js b/src/games/jewelquest/JewelQuestGame.js index 0cafbdf..e96fbb0 100644 --- a/src/games/jewelquest/JewelQuestGame.js +++ b/src/games/jewelquest/JewelQuestGame.js @@ -331,6 +331,8 @@ export default class JewelQuestGame extends Phaser.Scene { this.replayQueue = []; this.selected = null; this.dragFrom = null; + this.rowsTween?.stop(); + this.rowsTween = null; this.layer.removeAll(true); if (this.calloutText) { this.calloutText.destroy(); this.calloutText = null; } this.cellSprites = null; @@ -496,6 +498,11 @@ export default class JewelQuestGame extends Phaser.Scene { const left = cx - gridW / 2 + TILE / 2; const top = 530; + // The grid starts invisible so the background art gets a beat on its own + // before the level tiles fade in on top of it. + const rows = this.add.container(0, 0).setAlpha(0); + this.layer.add(rows); + this.bank.forEach((lv, i) => { const col = i % COLS; const row = Math.floor(i / COLS); @@ -525,7 +532,7 @@ export default class JewelQuestGame extends Phaser.Scene { color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', }).setOrigin(0.5); objs.push(tag); - this.layer.add(objs); + rows.add(objs); if (playable) { tile.setInteractive({ useHandCursor: true }); @@ -535,6 +542,10 @@ export default class JewelQuestGame extends Phaser.Scene { } }); + this.rowsTween = this.tweens.add({ + targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear', + }); + const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel), { width: 280, height: 58, fontSize: 24 }); const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), diff --git a/src/games/rushhour/RushHourArt.js b/src/games/rushhour/RushHourArt.js new file mode 100644 index 0000000..abbf9d3 --- /dev/null +++ b/src/games/rushhour/RushHourArt.js @@ -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); + } +} diff --git a/src/games/rushhour/RushHourGame.js b/src/games/rushhour/RushHourGame.js index 6e6c0c8..d4c988c 100644 --- a/src/games/rushhour/RushHourGame.js +++ b/src/games/rushhour/RushHourGame.js @@ -7,23 +7,20 @@ import { api } from '../../services/api.js'; import { GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve, } from './RushHourLogic.js'; +import { + CELL, themeFor, ensureVehicleTextures, drawBoardSurface, +} from './RushHourArt.js'; + +const BANK_URL = 'assets/gamedata/rushhour/levels.json'; -const CELL = 132; const BOARD_PX = CELL * GRID; const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2); const BOARD_TOP = 188; -const PAD = 10; // inset of a vehicle body within its cells -const FELT = 0x0e2233; -const FRAME = 0x0a1722; -const CELLBG = 0x14304a; -const GRIDLN = 0x21466a; -const TARGET_COLOR = 0xe03131; -const CAR_COLORS = [ - 0x4a90d9, 0x2ecc71, 0xf1c40f, 0x9b59b6, 0xe67e22, 0x1abc9c, - 0x3498db, 0xe84393, 0x16a085, 0xd35400, 0x27ae60, 0x8e44ad, - 0x7f8c8d, 0x2980b9, 0xf39c12, 0x6ab04c, -]; +// Hints solve from wherever the player currently is, which on a tier-5 board +// can be a large reachable space. Bound it so a press can never stall the +// main thread; an unsolvable-within-budget hint just does nothing. +const HINT_MAX_STATES = 120000; const D = { felt: -2, frame: -1, grid: 0, exit: 1, vehicle: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 }; @@ -33,6 +30,7 @@ export default class RushHourGame extends Phaser.Scene { init(data) { this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' }; this.bank = []; + this.tiers = []; this.levelsCompleted = 0; // highest contiguous level cleared this.canPersist = true; this.view = 'select'; @@ -54,10 +52,20 @@ export default class RushHourGame extends Phaser.Scene { if (music?.tracks) new MusicPlayer(this, music.tracks); } catch (_) { /* optional */ } - this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt); + this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, + themeFor('downtown').board.felt).setDepth(D.felt); - const raw = this.cache.json.get('rushhour'); - this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level); + // Fetched on entry rather than preloaded at boot — the bank is dead weight + // for every player who never opens this game. + try { + const res = await fetch(BANK_URL); + const data = await res.json(); + this.bank = (data?.levels ?? []).slice().sort((a, b) => a.level - b.level); + this.tiers = data?.tiers ?? []; + } catch (_) { + this.bank = []; + this.tiers = []; + } try { const res = await api.get('/puzzles/rushhour/progress'); @@ -74,6 +82,8 @@ export default class RushHourGame extends Phaser.Scene { clearLayer() { this.sprites.clear(); + this.rowsTween?.stop(); + this.rowsTween = null; this.layer.removeAll(true); // These were just destroyed; drop the stale references so updateMoves() // doesn't poke a dead Button (whose scene is now undefined) before drawHud @@ -89,18 +99,33 @@ export default class RushHourGame extends Phaser.Scene { this.overlayUp = false; this.busy = false; this.clearLayer(); + // Level select is theme-neutral; otherwise it keeps whatever tier colour + // the last level painted the background. + this.bg.setFillStyle(themeFor('downtown').board.felt); const cx = GAME_WIDTH / 2; - const title = this.add.text(cx, 84, 'RUSH HOUR', { - fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, - }).setOrigin(0.5); - const sub = this.add.text(cx, 138, 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', { - fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, - }).setOrigin(0.5); - this.layer.add([title, sub]); + // The artwork carries the wordmark, so no heading is drawn over it. A + // scrim keeps the level grid legible against a busy illustration. + const hasArt = this.textures.exists('bg-rushhour-menu'); + if (hasArt) { + this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-rushhour-menu') + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); + this.layer.add(this.add.rectangle(cx, GAME_HEIGHT / 2 + 90, GAME_WIDTH, GAME_HEIGHT - 180, + 0x000000, 0.62)); + } else { + const title = this.add.text(cx, 84, 'RUSH HOUR', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add(title); + } + const sub = this.add.text(cx, hasArt ? 214 : 138, + 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(sub); if (!this.bank.length) { - const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genRushHour.js', { + const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node tools/genRushHour.js', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', }).setOrigin(0.5); this.layer.add(msg); @@ -110,46 +135,77 @@ export default class RushHourGame extends Phaser.Scene { } const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); - const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { + const prog = this.add.text(cx, hasArt ? 252 : 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5); this.layer.add(prog); - const COLS = 12; - const SIZE = 104; - const GAP = 14; - const gridW = COLS * SIZE + (COLS - 1) * GAP; - const left = cx - gridW / 2 + SIZE / 2; - const top = 268; + // One row per tier, headed by the tier name in its own theme colour, so the + // themes still to come are visible from the start. + const SIZE = 88; + const GAP = 12; + const ROW_STRIDE = 128; + const top = hasArt ? 330 : 262; - this.bank.forEach((p, i) => { - const col = i % COLS; - const row = Math.floor(i / COLS); - const x = left + col * (SIZE + GAP); - const y = top + row * (SIZE + GAP); - const level = p.level; - const cleared = level <= this.levelsCompleted; - const playable = level <= nextLevel; + const tierList = this.tiers.length + ? this.tiers + : [{ id: 'downtown', name: 'Levels', from: 1, to: this.bank.length }]; - const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b; - const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; - const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); - const num = this.add.text(x, y - 8, String(level), { - fontFamily: 'Righteous', fontSize: '34px', - color: playable || cleared ? COLORS.textHex : '#54606b', - }).setOrigin(0.5); - const tag = this.add.text(x, y + 28, cleared ? '✓ cleared' : playable ? `par ${p.minMoves}` : 'locked', { - fontFamily: '"Julius Sans One"', fontSize: '14px', - color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', - }).setOrigin(0.5); - this.layer.add([tile, num, tag]); + // The rows start invisible so the background art gets a beat on its own + // before the level grid fades in on top of it. + const rows = this.add.container(0, 0).setAlpha(0); + this.layer.add(rows); - if (playable) { - tile.setInteractive({ useHandCursor: true }); - tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); - tile.on('pointerout', () => tile.setStrokeStyle(cleared ? 3 : 3, stroke, 1)); - tile.on('pointerup', () => this.playLevel(level)); - } + tierList.forEach((tier, ti) => { + const theme = themeFor(tier.theme ?? tier.id); + const levels = this.bank.filter((p) => p.level >= tier.from && p.level <= tier.to); + if (!levels.length) return; + + const gridW = levels.length * SIZE + (levels.length - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const rowY = top + ti * ROW_STRIDE; + const unlockedHere = levels.some((p) => p.level <= nextLevel); + + const label = this.add.text(left - SIZE / 2, rowY - SIZE / 2 - 22, tier.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '22px', + color: unlockedHere ? COLORS.goldHex : '#54606b', + }).setOrigin(0, 0.5); + const swatch = this.add.rectangle(left - SIZE / 2 - 16, rowY - SIZE / 2 - 22, 8, 22, + Phaser.Display.Color.HexStringToColor(theme.palette[0]).color) + .setAlpha(unlockedHere ? 1 : 0.3); + rows.add([label, swatch]); + + levels.forEach((p, i) => { + const x = left + i * (SIZE + GAP); + const level = p.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + + const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b; + const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; + const tile = this.add.rectangle(x, rowY, SIZE, SIZE, fill) + .setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, rowY - 8, String(level), { + fontFamily: 'Righteous', fontSize: '30px', + color: playable || cleared ? COLORS.textHex : '#54606b', + }).setOrigin(0.5); + const tag = this.add.text(x, rowY + 24, cleared ? '✓' : playable ? `par ${p.par}` : 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '13px', + color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', + }).setOrigin(0.5); + rows.add([tile, num, tag]); + + if (playable) { + tile.setInteractive({ useHandCursor: true }); + tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); + tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); + tile.on('pointerup', () => this.playLevel(level)); + } + }); + }); + + this.rowsTween = this.tweens.add({ + targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear', }); const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), @@ -206,7 +262,10 @@ export default class RushHourGame extends Phaser.Scene { this.level = level; this.startVehicles = cloneVehicles(puzzle.vehicles); this.vehicles = cloneVehicles(puzzle.vehicles); - this.par = puzzle.minMoves; + this.par = puzzle.par; + this.levelName = puzzle.name ?? ''; + this.theme = themeFor(this.tierFor(level)?.theme ?? 'downtown'); + this.bg.setFillStyle(this.theme.board.felt); this.undoStack = []; this.moves = 0; this.overlayUp = false; @@ -219,27 +278,22 @@ export default class RushHourGame extends Phaser.Scene { } drawBoardChrome() { + const board = this.theme.board; const g = this.add.graphics().setDepth(D.frame); // Outer frame - g.fillStyle(FRAME, 1); + g.fillStyle(board.frame, 1); g.fillRoundedRect(BOARD_LEFT - 22, BOARD_TOP - 22, BOARD_PX + 44, BOARD_PX + 44, 18); - // Playfield - g.fillStyle(CELLBG, 1); - g.fillRect(BOARD_LEFT, BOARD_TOP, BOARD_PX, BOARD_PX); this.layer.add(g); + // Playfield surface + per-theme road markings. const grid = this.add.graphics().setDepth(D.grid); - grid.lineStyle(2, GRIDLN, 0.9); - for (let i = 0; i <= GRID; i++) { - grid.lineBetween(BOARD_LEFT + i * CELL, BOARD_TOP, BOARD_LEFT + i * CELL, BOARD_TOP + BOARD_PX); - grid.lineBetween(BOARD_LEFT, BOARD_TOP + i * CELL, BOARD_LEFT + BOARD_PX, BOARD_TOP + i * CELL); - } + drawBoardSurface(grid, this.theme, BOARD_LEFT, BOARD_TOP, BOARD_PX, GRID); this.layer.add(grid); // Exit gap on the right wall at EXIT_ROW const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL; const ex = this.add.graphics().setDepth(D.exit); - ex.fillStyle(FELT, 1); + ex.fillStyle(board.felt, 1); ex.fillRect(BOARD_LEFT + BOARD_PX - 1, BOARD_TOP + EXIT_ROW * CELL + 6, 46, CELL - 12); ex.fillStyle(COLORS.gold, 0.9); ex.fillTriangle( @@ -255,10 +309,14 @@ export default class RushHourGame extends Phaser.Scene { } drawHud() { - const title = this.add.text(BOARD_LEFT - 22, 92, `Level ${this.level}`, { - fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex, + const title = this.add.text(BOARD_LEFT - 22, 84, `Level ${this.level}`, { + fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex, }).setOrigin(0, 0.5).setDepth(D.ui); - this.layer.add(title); + const sub = this.add.text(BOARD_LEFT - 20, 124, + `${this.levelName}${this.levelName ? ' · ' : ''}${this.theme.name}`, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.layer.add([title, sub]); this.movesText = this.add.text(BOARD_LEFT + BOARD_PX + 22, 92, '', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, @@ -310,36 +368,17 @@ export default class RushHourGame extends Phaser.Scene { this.sprites.forEach((c) => c.destroy()); this.sprites.clear(); - let carIdx = 0; + // Textures are baked once per theme+slot+shape and reused; a repeat visit + // to a tier costs nothing. + const keys = ensureVehicleTextures(this, this.theme.id, this.vehicles); + for (const v of this.vehicles) { - const wCells = v.orient === 'h' ? v.len : 1; - const hCells = v.orient === 'v' ? v.len : 1; - const w = wCells * CELL - PAD * 2; - const h = hCells * CELL - PAD * 2; - const color = v.isTarget ? TARGET_COLOR : CAR_COLORS[carIdx++ % CAR_COLORS.length]; - const { x, y } = this.centerFor(v); - const c = this.add.container(x, y).setDepth(D.vehicle); - - const body = this.add.graphics(); - body.fillStyle(color, 1); - body.fillRoundedRect(-w / 2, -h / 2, w, h, 16); - body.fillStyle(0xffffff, 0.14); - body.fillRoundedRect(-w / 2 + 8, -h / 2 + 8, w - 16, Math.max(10, h * 0.22), 10); - body.lineStyle(3, 0x000000, 0.28); - body.strokeRoundedRect(-w / 2, -h / 2, w, h, 16); - c.add(body); - - if (v.isTarget) { - c.add(this.add.text(0, 0, '★', { fontFamily: 'serif', fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setAlpha(0.9)); - } - - // The body graphics are centered on the container origin, but setSize() - // makes Phaser offset the input hit test by the display origin (w/2, h/2). - // So the hit area must be specified top-left at (0,0) — NOT (-w/2,-h/2) — - // to line up with the visible car (mirrors Battleship's draggable ships). - c.setSize(w, h); - c.setInteractive(new Phaser.Geom.Rectangle(0, 0, w, h), Phaser.Geom.Rectangle.Contains); + // A plain Image rather than a Container: its origin is 0.5 and its hit + // area is the texture bounds, which sidesteps the top-left-hit-rect quirk + // the old Container version needed a comment to explain. + const c = this.add.image(x, y, keys.get(v.id)).setDepth(D.vehicle); + c.setInteractive({ useHandCursor: true }); c.input.cursor = 'grab'; this.input.setDraggable(c); @@ -350,6 +389,10 @@ export default class RushHourGame extends Phaser.Scene { } } + tierFor(level) { + return this.tiers.find((t) => level >= t.from && level <= t.to) ?? null; + } + attachDrag(c, _v) { c.on('dragstart', () => { if (this.busy || this.overlayUp) { c._noDrag = true; return; } @@ -438,7 +481,7 @@ export default class RushHourGame extends Phaser.Scene { showHint() { if (this.busy || this.overlayUp) return; - const { path } = solve(this.vehicles); + const { path } = solve(this.vehicles, { maxStates: HINT_MAX_STATES }); if (!path || !path.length) return; const mv = path[0]; const c = this.sprites.get(mv.id); diff --git a/src/games/rushhour/RushHourLogic.js b/src/games/rushhour/RushHourLogic.js index 1ef6efb..98e116d 100644 --- a/src/games/rushhour/RushHourLogic.js +++ b/src/games/rushhour/RushHourLogic.js @@ -1,5 +1,5 @@ -// Rush Hour — pure board model + BFS solver. No Phaser, no DOM. -// Shared by the client scene and the offline puzzle generator (both ESM). +// Rush Hour — pure board model + solver. No Phaser, no DOM. +// Shared by the client scene, the offline generator and the verifier (all ESM). // // Grid is 6x6. x = column (0 left .. 5 right), y = row (0 top .. 5 bottom). // The red target car is horizontal in EXIT_ROW and escapes through a gap in @@ -9,12 +9,26 @@ // horizontal -> occupies (x..x+len-1, y); vertical -> occupies (x, y..y+len-1) // // Move metric (matches published Rush Hour difficulty): sliding one car any -// number of squares in a single direction counts as ONE move. +// number of squares in a single direction counts as ONE move. Fogleman calls +// this a "move" as opposed to a "step" (one square); the published ThinkFun +// difficulties are quoted in moves. +// +// ── Representation ─────────────────────────────────────────────────────────── +// A vehicle's orientation, length and fixed axis never change, so a whole board +// state is just the moving coordinate of each vehicle. Everything below packs a +// state into a Uint8Array of those coordinates plus a single numeric key +// (base-7, since each coordinate is 0..5 and 7^18 still lands inside the exact +// integer range of a double). Occupancy is a reused Uint8Array(36) repainted +// per expansion rather than an array of cloned objects — that is what makes a +// full-cluster analysis affordable. export const GRID = 6; export const EXIT_ROW = 2; export const TARGET_ID = 'X'; +const CELLS = GRID * GRID; +const MAX_PACKED_VEHICLES = 18; // 7^18 < 2^53, so keys stay exact + export function vehicleCells(v) { const cells = []; for (let i = 0; i < v.len; i++) { @@ -89,47 +103,246 @@ export function cloneVehicles(vehicles) { return vehicles.map((v) => ({ ...v })); } +// ── Packed engine ──────────────────────────────────────────────────────────── + +// Freezes the immutable facts about a vehicle set (length, orientation, fixed +// axis, id) and exposes cheap operations over packed position vectors. +export function packBoard(vehicles) { + const n = vehicles.length; + if (n > MAX_PACKED_VEHICLES) { + throw new Error(`RushHour: ${n} vehicles exceeds the packed limit of ${MAX_PACKED_VEHICLES}`); + } + const len = new Uint8Array(n); + const horiz = new Uint8Array(n); + const fixed = new Uint8Array(n); + const maxPos = new Uint8Array(n); + const pow = new Float64Array(n); + const ids = new Array(n); + let targetIdx = -1; + + for (let i = 0; i < n; i++) { + const v = vehicles[i]; + len[i] = v.len; + horiz[i] = v.orient === 'h' ? 1 : 0; + fixed[i] = horiz[i] ? v.y : v.x; + maxPos[i] = GRID - v.len; + pow[i] = 7 ** i; + ids[i] = v.id; + if (v.isTarget) targetIdx = i; + } + + const start = new Uint8Array(n); + for (let i = 0; i < n; i++) start[i] = horiz[i] ? vehicles[i].x : vehicles[i].y; + + // Cell index of the k-th square of vehicle i when its head sits at p. + const cell = (i, p, k) => (horiz[i] ? fixed[i] * GRID + p + k : (p + k) * GRID + fixed[i]); + + const goalPos = targetIdx >= 0 ? GRID - len[targetIdx] : -1; + const targetEscapes = targetIdx >= 0 && horiz[targetIdx] === 1 && fixed[targetIdx] === EXIT_ROW; + + function key(P) { + let s = 0; + for (let i = 0; i < n; i++) s += P[i] * pow[i]; + return s; + } + function paint(P, occ) { + occ.fill(0); + for (let i = 0; i < n; i++) { + for (let k = 0; k < len[i]; k++) occ[cell(i, P[i], k)] = 1; + } + } + function isGoal(P) { + return targetEscapes && P[targetIdx] === goalPos; + } + function toVehicles(P) { + return vehicles.map((v, i) => ({ + ...v, + x: horiz[i] ? P[i] : fixed[i], + y: horiz[i] ? fixed[i] : P[i], + })); + } + + return { + n, len, horiz, fixed, maxPos, pow, ids, targetIdx, goalPos, targetEscapes, + start, cell, key, paint, isGoal, toVehicles, + }; +} + +// Every one-slide neighbour of P, given occupancy already painted into `occ`. +// Calls visit(vehicleIdx, newPos) — no allocation per neighbour. +function forEachSlide(B, P, occ, visit) { + for (let i = 0; i < B.n; i++) { + const head = P[i]; + // Toward 0: each step exposes exactly one new leading cell at `np`. + for (let np = head - 1; np >= 0; np--) { + if (occ[B.cell(i, np, 0)]) break; + visit(i, np); + } + // Toward GRID: the new leading cell is the tail square, np + len - 1. + const tail = B.len[i] - 1; + for (let np = head + 1; np <= B.maxPos[i]; np++) { + if (occ[B.cell(i, np, tail)]) break; + visit(i, np); + } + } +} + // Breadth-first shortest solution. Returns { moves, path }. // moves: minimum number of slides to solve (0 if already solved, -1 if none) // path: array of { id, x, y } optimal moves (null if unsolvable) -// The 6x6 state space is small, but packed unsolvable layouts can still have a -// large reachable space. `maxStates` bounds the search so a single solve stays -// cheap (used heavily by the generator); exceeding it returns moves:-1. +// `maxStates` bounds the search so a single solve stays cheap; exceeding it +// returns moves:-1, which is indistinguishable from "unsolvable" by design — +// callers treat both as "reject this board". export function solve(vehicles, { maxStates = 600000 } = {}) { - const start = cloneVehicles(vehicles); - const startKey = stateKey(start); - if (isSolved(start)) return { moves: 0, path: [] }; + const B = packBoard(vehicles); + if (B.isGoal(B.start)) return { moves: 0, path: [] }; + if (!B.targetEscapes) return { moves: -1, path: null }; - const meta = new Map([[startKey, null]]); // key -> { parentKey, move } - const stateByKey = new Map([[startKey, start]]); - let frontier = [startKey]; - let depth = 0; + const states = [B.start]; + const keys = [B.key(B.start)]; + const parent = [-1]; + const mvVeh = [-1]; + const mvPos = [-1]; + const depth = [0]; + const seen = new Map([[keys[0], 0]]); + const occ = new Uint8Array(CELLS); - while (frontier.length) { - depth++; - const next = []; - for (const key of frontier) { - const state = stateByKey.get(key); - for (const mv of legalMoves(state)) { - const ns = cloneVehicles(state); - ns[mv.idx].x = mv.x; - ns[mv.idx].y = mv.y; - const nk = stateKey(ns); - if (meta.has(nk)) continue; - meta.set(nk, { parentKey: key, move: { id: mv.id, x: mv.x, y: mv.y } }); - stateByKey.set(nk, ns); - if (isSolved(ns)) { - const path = []; - let cur = nk; - while (meta.get(cur)) { const e = meta.get(cur); path.unshift(e.move); cur = e.parentKey; } - return { moves: depth, path }; - } - next.push(nk); - } - if (meta.size > maxStates) return { moves: -1, path: null }; + const build = (endIdx) => { + const path = []; + for (let at = endIdx; at > 0; at = parent[at]) { + const i = mvVeh[at]; + const p = mvPos[at]; + path.push({ + id: B.ids[i], + x: B.horiz[i] ? p : B.fixed[i], + y: B.horiz[i] ? B.fixed[i] : p, + }); } - frontier = next; - if (depth > 100) break; + path.reverse(); + return path; + }; + + for (let qi = 0; qi < states.length; qi++) { + const P = states[qi]; + const baseKey = keys[qi]; + const d = depth[qi]; + B.paint(P, occ); + + let done = -1; + forEachSlide(B, P, occ, (i, np) => { + if (done >= 0) return; + const k = baseKey + (np - P[i]) * B.pow[i]; + if (seen.has(k)) return; + const Q = Uint8Array.from(P); + Q[i] = np; + const idx = states.length; + seen.set(k, idx); + states.push(Q); keys.push(k); parent.push(qi); mvVeh.push(i); mvPos.push(np); depth.push(d + 1); + if (B.isGoal(Q)) done = idx; + }); + + if (done >= 0) return { moves: depth[done], path: build(done) }; + if (states.length > maxStates) return { moves: -1, path: null }; } return { moves: -1, path: null }; } + +// Full reachable cluster of a board, plus each state's distance to the goal. +// +// Every slide is reversible, so the state graph is undirected: a multi-source +// BFS from the goal states yields optimal distance-to-goal for the whole +// cluster in one pass. Neighbours are recomputed rather than stored — clusters +// run to hundreds of thousands of states and an explicit adjacency list costs +// far more memory than the second enumeration costs time. +// +// Returns null if the cluster exceeds maxStates or contains no goal state. +// { size, states, dist, startDist, maxDist, hardest, keyOf, indexOf, board } +// `hardest` is a state at maximum distance from the goal — i.e. the hardest +// arrangement of this piece set, which is what makes a puzzle "unsolved" in +// Fogleman's sense: no rearrangement of these pieces is harder. +export function analyzeCluster(vehicles, { maxStates = 400000 } = {}) { + const B = packBoard(vehicles); + if (!B.targetEscapes) return null; + + const states = [B.start]; + const keys = [B.key(B.start)]; + const index = new Map([[keys[0], 0]]); + const occ = new Uint8Array(CELLS); + + for (let qi = 0; qi < states.length; qi++) { + const P = states[qi]; + const baseKey = keys[qi]; + B.paint(P, occ); + forEachSlide(B, P, occ, (i, np) => { + const k = baseKey + (np - P[i]) * B.pow[i]; + if (index.has(k)) return; + const Q = Uint8Array.from(P); + Q[i] = np; + index.set(k, states.length); + states.push(Q); + keys.push(k); + }); + if (states.length > maxStates) return null; + } + + const size = states.length; + const dist = new Int32Array(size).fill(-1); + let frontier = []; + for (let i = 0; i < size; i++) { + if (B.isGoal(states[i])) { dist[i] = 0; frontier.push(i); } + } + if (!frontier.length) return null; + + let d = 0; + while (frontier.length) { + d++; + const next = []; + for (const qi of frontier) { + const P = states[qi]; + const baseKey = keys[qi]; + B.paint(P, occ); + forEachSlide(B, P, occ, (i, np) => { + const k = baseKey + (np - P[i]) * B.pow[i]; + const ni = index.get(k); + if (ni === undefined || dist[ni] >= 0) return; + dist[ni] = d; + next.push(ni); + }); + } + frontier = next; + } + + let maxDist = 0; + let hardestIdx = 0; + for (let i = 0; i < size; i++) { + if (dist[i] > maxDist) { maxDist = dist[i]; hardestIdx = i; } + } + + // One-slide neighbours of a state, as { index, vehicleIdx, pos }. Used by the + // generator to measure how many legal moves at each step are dead ends. + const nbrOcc = new Uint8Array(CELLS); + function neighbors(qi) { + const P = states[qi]; + const baseKey = keys[qi]; + B.paint(P, nbrOcc); + const out = []; + forEachSlide(B, P, nbrOcc, (i, np) => { + out.push({ index: index.get(baseKey + (np - P[i]) * B.pow[i]), vehicleIdx: i, pos: np }); + }); + return out; + } + + return { + board: B, + size, + states, + dist, + startDist: dist[0], + maxDist, + hardest: states[hardestIdx], + hardestIndex: hardestIdx, + neighbors, + indexOf: (P) => index.get(B.key(P)), + toVehicles: (P) => B.toVehicles(P), + }; +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 400aa14..05e77d7 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -52,7 +52,6 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('advancewars-artwork', 'data/advancewars-artwork.json'); this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json'); - this.load.json('rushhour', 'data/rushhour.json'); this.load.json('bloxorz', 'data/bloxorz.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json'); diff --git a/tools/genRushHour.js b/tools/genRushHour.js index f850ed9..8de991c 100644 --- a/tools/genRushHour.js +++ b/tools/genRushHour.js @@ -1,29 +1,101 @@ -// Offline generator for Rush Hour puzzles. -// -// Random-fills a 6x6 board with a red target car plus cars (len 2) and trucks -// (len 3), runs the BFS solver to (a) reject unsolvable/trivial layouts and -// (b) label each survivor with its minimum-moves difficulty, then selects a -// smooth easy->expert curve and writes ordered levels to data/rushhour.json. +// Offline curriculum generator for Rush Hour. // // Usage: -// node server/scripts/genRushHour.js [seed] [outFile] +// node tools/genRushHour.js [seed] [outFile] // -// Deterministic: same seed -> same bank. Re-run after changing the curve. +// Deterministic: same seed -> same bank. +// +// ── Why this is not just "random boards, keep the solvable ones" ───────────── +// The previous generator reject-sampled random layouts and kept whatever the +// solver reported. That produces padded puzzles: measured on the old 45-level +// bank, 32 levels carried at least one vehicle that could be deleted without +// changing the solution, and level 1 had 10 vehicles of which 8 were pure +// decoration and solved in 2 moves. A board that *looks* busy but is trivially +// empty is exactly what makes a sliding-block game feel unengaging. +// +// Michael Fogleman's exhaustive study of the 6x6 board +// (https://www.michaelfogleman.com/rush/) names the properties that separate a +// real puzzle from a padded one. We enforce four: +// +// 1. MINIMAL — removing any vehicle changes the solution. +// 2. UNSOLVED — the start is the state farthest from the goal in its +// reachable cluster, so no rearrangement of these pieces is +// harder and there is no shortcut to stumble into. +// 3. No complete row of horizontal pieces, no complete column of vertical +// pieces (such lines can never move). +// 4. Nothing but the red car on the exit row. +// +// ── Pipeline ───────────────────────────────────────────────────────────────── +// The key move is `refine()`. Rather than *rejecting* a board that carries a +// spare vehicle, it strips the spare out — removing a redundant piece leaves +// par unchanged by definition — and then re-hardens: with fewer pieces the +// cluster often reaches a farther state, so par goes UP. Stripping and +// hardening feed each other, and the loop terminates at a board that is +// simultaneously minimal and unsolved. Car count therefore falls out of the +// difficulty rather than being dialled in, which is what gives the curriculum +// its shape: easy levels are genuinely small boards where every car matters, +// not big boards with a two-move answer. +// +// Phase A seeds a pool by random sampling. Phase B hill-climbs from the best +// boards found (add / relocate / lengthen a piece, then refine again), which is +// what reaches the top tiers — pure sampling plateaus around par 24. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - GRID, EXIT_ROW, TARGET_ID, isSolved, solve, vehicleCells, + GRID, EXIT_ROW, TARGET_ID, analyzeCluster, solve, } from '../src/games/rushhour/RushHourLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[3] ? path.resolve(process.argv[3]) - : path.join(__dirname, '../data/rushhour.json'); + : path.join(__dirname, '../assets/gamedata/rushhour/levels.json'); const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x9e3779b9; +// ── Tuning ─────────────────────────────────────────────────────────────────── + +// Budgets are counted in iterations, not wall clock: a time-bounded loop makes +// the output depend on how fast the machine happened to be, which would mean +// the committed bank could not be reproduced from its seed. WALL_CLOCK_CAP is +// only an emergency brake, and firing it is treated as a failure. +const PHASE_A_ATTEMPTS = Number(process.env.RH_PHASE_A ?? 700); +const PHASE_B_CLIMBS = Number(process.env.RH_PHASE_B ?? 3000); +const WALL_CLOCK_CAP = Number(process.env.RH_MAX_SECONDS ?? 2400); +const CLUSTER_MAX_STATES = 300000; +const ELITE_POOL = 48; + +// Difficulty tiers. `par` is the move band; vehicle count is an *outcome* of +// refine(), not an input, so it is reported rather than constrained. +const TIERS = [ + { + id: 'downtown', name: 'Downtown', count: 12, par: [5, 9], + names: ['First Gear', 'Fender Bender', 'One Way Out', 'Meter Maid', 'Corner Store', 'Crosswalk', + 'Double Park', 'Side Street', 'Red Light', 'Delivery Van', 'Taxi Stand', 'Grid Lock'], + }, + { + id: 'freightyard', name: 'Freight Yard', count: 12, par: [10, 14], + names: ['Loading Dock', 'Long Haul', 'Container Row', 'Weigh Station', 'Flatbed', 'Coupling', + 'Yard Shunt', 'Box Car', 'Diesel Lane', 'The Straddle', 'Cargo Jam', 'Last Wagon'], + }, + { + id: 'airport', name: 'Airport Apron', count: 12, par: [15, 19], + names: ['Pushback', 'Baggage Train', 'Fuel Bowser', 'Taxiway Bravo', 'Ground Hold', 'Jet Bridge', + 'De-icer', 'Catering Lift', 'Stand 21', 'Runway Cross', 'Apron Shuffle', 'Final Approach'], + }, + { + id: 'construction', name: 'Construction', count: 12, par: [20, 25], + names: ['Ground Break', 'Skip Loader', 'Cement Mixer', 'Steel Beam', 'Backhoe', 'Scaffold', + 'Tipper Truck', 'Crane Base', 'Rebar', 'Site Gate', 'Dozer Line', 'Hard Hat'], + }, + { + id: 'nightcity', name: 'Night City', count: 12, par: [26, 99], + names: ['Neon Mile', 'Last Call', 'Wet Asphalt', 'Midnight Run', 'Streetlight', 'After Hours', + 'Rain Check', 'Chrome', 'Blackout', 'Red Line', 'The Long Night', 'Dead of Night'], + }, +]; + // ── Seeded RNG (mulberry32) ────────────────────────────────────────────────── function makeRng(seed) { let a = seed >>> 0; @@ -36,22 +108,30 @@ function makeRng(seed) { } const rng = makeRng(SEED); const randInt = (n) => Math.floor(rng() * n); -const pick = (arr) => arr[randInt(arr.length)]; -// Difficulty curve: count + [min,max] minMoves per tier. Sums to the bank size. -const TIERS = [ - { count: 8, min: 2, max: 4 }, - { count: 10, min: 5, max: 7 }, - { count: 10, min: 8, max: 11 }, - { count: 8, min: 12, max: 15 }, - { count: 7, min: 16, max: 20 }, - { count: 5, min: 21, max: 40 }, -]; -const MAX_ATTEMPTS = 8000000; -const MAX_SECONDS = 220; // wall-clock budget -const SOLVE_MAX_STATES = 12000; // bound per-solve cost (rejects pathological layouts fast) +// ── Board helpers ──────────────────────────────────────────────────────────── -const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop'; +const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVW'; + +function cellsOf(v) { + const out = []; + for (let i = 0; i < v.len; i++) out.push(v.orient === 'h' ? [v.x + i, v.y] : [v.x, v.y + i]); + return out; +} + +function occupancy(vehicles) { + const occ = Array.from({ length: GRID }, () => Array(GRID).fill(null)); + for (const v of vehicles) for (const [x, y] of cellsOf(v)) occ[y][x] = v.id; + return occ; +} + +function fits(occ, v) { + for (const [x, y] of cellsOf(v)) { + if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false; + if (occ[y][x] !== null) return false; + } + return true; +} // Canonical, label-independent key for dedup. function canonKey(vehicles) { @@ -61,117 +141,359 @@ function canonKey(vehicles) { .join('|'); } -function fits(grid, v) { - for (const [x, y] of vehicleCells(v)) { - if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false; - if (grid[y][x] !== null) return false; +// Criteria 3 and 4. Orientation and fixed axis never change as pieces slide, so +// the exit-row rule is invariant once placed; the frozen-line rule is not, and +// must be checked on the final arrangement. +function structurallyOk(vehicles) { + for (const v of vehicles) { + if (!v.isTarget && v.orient === 'h' && v.y === EXIT_ROW) return false; + } + const occ = occupancy(vehicles); + const byId = new Map(vehicles.map((v) => [v.id, v])); + for (let y = 0; y < GRID; y++) { + let full = true; + for (let x = 0; x < GRID; x++) { + const id = occ[y][x]; + if (id === null || byId.get(id).orient !== 'h') { full = false; break; } + } + if (full) return false; + } + for (let x = 0; x < GRID; x++) { + let full = true; + for (let y = 0; y < GRID; y++) { + const id = occ[y][x]; + if (id === null || byId.get(id).orient !== 'v') { full = false; break; } + } + if (full) return false; } return true; } -function stamp(grid, v) { - for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id; + +// Relabel deterministically: target is X, the rest A.. in reading order. Keeps +// the shipped JSON (and the colour slots the scene derives from it) stable. +function normalize(vehicles) { + const target = vehicles.find((v) => v.isTarget); + const rest = vehicles.filter((v) => !v.isTarget) + .sort((a, b) => (a.y - b.y) || (a.x - b.x) || (a.orient < b.orient ? -1 : 1)); + return [ + { id: TARGET_ID, x: target.x, y: target.y, len: target.len, orient: 'h', isTarget: true }, + ...rest.map((v, i) => ({ id: LETTERS[i], x: v.x, y: v.y, len: v.len, orient: v.orient, isTarget: false })), + ]; } -// Build one random candidate layout (target + blockers), or null if it failed -// to place the target. -function randomLayout() { - const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null)); - const vehicles = []; +function randomBlocker(id) { + const orient = rng() < 0.55 ? 'v' : 'h'; + const len = rng() < 0.35 ? 3 : 2; + const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID); + const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1); + return { id, x, y, len, orient, isTarget: false }; +} - // Target car: horizontal, length 2, starting far left so its escape path is - // long and likely blocked -> richer puzzles. - const target = { id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }; - stamp(grid, target); - vehicles.push(target); - - const count = 6 + randInt(5); // 6..10 blockers +function randomLayout(nBlockers) { + const vehicles = [{ id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }]; + const occ = occupancy(vehicles); let letterIdx = 0; let tries = 0; - while (vehicles.length < count + 1 && tries < 200) { + while (vehicles.length < nBlockers + 1 && tries < 400) { tries++; - // Favour vertical cars: they cross the exit row and create real blockades. - const orient = rng() < 0.6 ? 'v' : 'h'; - const len = rng() < 0.3 ? 3 : 2; - const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID); - const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1); - const v = { id: LETTERS[letterIdx], x, y, len, orient, isTarget: false }; - if (!fits(grid, v)) continue; - stamp(grid, v); + const v = randomBlocker(LETTERS[letterIdx]); + if (v.orient === 'h' && v.y === EXIT_ROW) continue; // would block the exit forever + if (!fits(occ, v)) continue; + for (const [x, y] of cellsOf(v)) occ[y][x] = v.id; vehicles.push(v); letterIdx++; } return vehicles; } -// ── Generate pool ──────────────────────────────────────────────────────────── -console.log(`[rushhour] generating with seed ${SEED}…`); -const target = TIERS.reduce((t, x) => t + x.count, 0); +// ── The core: strip to minimal, harden to the cluster's farthest state ─────── +// +// Removing a redundant piece cannot change par (that is what redundant means), +// but it can enlarge the cluster, so the re-harden on the next pass may find a +// strictly harder start. Terminates when a full sweep strips nothing. +function refine(vehicles) { + let cur = vehicles; + for (let round = 0; round < 24; round++) { + const C = analyzeCluster(cur, { maxStates: CLUSTER_MAX_STATES }); + if (!C || C.maxDist < 2) return null; + cur = C.toVehicles(C.hardest); + const par = C.maxDist; + + let stripped = false; + for (const v of cur) { + if (v.isTarget) continue; + const reduced = cur.filter((w) => w.id !== v.id); + if (reduced.length < 2) continue; + if (solve(reduced, { maxStates: CLUSTER_MAX_STATES }).moves === par) { + cur = reduced; + stripped = true; + break; + } + } + if (!stripped) return { vehicles: normalize(cur), par }; + } + return null; +} + +// ── Difficulty metrics ─────────────────────────────────────────────────────── +// +// Par alone is a poor proxy for how hard a puzzle *feels*: a 20-move solution +// made of forced moves is easier than a 12-move one with three retreats. All of +// these fall out of the distance-to-goal map the cluster analysis already +// computed, so they cost nothing extra. +function measure(vehicles, par) { + const C = analyzeCluster(vehicles, { maxStates: CLUSTER_MAX_STATES }); + if (!C || C.startDist !== par) return null; + + const targetIdx = C.board.targetIdx; + const movedBy = new Set(); + let targetRetreats = 0; + let decoySum = 0; + let decoySteps = 0; + + let at = 0; + let guard = 0; + while (C.dist[at] > 0 && guard++ < 200) { + const nbrs = C.neighbors(at); + const here = C.dist[at]; + const dead = nbrs.filter((nb) => C.dist[nb.index] >= here).length; + if (nbrs.length) { decoySum += dead / nbrs.length; decoySteps++; } + + const step = nbrs.find((nb) => C.dist[nb.index] === here - 1); + if (!step) return null; + movedBy.add(step.vehicleIdx); + if (step.vehicleIdx === targetIdx && step.pos < C.states[at][targetIdx]) targetRetreats++; + at = step.index; + } + + const firstMoveFanout = C.neighbors(0).filter((nb) => C.dist[nb.index] === par - 1).length; + + return { + carsMoved: movedBy.size, + decoyDensity: decoySteps ? decoySum / decoySteps : 0, + targetRetreats, + firstMoveFanout, + clusterSize: C.size, + }; +} + +// Composite ranking. Par dominates (it is the player-visible number), with the +// "how easy is it to go wrong" terms breaking ties within a band. +function composite(par, m) { + return par + + 0.8 * m.carsMoved + + 10 * m.decoyDensity + + 2.5 * m.targetRetreats + - 0.4 * m.firstMoveFanout; +} + +// ── Search ─────────────────────────────────────────────────────────────────── + const buckets = TIERS.map(() => []); const seen = new Set(); +const elites = []; +let refined = 0; let attempts = 0; -let solved = 0; -function tierIndexFor(moves) { +function tierFor(par) { for (let i = 0; i < TIERS.length; i++) { - if (moves >= TIERS[i].min && moves <= TIERS[i].max) return i; + if (par >= TIERS[i].par[0] && par <= TIERS[i].par[1]) return i; } return -1; } -const tierFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); -const startedAt = Date.now(); -while (attempts < MAX_ATTEMPTS && !tierFull()) { - attempts++; - if ((attempts & 0x3ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { - console.log('\n[rushhour] time budget reached, stopping early'); - break; - } - const vehicles = randomLayout(); - if (isSolved(vehicles)) continue; // already at exit -> trivial +function consider(result) { + if (!result) return null; + const { vehicles, par } = result; + if (!structurallyOk(vehicles)) return null; const key = canonKey(vehicles); - if (seen.has(key)) continue; + if (seen.has(key)) return null; seen.add(key); + refined++; - const { moves } = solve(vehicles, { maxStates: SOLVE_MAX_STATES }); - if (moves < 2) continue; // unsolvable or 1-move (no blockers) - solved++; - const ti = tierIndexFor(moves); - if (ti === -1) continue; - if (buckets[ti].length >= TIERS[ti].count) continue; - buckets[ti].push({ minMoves: moves, vehicles }); + // Elite pool drives phase B regardless of whether this board lands in a tier. + elites.push({ vehicles, par }); + elites.sort((a, b) => b.par - a.par); + if (elites.length > ELITE_POOL) elites.length = ELITE_POOL; - if (solved % 2000 === 0) { - process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `); + const ti = tierFor(par); + if (ti === -1) return { par }; + const m = measure(vehicles, par); + if (!m) return { par }; + buckets[ti].push({ vehicles, par, ...m, score: composite(par, m) }); + return { par, tier: ti }; +} + +// `densify` biases toward adding and lengthening pieces. Dropping a piece +// usually lowers par, so when the hard tiers are the ones still short there is +// little point spending climbs on it. +function mutate(vehicles, densify) { + const out = vehicles.map((v) => ({ ...v })); + const blockers = out.filter((v) => !v.isTarget); + const roll = densify ? 0.55 + rng() * 0.45 : rng(); + + if (roll < 0.30 && blockers.length > 2) { + // Relocate: drop one piece, place a fresh one somewhere it fits. + const victim = blockers[randInt(blockers.length)]; + const kept = out.filter((v) => v.id !== victim.id); + const occ = occupancy(kept); + for (let t = 0; t < 60; t++) { + const v = randomBlocker('tmp'); + if (v.orient === 'h' && v.y === EXIT_ROW) continue; + if (!fits(occ, v)) continue; + return normalize([...kept, v]); + } + return null; + } + + if (roll < 0.55 && blockers.length > 2) { + // Drop a piece outright and let refine() re-harden the sparser board. + const victim = blockers[randInt(blockers.length)]; + return normalize(out.filter((v) => v.id !== victim.id)); + } + + if (roll < 0.80) { + // Lengthen a car into a truck where there is room. + const shorts = out.filter((v) => !v.isTarget && v.len === 2); + if (!shorts.length) return null; + const v = shorts[randInt(shorts.length)]; + const kept = out.filter((w) => w.id !== v.id); + const occ = occupancy(kept); + for (const cand of [{ ...v, len: 3 }, { ...v, len: 3, x: v.orient === 'h' ? v.x - 1 : v.x, y: v.orient === 'v' ? v.y - 1 : v.y }]) { + if (fits(occ, cand)) return normalize([...kept, cand]); + } + return null; + } + + // Add a piece: denser boards give refine() more to strip and harden against. + const occ = occupancy(out); + for (let t = 0; t < 60; t++) { + const v = randomBlocker('tmp'); + if (v.orient === 'h' && v.y === EXIT_ROW) continue; + if (!fits(occ, v)) continue; + return normalize([...out, v]); + } + return null; +} + +const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); +// Count toward the goal only what a tier can actually use, so the progress +// line reads as "levels we can ship", not "candidates collected". +const kept = () => buckets.reduce((t, b, i) => t + Math.min(b.length, TIERS[i].count), 0); +const wanted = TIERS.reduce((t, x) => t + x.count, 0); + +console.log(`[rushhour] generating with seed ${SEED}…`); + +const startedAt = Date.now(); +let timedOut = false; +const overCap = () => { + if ((Date.now() - startedAt) / 1000 < WALL_CLOCK_CAP) return false; + timedOut = true; + return true; +}; + +// Phase A — random seeding. +while (attempts < PHASE_A_ATTEMPTS && !tiersFull() && !overCap()) { + attempts++; + consider(refine(randomLayout(6 + randInt(8)))); + if (attempts % 25 === 0) { + process.stdout.write(`\r[rushhour] phase A attempts=${attempts} refined=${refined} kept=${kept()}/${wanted} `); + } +} +process.stdout.write('\n'); +console.log(`[rushhour] phase A done: ${kept()}/${wanted} kept, elite par max ${elites[0]?.par ?? 0}`); + +// Phase B — hill climb from the elite pool toward the tiers still short. +let climbs = 0; +while (climbs < PHASE_B_CLIMBS && !tiersFull() && !overCap()) { + climbs++; + // Bias seed choice toward the hardest boards when the top tiers are short. + const shortHigh = buckets.some((b, i) => i >= 3 && b.length < TIERS[i].count); + const pool = shortHigh ? elites.slice(0, Math.max(8, elites.length >> 1)) : elites; + if (!pool.length) break; + const seedBoard = pool[randInt(pool.length)]; + const mutated = mutate(seedBoard.vehicles, shortHigh); + if (!mutated) continue; + consider(refine(mutated)); + if (climbs % 25 === 0) { + process.stdout.write(`\r[rushhour] phase B climbs=${climbs} refined=${refined} kept=${kept()}/${wanted} best par=${elites[0]?.par ?? 0} `); } } process.stdout.write('\n'); -// ── Assemble ordered levels ────────────────────────────────────────────────── -const chosen = []; -buckets.forEach((b) => { - b.sort((p, q) => p.minMoves - q.minMoves); - chosen.push(...b); -}); -// Overall ascending so levels ramp smoothly even across tier boundaries. -chosen.sort((p, q) => p.minMoves - q.minMoves); +// ── Assemble ───────────────────────────────────────────────────────────────── -const puzzles = chosen.map((p, i) => ({ - level: i + 1, - minMoves: p.minMoves, - vehicles: p.vehicles, -})); +const shortfall = []; +const levels = []; +const tiersOut = []; +let levelNo = 1; + +TIERS.forEach((tier, ti) => { + const pool = buckets[ti].slice().sort((a, b) => a.score - b.score); + if (pool.length < tier.count) shortfall.push(`${tier.name}: ${pool.length}/${tier.count}`); + + // Spread the picks across the band rather than taking the 12 easiest. + const picks = []; + if (pool.length <= tier.count) { + picks.push(...pool); + } else { + for (let i = 0; i < tier.count; i++) { + picks.push(pool[Math.round((i * (pool.length - 1)) / (tier.count - 1))]); + } + } + + // Selection spreads across the composite score so a tier samples its whole + // band, but presentation order is by par: par is the number printed on the + // level tile, and a tile reading "par 22" after one reading "par 25" looks + // like the ramp went backwards even when the later puzzle is genuinely + // trickier. Composite breaks ties. + picks.sort((a, b) => (a.par - b.par) || (a.score - b.score)); + + const from = levelNo; + picks.forEach((p, i) => { + levels.push({ + level: levelNo++, + name: tier.names[i] ?? `${tier.name} ${i + 1}`, + tier: tier.id, + par: p.par, + carsMoved: p.carsMoved, + decoyDensity: Number(p.decoyDensity.toFixed(3)), + targetRetreats: p.targetRetreats, + firstMoveFanout: p.firstMoveFanout, + difficulty: Number(p.score.toFixed(2)), + vehicles: p.vehicles, + }); + }); + tiersOut.push({ id: tier.id, name: tier.name, theme: tier.id, from, to: levelNo - 1 }); +}); const payload = { - generatedAt: new Date().toISOString(), + version: 1, seed: SEED, - count: puzzles.length, - puzzles, + generatedAt: new Date().toISOString(), + count: levels.length, + tiers: tiersOut, + levels, }; fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); -const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' '); -console.log(`[rushhour] attempts=${attempts} solvable=${solved}`); -console.log(`[rushhour] tiers filled: ${perTier}`); -console.log(`[rushhour] wrote ${puzzles.length} levels (minMoves ${puzzles[0]?.minMoves}..${puzzles[puzzles.length - 1]?.minMoves}) -> ${OUT_FILE}`); +console.log(`[rushhour] attempts=${attempts} climbs=${climbs} distinct minimal boards=${refined}`); +TIERS.forEach((t, i) => { + const pool = buckets[i]; + const pars = pool.map((p) => p.par); + console.log(`[rushhour] ${t.name.padEnd(14)} ${String(pool.length).padStart(3)}/${t.count} candidates` + + (pars.length ? ` par ${Math.min(...pars)}..${Math.max(...pars)}` : '')); +}); +console.log(`[rushhour] wrote ${levels.length} levels -> ${OUT_FILE}`); + +if (timedOut) { + console.error(`\n[rushhour] WALL CLOCK CAP (${WALL_CLOCK_CAP}s) HIT — this bank is not reproducible from its seed.`); + process.exit(1); +} +if (shortfall.length) { + console.error(`\n[rushhour] TIERS SHORT: ${shortfall.join(', ')}`); + console.error('[rushhour] raise RH_PHASE_B, or widen the par bands, and re-run.'); + process.exit(1); +} diff --git a/tools/verifyRushHour.js b/tools/verifyRushHour.js new file mode 100644 index 0000000..f6d8824 --- /dev/null +++ b/tools/verifyRushHour.js @@ -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`);