diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 6c662e2..897b0bc 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index c73b9b2..7a95f13 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/data/puddingmonsters.json b/public/data/puddingmonsters.json new file mode 100644 index 0000000..34733c9 --- /dev/null +++ b/public/data/puddingmonsters.json @@ -0,0 +1,2167 @@ +{ + "generatedAt": "2026-06-09T03:30:42.804Z", + "seed": 1592594996, + "count": 40, + "levels": [ + { + "level": 1, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 2, + 0 + ] + ], + "monsters": [ + [ + 3, + 2 + ], + [ + 0, + 0 + ], + [ + 1, + 2 + ] + ], + "par": 2 + }, + { + "level": 2, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 4, + 2 + ], + [ + 4, + 3 + ], + [ + 4, + 4 + ] + ], + "monsters": [ + [ + 4, + 4 + ], + [ + 4, + 2 + ], + [ + 1, + 4 + ] + ], + "par": 2 + }, + { + "level": 3, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 2, + 0 + ] + ], + "monsters": [ + [ + 2, + 1 + ], + [ + 1, + 2 + ], + [ + 0, + 0 + ] + ], + "par": 2 + }, + { + "level": 4, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 0, + 1 + ] + ], + "monsters": [ + [ + 0, + 3 + ], + [ + 0, + 1 + ], + [ + 1, + 0 + ] + ], + "par": 2 + }, + { + "level": 5, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 1, + 0 + ], + [ + 2, + 0 + ], + [ + 2, + 1 + ] + ], + "monsters": [ + [ + 1, + 0 + ], + [ + 2, + 1 + ], + [ + 4, + 4 + ] + ], + "par": 2 + }, + { + "level": 6, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 0, + 3 + ], + [ + 1, + 3 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 1, + 0 + ], + [ + 1, + 2 + ], + [ + 0, + 3 + ] + ], + "par": 2 + }, + { + "level": 7, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 1, + 3 + ], + [ + 0, + 4 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 3, + 3 + ], + [ + 4, + 4 + ], + [ + 1, + 3 + ] + ], + "par": 2 + }, + { + "level": 8, + "cols": 5, + "rows": 5, + "walls": [], + "spikes": [], + "stars": [ + [ + 3, + 0 + ], + [ + 2, + 1 + ], + [ + 3, + 1 + ] + ], + "monsters": [ + [ + 0, + 4 + ], + [ + 4, + 3 + ], + [ + 2, + 1 + ] + ], + "par": 3 + }, + { + "level": 9, + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 4 + ], + [ + 0, + 1 + ] + ], + "spikes": [], + "stars": [ + [ + 1, + 0 + ], + [ + 2, + 0 + ], + [ + 3, + 0 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 1, + 3 + ], + [ + 3, + 1 + ] + ], + "par": 3 + }, + { + "level": 10, + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, + 0 + ], + [ + 1, + 3 + ] + ], + "spikes": [], + "stars": [ + [ + 5, + 4 + ], + [ + 4, + 5 + ], + [ + 5, + 5 + ] + ], + "monsters": [ + [ + 5, + 0 + ], + [ + 0, + 4 + ], + [ + 4, + 2 + ] + ], + "par": 3 + }, + { + "level": 11, + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 3 + ], + [ + 4, + 5 + ] + ], + "spikes": [], + "stars": [ + [ + 2, + 0 + ], + [ + 3, + 0 + ], + [ + 4, + 0 + ] + ], + "monsters": [ + [ + 4, + 4 + ], + [ + 3, + 2 + ], + [ + 2, + 3 + ] + ], + "par": 3 + }, + { + "level": 12, + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 3 + ], + [ + 2, + 1 + ] + ], + "spikes": [], + "stars": [ + [ + 2, + 4 + ], + [ + 2, + 5 + ], + [ + 3, + 5 + ] + ], + "monsters": [ + [ + 1, + 1 + ], + [ + 1, + 4 + ], + [ + 3, + 5 + ] + ], + "par": 3 + }, + { + "level": 13, + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 5 + ], + [ + 0, + 0 + ] + ], + "spikes": [], + "stars": [ + [ + 4, + 4 + ], + [ + 5, + 4 + ], + [ + 5, + 5 + ] + ], + "monsters": [ + [ + 2, + 2 + ], + [ + 5, + 5 + ], + [ + 1, + 0 + ] + ], + "par": 3 + }, + { + "level": 14, + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 1 + ], + [ + 0, + 4 + ] + ], + "spikes": [], + "stars": [ + [ + 2, + 0 + ], + [ + 3, + 0 + ], + [ + 4, + 0 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 0, + 3 + ], + [ + 4, + 0 + ] + ], + "par": 3 + }, + { + "level": 15, + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 3 + ], + [ + 0, + 0 + ] + ], + "spikes": [], + "stars": [ + [ + 3, + 1 + ], + [ + 3, + 2 + ], + [ + 4, + 2 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 4, + 2 + ], + [ + 0, + 4 + ] + ], + "par": 3 + }, + { + "level": 16, + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 3 + ], + [ + 2, + 5 + ] + ], + "spikes": [], + "stars": [ + [ + 0, + 0 + ], + [ + 0, + 1 + ], + [ + 0, + 2 + ] + ], + "monsters": [ + [ + 3, + 5 + ], + [ + 0, + 5 + ], + [ + 4, + 2 + ] + ], + "par": 4 + }, + { + "level": 17, + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 3 + ], + [ + 4, + 5 + ], + [ + 0, + 4 + ] + ], + "spikes": [], + "stars": [ + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 5 + ] + ], + "monsters": [ + [ + 0, + 0 + ], + [ + 2, + 5 + ], + [ + 4, + 4 + ], + [ + 4, + 2 + ] + ], + "par": 4 + }, + { + "level": 18, + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 2 + ], + [ + 5, + 2 + ], + [ + 4, + 2 + ] + ], + "spikes": [], + "stars": [ + [ + 2, + 0 + ], + [ + 1, + 1 + ], + [ + 2, + 1 + ] + ], + "monsters": [ + [ + 0, + 1 + ], + [ + 2, + 4 + ], + [ + 3, + 2 + ], + [ + 4, + 5 + ] + ], + "par": 4 + }, + { + "level": 19, + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 2 + ], + [ + 2, + 5 + ], + [ + 2, + 0 + ] + ], + "spikes": [], + "stars": [ + [ + 0, + 0 + ], + [ + 0, + 2 + ], + [ + 0, + 3 + ] + ], + "monsters": [ + [ + 1, + 2 + ], + [ + 5, + 3 + ], + [ + 1, + 0 + ], + [ + 4, + 2 + ] + ], + "par": 4 + }, + { + "level": 20, + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 5 + ], + [ + 4, + 3 + ], + [ + 5, + 1 + ] + ], + "spikes": [], + "stars": [ + [ + 3, + 4 + ], + [ + 4, + 5 + ], + [ + 5, + 5 + ] + ], + "monsters": [ + [ + 0, + 2 + ], + [ + 4, + 4 + ], + [ + 5, + 3 + ], + [ + 3, + 3 + ] + ], + "par": 4 + }, + { + "level": 21, + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 2 + ], + [ + 0, + 1 + ], + [ + 1, + 0 + ] + ], + "spikes": [], + "stars": [ + [ + 3, + 0 + ], + [ + 5, + 0 + ], + [ + 5, + 1 + ] + ], + "monsters": [ + [ + 1, + 5 + ], + [ + 4, + 0 + ], + [ + 2, + 0 + ], + [ + 5, + 3 + ] + ], + "par": 4 + }, + { + "level": 22, + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 5 + ], + [ + 3, + 2 + ], + [ + 4, + 4 + ] + ], + "spikes": [], + "stars": [ + [ + 2, + 3 + ], + [ + 2, + 4 + ], + [ + 2, + 5 + ] + ], + "monsters": [ + [ + 5, + 0 + ], + [ + 3, + 3 + ], + [ + 0, + 3 + ], + [ + 0, + 5 + ] + ], + "par": 4 + }, + { + "level": 23, + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 0 + ], + [ + 2, + 2 + ], + [ + 3, + 4 + ] + ], + "spikes": [], + "stars": [ + [ + 3, + 0 + ], + [ + 3, + 1 + ], + [ + 3, + 2 + ] + ], + "monsters": [ + [ + 0, + 5 + ], + [ + 5, + 3 + ], + [ + 3, + 2 + ], + [ + 2, + 1 + ] + ], + "par": 4 + }, + { + "level": 24, + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 3 + ], + [ + 4, + 2 + ], + [ + 2, + 4 + ] + ], + "spikes": [], + "stars": [ + [ + 5, + 0 + ], + [ + 5, + 1 + ], + [ + 5, + 2 + ] + ], + "monsters": [ + [ + 5, + 5 + ], + [ + 5, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 1 + ] + ], + "par": 5 + }, + { + "level": 25, + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 3 + ], + [ + 5, + 4 + ], + [ + 0, + 4 + ], + [ + 2, + 2 + ] + ], + "spikes": [ + [ + 3, + 3 + ] + ], + "stars": [ + [ + 4, + 0 + ], + [ + 4, + 1 + ], + [ + 4, + 2 + ] + ], + "monsters": [ + [ + 4, + 5 + ], + [ + 6, + 3 + ], + [ + 3, + 1 + ], + [ + 0, + 6 + ] + ], + "par": 5 + }, + { + "level": 26, + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 0 + ], + [ + 0, + 2 + ], + [ + 2, + 3 + ], + [ + 3, + 1 + ] + ], + "spikes": [ + [ + 3, + 4 + ] + ], + "stars": [ + [ + 1, + 4 + ], + [ + 0, + 6 + ], + [ + 1, + 6 + ] + ], + "monsters": [ + [ + 0, + 4 + ], + [ + 3, + 2 + ], + [ + 4, + 1 + ], + [ + 1, + 5 + ] + ], + "par": 5 + }, + { + "level": 27, + "cols": 7, + "rows": 7, + "walls": [ + [ + 1, + 4 + ], + [ + 0, + 6 + ], + [ + 5, + 3 + ], + [ + 2, + 2 + ] + ], + "spikes": [ + [ + 4, + 1 + ] + ], + "stars": [ + [ + 3, + 0 + ], + [ + 5, + 0 + ], + [ + 6, + 0 + ] + ], + "monsters": [ + [ + 6, + 2 + ], + [ + 3, + 6 + ], + [ + 2, + 4 + ], + [ + 4, + 0 + ] + ], + "par": 5 + }, + { + "level": 28, + "cols": 7, + "rows": 7, + "walls": [ + [ + 1, + 2 + ], + [ + 2, + 5 + ], + [ + 1, + 1 + ], + [ + 2, + 4 + ] + ], + "spikes": [ + [ + 2, + 6 + ] + ], + "stars": [ + [ + 5, + 3 + ], + [ + 5, + 4 + ], + [ + 6, + 4 + ] + ], + "monsters": [ + [ + 4, + 6 + ], + [ + 0, + 4 + ], + [ + 1, + 6 + ], + [ + 6, + 6 + ] + ], + "par": 5 + }, + { + "level": 29, + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 2 + ], + [ + 2, + 4 + ], + [ + 5, + 3 + ], + [ + 1, + 0 + ] + ], + "spikes": [ + [ + 0, + 0 + ] + ], + "stars": [ + [ + 1, + 3 + ], + [ + 3, + 3 + ], + [ + 3, + 4 + ] + ], + "monsters": [ + [ + 4, + 1 + ], + [ + 5, + 0 + ], + [ + 5, + 4 + ], + [ + 0, + 3 + ] + ], + "par": 5 + }, + { + "level": 30, + "cols": 7, + "rows": 7, + "walls": [ + [ + 1, + 1 + ], + [ + 3, + 5 + ], + [ + 2, + 4 + ], + [ + 4, + 4 + ] + ], + "spikes": [ + [ + 0, + 3 + ] + ], + "stars": [ + [ + 6, + 1 + ], + [ + 6, + 2 + ], + [ + 5, + 3 + ] + ], + "monsters": [ + [ + 6, + 3 + ], + [ + 3, + 1 + ], + [ + 4, + 3 + ], + [ + 0, + 4 + ] + ], + "par": 5 + }, + { + "level": 31, + "cols": 7, + "rows": 7, + "walls": [ + [ + 1, + 4 + ], + [ + 4, + 0 + ], + [ + 6, + 6 + ], + [ + 1, + 6 + ] + ], + "spikes": [ + [ + 3, + 5 + ] + ], + "stars": [ + [ + 3, + 0 + ], + [ + 2, + 1 + ], + [ + 3, + 1 + ] + ], + "monsters": [ + [ + 1, + 5 + ], + [ + 5, + 1 + ], + [ + 4, + 5 + ], + [ + 1, + 1 + ] + ], + "par": 5 + }, + { + "level": 32, + "cols": 7, + "rows": 7, + "walls": [ + [ + 3, + 2 + ], + [ + 2, + 6 + ], + [ + 5, + 4 + ], + [ + 5, + 6 + ] + ], + "spikes": [ + [ + 4, + 3 + ] + ], + "stars": [ + [ + 3, + 0 + ], + [ + 4, + 0 + ], + [ + 3, + 1 + ] + ], + "monsters": [ + [ + 6, + 2 + ], + [ + 4, + 2 + ], + [ + 4, + 5 + ], + [ + 2, + 0 + ] + ], + "par": 5 + }, + { + "level": 33, + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 3 + ], + [ + 1, + 5 + ], + [ + 6, + 1 + ], + [ + 0, + 5 + ], + [ + 2, + 0 + ] + ], + "spikes": [ + [ + 3, + 5 + ], + [ + 3, + 6 + ] + ], + "stars": [ + [ + 3, + 2 + ], + [ + 3, + 4 + ], + [ + 4, + 4 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 4, + 1 + ], + [ + 6, + 0 + ], + [ + 0, + 0 + ], + [ + 4, + 5 + ] + ], + "par": 7 + }, + { + "level": 34, + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 3 + ], + [ + 5, + 0 + ], + [ + 4, + 4 + ], + [ + 1, + 2 + ], + [ + 6, + 5 + ] + ], + "spikes": [ + [ + 6, + 3 + ], + [ + 3, + 4 + ] + ], + "stars": [ + [ + 4, + 3 + ], + [ + 5, + 3 + ], + [ + 5, + 4 + ] + ], + "monsters": [ + [ + 3, + 6 + ], + [ + 5, + 5 + ], + [ + 3, + 3 + ], + [ + 5, + 2 + ], + [ + 0, + 2 + ] + ], + "par": 7 + }, + { + "level": 35, + "cols": 7, + "rows": 7, + "walls": [ + [ + 6, + 1 + ], + [ + 5, + 6 + ], + [ + 5, + 3 + ], + [ + 0, + 2 + ], + [ + 3, + 0 + ] + ], + "spikes": [ + [ + 1, + 1 + ], + [ + 1, + 5 + ] + ], + "stars": [ + [ + 2, + 0 + ], + [ + 1, + 2 + ], + [ + 2, + 2 + ] + ], + "monsters": [ + [ + 5, + 2 + ], + [ + 4, + 6 + ], + [ + 0, + 1 + ], + [ + 3, + 2 + ], + [ + 6, + 6 + ] + ], + "par": 7 + }, + { + "level": 36, + "cols": 7, + "rows": 7, + "walls": [ + [ + 5, + 3 + ], + [ + 2, + 1 + ], + [ + 2, + 2 + ], + [ + 2, + 3 + ], + [ + 1, + 4 + ] + ], + "spikes": [ + [ + 6, + 0 + ], + [ + 4, + 4 + ] + ], + "stars": [ + [ + 1, + 0 + ], + [ + 4, + 0 + ], + [ + 1, + 1 + ] + ], + "monsters": [ + [ + 2, + 6 + ], + [ + 6, + 5 + ], + [ + 4, + 3 + ], + [ + 5, + 2 + ], + [ + 2, + 0 + ] + ], + "par": 7 + }, + { + "level": 37, + "cols": 7, + "rows": 7, + "walls": [ + [ + 1, + 6 + ], + [ + 1, + 4 + ], + [ + 0, + 1 + ], + [ + 1, + 2 + ], + [ + 3, + 3 + ] + ], + "spikes": [ + [ + 2, + 6 + ], + [ + 2, + 3 + ] + ], + "stars": [ + [ + 2, + 4 + ], + [ + 1, + 5 + ], + [ + 2, + 5 + ] + ], + "monsters": [ + [ + 5, + 2 + ], + [ + 3, + 1 + ], + [ + 1, + 0 + ], + [ + 0, + 4 + ], + [ + 5, + 4 + ] + ], + "par": 8 + }, + { + "level": 38, + "cols": 7, + "rows": 7, + "walls": [ + [ + 2, + 6 + ], + [ + 6, + 5 + ], + [ + 1, + 5 + ], + [ + 6, + 0 + ], + [ + 0, + 0 + ] + ], + "spikes": [ + [ + 4, + 3 + ], + [ + 3, + 1 + ] + ], + "stars": [ + [ + 0, + 4 + ], + [ + 2, + 4 + ], + [ + 2, + 5 + ] + ], + "monsters": [ + [ + 6, + 6 + ], + [ + 0, + 3 + ], + [ + 5, + 0 + ], + [ + 5, + 5 + ], + [ + 6, + 4 + ] + ], + "par": 8 + }, + { + "level": 39, + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 6 + ], + [ + 5, + 1 + ], + [ + 4, + 1 + ], + [ + 6, + 5 + ], + [ + 1, + 1 + ] + ], + "spikes": [ + [ + 3, + 5 + ], + [ + 1, + 2 + ] + ], + "stars": [ + [ + 0, + 0 + ], + [ + 2, + 0 + ], + [ + 3, + 0 + ] + ], + "monsters": [ + [ + 1, + 6 + ], + [ + 5, + 6 + ], + [ + 3, + 1 + ], + [ + 5, + 0 + ], + [ + 0, + 1 + ] + ], + "par": 8 + }, + { + "level": 40, + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 2 + ], + [ + 2, + 2 + ], + [ + 4, + 0 + ], + [ + 4, + 4 + ], + [ + 4, + 2 + ] + ], + "spikes": [ + [ + 3, + 6 + ], + [ + 6, + 3 + ] + ], + "stars": [ + [ + 3, + 0 + ], + [ + 4, + 1 + ], + [ + 5, + 2 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 3, + 2 + ], + [ + 6, + 2 + ], + [ + 1, + 2 + ], + [ + 0, + 3 + ] + ], + "par": 10 + } + ] +} \ No newline at end of file diff --git a/public/src/games/puddingmonsters/PuddingMonstersGame.js b/public/src/games/puddingmonsters/PuddingMonstersGame.js new file mode 100644 index 0000000..7527326 --- /dev/null +++ b/public/src/games/puddingmonsters/PuddingMonstersGame.js @@ -0,0 +1,662 @@ +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 { + DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, starsCollected, +} from './PuddingMonstersLogic.js'; + +const BG = 0x161226; +const FRAME = 0x241c3a; +const FLOOR = 0x2b2347; +const FLOOR_LN = 0x372c59; +const WALL = 0x0e0a1a; +const WALL_HI = 0x3a2f56; +const SPIKE_BG = 0x3a1320; +const SPIKE = 0xe0506b; +const STAR_OFF = 0x6b5a8a; +const STAR_ON = 0xffd54a; + +const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d]; + +const D = { frame: -1, floor: 0, deco: 1, star: 2, monster: 10, anim: 12, ui: 30, overlay: 60, overlayUI: 62 }; + +export default class PuddingMonstersGame extends Phaser.Scene { + constructor() { super('PuddingMonstersGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'puddingmonsters', name: 'Jell-o Monsters' }; + this.bank = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + + this.level = 0; + this.levelDef = null; + this.state = null; + this.undoStack = []; + this.moves = 0; + this.par = 0; + this.overlayUp = false; + this.busy = false; + this.drag = null; + this.selectedCell = null; + this.animGfx = null; + } + + 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, BG).setDepth(-2); + + const raw = this.cache.json.get('puddingmonsters'); + this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await api.get('/puzzles/puddingmonsters/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.layer = this.add.container(0, 0); + this.bindInput(); + this.showLevelSelect(); + } + + bindInput() { + this.input.on('pointerdown', (p) => this.onPointerDown(p)); + this.input.on('pointerup', (p) => this.onPointerUp(p)); + this.input.keyboard.on('keydown', (e) => this.onKey(e)); + } + + clearLayer() { + this.layer.removeAll(true); + this.monsterGfx = null; + this.starGfx = null; + this.animGfx = null; + this.undoBtn = null; + this.movesText = null; + this.starsText = null; + } + + bestStars(level) { + try { return Number(localStorage.getItem(`pm-stars-${level}`)) || 0; } catch (_) { return 0; } + } + + saveStars(level, stars) { + try { + if (stars > this.bestStars(level)) localStorage.setItem(`pm-stars-${level}`, String(stars)); + } catch (_) { /* ignore */ } + } + + // ── Level select ──────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.overlayUp = false; + this.busy = false; + this.drag = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 84, 'JELL-O MONSTERS', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 140, 'Flick the jellies so they slide and stick — merge them all into one. Grab the 3 stars!', { + 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 server/scripts/genPuddingMonsters.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, 184, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add(prog); + + const COLS = 10; + const SIZE = 120; + const GAP = 16; + const gridW = COLS * SIZE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const top = 280; + + 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 ? 0x35265a : playable ? 0x271f44 : 0x191427; + const stroke = cleared ? 0xc792ff : playable ? COLORS.gold : 0x2a2440; + const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - 18, String(level), { + fontFamily: 'Righteous', fontSize: '40px', + color: playable || cleared ? COLORS.textHex : '#564f6b', + }).setOrigin(0.5); + this.layer.add([tile, num]); + + if (playable || cleared) { + const earned = this.bestStars(level); + const stars = this.add.text(x, y + 30, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), { + fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#6b5a8a', + }).setOrigin(0.5); + const par = this.add.text(x, y + 52, `par ${p.par}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([stars, par]); + } else { + const lock = this.add.text(x, y + 36, 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#564f6b', + }).setOrigin(0.5); + this.layer.add(lock); + } + + 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 - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), + { width: 300, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + this.layer.add([resume, back]); + + if (!this.canPersist) { + const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(note); + } + } + + // ── Play a level ────────────────────────────────────────────────────────────── + + playLevel(level) { + const def = this.bank.find((p) => p.level === level); + if (!def) return; + this.view = 'play'; + this.level = level; + this.levelDef = def; + this.par = def.par; + this.state = newState(def); + this.undoStack = []; + this.moves = 0; + this.overlayUp = false; + this.busy = false; + this.drag = null; + this.selectedCell = null; + + this.clearLayer(); + this.computeLayout(); + this.buildBoard(); + this.starGfx = this.add.graphics().setDepth(D.star); this.layer.add(this.starGfx); + this.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx); + this.drawStars(); + this.drawAllMonsters(); + this.drawHud(); + this.updateHud(); + } + + computeLayout() { + const { cols, rows } = this.state; + const LEFT_STRIP = 300; + const TOP = 170; + const BOTTOM = GAME_HEIGHT - 50; + const RIGHT = GAME_WIDTH - 50; + this.cell = Math.min((RIGHT - LEFT_STRIP) / cols, (BOTTOM - TOP) / rows, 132); + const boardW = cols * this.cell; + const boardH = rows * this.cell; + this.originX = LEFT_STRIP + (RIGHT - LEFT_STRIP - boardW) / 2; + this.originY = TOP + (BOTTOM - TOP - boardH) / 2; + } + + cellLeft(x) { return this.originX + x * this.cell; } + cellTop(y) { return this.originY + y * this.cell; } + cellCx(x) { return this.originX + (x + 0.5) * this.cell; } + cellCy(y) { return this.originY + (y + 0.5) * this.cell; } + + cellFromPx(px, py) { + const x = Math.floor((px - this.originX) / this.cell); + const y = Math.floor((py - this.originY) / this.cell); + if (x < 0 || y < 0 || x >= this.state.cols || y >= this.state.rows) return null; + return { x, y }; + } + + buildBoard() { + const { cols, rows } = this.state; + const boardW = cols * this.cell; + const boardH = rows * this.cell; + + const frame = this.add.graphics().setDepth(D.frame); + frame.fillStyle(FRAME, 1); + frame.fillRoundedRect(this.originX - 18, this.originY - 18, boardW + 36, boardH + 36, 22); + this.layer.add(frame); + + const floor = this.add.graphics().setDepth(D.floor); + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const k = `${x},${y}`; + if (this.state.walls.has(k)) continue; + floor.fillStyle(FLOOR, 1); + floor.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8); + floor.lineStyle(1, FLOOR_LN, 1); + floor.strokeRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8); + } + } + this.layer.add(floor); + + const deco = this.add.graphics().setDepth(D.deco); + for (const k of this.state.walls) { + const [x, y] = k.split(',').map(Number); + deco.fillStyle(WALL, 1); + deco.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8); + deco.fillStyle(WALL_HI, 1); + deco.fillRoundedRect(this.cellLeft(x) + 6, this.cellTop(y) + 6, this.cell - 12, this.cell * 0.22, 6); + } + for (const k of this.state.spikes) { + const [x, y] = k.split(',').map(Number); + const L = this.cellLeft(x), T = this.cellTop(y), c = this.cell; + deco.fillStyle(SPIKE_BG, 1); + deco.fillRoundedRect(L + 2, T + 2, c - 4, c - 4, 8); + deco.fillStyle(SPIKE, 1); + const baseY = T + c - c * 0.18; + const tipY = T + c * 0.24; + for (let i = 0; i < 3; i++) { + const bx = L + c * (0.2 + i * 0.3); + deco.fillTriangle(bx, baseY, bx + c * 0.2, baseY, bx + c * 0.1, tipY); + } + } + this.layer.add(deco); + } + + drawStars() { + const g = this.starGfx; + g.clear(); + for (const [sx, sy] of this.state.stars) { + const on = this.state.collected.has(`${sx},${sy}`); + const cx = this.cellCx(sx), cy = this.cellCy(sy); + if (on) { + g.fillStyle(STAR_ON, 0.22); + g.fillCircle(cx, cy, this.cell * 0.42); + } + this.drawStarShape(g, cx, cy, this.cell * 0.26, on); + } + } + + drawStarShape(g, cx, cy, r, filled) { + const pts = []; + for (let i = 0; i < 10; i++) { + const ang = (Math.PI / 180) * (-90 + i * 36); + const rad = i % 2 === 0 ? r : r * 0.45; + pts.push(cx + rad * Math.cos(ang), cy + rad * Math.sin(ang)); + } + if (filled) { + g.fillStyle(STAR_ON, 1); + g.beginPath(); g.moveTo(pts[0], pts[1]); + for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]); + g.closePath(); g.fillPath(); + } + g.lineStyle(3, filled ? 0xffec99 : STAR_OFF, 1); + g.beginPath(); g.moveTo(pts[0], pts[1]); + for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]); + g.closePath(); g.strokePath(); + } + + colorFor(blob) { + const [x, y] = repCell(blob); + return PALETTE[(x * 7 + y * 13) % PALETTE.length]; + } + + drawAllMonsters(excludeIdx = -1) { + const g = this.monsterGfx; + g.clear(); + this.state.blobs.forEach((blob, i) => { + if (i === excludeIdx) return; + const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i; + this.drawBlobInto(g, blob.cells, this.colorFor(blob), 0, 0, sel); + }); + } + + drawBlobInto(g, cells, color, ox, oy, selected = false) { + const c = this.cell; + const inset = c * 0.10; + const r = c * 0.30; + const set = new Set(cells.map(([x, y]) => `${x},${y}`)); + + g.fillStyle(color, 1); + for (const [x, y] of cells) { + g.fillRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r); + } + for (const [x, y] of cells) { + const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy; + if (set.has(`${x + 1},${y}`)) g.fillRect(L + c - inset - 1, T + inset, 2 * inset + 2, c - 2 * inset); + if (set.has(`${x},${y + 1}`)) g.fillRect(L + inset, T + c - inset - 1, c - 2 * inset, 2 * inset + 2); + } + // glossy highlight + g.fillStyle(0xffffff, 0.18); + for (const [x, y] of cells) { + const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy; + g.fillRoundedRect(L + inset + c * 0.12, T + inset + c * 0.10, c * 0.30, c * 0.14, c * 0.07); + } + if (selected) { + g.lineStyle(3, 0xffffff, 0.85); + for (const [x, y] of cells) { + g.strokeRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r); + } + } + // eyes at centroid + let ex = 0, ey = 0; + for (const [x, y] of cells) { ex += this.cellCx(x) + ox; ey += this.cellCy(y) + oy; } + ex /= cells.length; ey /= cells.length; + const er = c * 0.12, sp = c * 0.17; + g.fillStyle(0xffffff, 1); + g.fillCircle(ex - sp, ey - er * 0.6, er); g.fillCircle(ex + sp, ey - er * 0.6, er); + g.fillStyle(0x1a1a1a, 1); + g.fillCircle(ex - sp + er * 0.2, ey - er * 0.45, er * 0.5); g.fillCircle(ex + sp + er * 0.2, ey - er * 0.45, er * 0.5); + } + + drawHud() { + const title = this.add.text(40, 80, 'JELL-O\nMONSTERS', { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, lineSpacing: 2, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.layer.add(title); + + this.movesText = this.add.text(GAME_WIDTH - 50, 80, '', { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.starsText = this.add.text(GAME_WIDTH - 50, 124, '', { + fontFamily: 'serif', fontSize: '30px', color: '#ffd54a', + }).setOrigin(1, 0.5).setDepth(D.ui); + this.layer.add([this.movesText, this.starsText]); + + const BTN_W = 200; + const BTN_H = 56; + const BTN_GAP = 14; + const BTN_X = 150; + const totalH = 4 * BTN_H + 3 * BTN_GAP; + let y = GAME_HEIGHT / 2 - totalH / 2; + + const undo = new Button(this, BTN_X, y, 'Undo', () => this.undo(), { width: BTN_W, height: BTN_H, fontSize: 22 }); + y += BTN_H + BTN_GAP; + const reset = new Button(this, BTN_X, y, 'Reset', () => this.resetLevel(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + y += BTN_H + BTN_GAP; + const hint = new Button(this, BTN_X, y, 'Hint', () => this.showHint(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + y += BTN_H + BTN_GAP; + const levels = new Button(this, BTN_X, y, 'Levels', () => this.showLevelSelect(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + this.undoBtn = undo; + this.layer.add([undo, reset, hint, levels]); + + const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag a monster to\nslide it. Arrow keys\nflick the selected one.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5, 0).setDepth(D.ui); + this.layer.add(tip); + } + + updateHud() { + if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`); + if (this.starsText) this.starsText.setText(`★ ${starsCollected(this.state)}/3`); + if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0); + } + + // ── Input handling ────────────────────────────────────────────────────────── + + onPointerDown(p) { + if (this.view !== 'play' || this.overlayUp || this.busy) return; + const cell = this.cellFromPx(p.x, p.y); + if (cell && blobAt(this.state, cell.x, cell.y) >= 0) { + this.drag = { cell, sx: p.x, sy: p.y }; + this.selectedCell = cell; + this.drawAllMonsters(); + } else { + this.drag = null; + } + } + + onPointerUp(p) { + if (this.view !== 'play' || this.overlayUp || this.busy || !this.drag) return; + const dx = p.x - this.drag.sx; + const dy = p.y - this.drag.sy; + const cell = this.drag.cell; + this.drag = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < this.cell * 0.28) return; // tap, not a flick + const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'); + this.doFlick(cell, dir); + } + + onKey(e) { + if (this.view !== 'play' || this.overlayUp || this.busy || !this.selectedCell) return; + const map = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; + const dir = map[e.key]; + if (dir) { e.preventDefault?.(); this.doFlick(this.selectedCell, dir); } + } + + // ── Flick / move ──────────────────────────────────────────────────────────── + + doFlick(cell, dir) { + const idx = blobAt(this.state, cell.x, cell.y); + if (idx < 0) return; + const { maxSteps, deathStep } = computeSlide(this.state, idx, dir); + if (maxSteps === 0) { this.nudgeInvalid(idx); return; } + + this.busy = true; + this.undoStack.push(cloneState(this.state)); + this.moves++; + this.updateHud(); + + const movingCells = this.state.blobs[idx].cells.map((c) => [c[0], c[1]]); + const color = this.colorFor(this.state.blobs[idx]); + const [dx, dy] = DIRS[dir]; + const steps = deathStep > 0 ? deathStep : maxSteps; + + this.drawAllMonsters(idx); + this.animGfx = this.add.graphics().setDepth(D.anim); + this.layer.add(this.animGfx); + const proxy = { v: 0 }; + const tgtX = dx * steps * this.cell; + const tgtY = dy * steps * this.cell; + const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, color, tgtX * t, tgtY * t); }; + drawAt(0); + playSound(this, SFX.PIECE_CLICK); + + this.tweens.add({ + targets: proxy, v: 1, + duration: Math.min(420, 110 + steps * 55), + ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut', + onUpdate: () => drawAt(proxy.v), + onComplete: () => { + if (deathStep > 0) this.splatAndFail(idx, dir); + else this.commitMove(idx, dir, movingCells, dx, dy, steps); + }, + }); + } + + commitMove(idx, dir, movingCells, dx, dy, steps) { + const res = slide(this.state, idx, dir); + if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; } + // keep the moved blob selected at its new resting position + this.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps }; + this.drawAllMonsters(); + this.drawStars(); + this.updateHud(); + if (res.merged) playSound(this, SFX.CARD_PLACE); + this.busy = false; + if (this.state.state === 'won') this.onSolved(); + } + + splatAndFail(idx, dir) { + slide(this.state, idx, dir); // marks state 'dead' (positions unchanged) + playSound(this, SFX.SCIFI_EXPLODE); + const g = this.animGfx; + if (g) { + this.tweens.add({ + targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn', + onComplete: () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(); }, + }); + } else { + this.onDead(); + } + } + + nudgeInvalid(idx) { + // brief wobble of the whole monster layer to signal "can't move" + const blob = this.state.blobs[idx]; + const cx = this.cellCx(repCell(blob)[0]); + void cx; + this.tweens.add({ targets: this.monsterGfx, x: 6, duration: 50, yoyo: true, repeat: 1, onComplete: () => { this.monsterGfx.x = 0; } }); + } + + undo() { + if (!this.undoStack.length || this.busy || this.overlayUp) return; + this.state = this.undoStack.pop(); + this.moves++; // an undo still counts as a move (matches Rush Hour) + this.selectedCell = null; + this.drawAllMonsters(); + this.drawStars(); + this.updateHud(); + playSound(this, SFX.PIECE_CLICK); + } + + resetLevel() { + if (this.busy || this.overlayUp) return; + this.state = newState(this.levelDef); + this.undoStack = []; + this.moves = 0; + this.selectedCell = null; + this.drawAllMonsters(); + this.drawStars(); + this.updateHud(); + playSound(this, SFX.CARD_SHUFFLE); + } + + showHint() { + if (this.busy || this.overlayUp) return; + const { path } = solve(this.state, { maxStates: 200000 }); + if (!path || !path.length) return; + const mv = path[0]; + const idx = blobAt(this.state, mv.cell[0], mv.cell[1]); + if (idx < 0) return; + this.selectedCell = { x: mv.cell[0], y: mv.cell[1] }; + this.drawAllMonsters(); + const [dx, dy] = DIRS[mv.dir]; + const cx = this.cellCx(mv.cell[0]); + const cy = this.cellCy(mv.cell[1]); + const arrow = this.add.text(cx + dx * this.cell * 0.5, cy + dy * this.cell * 0.5, + ({ up: '↑', down: '↓', left: '←', right: '→' })[mv.dir], { + fontFamily: 'Righteous', fontSize: `${Math.round(this.cell * 0.6)}px`, color: '#ffffff', + }).setOrigin(0.5).setDepth(D.anim); + this.layer.add(arrow); + this.tweens.add({ targets: arrow, alpha: 0, scale: 1.4, duration: 900, ease: 'Quad.easeOut', onComplete: () => arrow.destroy() }); + } + + // ── End states ────────────────────────────────────────────────────────────── + + onDead() { + this.overlayUp = true; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).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, 'Splat!', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, 'A monster slid onto the spikes. 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; + const stars = starsCollected(this.state); + this.saveStars(this.level, stars); + + if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; + api.post('/puzzles/puddingmonsters/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: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + playSound(this, SFX.VICTORY_SHORT); + + 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 - 210, 640, 420, 20); + panel.lineStyle(3, 0xc792ff, 1); + panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20); + this.layer.add(panel); + + const title = this.add.text(cx, cy - 140, 'Solved!', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { + fontFamily: 'serif', fontSize: '56px', color: '#ffd54a', + }).setOrigin(0.5).setDepth(D.overlayUI); + const beatPar = this.moves <= this.par; + const stat = this.add.text(cx, cy - 4, + `Level ${this.level} cleared in ${this.moves} moves (par ${this.par})${beatPar ? ' ★ par or better!' : ''}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, starRow, stat]); + + const hasNext = this.level < this.bank.length; + const btns = []; + if (hasNext) { + btns.push(new Button(this, cx, cy + 70, `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 + 62, 'You cleared every level. Sweet!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI)); + } + const replay = new Button(this, cx - 120, cy + 150, 'Replay', () => this.playLevel(this.level), + { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + const levels = new Button(this, cx + 120, cy + 150, 'Levels', () => this.showLevelSelect(), + { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + btns.push(replay, levels); + this.layer.add(btns); + } +} diff --git a/public/src/games/puddingmonsters/PuddingMonstersLogic.js b/public/src/games/puddingmonsters/PuddingMonstersLogic.js new file mode 100644 index 0000000..39965c7 --- /dev/null +++ b/public/src/games/puddingmonsters/PuddingMonstersLogic.js @@ -0,0 +1,232 @@ +// Pudding Monsters — pure slide-and-merge model + BFS solver. No Phaser, no DOM. +// Shared by the client scene and the offline level generator (both ESM). +// +// A level is a grid of cols x rows. Monsters are jelly blobs (each starts as one +// cell). Flicking a blob slides it in a direction until any of its cells would +// leave the board, hit a WALL, or hit another blob — classic "ice slide". When a +// blob comes to rest orthogonally adjacent to another blob they STICK into one +// rigid blob. The level is solved when every monster has merged into a single +// connected blob. +// +// SPIKES are deadly: if a blob's slide path crosses a spike it dies (the run +// fails and the level must be restarted). STARS are bonus floor tiles collected +// when a monster covers them. +// +// A blob: { cells: [[x,y], ...] } (rigid; moves as a unit) + +export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +export const DIR_LIST = ['up', 'down', 'left', 'right']; + +const ADJ = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const key = (x, y) => `${x},${y}`; + +function occupiedAt(state, x, y) { + for (const b of state.blobs) { + for (const [cx, cy] of b.cells) if (cx === x && cy === y) return true; + } + return false; +} + +export function blobAt(state, x, y) { + for (let i = 0; i < state.blobs.length; i++) { + for (const [cx, cy] of state.blobs[i].cells) if (cx === x && cy === y) return i; + } + return -1; +} + +// Lowest cell (top-left in row-major order) — a stable representative for a blob. +export function repCell(blob) { + return blob.cells.reduce((best, c) => + (c[1] < best[1] || (c[1] === best[1] && c[0] < best[0])) ? c : best, blob.cells[0]); +} + +// Union any blobs that are orthogonally adjacent, transitively, into single blobs. +function mergeBlobs(state) { + const occ = new Map(); + state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i))); + + const parent = state.blobs.map((_, i) => i); + const find = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; }; + const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; }; + + state.blobs.forEach((b, i) => { + for (const [x, y] of b.cells) { + for (const [dx, dy] of ADJ) { + const j = occ.get(key(x + dx, y + dy)); + if (j !== undefined && j !== i) union(i, j); + } + } + }); + + const groups = new Map(); + state.blobs.forEach((b, i) => { + const r = find(i); + if (!groups.has(r)) groups.set(r, []); + groups.get(r).push(...b.cells); + }); + state.blobs = [...groups.values()].map((cells) => ({ cells })); +} + +function pickUpStars(state) { + for (const [sx, sy] of state.stars) { + if (!state.collected.has(key(sx, sy)) && occupiedAt(state, sx, sy)) { + state.collected.add(key(sx, sy)); + } + } +} + +export function starsCollected(state) { + return state.collected.size; +} + +// How far blob `idx` can slide in `dir`, and whether that path crosses a spike. +// Pure (does not mutate). { maxSteps, deathStep } — deathStep>0 means fatal. +export function computeSlide(state, idx, dir) { + const [dx, dy] = DIRS[dir]; + const blob = state.blobs[idx]; + + const other = new Set(); + state.blobs.forEach((b, i) => { + if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y))); + }); + + let maxSteps = 0; + const limit = state.cols + state.rows; + for (let s = 1; s <= limit; s++) { + let ok = true; + for (const [x, y] of blob.cells) { + const nx = x + dx * s, ny = y + dy * s; + if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { ok = false; break; } + if (state.walls.has(key(nx, ny)) || other.has(key(nx, ny))) { ok = false; break; } + } + if (!ok) break; + maxSteps = s; + } + + let deathStep = 0; + for (let s = 1; s <= maxSteps && deathStep === 0; s++) { + for (const [x, y] of blob.cells) { + if (state.spikes.has(key(x + dx * s, y + dy * s))) { deathStep = s; break; } + } + } + return { maxSteps, deathStep }; +} + +// Flick blob `idx` in `dir`. Mutates state. Returns: +// { moved:false } — couldn't move (no-op) +// { moved:true, dead:true, deathStep } — slid onto a spike (run fails) +// { moved:true, dead:false, merged, steps } — slid and (maybe) merged +export function slide(state, idx, dir) { + const { maxSteps, deathStep } = computeSlide(state, idx, dir); + if (maxSteps === 0) return { moved: false }; + + const [dx, dy] = DIRS[dir]; + if (deathStep > 0) { + state.state = 'dead'; + return { moved: true, dead: true, deathStep }; + } + + const blob = state.blobs[idx]; + blob.cells = blob.cells.map(([x, y]) => [x + dx * maxSteps, y + dy * maxSteps]); + const before = state.blobs.length; + mergeBlobs(state); + pickUpStars(state); + state.state = state.blobs.length === 1 ? 'won' : 'playing'; + return { moved: true, dead: false, merged: state.blobs.length < before, steps: maxSteps }; +} + +// Every non-fatal flick available. Each entry carries the blob's representative +// cell so a move stays identifiable after merges renumber the blobs. +export function legalMoves(state) { + const moves = []; + state.blobs.forEach((blob, idx) => { + for (const dir of DIR_LIST) { + const { maxSteps, deathStep } = computeSlide(state, idx, dir); + if (maxSteps > 0 && deathStep === 0) moves.push({ idx, dir, cell: repCell(blob) }); + } + }); + return moves; +} + +// Canonical key: cells sorted within each blob, blobs sorted, joined. Walls and +// spikes are fixed for a level, so this fully identifies a configuration. +export function stateKey(state) { + return state.blobs + .map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';')) + .sort() + .join('|'); +} + +export function cloneState(state) { + return { + cols: state.cols, + rows: state.rows, + walls: state.walls, // immutable during play — shared + spikes: state.spikes, // immutable during play — shared + stars: state.stars, // immutable during play — shared + blobs: state.blobs.map((b) => ({ cells: b.cells.map(([x, y]) => [x, y]) })), + collected: new Set(state.collected), + state: state.state, + }; +} + +export function newState(level) { + const state = { + cols: level.cols, + rows: level.rows, + walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))), + spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))), + stars: (level.stars ?? []).map((c) => [c[0], c[1]]), + blobs: (level.monsters ?? []).map(([x, y]) => ({ cells: [[x, y]] })), + collected: new Set(), + state: 'playing', + }; + mergeBlobs(state); // merge any monsters that start touching + pickUpStars(state); + state.state = state.blobs.length === 1 ? 'won' : 'playing'; + return state; +} + +// Breadth-first shortest solution (fewest flicks to merge everything into one +// blob). Returns { moves, path, footprint }: +// moves — minimum flicks (0 if already solved, -1 if none within maxStates) +// path — [{ cell:[x,y], dir }] optimal flicks (null if unsolvable) +// footprint — cells of the final single blob (null if unsolvable); the +// generator marks 3 of these as stars to guarantee a 3-star clear. +export function solve(state, { maxStates = 200000 } = {}) { + const start = cloneState(state); + if (start.blobs.length === 1) return { moves: 0, path: [], footprint: start.blobs[0].cells }; + + const startKey = stateKey(start); + const meta = new Map([[startKey, null]]); + 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 mv of legalMoves(cur)) { + const ns = cloneState(cur); + slide(ns, mv.idx, mv.dir); + const nk = stateKey(ns); + if (meta.has(nk)) continue; + meta.set(nk, { parentKey: k, move: { cell: mv.cell, dir: mv.dir } }); + if (ns.blobs.length === 1) { + 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, footprint: ns.blobs[0].cells }; + } + stateByKey.set(nk, ns); + next.push(nk); + } + if (meta.size > maxStates) return { moves: -1, path: null, footprint: null }; + } + frontier = next; + if (depth > 60) break; + } + return { moves: -1, path: null, footprint: null }; +} diff --git a/public/src/main.js b/public/src/main.js index d0fed6d..13e71bb 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -63,6 +63,7 @@ import TriominoesGame from './games/triominoes/TriominoesGame.js'; import FreecellGame from './games/freecell/FreecellGame.js'; import RushHourGame from './games/rushhour/RushHourGame.js'; import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js'; +import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'; const config = { type: Phaser.AUTO, @@ -139,6 +140,7 @@ const config = { FreecellGame, RushHourGame, HexsweeperGame, + PuddingMonstersGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index ee83d87..2946e01 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,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' }; + 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' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 423e9e8..60be028 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -60,6 +60,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('card-backs', '/data/card-backs.json'); this.load.json('music', '/data/music.json'); this.load.json('rushhour', '/data/rushhour.json'); + this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); diff --git a/server/games/registry.js b/server/games/registry.js index 0194fc5..8b71c77 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -78,3 +78,4 @@ registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 }); registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 }); registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 }); +registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 }); diff --git a/server/scripts/genPuddingMonsters.js b/server/scripts/genPuddingMonsters.js new file mode 100644 index 0000000..5ac6541 --- /dev/null +++ b/server/scripts/genPuddingMonsters.js @@ -0,0 +1,178 @@ +// Offline generator for Pudding Monsters levels. +// +// For each difficulty tier it random-fills a grid with walls, spikes and K +// monsters, runs the BFS solver to (a) reject unsolvable/trivial layouts and +// (b) label each survivor with its minimum flick count (par), then places 3 +// guaranteed-collectable stars on cells of that solution's final footprint and +// writes ordered levels to public/data/puddingmonsters.json. +// +// Usage: +// node server/scripts/genPuddingMonsters.js [seed] [outFile] +// +// Deterministic: same seed -> same bank. Re-run after changing the curve. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { newState, solve } from '../../public/src/games/puddingmonsters/PuddingMonstersLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT_FILE = process.argv[3] + ? path.resolve(process.argv[3]) + : path.join(__dirname, '../../public/data/puddingmonsters.json'); + +const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x5eed1234; + +// ── Seeded RNG (mulberry32) ────────────────────────────────────────────────── +function makeRng(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rng = makeRng(SEED); +const randInt = (n) => Math.floor(rng() * n); + +// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each +// keeps `count` levels whose par falls in [minPar, maxPar]. Spikes appear only +// in later tiers (yield + difficulty). Levels are numbered tier-by-tier. +const TIERS = [ + { count: 8, cols: 5, rows: 5, monsters: 3, walls: 0, spikes: 0, minPar: 2, maxPar: 3 }, + { count: 8, cols: 6, rows: 6, monsters: 3, walls: 2, spikes: 0, minPar: 3, maxPar: 5 }, + { count: 8, cols: 6, rows: 6, monsters: 4, walls: 3, spikes: 0, minPar: 4, maxPar: 7 }, + { count: 8, cols: 7, rows: 7, monsters: 4, walls: 4, spikes: 1, minPar: 5, maxPar: 9 }, + { count: 8, cols: 7, rows: 7, monsters: 5, walls: 5, spikes: 2, minPar: 7, maxPar: 14 }, +]; +const MAX_ATTEMPTS = 6000000; +const MAX_SECONDS = 200; +const SOLVE_MAX_STATES = 80000; + +const keyOf = (x, y) => `${x},${y}`; + +// Place `n` distinct random cells avoiding `taken`; returns null if it can't. +function placeCells(n, cols, rows, taken) { + const out = []; + let tries = 0; + while (out.length < n && tries < 400) { + tries++; + const x = randInt(cols), y = randInt(rows); + const k = keyOf(x, y); + if (taken.has(k)) continue; + taken.add(k); + out.push([x, y]); + } + return out.length === n ? out : null; +} + +// Build one random candidate level for a tier (or null on failure). +function randomLevel(tier) { + const taken = new Set(); + const walls = placeCells(tier.walls, tier.cols, tier.rows, taken); + if (!walls) return null; + const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken); + if (!spikes) return null; + const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken); + if (!monsters) return null; + return { + cols: tier.cols, rows: tier.rows, walls, spikes, monsters, stars: [], + }; +} + +function canonKey(lvl) { + const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' '); + return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`; +} + +// Pick 3 spread-out, ideally non-starting footprint cells -> guaranteed stars. +function chooseStars(footprint, monsters) { + const startSet = new Set(monsters.map(([x, y]) => keyOf(x, y))); + const nonStart = footprint.filter(([x, y]) => !startSet.has(keyOf(x, y))); + const pool = nonStart.length >= 3 ? nonStart : footprint; + const sorted = pool.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); + const idx = [0, Math.floor(sorted.length / 2), sorted.length - 1]; + return [...new Set(idx)].map((i) => [sorted[i][0], sorted[i][1]]); +} + +// ── Generate pool ──────────────────────────────────────────────────────────── +console.log(`[pudding] generating with seed ${SEED}…`); +const target = TIERS.reduce((t, x) => t + x.count, 0); +const buckets = TIERS.map(() => []); +const seen = new Set(); +let attempts = 0; +let solved = 0; +const startedAt = Date.now(); +const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); + +while (attempts < MAX_ATTEMPTS && !tiersFull()) { + // Round-robin the tiers that still need levels so every tier gets airtime. + for (let ti = 0; ti < TIERS.length; ti++) { + if (buckets[ti].length >= TIERS[ti].count) continue; + attempts++; + if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { + console.log('\n[pudding] time budget reached, stopping early'); + break; + } + const tier = TIERS[ti]; + const lvl = randomLevel(tier); + if (!lvl) continue; + + const ck = canonKey(lvl); + if (seen.has(ck)) continue; + seen.add(ck); + + const state = newState(lvl); + if (state.blobs.length !== tier.monsters) continue; // started adjacent -> skip + + const res = solve(state, { maxStates: SOLVE_MAX_STATES }); + if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue; + solved++; + + lvl.stars = chooseStars(res.footprint, lvl.monsters); + if (lvl.stars.length !== 3) continue; + lvl.par = res.moves; + buckets[ti].push(lvl); + + if (solved % 200 === 0) { + const kept = buckets.reduce((t, b) => t + b.length, 0); + process.stdout.write(`\r[pudding] attempts=${attempts} kept=${kept}/${target} `); + } + } + if ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break; +} +process.stdout.write('\n'); + +// ── Assemble ordered levels (tier order, then par ascending within tier) ────── +const chosen = []; +buckets.forEach((b) => { + b.sort((p, q) => p.par - q.par); + chosen.push(...b); +}); + +const levels = chosen.map((lvl, i) => ({ + level: i + 1, + cols: lvl.cols, + rows: lvl.rows, + walls: lvl.walls, + spikes: lvl.spikes, + stars: lvl.stars, + monsters: lvl.monsters, + par: lvl.par, +})); + +const payload = { + generatedAt: new Date().toISOString(), + seed: SEED, + count: levels.length, + levels, +}; + +fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); +fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); + +const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' '); +console.log(`[pudding] attempts=${attempts} solvable=${solved}`); +console.log(`[pudding] tiers filled: ${perTier}`); +console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`);