diff --git a/assets/images/ta/arm-structures.png b/assets/images/ta/arm-structures.png new file mode 100644 index 0000000..6f9c80a Binary files /dev/null and b/assets/images/ta/arm-structures.png differ diff --git a/assets/images/ta/arm-structures.psd b/assets/images/ta/arm-structures.psd new file mode 100644 index 0000000..2315de0 Binary files /dev/null and b/assets/images/ta/arm-structures.psd differ diff --git a/assets/images/ta/originals/arm_barracks.png b/assets/images/ta/originals/arm_barracks.png new file mode 100644 index 0000000..07af330 Binary files /dev/null and b/assets/images/ta/originals/arm_barracks.png differ diff --git a/assets/images/ta/originals/arm_barracks_build-2.png b/assets/images/ta/originals/arm_barracks_build-2.png new file mode 100644 index 0000000..d60f8e8 Binary files /dev/null and b/assets/images/ta/originals/arm_barracks_build-2.png differ diff --git a/assets/images/ta/originals/arm_barracks_build.png b/assets/images/ta/originals/arm_barracks_build.png new file mode 100644 index 0000000..4925a86 Binary files /dev/null and b/assets/images/ta/originals/arm_barracks_build.png differ diff --git a/assets/images/ta/originals/arm_vehicle.png b/assets/images/ta/originals/arm_vehicle.png new file mode 100644 index 0000000..7a5c9a2 Binary files /dev/null and b/assets/images/ta/originals/arm_vehicle.png differ diff --git a/data/bloxorz.json b/data/bloxorz.json new file mode 100644 index 0000000..740215e --- /dev/null +++ b/data/bloxorz.json @@ -0,0 +1,6002 @@ +{ + "generatedAt": "2026-07-25T14:50:43.614Z", + "seed": null, + "count": 36, + "levels": [ + { + "level": 1, + "name": "Warm Up", + "cols": 6, + "rows": 5, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 4, + "y": 3 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "goal" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + } + ], + "par": 4 + }, + { + "level": 2, + "name": "Stretch Out", + "cols": 7, + "rows": 5, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 5, + "y": 3 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "goal" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + } + ], + "par": 6 + }, + { + "level": 3, + "name": "Open Floor", + "cols": 8, + "rows": 6, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 6, + "y": 4 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "goal" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + } + ], + "par": 6 + }, + { + "level": 4, + "name": "The Corner", + "cols": 9, + "rows": 7, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 7, + "y": 5 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 0, + "y": 6, + "type": "floor" + }, + { + "x": 1, + "y": 6, + "type": "floor" + }, + { + "x": 2, + "y": 6, + "type": "floor" + }, + { + "x": 3, + "y": 6, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 8, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "goal" + }, + { + "x": 8, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 8, + "y": 6, + "type": "floor" + } + ], + "par": 7 + }, + { + "level": 5, + "name": "Crossroads", + "cols": 9, + "rows": 9, + "start": { + "x": 4, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 1, + "y": 4 + }, + "tiles": [ + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 6, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 3, + "y": 7, + "type": "floor" + }, + { + "x": 4, + "y": 7, + "type": "floor" + }, + { + "x": 5, + "y": 7, + "type": "floor" + }, + { + "x": 3, + "y": 8, + "type": "floor" + }, + { + "x": 4, + "y": 8, + "type": "floor" + }, + { + "x": 5, + "y": 8, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 8, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "goal" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 8, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + }, + { + "x": 8, + "y": 5, + "type": "floor" + } + ], + "par": 4 + }, + { + "level": 6, + "name": "Staircase", + "cols": 10, + "rows": 9, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 8, + "y": 7 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 8, + "y": 5, + "type": "floor" + }, + { + "x": 9, + "y": 5, + "type": "floor" + }, + { + "x": 8, + "y": 6, + "type": "floor" + }, + { + "x": 9, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 7, + "type": "floor" + }, + { + "x": 7, + "y": 7, + "type": "floor" + }, + { + "x": 8, + "y": 7, + "type": "goal" + }, + { + "x": 9, + "y": 7, + "type": "floor" + }, + { + "x": 6, + "y": 8, + "type": "floor" + }, + { + "x": 7, + "y": 8, + "type": "floor" + }, + { + "x": 8, + "y": 8, + "type": "floor" + }, + { + "x": 9, + "y": 8, + "type": "floor" + } + ], + "par": 9 + }, + { + "level": 7, + "name": "Mind the Gap", + "cols": 8, + "rows": 6, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 6, + "y": 4 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "goal" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + } + ], + "par": 6 + }, + { + "level": 8, + "name": "Pinwheel", + "cols": 9, + "rows": 7, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 7, + "y": 5 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 8, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 8, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 8, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 8, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "goal" + }, + { + "x": 8, + "y": 5, + "type": "floor" + }, + { + "x": 0, + "y": 6, + "type": "floor" + }, + { + "x": 1, + "y": 6, + "type": "floor" + }, + { + "x": 2, + "y": 6, + "type": "floor" + }, + { + "x": 3, + "y": 6, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 8, + "y": 6, + "type": "floor" + } + ], + "par": 7 + }, + { + "level": 9, + "name": "Causeway", + "cols": 9, + "rows": 3, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 7, + "y": 1 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 8, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "goal" + }, + { + "x": 8, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + } + ], + "par": 4 + }, + { + "level": 10, + "name": "Bent Causeway", + "cols": 8, + "rows": 8, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 6, + "y": 6 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 6, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "goal" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 3, + "y": 7, + "type": "floor" + }, + { + "x": 4, + "y": 7, + "type": "floor" + }, + { + "x": 5, + "y": 7, + "type": "floor" + }, + { + "x": 6, + "y": 7, + "type": "floor" + }, + { + "x": 7, + "y": 7, + "type": "floor" + } + ], + "par": 10 + }, + { + "level": 11, + "name": "Scattered", + "cols": 10, + "rows": 8, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 8, + "y": 6 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 8, + "y": 0, + "type": "floor" + }, + { + "x": 9, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 8, + "y": 1, + "type": "floor" + }, + { + "x": 9, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 8, + "y": 3, + "type": "floor" + }, + { + "x": 9, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 8, + "y": 4, + "type": "floor" + }, + { + "x": 9, + "y": 4, + "type": "floor" + }, + { + "x": 0, + "y": 5, + "type": "floor" + }, + { + "x": 1, + "y": 5, + "type": "floor" + }, + { + "x": 2, + "y": 5, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 8, + "y": 5, + "type": "floor" + }, + { + "x": 9, + "y": 5, + "type": "floor" + }, + { + "x": 0, + "y": 6, + "type": "floor" + }, + { + "x": 1, + "y": 6, + "type": "floor" + }, + { + "x": 3, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 8, + "y": 6, + "type": "goal" + }, + { + "x": 9, + "y": 6, + "type": "floor" + }, + { + "x": 0, + "y": 7, + "type": "floor" + }, + { + "x": 1, + "y": 7, + "type": "floor" + }, + { + "x": 2, + "y": 7, + "type": "floor" + }, + { + "x": 3, + "y": 7, + "type": "floor" + }, + { + "x": 4, + "y": 7, + "type": "floor" + }, + { + "x": 5, + "y": 7, + "type": "floor" + }, + { + "x": 6, + "y": 7, + "type": "floor" + }, + { + "x": 7, + "y": 7, + "type": "floor" + }, + { + "x": 8, + "y": 7, + "type": "floor" + }, + { + "x": 9, + "y": 7, + "type": "floor" + } + ], + "par": 10 + }, + { + "level": 12, + "name": "Tightrope", + "cols": 8, + "rows": 8, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 6, + "y": 6 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 4, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "goal" + }, + { + "x": 7, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 5, + "y": 7, + "type": "floor" + }, + { + "x": 6, + "y": 7, + "type": "floor" + }, + { + "x": 7, + "y": 7, + "type": "floor" + } + ], + "par": 8 + }, + { + "level": 13, + "name": "Flip the Switch", + "cols": 10, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 9, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "goal" + } + ], + "par": 6 + }, + { + "level": 14, + "name": "Weight and See", + "cols": 7, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 6, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 3, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "goal" + } + ], + "par": 4 + }, + { + "level": 15, + "name": "Double Gate", + "cols": 13, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 12, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1", + "b2" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "bridge", + "bridgeId": "b2", + "initiallyOpen": false + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "goal" + } + ], + "par": 8 + }, + { + "level": 16, + "name": "Two Keys", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "switch", + "switchId": "s2", + "linkedBridgeIds": [ + "b2" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "bridge", + "bridgeId": "b2", + "initiallyOpen": false + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 10 + }, + { + "level": 17, + "name": "Soft Landing", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "switch", + "switchId": "s2", + "linkedBridgeIds": [ + "b2" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 9, + "y": 2, + "type": "bridge", + "bridgeId": "b2", + "initiallyOpen": false + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 10 + }, + { + "level": 18, + "name": "Three Gates", + "cols": 19, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 18, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "switch", + "switchId": "s2", + "linkedBridgeIds": [ + "b2" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 9, + "y": 2, + "type": "bridge", + "bridgeId": "b2", + "initiallyOpen": false + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "switch", + "switchId": "s3", + "linkedBridgeIds": [ + "b3" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "bridge", + "bridgeId": "b3", + "initiallyOpen": false + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "goal" + } + ], + "par": 12 + }, + { + "level": 19, + "name": "Cracked Floor", + "cols": 10, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 9, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "goal" + } + ], + "par": 6 + }, + { + "level": 20, + "name": "Eggshells", + "cols": 13, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 12, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "fragile" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "goal" + } + ], + "par": 8 + }, + { + "level": 21, + "name": "No Turning Back", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "fragile" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "fragile" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 10 + }, + { + "level": 22, + "name": "Fragile Gate", + "cols": 13, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 12, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "fragile" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "goal" + } + ], + "par": 8 + }, + { + "level": 23, + "name": "Soft and Thin", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "fragile" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 10 + }, + { + "level": 24, + "name": "Every Step Counts", + "cols": 19, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 18, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "fragile" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "switch", + "switchId": "s2", + "linkedBridgeIds": [ + "b2" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 15, + "y": 2, + "type": "bridge", + "bridgeId": "b2", + "initiallyOpen": false + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "goal" + } + ], + "par": 12 + }, + { + "level": 25, + "name": "Shortcut", + "cols": 14, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 13, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "goal" + } + ], + "par": 4 + }, + { + "level": 26, + "name": "Two Hops", + "cols": 22, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 21, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "teleport", + "teleportId": "t2" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "teleport", + "teleportId": "t2" + }, + { + "x": 19, + "y": 2, + "type": "floor" + }, + { + "x": 20, + "y": 2, + "type": "floor" + }, + { + "x": 21, + "y": 2, + "type": "goal" + } + ], + "par": 6 + }, + { + "level": 27, + "name": "Triple Hop", + "cols": 28, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 27, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "teleport", + "teleportId": "t2" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "teleport", + "teleportId": "t2" + }, + { + "x": 19, + "y": 2, + "type": "floor" + }, + { + "x": 20, + "y": 2, + "type": "floor" + }, + { + "x": 21, + "y": 2, + "type": "teleport", + "teleportId": "t3" + }, + { + "x": 22, + "y": 2, + "type": "floor" + }, + { + "x": 23, + "y": 2, + "type": "floor" + }, + { + "x": 24, + "y": 2, + "type": "teleport", + "teleportId": "t3" + }, + { + "x": 25, + "y": 2, + "type": "floor" + }, + { + "x": 26, + "y": 2, + "type": "floor" + }, + { + "x": 27, + "y": 2, + "type": "goal" + } + ], + "par": 8 + }, + { + "level": 28, + "name": "Gate and Go", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 6 + }, + { + "level": 29, + "name": "Brittle Hop", + "cols": 13, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 12, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "goal" + } + ], + "par": 6 + }, + { + "level": 30, + "name": "All at Once", + "cols": 19, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 18, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "lying", + "mode": "momentary" + }, + { + "x": 3, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "fragile" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "goal" + } + ], + "par": 8 + }, + { + "level": 31, + "name": "Split Decision", + "cols": 11, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 10, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 5, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "goal" + } + ], + "par": 7 + }, + { + "level": 32, + "name": "Split and Gate", + "cols": 20, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 19, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 8, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "floor" + }, + { + "x": 19, + "y": 2, + "type": "goal" + } + ], + "par": 13 + }, + { + "level": 33, + "name": "Split and Crack", + "cols": 16, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 15, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "fragile" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 7, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "goal" + } + ], + "par": 10 + }, + { + "level": 34, + "name": "Split and Hop", + "cols": 17, + "rows": 5, + "start": { + "x": 0, + "y": 2, + "orient": "up" + }, + "goal": { + "x": 16, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 5, + "y": 2, + "type": "floor", + "split": true + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "teleport", + "teleportId": "t1" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "goal" + } + ], + "par": 7 + }, + { + "level": 35, + "name": "Around the Pillar", + "cols": 16, + "rows": 7, + "start": { + "x": 1, + "y": 3, + "orient": "up" + }, + "goal": { + "x": 14, + "y": 3 + }, + "tiles": [ + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "floor" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 0, + "y": 4, + "type": "floor" + }, + { + "x": 1, + "y": 4, + "type": "floor" + }, + { + "x": 2, + "y": 4, + "type": "floor" + }, + { + "x": 3, + "y": 4, + "type": "floor" + }, + { + "x": 4, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 3, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 8, + "y": 0, + "type": "floor" + }, + { + "x": 9, + "y": 0, + "type": "floor" + }, + { + "x": 10, + "y": 0, + "type": "floor" + }, + { + "x": 11, + "y": 0, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 8, + "y": 1, + "type": "floor" + }, + { + "x": 9, + "y": 1, + "type": "floor" + }, + { + "x": 10, + "y": 1, + "type": "floor" + }, + { + "x": 11, + "y": 1, + "type": "floor" + }, + { + "x": 5, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "floor" + }, + { + "x": 8, + "y": 2, + "type": "wall" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 8, + "y": 3, + "type": "wall" + }, + { + "x": 9, + "y": 3, + "type": "floor" + }, + { + "x": 10, + "y": 3, + "type": "floor" + }, + { + "x": 11, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 4, + "type": "floor" + }, + { + "x": 6, + "y": 4, + "type": "floor" + }, + { + "x": 7, + "y": 4, + "type": "floor" + }, + { + "x": 8, + "y": 4, + "type": "wall" + }, + { + "x": 9, + "y": 4, + "type": "floor" + }, + { + "x": 10, + "y": 4, + "type": "floor" + }, + { + "x": 11, + "y": 4, + "type": "floor" + }, + { + "x": 5, + "y": 5, + "type": "floor" + }, + { + "x": 6, + "y": 5, + "type": "floor" + }, + { + "x": 7, + "y": 5, + "type": "floor" + }, + { + "x": 8, + "y": 5, + "type": "floor" + }, + { + "x": 9, + "y": 5, + "type": "floor" + }, + { + "x": 10, + "y": 5, + "type": "floor" + }, + { + "x": 11, + "y": 5, + "type": "floor" + }, + { + "x": 5, + "y": 6, + "type": "floor" + }, + { + "x": 6, + "y": 6, + "type": "floor" + }, + { + "x": 7, + "y": 6, + "type": "floor" + }, + { + "x": 8, + "y": 6, + "type": "floor" + }, + { + "x": 9, + "y": 6, + "type": "floor" + }, + { + "x": 10, + "y": 6, + "type": "floor" + }, + { + "x": 11, + "y": 6, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "floor" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 3, + "type": "floor" + }, + { + "x": 13, + "y": 3, + "type": "floor" + }, + { + "x": 14, + "y": 3, + "type": "goal" + }, + { + "x": 15, + "y": 3, + "type": "floor" + }, + { + "x": 12, + "y": 4, + "type": "floor" + }, + { + "x": 13, + "y": 4, + "type": "floor" + }, + { + "x": 14, + "y": 4, + "type": "floor" + }, + { + "x": 15, + "y": 4, + "type": "floor" + } + ], + "par": 13 + }, + { + "level": 36, + "name": "Grand Finale", + "cols": 22, + "rows": 5, + "start": { + "x": 1, + "y": 1, + "orient": "up" + }, + "goal": { + "x": 19, + "y": 2 + }, + "tiles": [ + { + "x": 0, + "y": 0, + "type": "floor" + }, + { + "x": 1, + "y": 0, + "type": "floor" + }, + { + "x": 2, + "y": 0, + "type": "floor" + }, + { + "x": 3, + "y": 0, + "type": "floor" + }, + { + "x": 4, + "y": 0, + "type": "floor" + }, + { + "x": 0, + "y": 1, + "type": "floor" + }, + { + "x": 1, + "y": 1, + "type": "floor" + }, + { + "x": 2, + "y": 1, + "type": "floor" + }, + { + "x": 3, + "y": 1, + "type": "floor" + }, + { + "x": 4, + "y": 1, + "type": "floor" + }, + { + "x": 0, + "y": 2, + "type": "floor" + }, + { + "x": 1, + "y": 2, + "type": "floor" + }, + { + "x": 2, + "y": 2, + "type": "switch", + "switchId": "s1", + "linkedBridgeIds": [ + "b1" + ], + "requireOrientation": "any", + "mode": "toggle" + }, + { + "x": 3, + "y": 2, + "type": "floor" + }, + { + "x": 4, + "y": 2, + "type": "floor" + }, + { + "x": 0, + "y": 3, + "type": "floor" + }, + { + "x": 1, + "y": 3, + "type": "floor" + }, + { + "x": 2, + "y": 3, + "type": "floor" + }, + { + "x": 3, + "y": 3, + "type": "floor" + }, + { + "x": 4, + "y": 3, + "type": "floor" + }, + { + "x": 5, + "y": 1, + "type": "bridge", + "bridgeId": "b1", + "initiallyOpen": false + }, + { + "x": 6, + "y": 0, + "type": "floor" + }, + { + "x": 7, + "y": 0, + "type": "floor" + }, + { + "x": 8, + "y": 0, + "type": "floor" + }, + { + "x": 9, + "y": 0, + "type": "floor" + }, + { + "x": 10, + "y": 0, + "type": "floor" + }, + { + "x": 6, + "y": 1, + "type": "floor" + }, + { + "x": 7, + "y": 1, + "type": "floor" + }, + { + "x": 8, + "y": 1, + "type": "floor" + }, + { + "x": 9, + "y": 1, + "type": "floor", + "split": true + }, + { + "x": 10, + "y": 1, + "type": "floor", + "split": true + }, + { + "x": 6, + "y": 2, + "type": "floor" + }, + { + "x": 7, + "y": 2, + "type": "fragile" + }, + { + "x": 8, + "y": 2, + "type": "floor" + }, + { + "x": 9, + "y": 2, + "type": "floor" + }, + { + "x": 10, + "y": 2, + "type": "floor" + }, + { + "x": 6, + "y": 3, + "type": "floor" + }, + { + "x": 7, + "y": 3, + "type": "floor" + }, + { + "x": 8, + "y": 3, + "type": "floor" + }, + { + "x": 9, + "y": 3, + "type": "floor" + }, + { + "x": 10, + "y": 3, + "type": "floor" + }, + { + "x": 11, + "y": 0, + "type": "floor" + }, + { + "x": 12, + "y": 0, + "type": "floor" + }, + { + "x": 13, + "y": 0, + "type": "floor" + }, + { + "x": 14, + "y": 0, + "type": "floor" + }, + { + "x": 15, + "y": 0, + "type": "floor" + }, + { + "x": 16, + "y": 0, + "type": "floor" + }, + { + "x": 17, + "y": 0, + "type": "floor" + }, + { + "x": 11, + "y": 1, + "type": "floor" + }, + { + "x": 12, + "y": 1, + "type": "floor" + }, + { + "x": 13, + "y": 1, + "type": "floor" + }, + { + "x": 14, + "y": 1, + "type": "wall" + }, + { + "x": 15, + "y": 1, + "type": "floor" + }, + { + "x": 16, + "y": 1, + "type": "floor" + }, + { + "x": 17, + "y": 1, + "type": "floor" + }, + { + "x": 11, + "y": 2, + "type": "floor" + }, + { + "x": 12, + "y": 2, + "type": "floor" + }, + { + "x": 13, + "y": 2, + "type": "floor" + }, + { + "x": 14, + "y": 2, + "type": "wall" + }, + { + "x": 15, + "y": 2, + "type": "floor" + }, + { + "x": 16, + "y": 2, + "type": "floor" + }, + { + "x": 17, + "y": 2, + "type": "floor" + }, + { + "x": 11, + "y": 3, + "type": "floor" + }, + { + "x": 12, + "y": 3, + "type": "floor" + }, + { + "x": 13, + "y": 3, + "type": "floor" + }, + { + "x": 14, + "y": 3, + "type": "wall" + }, + { + "x": 15, + "y": 3, + "type": "floor" + }, + { + "x": 16, + "y": 3, + "type": "floor" + }, + { + "x": 17, + "y": 3, + "type": "floor" + }, + { + "x": 11, + "y": 4, + "type": "floor" + }, + { + "x": 12, + "y": 4, + "type": "floor" + }, + { + "x": 13, + "y": 4, + "type": "floor" + }, + { + "x": 14, + "y": 4, + "type": "floor" + }, + { + "x": 15, + "y": 4, + "type": "floor" + }, + { + "x": 16, + "y": 4, + "type": "floor" + }, + { + "x": 17, + "y": 4, + "type": "floor" + }, + { + "x": 18, + "y": 0, + "type": "floor" + }, + { + "x": 19, + "y": 0, + "type": "floor" + }, + { + "x": 20, + "y": 0, + "type": "floor" + }, + { + "x": 21, + "y": 0, + "type": "floor" + }, + { + "x": 18, + "y": 1, + "type": "floor" + }, + { + "x": 19, + "y": 1, + "type": "floor" + }, + { + "x": 20, + "y": 1, + "type": "floor" + }, + { + "x": 21, + "y": 1, + "type": "floor" + }, + { + "x": 18, + "y": 2, + "type": "floor" + }, + { + "x": 19, + "y": 2, + "type": "goal" + }, + { + "x": 20, + "y": 2, + "type": "floor" + }, + { + "x": 21, + "y": 2, + "type": "floor" + }, + { + "x": 18, + "y": 3, + "type": "floor" + }, + { + "x": 19, + "y": 3, + "type": "floor" + }, + { + "x": 20, + "y": 3, + "type": "floor" + }, + { + "x": 21, + "y": 3, + "type": "floor" + } + ], + "par": 17 + } + ] +} \ No newline at end of file diff --git a/data/totalannihilation-artwork.json b/data/totalannihilation-artwork.json index 1e633a0..5cd5875 100644 --- a/data/totalannihilation-artwork.json +++ b/data/totalannihilation-artwork.json @@ -35,8 +35,8 @@ "frameWidth": 64, "frameHeight": 64, "cols": 8 }, "arm-structures": { - "key": "ta-arm-structures", "path": null, "kind": "structure", - "frameWidth": 128, "frameHeight": 128, "cols": 6 + "key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure", + "frameWidth": 192, "frameHeight": 192, "cols": 6 }, "core-units": { "key": "ta-core-units", "path": null, "kind": "unit", diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 5974035..bb75825 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -277,7 +277,7 @@ "id": "tankgun", "name": "120mm Cannon", "kind": "ballistic", - "damage": 95, + "damage": 145, "reload": 2.0, "burst": 1, "range": 350, @@ -308,7 +308,7 @@ "id": "rocketpod", "name": "Rocket Pod", "kind": "guided", - "damage": 130, + "damage": 80, "reload": 4.0, "burst": 2, "burstDelay": 0.25, @@ -395,8 +395,8 @@ "id": "towerlaser", "name": "Heavy Laser", "kind": "hitscan", - "damage": 62, - "reload": 1.4, + "damage": 52, + "reload": 0.4, "burst": 1, "range": 340, "spread": 0.01, @@ -422,8 +422,8 @@ "id": "towermissile", "name": "Guided Missile", "kind": "guided", - "damage": 175, - "reload": 4.5, + "damage": 135, + "reload": 1.5, "burst": 1, "range": 620, "minRange": 180, diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index c024e00..4baac30 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -116,3 +116,4 @@ registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console- registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 }); registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 }); registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 }); +registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 }); diff --git a/src/games/bloxorz/BloxorzGame.js b/src/games/bloxorz/BloxorzGame.js new file mode 100644 index 0000000..bfa9855 --- /dev/null +++ b/src/games/bloxorz/BloxorzGame.js @@ -0,0 +1,627 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + loadLevel, newState, cloneState, applyMove, solve, tileRenderInfo, +} from './BloxorzLogic.js'; + +// ── Isometric projection ────────────────────────────────────────────────────── +// 2:1 diamond projection. Board size varies a lot across the 36 levels (a 6x5 +// intro vs. a 22x5 capstone), so everything scales per-level to fit the same +// screen area rather than using fixed pixel constants. +const TW = 96; +const TH = 48; +const TILE_DEPTH = 26; +const BLOCK_HEIGHT = 84; +const SPLIT_HEIGHT = 44; + +const VOID_BG = 0x05070a; +const FLOOR_TOP = 0xd8b98a; +const FLOOR_CLIFF_S = 0x8a6a45; +const FLOOR_CLIFF_E = 0xa9835a; +const BRIDGE_OPEN_TOP = 0xd8b98a; +const BRIDGE_CLOSED_TOP = 0x3a3f47; +const FRAGILE_TOP = 0xd97b3f; +const WALL_TOP = 0x5b6472; +const WALL_CLIFF_S = 0x363b44; +const WALL_CLIFF_E = 0x454c57; +const GOAL_TOP = 0x0a0c10; +const BLOCK_TOP = 0x4a90d9; +const BLOCK_SIDE_S = 0x2f5f8f; +const BLOCK_SIDE_E = 0x3a74ad; + +const D = { board: 0, block: 10, ui: 30, overlay: 60, overlayUI: 62 }; + +const DIR_KEYS = { + ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down', + KeyA: 'left', KeyD: 'right', KeyW: 'up', KeyS: 'down', +}; + +function shapeFromBlock(block) { + if (block.mode === 'split') { + return [ + { gx0: block.a.x, gy0: block.a.y, gx1: block.a.x + 1, gy1: block.a.y + 1, height: SPLIT_HEIGHT }, + { gx0: block.b.x, gy0: block.b.y, gx1: block.b.x + 1, gy1: block.b.y + 1, height: SPLIT_HEIGHT }, + ]; + } + if (block.orient === 'up') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 1, height: BLOCK_HEIGHT }]; + if (block.orient === 'x') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 2, gy1: block.y + 1, height: BLOCK_HEIGHT }]; + return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 2, height: BLOCK_HEIGHT }]; +} + +function lerpPart(a, b, t) { + return { + gx0: a.gx0 + (b.gx0 - a.gx0) * t, + gy0: a.gy0 + (b.gy0 - a.gy0) * t, + gx1: a.gx1 + (b.gx1 - a.gx1) * t, + gy1: a.gy1 + (b.gy1 - a.gy1) * t, + height: a.height + (b.height - a.height) * t, + }; +} + +export default class BloxorzGame extends Phaser.Scene { + constructor() { super('BloxorzGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'bloxorz', name: 'Bloxorz' }; + this.bank = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + + this.level = 0; + this.def = null; + this.compiled = null; + this.state = null; + this.undoStack = []; + this.moves = 0; + this.par = 0; + this.busy = false; + this.overlayUp = false; + + this.scaleF = 1; + this.originX = 0; + this.originY = 0; + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, VOID_BG).setDepth(-10); + + const raw = this.cache.json.get('bloxorz'); + this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await api.get('/puzzles/bloxorz/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.layer = this.add.container(0, 0); + this._registerInput(); + this.showLevelSelect(); + } + + clearLayer() { + this.layer.removeAll(true); + this.tileGfx = null; + this.blockGfx = null; + this.movesText = null; + this.undoBtn = null; + } + + // ── Input ──────────────────────────────────────────────────────────────────── + + _registerInput() { + this.input.keyboard.on('keydown', (e) => { + if (this.view !== 'play') return; + const dir = DIR_KEYS[e.code]; + if (dir) { e.preventDefault?.(); this.tryMove(dir); } + }); + + let touchStart = null; + this.input.on('pointerdown', (p) => { touchStart = { x: p.x, y: p.y }; }); + this.input.on('pointerup', (p) => { + if (!touchStart || this.view !== 'play') { touchStart = null; return; } + const dx = p.x - touchStart.x; + const dy = p.y - touchStart.y; + touchStart = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) return; + const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'); + this.tryMove(dir); + }); + } + + // ── Level select ───────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.overlayUp = false; + this.busy = false; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 84, 'BLOXORZ', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 138, 'Roll the block and drop it standing into the hole. Clear each level to unlock the next.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + if (!this.bank.length) { + const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genBloxorz.js', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', + }).setOrigin(0.5); + this.layer.add(msg); + const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); + this.layer.add(back); + return; + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); + const prog = this.add.text(cx, 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; + + 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 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.par}` : 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '14px', + color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', + }).setOrigin(0.5); + this.layer.add([tile, num, tag]); + + if (playable) { + 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)); + } + }); + + const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), + { width: 280, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.layer.add([resume, back, reset]); + + if (!this.canPersist) { + const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(note); + } + } + + confirmResetProgress() { + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20); + const title = this.add.text(cx, cy - 92, 'Reset Progress?', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5); + const msg = this.add.text(cx, cy - 14, + 'This clears every level you have cleared and\nstarts you back at Level 1. This cannot be undone.', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5); + const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(), + { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }); + const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), + { variant: 'ghost', width: 250, height: 58, fontSize: 24 }); + this.layer.add([dim, panel, title, msg, yes, no]); + } + + doResetProgress() { + api.post('/puzzles/bloxorz/reset').catch(() => { /* best effort */ }); + this.levelsCompleted = 0; + this.showLevelSelect(); + } + + // ── Play a level ───────────────────────────────────────────────────────────── + + playLevel(level) { + const def = this.bank.find((p) => p.level === level); + if (!def) return; + this.view = 'play'; + this.level = level; + this.def = def; + this.compiled = loadLevel(def); + this.state = newState(this.compiled); + this.par = def.par; + this.undoStack = []; + this.moves = 0; + this.busy = false; + this.overlayUp = false; + + this.clearLayer(); + this._computeProjection(); + this.drawHud(); + this.tileGfx = this.add.graphics().setDepth(D.board); + this.blockGfx = this.add.graphics().setDepth(D.block); + this.layer.add([this.tileGfx, this.blockGfx]); + this.redrawTiles(); + this.redrawBlock(shapeFromBlock(this.state.block)); + } + + _computeProjection() { + const { cols, rows } = this.compiled; + const span = cols + rows; + const boardW = span * (TW / 2); + const boardH = span * (TH / 2); + const availW = 1300; + const availH = 560; + this.scaleF = Math.min(1, availW / boardW, availH / (boardH + 220)); + this.originX = GAME_WIDTH / 2 - (cols - rows) * (TW / 2) * this.scaleF / 2; + this.originY = 300; + } + + isoX(x, y) { return this.originX + (x - y) * (TW / 2) * this.scaleF; } + + isoY(x, y) { return this.originY + (x + y) * (TH / 2) * this.scaleF; } + + footprintCorners(gx0, gy0, gx1, gy1) { + return [ + [this.isoX(gx0, gy0), this.isoY(gx0, gy0)], + [this.isoX(gx1, gy0), this.isoY(gx1, gy0)], + [this.isoX(gx1, gy1), this.isoY(gx1, gy1)], + [this.isoX(gx0, gy1), this.isoY(gx0, gy1)], + ]; + } + + // ── Rendering ──────────────────────────────────────────────────────────────── + + fillPoly(gfx, corners, color, alpha = 1) { + gfx.fillStyle(color, alpha); + gfx.beginPath(); + gfx.moveTo(corners[0][0], corners[0][1]); + for (let i = 1; i < corners.length; i++) gfx.lineTo(corners[i][0], corners[i][1]); + gfx.closePath(); + gfx.fillPath(); + } + + fillFace(gfx, cA, cB, dy, color) { + const quad = [cA, cB, [cB[0], cB[1] + dy], [cA[0], cA[1] + dy]]; + this.fillPoly(gfx, quad, color, 1); + } + + redrawTiles() { + this.tileGfx.clear(); + const cells = [...this.compiled.tiles.keys()].map((k) => k.split(',').map(Number)); + cells.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1])); + for (const [x, y] of cells) this._drawTile(x, y); + } + + _drawTile(x, y) { + const info = tileRenderInfo(this.compiled, this.state, x, y); + if (!info) return; + if (info.type === 'fragile' && info.broken) return; // broken = void, nothing to draw + + const c = this.footprintCorners(x, y, x + 1, y + 1); + const hasSouth = this.compiled.tiles.has(`${x},${y + 1}`); + const hasEast = this.compiled.tiles.has(`${x + 1},${y}`); + const depth = TILE_DEPTH * this.scaleF; + const isWall = info.type === 'wall'; + + if (!hasSouth) this.fillFace(this.tileGfx, c[3], c[2], depth, isWall ? WALL_CLIFF_S : FLOOR_CLIFF_S); + if (!hasEast) this.fillFace(this.tileGfx, c[1], c[2], depth, isWall ? WALL_CLIFF_E : FLOOR_CLIFF_E); + + let topColor = FLOOR_TOP; + if (info.type === 'bridge') topColor = info.open ? BRIDGE_OPEN_TOP : BRIDGE_CLOSED_TOP; + else if (info.type === 'fragile') topColor = FRAGILE_TOP; + else if (info.type === 'goal') topColor = GOAL_TOP; + else if (info.type === 'wall') topColor = WALL_TOP; + + this.fillPoly(this.tileGfx, c, topColor, 1); + + if (info.type === 'goal') { + this.tileGfx.lineStyle(Math.max(2, 3 * this.scaleF), COLORS.gold, 0.9); + this.tileGfx.beginPath(); + this.tileGfx.moveTo(c[0][0], c[0][1]); + for (let i = 1; i < c.length; i++) this.tileGfx.lineTo(c[i][0], c[i][1]); + this.tileGfx.closePath(); + this.tileGfx.strokePath(); + } + + const mid = [(c[0][0] + c[2][0]) / 2, (c[0][1] + c[2][1]) / 2]; + const r = 10 * this.scaleF; + if (info.type === 'switch') { + const isSoft = info.requireOrientation === 'lying'; + this.tileGfx.fillStyle(isSoft ? 0xf2ead8 : 0xd4602a, 0.95); + if (isSoft) this.tileGfx.fillCircle(mid[0], mid[1], r); + else this.tileGfx.fillRect(mid[0] - r, mid[1] - r, r * 2, r * 2); + } else if (info.type === 'teleport') { + const hue = (hashStr(info.teleportId) % 360) / 360; + const color = Phaser.Display.Color.HSVToRGB(hue, 0.65, 0.95).color; + this.tileGfx.lineStyle(Math.max(2, 3 * this.scaleF), color, 1); + this.tileGfx.strokeCircle(mid[0], mid[1], r); + this.tileGfx.strokeCircle(mid[0], mid[1], r * 0.55); + } + if (info.split) { + this.tileGfx.lineStyle(Math.max(1, 2 * this.scaleF), 0xffffff, 0.35); + this.tileGfx.lineBetween(c[3][0], c[3][1], c[1][0], c[1][1]); + } + } + + redrawBlock(parts) { + this.blockGfx.clear(); + for (const part of parts) this._drawBlockPart(part); + } + + _drawBlockPart({ gx0, gy0, gx1, gy1, height }) { + const h = height * this.scaleF; + const c = this.footprintCorners(gx0, gy0, gx1, gy1); + this.fillFace(this.blockGfx, c[3], c[2], -h, BLOCK_SIDE_S); + this.fillFace(this.blockGfx, c[1], c[2], -h, BLOCK_SIDE_E); + const top = c.map(([px, py]) => [px, py - h]); + this.fillPoly(this.blockGfx, top, BLOCK_TOP, 1); + } + + // ── HUD ────────────────────────────────────────────────────────────────────── + + drawHud() { + const title = this.add.text(120, 92, `Level ${this.level}`, { + fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.layer.add(title); + + this.movesText = this.add.text(GAME_WIDTH - 120, 92, '', { + fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.layer.add(this.movesText); + this.updateMoves(); + + const BTN_W = 150; + const BTN_H = 56; + const BTN_GAP = 12; + const BTN_X = 130; + const totalH = 4 * BTN_H + 3 * BTN_GAP; + let btnY = GAME_HEIGHT / 2 - totalH / 2; + + const undo = new Button(this, BTN_X, btnY, 'Undo', () => this.undo(), + { width: BTN_W, height: BTN_H, fontSize: 22 }); + btnY += BTN_H + BTN_GAP; + const reset = new Button(this, BTN_X, btnY, 'Reset', () => this.resetLevel(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + btnY += BTN_H + BTN_GAP; + const hint = new Button(this, BTN_X, btnY, 'Hint', () => this.showHint(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + btnY += BTN_H + BTN_GAP; + const levels = new Button(this, BTN_X, btnY, 'Levels', () => this.showLevelSelect(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + this.undoBtn = undo; + this.layer.add([undo, reset, hint, levels]); + this.updateMoves(); + } + + updateMoves() { + if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`); + if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0); + } + + // ── Moves ──────────────────────────────────────────────────────────────────── + + tryMove(dir) { + if (this.busy || this.overlayUp || !this.state || this.state.status !== 'playing') return; + const prevBlock = this.state.block; + const snapshot = cloneState(this.state); + const res = applyMove(this.compiled, this.state, dir); + if (!res.moved) { this._shakeBlock(); return; } + + this.undoStack.push(snapshot); + this.moves++; + this.updateMoves(); + this.busy = true; + playSound(this, SFX.PIECE_CLICK); + this._animateMove(prevBlock, this.state.block, () => { + this.busy = false; + this.redrawTiles(); + if (this.state.status === 'dead') this._onFall(); + else if (this.state.status === 'won') this.onSolved(); + }); + } + + _animateMove(prevBlock, nextBlock, onDone) { + const prevParts = shapeFromBlock(prevBlock); + const nextParts = shapeFromBlock(nextBlock); + if (prevParts.length !== nextParts.length) { + this.redrawBlock(nextParts); + this.blockGfx.setAlpha(0.35); + this.tweens.add({ targets: this.blockGfx, alpha: 1, duration: 160, onComplete: onDone }); + return; + } + const tw = { t: 0 }; + this.tweens.add({ + targets: tw, + t: 1, + duration: 170, + ease: 'Sine.easeInOut', + onUpdate: () => { + const parts = prevParts.map((p, i) => lerpPart(p, nextParts[i], tw.t)); + this.redrawBlock(parts); + }, + onComplete: onDone, + }); + } + + _shakeBlock() { + if (!this.blockGfx) return; + const x0 = this.blockGfx.x; + this.tweens.add({ + targets: this.blockGfx, x: x0 + 10, duration: 60, yoyo: true, repeat: 1, + onComplete: () => { this.blockGfx.x = x0; }, + }); + } + + undo() { + if (!this.undoStack.length || this.busy || this.overlayUp) return; + this.state = this.undoStack.pop(); + this.moves++; + this.updateMoves(); + this.redrawTiles(); + this.redrawBlock(shapeFromBlock(this.state.block)); + playSound(this, SFX.PIECE_CLICK); + } + + resetLevel() { + if (this.busy || this.overlayUp) return; + this.state = newState(this.compiled); + this.undoStack = []; + this.moves = 0; + this.updateMoves(); + this.blockGfx.setAlpha(1); + this.redrawTiles(); + this.redrawBlock(shapeFromBlock(this.state.block)); + playSound(this, SFX.CARD_SHUFFLE); + } + + showHint() { + if (this.busy || this.overlayUp || !this.state) return; + const { path } = solve(this.compiled, { startState: this.state }); + if (!path || !path.length) return; + const cx = GAME_WIDTH / 2; + const label = this.add.text(cx, 220, `Try: ${path[0].toUpperCase()}`, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui).setAlpha(0); + this.layer.add(label); + this.tweens.add({ + targets: label, alpha: 1, duration: 200, yoyo: true, hold: 900, + onComplete: () => label.destroy(), + }); + } + + // ── Fall / solve flow ───────────────────────────────────────────────────────── + + _onFall() { + this.overlayUp = true; + playSound(this, SFX.GEM_BIG_DROP); + this.tweens.add({ + targets: this.blockGfx, y: '+=160', alpha: 0, duration: 420, ease: 'Quad.easeIn', + onComplete: () => this._showFallOverlay(), + }); + } + + _showFallOverlay() { + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + this.layer.add(dim); + + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20); + this.layer.add(panel); + + const title = this.add.text(cx, cy - 90, 'Overboard!', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, 'The block fell off. Try again!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, msg]); + + const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level), + { width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); + const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(), + { width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI); + this.layer.add([retry, levels]); + } + + onSolved() { + this.overlayUp = true; + playSound(this, SFX.VICTORY_SHORT); + if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; + + api.post('/puzzles/bloxorz/complete', { level: this.level }) + .then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); }) + .catch(() => { /* best effort */ }); + api.post('/history/single-player', { + slug: 'bloxorz', score: this.moves, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + this.layer.add(dim); + + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 200, 640, 400, 20); + panel.lineStyle(3, COLORS.accent, 1); + panel.strokeRoundedRect(cx - 320, cy - 200, 640, 400, 20); + this.layer.add(panel); + + const beatPar = this.moves <= this.par; + const title = this.add.text(cx, cy - 130, 'In the Hole!', { + fontFamily: 'Righteous', fontSize: '56px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const stat = this.add.text(cx, cy - 50, + `Level ${this.level} cleared in ${this.moves} moves\nPar: ${this.par}${beatPar ? ' ★ par or better!' : ''}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, stat]); + + const hasNext = this.level < this.bank.length; + const btns = []; + if (hasNext) { + btns.push(new Button(this, cx, cy + 60, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1), + { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + } else { + btns.push(this.add.text(cx, cy + 50, 'You cleared every level. Bravo!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI)); + } + const replay = new Button(this, cx - 110, cy + 140, 'Replay', () => this.playLevel(this.level), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + const levels = new Button(this, cx + 120, cy + 140, 'Levels', () => this.showLevelSelect(), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + btns.push(replay, levels); + this.layer.add(btns); + } +} + +function hashStr(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return h; +} diff --git a/src/games/bloxorz/BloxorzLogic.js b/src/games/bloxorz/BloxorzLogic.js new file mode 100644 index 0000000..5961aff --- /dev/null +++ b/src/games/bloxorz/BloxorzLogic.js @@ -0,0 +1,350 @@ +// Bloxorz — pure board model + BFS solver. No Phaser, no DOM. +// Shared by the client scene and the offline level generator (both ESM). +// +// Grid is sparse: any (x,y) not present in a level's tile map is void (the +// block falls off if any of its occupied cells lands there). +// +// Block states: +// joined: { mode:'joined', x, y, orient } orient: 'up' | 'x' | 'y' +// 'up' -> standing, footprint [(x,y)] +// 'x' -> lying along X, footprint [(x,y),(x+1,y)] (x = min column) +// 'y' -> lying along Y, footprint [(x,y),(x,y+1)] (y = min row) +// split: { mode:'split', a:{x,y}, b:{x,y} } two independent 1x1 units, +// produced when a lying block lands on two tiles both flagged `split`. +// They move together (same input, independent per-tile checks) and +// automatically re-fuse into a joined lying block the instant they end up +// orthogonally adjacent after a move. +// +// Tile types: floor | wall | bridge | fragile | switch | teleport | goal. +// wall — a permanent, non-lethal obstacle: a move into it is simply +// illegal (no-op), never fatal. Used to give split cubes +// something to independently route around. +// bridge — impassable unless its linked switch(es) say otherwise: +// mode:'toggle' -> persistent flip on any contact (hard switch) +// mode:'momentary' -> open only while a LYING block currently +// covers the switch cell (soft switch) +// fragile — supports exactly one standing landing, then breaks permanently +// teleport — a standing (single-cell) landing is relocated to its pair +// goal — only a standing joined block ('up') is supported here; any +// other landing shape falls through like void + +export const DIR_LIST = ['up', 'down', 'left', 'right']; +const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; + +const key = (x, y) => `${x},${y}`; + +export function blockCells(block) { + if (block.mode === 'split') return [[block.a.x, block.a.y], [block.b.x, block.b.y]]; + if (block.orient === 'up') return [[block.x, block.y]]; + return block.orient === 'x' + ? [[block.x, block.y], [block.x + 1, block.y]] + : [[block.x, block.y], [block.x, block.y + 1]]; +} + +// ── Level compilation ──────────────────────────────────────────────────────── + +export function loadLevel(def) { + const tiles = new Map(); + const switches = []; + const switchesByBridge = new Map(); + const teleportPairs = new Map(); + + for (const t of def.tiles) { + tiles.set(key(t.x, t.y), t); + if (t.type === 'switch') { + switches.push(t); + for (const bid of t.linkedBridgeIds) { + if (!switchesByBridge.has(bid)) switchesByBridge.set(bid, []); + switchesByBridge.get(bid).push(t); + } + } + if (t.type === 'teleport') { + if (!teleportPairs.has(t.teleportId)) teleportPairs.set(t.teleportId, []); + teleportPairs.get(t.teleportId).push({ x: t.x, y: t.y }); + } + } + + return { + cols: def.cols, + rows: def.rows, + tiles, + switches, + switchesByBridge, + teleportPairs, + start: def.start, + goal: def.goal, + }; +} + +// ── Dynamic tile lookups ───────────────────────────────────────────────────── + +function isPressed(state, sw) { + const cells = blockCells(state.block); + const onIt = cells.some(([x, y]) => x === sw.x && y === sw.y); + if (!onIt) return false; + if (sw.requireOrientation === 'any') return true; + return state.block.mode === 'joined' && state.block.orient !== 'up'; +} + +function bridgeOpen(level, state, bridgeId) { + const sws = level.switchesByBridge.get(bridgeId) ?? []; + const momentary = sws.filter((s) => s.mode === 'momentary'); + if (momentary.length) return momentary.some((s) => isPressed(state, s)); + return state.toggleBridges.get(bridgeId) ?? false; +} + +function tileAt(level, state, x, y) { + const t = level.tiles.get(key(x, y)); + if (!t) return null; + if (t.type === 'bridge' && !bridgeOpen(level, state, t.bridgeId)) return null; + if (t.type === 'fragile' && state.broken.has(key(x, y))) return null; + return t; +} + +function tileSupports(tile, mode, orient) { + if (!tile) return false; + if (tile.type === 'goal') return mode === 'joined' && orient === 'up'; + return true; +} + +// 'ok' (safe to land), 'blocked' (wall — illegal move, not fatal), or 'fatal' +// (void / closed bridge / broken fragile / lying-or-split onto a goal). +function cellStatus(level, state, x, y, mode, orient) { + const raw = level.tiles.get(key(x, y)); + if (raw?.type === 'wall') return 'blocked'; + const tile = tileAt(level, state, x, y); + return tileSupports(tile, mode, orient) ? 'ok' : 'fatal'; +} + +// Renderer-facing view of a tile including its current dynamic state. +export function tileRenderInfo(level, state, x, y) { + const t = level.tiles.get(key(x, y)); + if (!t) return null; + if (t.type === 'bridge') return { ...t, open: bridgeOpen(level, state, t.bridgeId) }; + if (t.type === 'fragile') return { ...t, broken: state.broken.has(key(x, y)) }; + return t; +} + +function evaluateSwitches(level, state) { + for (const sw of level.switches) { + if (sw.mode !== 'toggle') continue; + if (isPressed(state, sw)) { + for (const id of sw.linkedBridgeIds) state.toggleBridges.set(id, !state.toggleBridges.get(id)); + } + } +} + +function maybeTeleportPoint(level, pt) { + const tile = level.tiles.get(key(pt.x, pt.y)); + if (tile?.type !== 'teleport') return pt; + const pair = level.teleportPairs.get(tile.teleportId) ?? []; + const dest = pair.find((c) => c.x !== pt.x || c.y !== pt.y); + return dest ? { x: dest.x, y: dest.y } : pt; +} + +// ── Roll physics ───────────────────────────────────────────────────────────── + +function rollJoined(block, dir) { + const [dx, dy] = DIRS[dir]; + if (block.orient === 'up') { + return dx !== 0 + ? { mode: 'joined', orient: 'x', x: dx > 0 ? block.x + 1 : block.x - 2, y: block.y } + : { mode: 'joined', orient: 'y', x: block.x, y: dy > 0 ? block.y + 1 : block.y - 2 }; + } + if (block.orient === 'x') { + return dx !== 0 + ? { mode: 'joined', orient: 'up', x: dx > 0 ? block.x + 2 : block.x - 1, y: block.y } + : { mode: 'joined', orient: 'x', x: block.x, y: block.y + dy }; + } + // orient === 'y' + return dy !== 0 + ? { mode: 'joined', orient: 'up', x: block.x, y: dy > 0 ? block.y + 2 : block.y - 1 } + : { mode: 'joined', orient: 'y', x: block.x + dx, y: block.y }; +} + +function tryMerge(a, b) { + if (a.x === b.x && Math.abs(a.y - b.y) === 1) return { mode: 'joined', orient: 'y', x: a.x, y: Math.min(a.y, b.y) }; + if (a.y === b.y && Math.abs(a.x - b.x) === 1) return { mode: 'joined', orient: 'x', x: Math.min(a.x, b.x), y: a.y }; + return null; +} + +// Shared tail after a successful (non-dead) landing: fragile breakage under a +// standing footprint, goal check (joined+up only), then switch evaluation. +function settle(level, state) { + const standingCells = state.block.mode === 'split' + ? [state.block.a, state.block.b] + : (state.block.orient === 'up' ? [{ x: state.block.x, y: state.block.y }] : []); + + if (state.block.mode === 'joined' && state.block.orient === 'up') { + state.block = { ...state.block, ...maybeTeleportPoint(level, { x: state.block.x, y: state.block.y }) }; + standingCells[0] = { x: state.block.x, y: state.block.y }; + } + + for (const { x, y } of standingCells) { + const tile = level.tiles.get(key(x, y)); + if (tile?.type === 'fragile') state.broken.add(key(x, y)); + } + + if (state.block.mode === 'joined' && state.block.orient === 'up') { + const tile = level.tiles.get(key(state.block.x, state.block.y)); + if (tile?.type === 'goal') state.status = 'won'; + } + + evaluateSwitches(level, state); + return { moved: true, dead: false, won: state.status === 'won' }; +} + +function jointStep(level, state, dir) { + const candidate = rollJoined(state.block, dir); + const cells = blockCells(candidate); + + for (const [x, y] of cells) { + const status = cellStatus(level, state, x, y, candidate.mode, candidate.orient); + if (status === 'blocked') return { moved: false }; + if (status === 'fatal') { state.status = 'dead'; return { moved: true, dead: true }; } + } + + if (candidate.orient !== 'up' && cells.every(([x, y]) => level.tiles.get(key(x, y))?.split)) { + state.block = { mode: 'split', a: { x: cells[0][0], y: cells[0][1] }, b: { x: cells[1][0], y: cells[1][1] } }; + } else { + state.block = candidate; + } + return settle(level, state); +} + +// A single split cube's attempt to step. A wall simply leaves it in place +// (non-lethal) — this is what lets the two units diverge around an obstacle +// over a sequence of identical-direction inputs instead of moving in lockstep. +function stepUnit(level, state, pt, dx, dy) { + const nx = pt.x + dx; + const ny = pt.y + dy; + const status = cellStatus(level, state, nx, ny, 'joined', 'up'); + if (status === 'blocked') return { pt, dead: false }; + if (status === 'fatal') return { pt, dead: true }; + return { pt: maybeTeleportPoint(level, { x: nx, y: ny }), dead: false }; +} + +function splitStep(level, state, dir) { + const [dx, dy] = DIRS[dir]; + const ra = stepUnit(level, state, state.block.a, dx, dy); + const rb = stepUnit(level, state, state.block.b, dx, dy); + if (ra.dead || rb.dead) { state.status = 'dead'; return { moved: true, dead: true }; } + + const merged = tryMerge(ra.pt, rb.pt); + state.block = merged ?? { mode: 'split', a: ra.pt, b: rb.pt }; + return settle(level, state); +} + +// Mutates `state`. Returns { moved, dead?, won? }. +export function applyMove(level, state, dir) { + if (state.status !== 'playing') return { moved: false }; + return state.block.mode === 'split' ? splitStep(level, state, dir) : jointStep(level, state, dir); +} + +// ── State plumbing (mirrors RushHourLogic/PuddingMonstersLogic conventions) ── + +export function newState(level) { + const toggleBridges = new Map(); + for (const t of level.tiles.values()) { + if (t.type !== 'bridge' || toggleBridges.has(t.bridgeId)) continue; + const sws = level.switchesByBridge.get(t.bridgeId) ?? []; + if (sws.some((s) => s.mode === 'toggle')) toggleBridges.set(t.bridgeId, !!t.initiallyOpen); + } + + const state = { + block: { mode: 'joined', orient: level.start.orient, x: level.start.x, y: level.start.y }, + toggleBridges, + broken: new Set(), + status: 'playing', + }; + evaluateSwitches(level, state); + return state; +} + +export function cloneState(state) { + return { + block: state.block.mode === 'joined' + ? { ...state.block } + : { mode: 'split', a: { ...state.block.a }, b: { ...state.block.b } }, + toggleBridges: new Map(state.toggleBridges), + broken: new Set(state.broken), + status: state.status, + }; +} + +export function stateKey(state) { + const b = state.block; + let blockPart; + if (b.mode === 'joined') { + blockPart = `J${b.orient}${b.x},${b.y}`; + } else { + const units = [b.a, b.b].slice().sort((p, q) => p.x - q.x || p.y - q.y); + blockPart = `S${units[0].x},${units[0].y}|${units[1].x},${units[1].y}`; + } + const bridgePart = [...state.toggleBridges.entries()] + .sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0)) + .map(([id, o]) => `${id}:${o ? 1 : 0}`) + .join(','); + const brokenPart = [...state.broken].sort().join(','); + return `${blockPart}#${bridgePart}#${brokenPart}`; +} + +export function isSolved(state) { + return state.status === 'won'; +} + +// Every direction that doesn't kill the run, with the resulting state. +export function legalMoves(level, state) { + const moves = []; + for (const dir of DIR_LIST) { + const ns = cloneState(state); + const res = applyMove(level, ns, dir); + if (res.moved && !res.dead) moves.push({ dir, state: ns, won: res.won }); + } + return moves; +} + +// Breadth-first shortest solution. Returns { moves, path }. +// moves: minimum rolls to solve (0 if already solved, -1 if none found) +// path: array of direction strings (null if unsolvable within maxStates) +// `startState` lets the in-game Hint button solve from wherever the player +// currently is rather than always from the level's initial configuration +// (which is what genBloxorz.js/verifyBloxorz.js want, via the default). +export function solve(level, { maxStates = 300000, startState = null } = {}) { + const start = startState ? cloneState(startState) : newState(level); + if (isSolved(start)) return { moves: 0, path: [] }; + + const startKey = stateKey(start); + const meta = new Map([[startKey, null]]); // key -> { parentKey, move } + const stateByKey = new Map([[startKey, start]]); + let frontier = [startKey]; + let depth = 0; + + while (frontier.length) { + depth++; + const next = []; + for (const k of frontier) { + const cur = stateByKey.get(k); + for (const dir of DIR_LIST) { + const ns = cloneState(cur); + const res = applyMove(level, ns, dir); + if (!res.moved || res.dead) continue; + const nk = stateKey(ns); + if (meta.has(nk)) continue; + meta.set(nk, { parentKey: k, move: dir }); + if (ns.status === 'won') { + const path = []; + let c = nk; + while (meta.get(c)) { const e = meta.get(c); path.unshift(e.move); c = e.parentKey; } + return { moves: depth, path }; + } + stateByKey.set(nk, ns); + next.push(nk); + } + if (meta.size > maxStates) return { moves: -1, path: null }; + } + frontier = next; + if (depth > 400) break; + } + return { moves: -1, path: null }; +} diff --git a/src/games/bloxorz/tutorial.md b/src/games/bloxorz/tutorial.md new file mode 100644 index 0000000..6d8e9e0 --- /dev/null +++ b/src/games/bloxorz/tutorial.md @@ -0,0 +1,50 @@ +# Bloxorz + +Roll a 1x1x2 block across a floating platform and drop it standing into the +goal hole. Fall off the edge and you're back at the start of the level. + +## Rolling + +- Use the **arrow keys / WASD** (or swipe) to tip the block one direction at + a time. +- Standing upright, it takes up one tile. Tip it over and it lies flat across + two tiles — tip it again along its length and it stands back up. +- The goal only accepts the block **standing upright**. Landing on it lying + down just falls through. + +## Bridges & Switches + +- **Bridges** are gaps in the platform that only become solid once their + linked switch is thrown. +- An **orange square switch** opens its bridge permanently the instant the + block touches it, in any orientation. +- A **pale circle switch** only holds its bridge open while the block is + lying down directly on top of it — time your crossing so you're still + covering the switch as you tip onto the bridge. + +## Fragile Tiles + +- Orange, cracked tiles support the block's weight **once**. Stand on one and + it's fine — but come back to that exact tile a second time and it gives + way. + +## Teleporters + +- Colored ring tiles come in pairs. Land on one standing upright and you're + instantly moved to its partner. + +## Splitting + +- Some pairs of floor tiles are marked with a diagonal hatch. Tip the block + so it lands lying flat across both of them and it **splits into two + smaller cubes**. +- Both halves move together with every key press, but each obeys the ground + under it independently — one can be stopped by an obstacle while the other + keeps going. Losing either half off the edge fails the level. +- Move them back next to each other and they **fuse** back into the full + block. You can't finish a level while still split. + +## Levels + +Clear a level to unlock the next. Your move count is shown against each +level's par — beat it for a gold star of bragging rights. diff --git a/src/main.js b/src/main.js index 138a143..a07adaa 100644 --- a/src/main.js +++ b/src/main.js @@ -98,6 +98,7 @@ import SuperKartEditor from './games/superkart/SuperKartEditor.js'; import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js'; import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js'; import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js'; +import BloxorzGame from './games/bloxorz/BloxorzGame.js'; const config = { type: Phaser.AUTO, @@ -209,6 +210,7 @@ const config = { AdvanceWarsGame, TetrisAttackGame, TotalAnnihilationGame, + BloxorzGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index d42db29..cba8f79 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 043f131..40131d7 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -51,6 +51,7 @@ export default class PreloadScene extends Phaser.Scene { 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('shift-artwork', 'data/shift-artwork.json'); this.load.json('slots-artwork', 'data/slots-artwork.json'); diff --git a/tools/genBloxorz.js b/tools/genBloxorz.js new file mode 100644 index 0000000..e91a627 --- /dev/null +++ b/tools/genBloxorz.js @@ -0,0 +1,307 @@ +// Offline generator for the Bloxorz level bank. +// +// Levels are hand-authored (switch/bridge/teleporter/split puzzles need +// designed relational intent that random generation doesn't produce — see +// Rush Hour/Dot Link for where randomize-then-filter DOES work, and note this +// is deliberately NOT that). Each level is built from small composable tile +// helpers below, then solved with BloxorzLogic's BFS solver to compute `par` +// and hard-reject the whole build if any level turns out to be unsolvable. +// +// Usage: node tools/genBloxorz.js [outFile] + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT_FILE = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join(__dirname, '../data/bloxorz.json'); + +// ── Tile helpers ───────────────────────────────────────────────────────────── + +const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra }); +const splitFloor = (x, y) => floor(x, y, { split: true }); +const wallTile = (x, y) => ({ x, y, type: 'wall' }); +const goalTile = (x, y) => ({ x, y, type: 'goal' }); +const fragile = (x, y) => ({ x, y, type: 'fragile' }); +const bridge = (x, y, bridgeId, initiallyOpen = false) => ({ x, y, type: 'bridge', bridgeId, initiallyOpen }); +const hardSwitch = (x, y, switchId, bridgeIds) => ({ + x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'any', mode: 'toggle', +}); +const softSwitch = (x, y, switchId, bridgeIds) => ({ + x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'lying', mode: 'momentary', +}); +const teleport = (x, y, teleportId) => ({ x, y, type: 'teleport', teleportId }); + +function rect(x0, y0, w, h, extra = {}) { + const out = []; + for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) out.push(floor(x, y, extra)); + return out; +} + +// Later entries win on coordinate collision — lets a base rect/fill be +// selectively overridden with bridges/switches/fragile/goal/etc. +function compile(tileList) { + const map = new Map(); + for (const t of tileList) map.set(`${t.x},${t.y}`, t); + return [...map.values()]; +} + +function level(n, name, cols, rows, start, goal, tileList) { + return { level: n, name, cols, rows, start, goal, tiles: compile(tileList) }; +} + +// A single straight horizontal corridor at row `y`, columns 0..length. Rolling +// right from a standing start at column 0 lands STANDING on columns 0,3,6,9… +// and LYING on pairs (1,2),(4,5),(7,8)… — every level below places switches, +// bridges, fragile tiles, teleporters and split-flags using that fixed +// arithmetic, and always ends `length` on a multiple of 3 so the final +// approach lands standing exactly on the goal. `extraRows` widens the board +// so branch tiles (e.g. a switch reached by branching off the row) still fit. +function corridorLevel(n, name, { length, y = 2, rows = 5, overrides = [] }) { + const cols = length + 1; + const tiles = []; + for (let x = 0; x <= length; x++) tiles.push(floor(x, y)); + tiles.push(...overrides); + const hasGoal = overrides.some((o) => o.type === 'goal'); + const goalPos = hasGoal ? overrides.find((o) => o.type === 'goal') : { x: length, y }; + if (!hasGoal) tiles.push(goalTile(length, y)); + return level(n, name, cols, rows, { x: 0, y, orient: 'up' }, { x: goalPos.x, y: goalPos.y }, tiles); +} + +// ── Levels 1-6: plain rolling ──────────────────────────────────────────────── + +const L1 = level(1, 'Warm Up', 6, 5, { x: 1, y: 1, orient: 'up' }, { x: 4, y: 3 }, [ + ...rect(0, 0, 6, 5), goalTile(4, 3), +]); +const L2 = level(2, 'Stretch Out', 7, 5, { x: 1, y: 1, orient: 'up' }, { x: 5, y: 3 }, [ + ...rect(0, 0, 7, 5), goalTile(5, 3), +]); +const L3 = level(3, 'Open Floor', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 }, [ + ...rect(0, 0, 8, 6), goalTile(6, 4), +]); +const L4 = level(4, 'The Corner', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 }, [ + ...rect(0, 0, 5, 7), ...rect(0, 4, 9, 3), goalTile(7, 5), +]); +const L5 = level(5, 'Crossroads', 9, 9, { x: 4, y: 1, orient: 'up' }, { x: 1, y: 4 }, [ + ...rect(3, 0, 3, 9), ...rect(0, 3, 9, 3), goalTile(1, 4), +]); +const L6 = level(6, 'Staircase', 10, 9, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 7 }, [ + ...rect(0, 0, 4, 3), ...rect(2, 1, 4, 4), ...rect(4, 3, 4, 4), ...rect(6, 5, 4, 4), goalTile(8, 7), +]); + +// ── Levels 7-12: holes, ledges, static causeways ───────────────────────────── + +const L7 = level(7, 'Mind the Gap', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 }, + compile(rect(0, 0, 8, 6)).filter((t) => !(t.x === 4 && t.y === 2)).concat(goalTile(6, 4))); +const L8 = level(8, 'Pinwheel', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 }, + compile(rect(0, 0, 9, 7)) + .filter((t) => !([[4, 2], [4, 3], [4, 4], [3, 3], [5, 3]].some(([hx, hy]) => t.x === hx && t.y === hy))) + .concat(goalTile(7, 5))); +const L9 = level(9, 'Causeway', 9, 3, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 1 }, [ + ...rect(0, 0, 3, 3), ...rect(3, 1, 3, 1), ...rect(6, 0, 3, 3), goalTile(7, 1), +]); +const L10 = level(10, 'Bent Causeway', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [ + ...rect(0, 0, 3, 3), ...rect(3, 1, 3, 2), ...rect(4, 1, 2, 5), ...rect(3, 5, 5, 3), goalTile(6, 6), +]); +const L11 = level(11, 'Scattered', 10, 8, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 6 }, + compile(rect(0, 0, 10, 8)) + .filter((t) => !([[3, 3], [6, 2], [4, 6], [7, 5], [2, 6]].some(([hx, hy]) => t.x === hx && t.y === hy))) + .concat(goalTile(8, 6))); +const L12 = level(12, 'Tightrope', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [ + ...rect(0, 0, 3, 3), ...rect(1, 1, 3, 3), ...rect(2, 2, 3, 3), + ...rect(3, 3, 3, 3), ...rect(4, 4, 3, 3), ...rect(5, 5, 3, 3), + goalTile(6, 6), +]); + +// ── Levels 13-18: switches + bridges ───────────────────────────────────────── + +const L13 = corridorLevel(13, 'Flip the Switch', { + length: 9, + overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false)], +}); +const L14 = corridorLevel(14, 'Weight and See', { + length: 6, + overrides: [softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false)], +}); +const L15 = corridorLevel(15, 'Double Gate', { + length: 12, + overrides: [hardSwitch(2, 2, 's1', ['b1', 'b2']), bridge(4, 2, 'b1', false), bridge(8, 2, 'b2', false)], +}); +const L16 = corridorLevel(16, 'Two Keys', { + length: 15, + overrides: [ + hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false), + hardSwitch(7, 2, 's2', ['b2']), bridge(10, 2, 'b2', false), + ], +}); +const L17 = corridorLevel(17, 'Soft Landing', { + length: 15, + overrides: [ + hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false), + softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false), + ], +}); +const L18 = corridorLevel(18, 'Three Gates', { + length: 18, + overrides: [ + hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false), + softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false), + hardSwitch(13, 2, 's3', ['b3']), bridge(16, 2, 'b3', false), + ], +}); + +// ── Levels 19-24: fragile tiles ─────────────────────────────────────────────── + +const L19 = corridorLevel(19, 'Cracked Floor', { length: 9, overrides: [fragile(3, 2)] }); +const L20 = corridorLevel(20, 'Eggshells', { length: 12, overrides: [fragile(3, 2), fragile(7, 2)] }); +const L21 = corridorLevel(21, 'No Turning Back', { + length: 15, + overrides: [fragile(3, 2), fragile(6, 2), fragile(10, 2)], +}); +const L22 = corridorLevel(22, 'Fragile Gate', { + length: 12, + overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(5, 2, 'b1', false), fragile(8, 2)], +}); +const L23 = corridorLevel(23, 'Soft and Thin', { + length: 15, + overrides: [fragile(3, 2), softSwitch(7, 2, 's1', ['b1']), bridge(9, 2, 'b1', false), fragile(11, 2)], +}); +const L24 = corridorLevel(24, 'Every Step Counts', { + length: 18, + overrides: [ + fragile(3, 2), hardSwitch(6, 2, 's1', ['b1']), bridge(8, 2, 'b1', false), + fragile(11, 2), softSwitch(14, 2, 's2', ['b2']), bridge(15, 2, 'b2', false), + ], +}); + +// ── Levels 25-30: teleporters ───────────────────────────────────────────────── + +const L25 = corridorLevel(25, 'Shortcut', { length: 13, overrides: [teleport(3, 2, 't1'), teleport(10, 2, 't1')] }); +const L26 = corridorLevel(26, 'Two Hops', { + length: 21, + overrides: [teleport(3, 2, 't1'), teleport(9, 2, 't1'), teleport(12, 2, 't2'), teleport(18, 2, 't2')], +}); +const L27 = corridorLevel(27, 'Triple Hop', { + length: 27, + overrides: [ + teleport(3, 2, 't1'), teleport(9, 2, 't1'), + teleport(12, 2, 't2'), teleport(18, 2, 't2'), + teleport(21, 2, 't3'), teleport(24, 2, 't3'), + ], +}); +const L28 = corridorLevel(28, 'Gate and Go', { + length: 15, + overrides: [ + hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false), + teleport(6, 2, 't1'), teleport(12, 2, 't1'), + ], +}); +const L29 = corridorLevel(29, 'Brittle Hop', { + length: 12, + overrides: [fragile(3, 2), teleport(6, 2, 't1'), teleport(9, 2, 't1')], +}); +const L30 = corridorLevel(30, 'All at Once', { + length: 18, + overrides: [ + softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false), + fragile(6, 2), teleport(9, 2, 't1'), teleport(15, 2, 't1'), + ], +}); + +// ── Levels 31-36: split tiles ───────────────────────────────────────────────── + +const L31 = corridorLevel(31, 'Split Decision', { + length: 10, + overrides: [splitFloor(4, 2), splitFloor(5, 2)], +}); +// NOTE: a split-trigger-then-immediate-remerge costs one extra column versus +// a plain tip (the remerge lands one column further right than a same-shape +// non-split roll would have) — lengths below are chosen with that in mind. +const L32 = corridorLevel(32, 'Split and Gate', { + length: 19, + overrides: [ + hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false), + splitFloor(7, 2), splitFloor(8, 2), + ], +}); +const L33 = corridorLevel(33, 'Split and Crack', { + length: 15, + overrides: [fragile(3, 2), splitFloor(6, 2), splitFloor(7, 2)], +}); +const L34 = corridorLevel(34, 'Split and Hop', { + length: 16, + overrides: [splitFloor(4, 2), splitFloor(5, 2), teleport(7, 2, 't1'), teleport(13, 2, 't1')], +}); + +// A wide open room around the split point (not a tight corridor) so the two +// independent units have plenty of room to sidestep the wall pillars and +// reconverge — the BFS solver (not hand-traced choreography) is the actual +// proof this works. +const L35 = level(35, 'Around the Pillar', 16, 7, { x: 1, y: 3, orient: 'up' }, { x: 14, y: 3 }, [ + ...rect(0, 2, 5, 3), // entry platform, rows 2-4 + splitFloor(5, 3), splitFloor(6, 3), // split trigger, row 3 + ...rect(5, 0, 7, 7), // big open room, rows 0-6, cols 5-11 + wallTile(8, 2), wallTile(8, 3), wallTile(8, 4), // pillar splitting the room + ...rect(12, 2, 4, 3), // exit platform, rows 2-4 + goalTile(14, 3), +]); + +// Open rooms throughout (not a tight single-row corridor) so switch/fragile/ +// split placement doesn't depend on exact tip arithmetic — only reachability +// within each room, which the BFS solver confirms directly. +const L36 = level(36, 'Grand Finale', 22, 5, { x: 1, y: 1, orient: 'up' }, { x: 19, y: 2 }, [ + ...rect(0, 0, 5, 4), // room A: start + switch + hardSwitch(2, 2, 's1', ['b1']), + bridge(5, 1, 'b1', false), // gate into room B + ...rect(6, 0, 5, 4), // room B: fragile + split trigger + fragile(7, 2), + splitFloor(9, 1), splitFloor(10, 1), + ...rect(11, 0, 7, 5), // room C: pillar maze + wallTile(14, 1), wallTile(14, 2), wallTile(14, 3), + ...rect(18, 0, 4, 4), // room D: goal + goalTile(19, 2), +]); + +const LEVELS = [ + L1, L2, L3, L4, L5, L6, + L7, L8, L9, L10, L11, L12, + L13, L14, L15, L16, L17, L18, + L19, L20, L21, L22, L23, L24, + L25, L26, L27, L28, L29, L30, + L31, L32, L33, L34, L35, L36, +]; + +// ── Solve-gate + write ──────────────────────────────────────────────────────── + +console.log(`[bloxorz] solving ${LEVELS.length} hand-authored levels…`); +let failed = 0; +const levels = LEVELS.map((def) => { + const compiled = loadLevel(def); + const { moves } = solve(compiled, { maxStates: 300000 }); + if (moves < 0) { + console.error(` ✗ level ${def.level} "${def.name}" is UNSOLVABLE`); + failed++; + return { ...def, par: -1 }; + } + console.log(` ✓ level ${def.level} "${def.name}" — par ${moves}`); + return { ...def, par: moves }; +}); + +if (failed > 0) { + console.error(`[bloxorz] ${failed} level(s) unsolvable — refusing to write ${OUT_FILE}`); + process.exit(1); +} + +const payload = { + generatedAt: new Date().toISOString(), + seed: null, + count: levels.length, + levels, +}; + +fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); +fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); +console.log(`[bloxorz] wrote ${levels.length} levels -> ${OUT_FILE}`); diff --git a/tools/verifyBloxorz.js b/tools/verifyBloxorz.js new file mode 100644 index 0000000..c59f604 --- /dev/null +++ b/tools/verifyBloxorz.js @@ -0,0 +1,255 @@ +// Verifier for Bloxorz (Node only — no browser). +// +// 1. Schema-lints data/bloxorz.json (bounds, bridge/switch/teleport linkage). +// 2. Re-solves every level fresh from the JSON (independent of whatever +// genBloxorz.js already asserted at generation time) and checks par. +// 3. Unit-tests the engine primitives directly against small synthetic +// levels: tip-over physics, death cases, switches, teleport, walls, and +// the split/merge mechanic. +// +// Usage: node tools/verifyBloxorz.js + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + loadLevel, newState, applyMove, solve, +} from '../src/games/bloxorz/BloxorzLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FILE = path.join(__dirname, '../data/bloxorz.json'); + +let passes = 0; +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} + +// ── Bank checks ────────────────────────────────────────────────────────────── + +const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); +const levels = raw.levels ?? []; +console.log(`[verify] ${FILE}`); +console.log(`[verify] ${levels.length} levels`); + +check('bank has 36 levels', levels.length === 36, `found ${levels.length}`); + +let prevPar = 0; +for (const def of levels) { + const inBounds = (x, y) => x >= 0 && x < def.cols && y >= 0 && y < def.rows; + + let boundsOk = inBounds(def.start.x, def.start.y) && inBounds(def.goal.x, def.goal.y); + const bridgeIds = new Set(); + const switchLinks = new Map(); // bridgeId -> Set of switch modes + const teleportCounts = new Map(); + let splitCount = 0; + + for (const t of def.tiles) { + if (!inBounds(t.x, t.y)) boundsOk = false; + if (t.type === 'bridge') bridgeIds.add(t.bridgeId); + if (t.type === 'switch') { + for (const bid of t.linkedBridgeIds) { + if (!switchLinks.has(bid)) switchLinks.set(bid, new Set()); + switchLinks.get(bid).add(t.mode); + } + } + if (t.type === 'teleport') teleportCounts.set(t.teleportId, (teleportCounts.get(t.teleportId) ?? 0) + 1); + if (t.split) splitCount++; + } + check(`L${def.level}: all tiles/start/goal in bounds`, boundsOk); + + let bridgesLinked = true; + for (const bid of bridgeIds) if (!switchLinks.has(bid)) bridgesLinked = false; + check(`L${def.level}: every bridge has a linking switch`, bridgesLinked); + + let noMixedModes = true; + for (const modes of switchLinks.values()) if (modes.size > 1) noMixedModes = false; + check(`L${def.level}: no bridge mixes toggle+momentary switches`, noMixedModes); + + let teleportsPaired = true; + for (const count of teleportCounts.values()) if (count !== 2) teleportsPaired = false; + check(`L${def.level}: every teleporter id appears exactly twice`, teleportsPaired); + + check(`L${def.level}: split-flagged tile count is even`, splitCount % 2 === 0, `count=${splitCount}`); + + // Fresh solver re-run, independent of genBloxorz.js's own check at write time. + const compiled = loadLevel(def); + const { moves } = solve(compiled, { maxStates: 300000 }); + check(`L${def.level} "${def.name}": solvable`, moves >= 0, `solve() returned moves=${moves}`); + check(`L${def.level}: par matches fresh solve`, moves === def.par, `par=${def.par} solve=${moves}`); + + prevPar = def.par; +} +void prevPar; + +// ── Engine unit tests ──────────────────────────────────────────────────────── + +function synthLevel(tiles, start, cols = 12, rows = 12) { + return loadLevel({ cols, rows, start, goal: { x: 0, y: 0 }, tiles }); +} +const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra }); +const wall = (x, y) => ({ x, y, type: 'wall' }); +const goal = (x, y) => ({ x, y, type: 'goal' }); +const fragileT = (x, y) => ({ x, y, type: 'fragile' }); +const bridgeT = (x, y, id, open = false) => ({ x, y, type: 'bridge', bridgeId: id, initiallyOpen: open }); +const hardSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' }); +const softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'lying', mode: 'momentary' }); +const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id }); + +// -- Tip-over physics: standing -> lying -- +{ + const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), floor(0, 1), floor(0, 2)], { x: 0, y: 0, orient: 'up' }); + let s = newState(lvl); + applyMove(lvl, s, 'right'); + check('standing rolls right into lying-x at [1,2]', s.block.mode === 'joined' && s.block.orient === 'x' && s.block.x === 1 && s.block.y === 0, JSON.stringify(s.block)); + + s = newState(lvl); + applyMove(lvl, s, 'down'); + check('standing rolls down into lying-y at [1,2]', s.block.orient === 'y' && s.block.x === 0 && s.block.y === 1, JSON.stringify(s.block)); +} + +// -- Tip-over physics: lying -> standing (along axis) and lying -> lying (perpendicular shift) -- +{ + const lvl = synthLevel([ + floor(1, 0), floor(2, 0), floor(3, 0), floor(4, 0), floor(2, 1), floor(3, 1), + ], { x: 1, y: 0, orient: 'up' }); + + let s = newState(lvl); + applyMove(lvl, s, 'right'); // standing1 -> lying[2,3] + applyMove(lvl, s, 'right'); // lying[2,3] -> standing (anchor2+2=4) + check('lying-x tips right back to standing (x+2)', s.block.orient === 'up' && s.block.x === 4, JSON.stringify(s.block)); + + s = newState(lvl); + applyMove(lvl, s, 'right'); // standing1 -> lying[2,3] + applyMove(lvl, s, 'down'); // perpendicular shift: stays lying-x, y+1 + check('lying-x shifts perpendicular (down) without changing orientation', s.block.orient === 'x' && s.block.x === 2 && s.block.y === 1, JSON.stringify(s.block)); +} + +// -- Falling off the edge is fatal -- +{ + const lvl = synthLevel([floor(0, 0)], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + const res = applyMove(lvl, s, 'right'); + check('rolling off the platform edge is fatal', res.dead === true && s.status === 'dead'); +} + +// -- Goal only supports a standing landing -- +{ + const lvl = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + const res = applyMove(lvl, s, 'right'); // lands lying across [1,2]; 2 is void, 1 is goal (needs 'up') + check('a lying landing on/through the goal falls through (fatal)', res.dead === true); + + const lvl2 = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), goal(3, 0)], { x: 0, y: 0, orient: 'up' }); + const s2 = newState(lvl2); + applyMove(lvl2, s2, 'right'); // standing0 -> lying[1,2] + applyMove(lvl2, s2, 'right'); // lying[1,2] -> standing3 (goal) + check('a standing landing exactly on the goal wins', s2.status === 'won'); +} + +// -- Fragile tiles: safe once, fatal on a second (already-broken) visit -- +{ + const lvl = synthLevel([floor(0, 0), fragileT(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' }); + const s = { block: { mode: 'joined', orient: 'up', x: 0, y: 0 }, toggleBridges: new Map(), broken: new Set(), status: 'playing' }; + // Simulate having already broken (1,0) on a prior visit, then try to land on it again. + s.broken.add('1,0'); + const res = applyMove(lvl, s, 'right'); // lying[1,2] overlaps the broken cell + check('landing on an already-broken fragile tile is fatal', res.dead === true); +} +{ + const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0), floor(5, 0)], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] + applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (fragile) — first visit, safe + check('first standing visit to a fragile tile survives', s.status === 'playing'); + check('fragile tile is marked broken after the first standing visit', s.broken.has('3,0')); +} + +// -- Hard switch: any orientation, persistent -- +{ + const lvl = synthLevel([ + floor(0, 0), hardSw(1, 0, 's1', ['b1']), floor(2, 0), bridgeT(3, 0, 'b1', false), floor(4, 0), + ], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the hard switch + check('hard switch opens its bridge on contact (any orientation)', s.toggleBridges.get('b1') === true); + const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, onto the now-open bridge + check('hard-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing'); +} + +// -- Soft (momentary) switch: open only while covered, recloses immediately -- +{ + const lvl = synthLevel([ + floor(0, 0), floor(1, 0), softSw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(5, 0), + ], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the soft switch (col2) + const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, crossing the bridge in the same instant + check('soft-switch bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing'); + check('soft-switch bridge recloses once the block leaves', s.toggleBridges.get('b1') !== true); +} + +// -- Teleport relocates a standing landing -- +{ + const lvl = synthLevel([ + floor(0, 0), floor(1, 0), floor(2, 0), tp(3, 0, 't1'), tp(3, 3, 't1'), floor(4, 3), + ], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] + applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (teleport source) -> relocated to (3,3) + check('standing on a teleporter relocates to its paired tile', s.block.x === 3 && s.block.y === 3, JSON.stringify(s.block)); +} + +// -- Split trigger + auto-merge -- +{ + const lvl = synthLevel([ + floor(0, 0), floor(1, 0), floor(2, 0), floor(3, 0), + floor(0, 0), { x: 4, y: 0, type: 'floor', split: true }, { x: 5, y: 0, type: 'floor', split: true }, + floor(6, 0), floor(7, 0), + ], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] + applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 + applyMove(lvl, s, 'right'); // standing3 -> candidate lying[4,5] both split-flagged -> SPLIT + check('a lying landing on two split-flagged tiles splits the block', s.block.mode === 'split', JSON.stringify(s.block)); + applyMove(lvl, s, 'right'); // both units step +1, become adjacent again -> auto-merge + check('two adjacent split units auto-merge back into a joined lying block', s.block.mode === 'joined' && s.block.orient === 'x', JSON.stringify(s.block)); +} + +// -- Wall: illegal move, never fatal -- +{ + const lvl = synthLevel([floor(0, 0), wall(1, 0)], { x: 0, y: 0, orient: 'up' }); + const s = newState(lvl); + const res = applyMove(lvl, s, 'right'); + check('rolling into a wall is a no-op, not fatal', res.moved === false && s.status === 'playing'); + check('the block does not move when blocked by a wall', s.block.x === 0 && s.block.y === 0 && s.block.orient === 'up'); +} + +// -- Split units move independently: one blocked by a wall, the other proceeds -- +{ + const lvl = synthLevel([floor(5, 5), wall(6, 5), floor(6, 6), floor(7, 5)], { x: 5, y: 5, orient: 'up' }); + const s = { + block: { mode: 'split', a: { x: 5, y: 5 }, b: { x: 6, y: 5 } }, + toggleBridges: new Map(), broken: new Set(), status: 'playing', + }; + applyMove(lvl, s, 'right'); + const { a, b } = s.block; + check('a wall-blocked split unit stays put while its partner moves on', a.x === 5 && a.y === 5 && b.x === 7 && b.y === 5, JSON.stringify(s.block)); +} + +// -- Either split unit falling is fatal for the whole run -- +{ + const lvl = synthLevel([floor(0, 0), floor(1, 0)], { x: 0, y: 0, orient: 'up' }); + const s = { + block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 0 } }, + toggleBridges: new Map(), broken: new Set(), status: 'playing', + }; + const res = applyMove(lvl, s, 'down'); // both units roll off into the void + check('either split unit falling ends the run', res.dead === true && s.status === 'dead'); +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +console.log(`[verify] ${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);