feat: add Jumble word puzzle game with 58 curated levels
Unscramble 4 base words, then collect circled letters to solve a riddle
bonus answer. Includes a seeded puzzle generator (tools/genJumble.js),
a verifier (tools/verifyJumble.js), and 58 procedurally generated levels
with progressive difficulty.
Also:
- Fix Pai Gow Poker seat rendering to only draw active seats
- Reduce Pai Gow max players from 6→5, max opponents from 5→4
- Add track 19 ("Down the Line" by Journey) to music
- Add Michael opponent voice lines (terry, victor)
- Update game-icons sprite sheet
This commit is contained in:
parent
1ab626d431
commit
376f878a70
Binary file not shown.
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 286 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"riddles": [
|
||||
{ "id": "golfer-pants", "setup": "Q: Why did the golfer bring two pairs of pants?\nA: In case he got a ___.", "answer": "HOLE IN ONE" },
|
||||
{ "id": "bicycle-tired", "setup": "Q: Why couldn't the bicycle stand up by itself?\nA: It was ___.", "answer": "TWO TIRED" },
|
||||
{ "id": "baker-rich", "setup": "Q: Why did the baker stop making bread?\nA: He didn't have enough ___.", "answer": "DOUGH NUTS" },
|
||||
{ "id": "scarecrow-award", "setup": "Q: Why did the scarecrow win an award?\nA: He was outstanding in his ___.", "answer": "PUMPKIN FIELD" },
|
||||
{ "id": "skeleton-party", "setup": "Q: Why don't skeletons go to parties?\nA: They have no ___ to dance with.", "answer": "BODY" },
|
||||
{ "id": "clock-hungry", "setup": "Q: Why was the clock always hungry?\nA: It kept going back for ___.", "answer": "SECONDS" },
|
||||
{ "id": "spider-computer", "setup": "Q: Why did the spider buy a computer?\nA: To build a ___.", "answer": "WEBSITE" },
|
||||
{ "id": "book-astronaut", "setup": "Q: Why did the astronaut break up with his girlfriend?\nA: He needed ___.", "answer": "SPACE" },
|
||||
{ "id": "fish-piano", "setup": "Q: Why don't fish play the piano?\nA: You can't tuna ___.", "answer": "FISH" },
|
||||
{ "id": "banana-doctor", "setup": "Q: Why did the banana go to the doctor?\nA: It wasn't ___.", "answer": "PEELING WELL" },
|
||||
{ "id": "ghost-elevator", "setup": "Q: Why do ghosts ride elevators?\nA: To lift their ___.", "answer": "SPIRITS" },
|
||||
{ "id": "cow-farmer", "setup": "Q: Why did the farmer win an award?\nA: He was out ___ his field.", "answer": "STANDING IN" },
|
||||
{ "id": "egg-comedian", "setup": "Q: Why did the egg refuse to tell jokes?\nA: It might ___.", "answer": "CRACK UP" },
|
||||
{ "id": "snowman-carrot", "setup": "Q: Why was the snowman looking through the carrots?\nA: He was ___.", "answer": "PICKING HIS NOSE" },
|
||||
{ "id": "cat-piano", "setup": "Q: Why did the cat sit on the computer?\nA: To keep an eye on the ___.", "answer": "MOUSE" },
|
||||
{ "id": "toast-jam", "setup": "Q: Why did the toast go to therapy?\nA: It kept getting into a ___.", "answer": "JAM" },
|
||||
{ "id": "elephant-suitcase", "setup": "Q: Why did the elephant pack a trunk?\nA: For his ___.", "answer": "VACATION" },
|
||||
{ "id": "chef-race", "setup": "Q: Why did the chef quit the race?\nA: He couldn't ___ the pace.", "answer": "KETCHUP TO" },
|
||||
{ "id": "farmer-tractor", "setup": "Q: Why did the farmer name his rooster Robinson?\nA: Because it woke up at the ___.", "answer": "CRACK OF DAWN" },
|
||||
{ "id": "owl-homework", "setup": "Q: Why did the owl get invited to every party?\nA: Everybody thought he was a ___.", "answer": "HOOT" },
|
||||
{ "id": "carpenter-nails", "setup": "Q: Why did the carpenter cry?\nA: He couldn't ___ his mistake.", "answer": "NAIL DOWN" },
|
||||
{ "id": "detective-cheese", "setup": "Q: Why did the detective suspect the cheese?\nA: It looked pretty ___.", "answer": "GRATED" },
|
||||
{ "id": "tomato-race", "setup": "Q: Why did the tomato turn red?\nA: It saw the salad ___.", "answer": "DRESSING" },
|
||||
{ "id": "rabbit-haircut", "setup": "Q: Why did the rabbit need a haircut?\nA: It had too many bad ___ days.", "answer": "HARE" },
|
||||
{ "id": "penguin-fridge", "setup": "Q: Why does a penguin carry a fish in its beak?\nA: Because it forgot its ___.", "answer": "LUNCHBOX" },
|
||||
{ "id": "mountain-cold", "setup": "Q: Why did the mountain climber bring a blanket?\nA: The peak was ___.", "answer": "FREEZING COLD" },
|
||||
{ "id": "vampire-dentist", "setup": "Q: Why did the vampire visit the dentist?\nA: He had terrible ___.", "answer": "BAD BREATH" },
|
||||
{ "id": "artist-jail", "setup": "Q: Why was the painter arrested?\nA: He was framed for ___.", "answer": "ASSAULT" },
|
||||
{ "id": "wizard-school", "setup": "Q: Why did the wizard fail his exam?\nA: He didn't ___ enough.", "answer": "SPELL WELL" },
|
||||
{ "id": "pirate-map", "setup": "Q: Why couldn't the pirate find treasure?\nA: He couldn't read the ___.", "answer": "TREASURE MAP" },
|
||||
{ "id": "robot-vacation", "setup": "Q: Why did the robot go on vacation?\nA: It needed to ___.", "answer": "RECHARGE" },
|
||||
{ "id": "bee-wedding", "setup": "Q: Why did the bee get married?\nA: It found its ___.", "answer": "HONEY" },
|
||||
{ "id": "dinosaur-firework", "setup": "Q: Why don't dinosaurs talk?\nA: Because they're ___.", "answer": "EXTINCT" },
|
||||
{ "id": "gardener-race", "setup": "Q: Why did the gardener plant a race track?\nA: To grow ___ potatoes.", "answer": "SPEEDY" },
|
||||
{ "id": "teacher-blackboard", "setup": "Q: Why did the teacher wear sunglasses?\nA: Her students were too ___.", "answer": "BRIGHT" },
|
||||
{ "id": "mummy-vacation", "setup": "Q: Why wouldn't the mummy take a vacation?\nA: He was too ___ to relax.", "answer": "WOUND UP" },
|
||||
{ "id": "octopus-armor", "setup": "Q: Why did the octopus win the boxing match?\nA: It was well ___.", "answer": "ARMED" },
|
||||
{ "id": "cloud-underwear", "setup": "Q: Why did the cloud wear a raincoat?\nA: It thought it might ___.", "answer": "SPRINKLE" },
|
||||
{ "id": "lion-barber", "setup": "Q: Why did the lion visit the barber?\nA: His ___ was too shaggy.", "answer": "MANE" },
|
||||
{ "id": "computer-cold", "setup": "Q: Why did the computer catch a cold?\nA: It left its ___ open.", "answer": "WINDOWS" },
|
||||
{ "id": "cookie-doctor", "setup": "Q: Why did the cookie visit the doctor?\nA: It was feeling ___.", "answer": "CRUMBY" },
|
||||
{ "id": "surgeon-bakery", "setup": "Q: Why did the surgeon open a bakery?\nA: He kneaded the ___.", "answer": "DOUGH" },
|
||||
{ "id": "farmer-eggs", "setup": "Q: Why did the farmer separate the chickens?\nA: To settle a ___.", "answer": "FOWL DISPUTE" },
|
||||
{ "id": "magician-cards", "setup": "Q: Why did the magician's card trick fail?\nA: He lost his ___.", "answer": "CONFIDENCE" },
|
||||
{ "id": "snail-highway", "setup": "Q: Why did the snail avoid the highway?\nA: It feared the ___.", "answer": "FAST LANE" },
|
||||
{ "id": "astronomer-diet", "setup": "Q: Why did the astronomer start a diet?\nA: To watch her ___.", "answer": "WAISTLINE" },
|
||||
{ "id": "candle-argument", "setup": "Q: Why did the candles stop fighting?\nA: They agreed to ___.", "answer": "MAKE PEACE" },
|
||||
{ "id": "clockmaker-late", "setup": "Q: Why was the clockmaker always late?\nA: He kept losing ___.", "answer": "TRACK OF TIME" },
|
||||
{ "id": "onion-crying", "setup": "Q: Why did nobody want to fight the onion?\nA: It always made them ___.", "answer": "TEARFUL" },
|
||||
{ "id": "beekeeper-raise", "setup": "Q: Why did the beekeeper get a raise?\nA: He knew how to ___ the hive.", "answer": "MANAGE" },
|
||||
{ "id": "sailor-map", "setup": "Q: Why did the sailor carry seaweed?\nA: In case he got ___.", "answer": "SEASICK" },
|
||||
{ "id": "dragon-diet", "setup": "Q: Why did the dragon go on a diet?\nA: He had too much ___.", "answer": "FIRE POWER" },
|
||||
{ "id": "author-block", "setup": "Q: Why did the author sit in the freezer?\nA: To beat his writer's ___.", "answer": "BLOCK" },
|
||||
{ "id": "farmer-scarecrow", "setup": "Q: Why did the scarecrow get promoted?\nA: He was outstanding at ___.", "answer": "CROW CONTROL" },
|
||||
{ "id": "gym-teacher", "setup": "Q: Why did the gym teacher get locked out?\nA: He lost his ___.", "answer": "KEY CARD" },
|
||||
{ "id": "plumber-secret", "setup": "Q: Why did the plumber keep a secret?\nA: It was under ___.", "answer": "WRAPS" },
|
||||
{ "id": "beaver-carpenter", "setup": "Q: Why did the beaver quit carpentry?\nA: It was ___ with the deadlines.", "answer": "DAMMED" },
|
||||
{ "id": "painter-argument", "setup": "Q: Why did the painters stop arguing?\nA: They decided to ___.", "answer": "PATCH THINGS UP" },
|
||||
{ "id": "lumberjack-love", "setup": "Q: Why did the lumberjack fall in love?\nA: She was a total ___.", "answer": "KNOCKOUT" },
|
||||
{ "id": "farmer-corn", "setup": "Q: Why did the farmer win the lottery?\nA: He had ___.", "answer": "AMAZING LUCK" },
|
||||
{ "id": "computer-virus", "setup": "Q: Why did the computer go to the doctor?\nA: It caught a ___.", "answer": "COMPUTER VIRUS" },
|
||||
{ "id": "astronaut-breakup", "setup": "Q: Why did the astronaut break up with the alien?\nA: There was no ___.", "answer": "ATMOSPHERE" },
|
||||
{ "id": "broom-late", "setup": "Q: Why was the broom late to work?\nA: It ___.", "answer": "OVERSLEPT" },
|
||||
{ "id": "calendar-busy", "setup": "Q: Why did the calendar seem so important?\nA: It was full of ___.", "answer": "APPOINTMENTS" },
|
||||
{ "id": "boxer-clock", "setup": "Q: Why did the boxer's watch stop working?\nA: It got ___.", "answer": "KNOCKED OUT" },
|
||||
{ "id": "electrician-marriage", "setup": "Q: Why did the electrician have a happy marriage?\nA: They had great ___.", "answer": "CONNECTIONS" },
|
||||
{ "id": "karate-choir", "setup": "Q: Why did the karate champion join the choir?\nA: He had excellent ___.", "answer": "COORDINATION" },
|
||||
{ "id": "leopard-hide", "setup": "Q: Why couldn't the leopard hide from the zookeeper?\nA: It always got ___.", "answer": "RECOGNIZED" },
|
||||
{ "id": "calendar-therapy", "setup": "Q: Why did the calendar need therapy?\nA: Too many ___.", "answer": "COMMITMENTS" },
|
||||
{ "id": "mechanic-therapist", "setup": "Q: Why did the mechanic become a therapist?\nA: He was great with ___.", "answer": "RELATIONSHIPS" },
|
||||
{ "id": "photographer-calm", "setup": "Q: Why did the photographer stay so calm?\nA: He kept it all in ___.", "answer": "PERSPECTIVE" },
|
||||
{ "id": "marathoner-map", "setup": "Q: Why did the marathoner carry a map?\nA: To avoid getting ___.", "answer": "SIDETRACKED" },
|
||||
{ "id": "chess-jacket", "setup": "Q: Why did the chess player bring a jacket?\nA: In case of a ___.", "answer": "CHECKMATE" },
|
||||
{ "id": "librarian-race", "setup": "Q: Why did the librarian win the race?\nA: She knew all the ___.", "answer": "SHORTCUTS" },
|
||||
{ "id": "sculptor-humble", "setup": "Q: Why did the sculptor stay humble?\nA: He kept everything in ___.", "answer": "PROPORTION" },
|
||||
{ "id": "coach-promotion", "setup": "Q: Why did the coach get a promotion?\nA: Excellent team ___.", "answer": "MANAGEMENT" },
|
||||
{ "id": "tailor-detective", "setup": "Q: Why did the tailor make a great detective?\nA: He noticed every ___.", "answer": "LOOSE THREAD" },
|
||||
{ "id": "pilot-promotion", "setup": "Q: Why did the pilot get promoted so quickly?\nA: Outstanding ___.", "answer": "NAVIGATION" },
|
||||
{ "id": "historian-puzzles", "setup": "Q: Why did the historian love puzzles?\nA: Everything finally made ___.", "answer": "PERFECT SENSE" }
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -89,6 +89,16 @@
|
|||
"file": "track18.mp3",
|
||||
"artist": "How You Feel It",
|
||||
"title": "Mutemath"
|
||||
},
|
||||
{
|
||||
"file": "track19.mp3",
|
||||
"artist": "Down the Line",
|
||||
"title": "Journey"
|
||||
},
|
||||
{
|
||||
"file": "track20.mp3",
|
||||
"artist": "Relaxin' Playing Fertig Games",
|
||||
"title": "Sublime"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -457,7 +457,8 @@
|
|||
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
|
||||
"voice": "Djed Spence",
|
||||
"convo": [
|
||||
"michael"
|
||||
"terry",
|
||||
"victor"
|
||||
],
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2,
|
|||
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
||||
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 });
|
||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
|
||||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
|
||||
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,729 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
checkWordGuess, checkBonusGuess, applyWordHint, starsForHints,
|
||||
isPuzzleComplete, JUMBLE_HINT_WEIGHT, letterMultiset,
|
||||
} from './JumbleLogic.js';
|
||||
|
||||
// ── Palette (notebook paper, matches Sudoku/WordSearch) ──────────────────────
|
||||
const PAPER = 0xFFF8F0;
|
||||
const PAPER_EDGE = 0xE8D8C0;
|
||||
const INK = '#3a2a18';
|
||||
const TITLE_BROWN = '#5c3a1e';
|
||||
const FADED = '#b09a7a';
|
||||
const RED_CIRCLE = 0xc0392b;
|
||||
const SPIRAL_CLR = 0x8a7060;
|
||||
const TILE_BG = 0xfffdf5;
|
||||
const TILE_BG_CORRECT = 0x9fe3ab;
|
||||
const TILE_BG_WRONG = 0xf0a8a0;
|
||||
|
||||
const D = {
|
||||
bg: -2, paper: 0, lines: 1, slots: 2, tile: 10, ui: 20, overlay: 60, overlayUI: 62,
|
||||
};
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
const LEGEND_W = 300; // left strip reserved for the how-to-play legend
|
||||
const PX = LEGEND_W + 20, PY = 16, PW = GAME_WIDTH - PX - 40, PH = 985;
|
||||
const HEADER_SHIFT = 75; // extra breathing room below the spiral binding, title/stars only
|
||||
const TITLE_Y = PY + 56 + HEADER_SHIFT;
|
||||
const RIDDLE_Y = PY + 140;
|
||||
const WORD_ROWS_TOP = PY + 305; // fixed in place — not derived from RIDDLE_Y
|
||||
const WORD_ROW_H = 142;
|
||||
const BONUS_ROW_Y = WORD_ROWS_TOP + 4 * WORD_ROW_H + 34;
|
||||
|
||||
const TILE_W = 58, TILE_H = 66, STEP = 68, SEGMENT_GAP = 30;
|
||||
const SLOT_DY = -32, TRAY_DY = 40;
|
||||
|
||||
const BTN_Y = GAME_HEIGHT - 46;
|
||||
|
||||
export default class JumbleGame extends Phaser.Scene {
|
||||
constructor() { super('JumbleGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'jumble', name: 'Jumble' };
|
||||
this.bank = [];
|
||||
this.levelsCompleted = 0;
|
||||
this.canPersist = true;
|
||||
this.view = 'select';
|
||||
|
||||
this.level = 0;
|
||||
this.puzzle = null;
|
||||
this.wordStates = [];
|
||||
this.bonusState = null;
|
||||
this.bonusSubsetsByWord = [];
|
||||
this.hintsUsed = { word: 0, bonus: 0 };
|
||||
this.overlayUp = false;
|
||||
this.starsText = 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, COLORS.bg).setDepth(D.bg);
|
||||
|
||||
const raw = this.cache.json.get('jumble');
|
||||
this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
try {
|
||||
const res = await api.get('/puzzles/jumble/progress');
|
||||
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||||
} catch (_) {
|
||||
this.canPersist = false;
|
||||
this.levelsCompleted = 0;
|
||||
}
|
||||
|
||||
this.layer = this.add.container(0, 0);
|
||||
this.showLevelSelect();
|
||||
}
|
||||
|
||||
clearLayer() {
|
||||
this.layer.removeAll(true);
|
||||
this.starsText = null;
|
||||
}
|
||||
|
||||
bestStars(level) {
|
||||
try { return Number(localStorage.getItem(`jumble-stars-${level}`)) || 0; } catch (_) { return 0; }
|
||||
}
|
||||
|
||||
saveStars(level, stars) {
|
||||
try {
|
||||
if (stars > this.bestStars(level)) localStorage.setItem(`jumble-stars-${level}`, String(stars));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Level select ────────────────────────────────────────────────────────────
|
||||
|
||||
showLevelSelect() {
|
||||
this.view = 'select';
|
||||
this.overlayUp = false;
|
||||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'JUMBLE', {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '84px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 148,
|
||||
'Unscramble all 4 words, then unscramble the circled letters to solve the riddle.', {
|
||||
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 puzzles found.\nRun: node tools/genJumble.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, 190, `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 = 274;
|
||||
|
||||
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 - 18, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '34px',
|
||||
color: playable || cleared ? COLORS.textHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([tile, num]);
|
||||
|
||||
if (playable || cleared) {
|
||||
const earned = this.bestStars(level);
|
||||
const stars = this.add.text(x, y + 26, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), {
|
||||
fontFamily: 'serif', fontSize: '18px', color: earned ? '#ffd54a' : '#546080',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(stars);
|
||||
} else {
|
||||
const lock = this.add.text(x, y + 26, 'locked', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#54606b',
|
||||
}).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 - 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 your star\nmedals, back to 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/jumble/reset').catch(() => { /* best effort */ });
|
||||
this.levelsCompleted = 0;
|
||||
try { this.bank.forEach((p) => localStorage.removeItem(`jumble-stars-${p.level}`)); } catch (_) { /* ignore */ }
|
||||
this.showLevelSelect();
|
||||
}
|
||||
|
||||
// ── Play a level ────────────────────────────────────────────────────────────
|
||||
|
||||
partitionBonusByWord(puzzle) {
|
||||
const claimed = new Array(puzzle.bonusScrambled.length).fill(false);
|
||||
const subsets = [];
|
||||
for (const w of puzzle.words) {
|
||||
const circledChars = w.solution.split('').filter((_, i) => w.circledSlots[i]);
|
||||
const remaining = new Map(letterMultiset(circledChars));
|
||||
const ids = [];
|
||||
for (let i = 0; i < puzzle.bonusScrambled.length; i++) {
|
||||
if (claimed[i]) continue;
|
||||
const ch = puzzle.bonusScrambled[i].char;
|
||||
const cnt = remaining.get(ch) ?? 0;
|
||||
if (cnt > 0) { claimed[i] = true; remaining.set(ch, cnt - 1); ids.push(i); }
|
||||
}
|
||||
subsets.push(ids);
|
||||
}
|
||||
return subsets;
|
||||
}
|
||||
|
||||
playLevel(level) {
|
||||
const puzzle = this.bank.find((p) => p.level === level);
|
||||
if (!puzzle) return;
|
||||
this.view = 'play';
|
||||
this.level = level;
|
||||
this.puzzle = puzzle;
|
||||
this.overlayUp = false;
|
||||
this.hintsUsed = { word: 0, bonus: 0 };
|
||||
|
||||
this.wordStates = puzzle.words.map((w, i) => ({
|
||||
solution: w.solution,
|
||||
tileDefs: w.scrambled,
|
||||
circledSlots: w.circledSlots,
|
||||
slotFill: Array(w.solution.length).fill(null),
|
||||
trayOrder: w.scrambled.map((_, idx) => idx),
|
||||
containers: new Map(),
|
||||
solved: false,
|
||||
_wordIdx: i,
|
||||
}));
|
||||
|
||||
this.bonusSubsetsByWord = this.partitionBonusByWord(puzzle);
|
||||
const bonusLen = puzzle.bonusAnswer.replace(/[^A-Z]/g, '').length;
|
||||
this.bonusState = {
|
||||
answer: puzzle.bonusAnswer,
|
||||
tileDefs: puzzle.bonusScrambled,
|
||||
slotFill: Array(bonusLen).fill(null),
|
||||
trayOrder: [],
|
||||
containers: new Map(),
|
||||
solved: false,
|
||||
_wordIdx: null,
|
||||
};
|
||||
|
||||
this.clearLayer();
|
||||
this.buildPaper();
|
||||
this.buildRuledLines();
|
||||
this.buildLegend();
|
||||
this.buildTitleAndRiddle();
|
||||
this.buildWordRows();
|
||||
this.buildBonusRow();
|
||||
this.buildHudButtons();
|
||||
this.updateStarText();
|
||||
}
|
||||
|
||||
// ── Paper & notebook decoration ───────────────────────────────────────────────
|
||||
|
||||
buildPaper() {
|
||||
const g = this.add.graphics().setDepth(D.paper);
|
||||
g.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.55);
|
||||
g.fillStyle(PAPER, 1);
|
||||
g.fillRoundedRect(PX, PY, PW, PH, 16);
|
||||
g.lineStyle(2, PAPER_EDGE, 1);
|
||||
g.strokeRoundedRect(PX, PY, PW, PH, 16);
|
||||
this.layer.add(g);
|
||||
|
||||
const spiralG = this.add.graphics().setDepth(D.paper + 1);
|
||||
const spiralCnt = 34;
|
||||
const step = PW / (spiralCnt + 1);
|
||||
for (let i = 1; i <= spiralCnt; i++) {
|
||||
const sx = PX + step * i;
|
||||
const sy = PY + 34;
|
||||
spiralG.fillStyle(SPIRAL_CLR, 0.75);
|
||||
spiralG.fillCircle(sx, sy, 14);
|
||||
spiralG.fillStyle(0x000000, 1);
|
||||
spiralG.fillCircle(sx, sy, 9);
|
||||
}
|
||||
this.layer.add(spiralG);
|
||||
}
|
||||
|
||||
buildRuledLines() {
|
||||
const g = this.add.graphics().setDepth(D.lines);
|
||||
g.lineStyle(1, 0xbcd0e0, 0.35);
|
||||
for (let y = PY + 210; y < PY + PH - 20; y += 40) {
|
||||
g.lineBetween(PX + 30, y, PX + PW - 30, y);
|
||||
}
|
||||
this.layer.add(g);
|
||||
}
|
||||
|
||||
// Left-strip legend: how to play, plus a color key for the word-shading
|
||||
// feedback added once a word row is fully filled in.
|
||||
buildLegend() {
|
||||
const x = LEGEND_W / 2 + 10;
|
||||
let y = PY + 30;
|
||||
|
||||
const title = this.add.text(x, y, 'How to Play', {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5, 0).setDepth(D.ui);
|
||||
this.layer.add(title);
|
||||
y += 44;
|
||||
|
||||
const bodyStyle = {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
align: 'left', wordWrap: { width: LEGEND_W - 20 }, lineSpacing: 5,
|
||||
};
|
||||
const steps = [
|
||||
'Tap a scrambled letter to drop it into the next blank.',
|
||||
'Tap a filled blank to send that letter back to the tray.',
|
||||
'Solve all 4 words, then unscramble the circled letters below to reveal the riddle’s answer.',
|
||||
'Hint fills in one correct letter, but costs you a star.',
|
||||
];
|
||||
for (const line of steps) {
|
||||
const t = this.add.text(x - LEGEND_W / 2 + 10, y, line, bodyStyle).setOrigin(0, 0).setDepth(D.ui);
|
||||
this.layer.add(t);
|
||||
y += t.height + 14;
|
||||
}
|
||||
|
||||
y += 12;
|
||||
const legendTitle = this.add.text(x, y, 'Color key', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5, 0).setDepth(D.ui);
|
||||
this.layer.add(legendTitle);
|
||||
y += 36;
|
||||
|
||||
const swatchX = x - LEGEND_W / 2 + 22;
|
||||
const rows = [
|
||||
{ color: TILE_BG_CORRECT, label: 'Word is correct' },
|
||||
{ color: TILE_BG_WRONG, label: 'Word is filled in wrong' },
|
||||
];
|
||||
for (const row of rows) {
|
||||
const sw = this.add.graphics().setDepth(D.ui);
|
||||
sw.fillStyle(row.color, 1);
|
||||
sw.fillRoundedRect(swatchX - 12, y, 24, 24, 5);
|
||||
sw.lineStyle(2, PAPER_EDGE, 1);
|
||||
sw.strokeRoundedRect(swatchX - 12, y, 24, 24, 5);
|
||||
this.layer.add(sw);
|
||||
const label = this.add.text(swatchX + 20, y + 12, row.label, {
|
||||
...bodyStyle, wordWrap: { width: LEGEND_W - 60 },
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.layer.add(label);
|
||||
y += 40;
|
||||
}
|
||||
}
|
||||
|
||||
buildTitleAndRiddle() {
|
||||
const cx = PX + PW / 2;
|
||||
const title = this.add.text(PX + 60, TITLE_Y, `Jumble · Level ${this.level}`, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '52px', color: TITLE_BROWN,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.starsText = this.add.text(PX + PW - 60, TITLE_Y, '', {
|
||||
fontFamily: 'serif', fontSize: '38px', color: '#c98a1f',
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
const riddle = this.add.text(cx, RIDDLE_Y, this.puzzle.riddle, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '32px', color: INK, align: 'center', lineSpacing: 8,
|
||||
wordWrap: { width: PW - 200 },
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.layer.add([title, this.starsText, riddle]);
|
||||
}
|
||||
|
||||
updateStarText() {
|
||||
const weighted = this.hintsUsed.word * JUMBLE_HINT_WEIGHT.word + this.hintsUsed.bonus * JUMBLE_HINT_WEIGHT.bonus;
|
||||
if (this.starsText) this.starsText.setText(`★ ${starsForHints(weighted)}/3`);
|
||||
}
|
||||
|
||||
// ── Tile rows ────────────────────────────────────────────────────────────────
|
||||
|
||||
centeredXs(n, cx, step) {
|
||||
if (n === 0) return [];
|
||||
const span = (n - 1) * step;
|
||||
const offset = cx - span / 2;
|
||||
return Array.from({ length: n }, (_, i) => offset + i * step);
|
||||
}
|
||||
|
||||
answerSegments(answer) {
|
||||
return answer.trim().split(/\s+/).map((s) => s.replace(/[^A-Za-z]/g, ''));
|
||||
}
|
||||
|
||||
segmentedXs(segments, cx, step, gap) {
|
||||
const xsRel = [];
|
||||
let cursor = 0;
|
||||
segments.forEach((seg, si) => {
|
||||
for (let i = 0; i < seg.length; i++) { xsRel.push(cursor); cursor += step; }
|
||||
if (si < segments.length - 1) cursor += gap;
|
||||
});
|
||||
const span = xsRel[xsRel.length - 1] ?? 0;
|
||||
const offset = cx - span / 2;
|
||||
return xsRel.map((v) => v + offset);
|
||||
}
|
||||
|
||||
// Draws the empty answer-slot boxes at `xs`/`y`. Slots flagged in
|
||||
// `circledSlots` (a boolean[] parallel to `xs`, word rows only — the
|
||||
// bonus row never circles) get a permanent hand-drawn red ring around
|
||||
// that fixed position, visible whether the slot is empty or a tile is
|
||||
// sitting in it — the circle marks a spot in the answer, not a letter.
|
||||
drawSlotOutlines(xs, y, circledSlots = null) {
|
||||
const g = this.add.graphics().setDepth(D.slots);
|
||||
g.lineStyle(2, PAPER_EDGE, 0.9);
|
||||
for (const x of xs) {
|
||||
g.strokeRoundedRect(x - TILE_W / 2, y - TILE_H / 2, TILE_W, TILE_H, 8);
|
||||
}
|
||||
this.layer.add(g);
|
||||
|
||||
if (circledSlots) {
|
||||
const ring = this.add.graphics().setDepth(D.slots + 1);
|
||||
const ringW = TILE_W + 22, ringH = TILE_H + 22;
|
||||
xs.forEach((x, i) => {
|
||||
if (!circledSlots[i]) return;
|
||||
ring.lineStyle(3, RED_CIRCLE, 0.85);
|
||||
ring.strokeEllipse(x, y, ringW, ringH);
|
||||
ring.lineStyle(2, RED_CIRCLE, 0.3);
|
||||
ring.strokeEllipse(x + 2, y - 2, ringW + 6, ringH + 6);
|
||||
});
|
||||
this.layer.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
makeTileContainer(char) {
|
||||
const c = this.add.container(0, 0).setDepth(D.tile);
|
||||
const bg = this.add.graphics();
|
||||
c.bgGfx = bg;
|
||||
const txt = this.add.text(0, 2, char, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '40px', color: INK,
|
||||
}).setOrigin(0.5);
|
||||
c.add([bg, txt]);
|
||||
c.setSize(TILE_W, TILE_H);
|
||||
this.setTileFill(c, TILE_BG);
|
||||
return c;
|
||||
}
|
||||
|
||||
// Recolors a tile's background — cream by default, green once its word is
|
||||
// fully and correctly solved, red once all of a word's slots are filled
|
||||
// but the arrangement is wrong (cleared back to cream as soon as the
|
||||
// player pulls a tile back out to fix it).
|
||||
setTileFill(c, color) {
|
||||
if (!c?.bgGfx) return;
|
||||
c.bgGfx.clear();
|
||||
c.bgGfx.fillStyle(color, 1);
|
||||
c.bgGfx.fillRoundedRect(-TILE_W / 2, -TILE_H / 2, TILE_W, TILE_H, 8);
|
||||
c.bgGfx.lineStyle(2, PAPER_EDGE, 1);
|
||||
c.bgGfx.strokeRoundedRect(-TILE_W / 2, -TILE_H / 2, TILE_W, TILE_H, 8);
|
||||
}
|
||||
|
||||
updateWordShade(wordIdx) {
|
||||
const st = this.wordStates[wordIdx];
|
||||
const allFilled = st.slotFill.every((id) => id != null);
|
||||
const color = st.solved ? TILE_BG_CORRECT : allFilled ? TILE_BG_WRONG : TILE_BG;
|
||||
st.slotFill.forEach((id) => { if (id != null) this.setTileFill(st.containers.get(id), color); });
|
||||
st.trayOrder.forEach((id) => this.setTileFill(st.containers.get(id), TILE_BG));
|
||||
}
|
||||
|
||||
placeContainerAt(state, rowKind, id, x, y) {
|
||||
let c = state.containers.get(id);
|
||||
if (!c) {
|
||||
const def = state.tileDefs[id];
|
||||
c = this.makeTileContainer(def.char);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.on('pointerup', () => this.onTileTap(rowKind, state._wordIdx, id));
|
||||
c.setPosition(x, y);
|
||||
this.layer.add(c);
|
||||
state.containers.set(id, c);
|
||||
return;
|
||||
}
|
||||
if (c.x === x && c.y === y) return;
|
||||
this.tweens.add({ targets: c, x, y, duration: 150, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
buildWordRows() {
|
||||
this.rowLayout = [];
|
||||
const cx = PX + PW / 2;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = WORD_ROWS_TOP + i * WORD_ROW_H;
|
||||
const st = this.wordStates[i];
|
||||
const slotXs = this.centeredXs(st.solution.length, cx, STEP);
|
||||
this.rowLayout.push({ y, cx, slotXs });
|
||||
this.drawSlotOutlines(slotXs, y + SLOT_DY, st.circledSlots);
|
||||
this.renderWordRow(i);
|
||||
}
|
||||
}
|
||||
|
||||
renderWordRow(wordIdx) {
|
||||
const st = this.wordStates[wordIdx];
|
||||
const { y, cx, slotXs } = this.rowLayout[wordIdx];
|
||||
const slotY = y + SLOT_DY, trayY = y + TRAY_DY;
|
||||
st.slotFill.forEach((id, i) => { if (id != null) this.placeContainerAt(st, 'word', id, slotXs[i], slotY); });
|
||||
const trayXs = this.centeredXs(st.trayOrder.length, cx, STEP);
|
||||
st.trayOrder.forEach((id, idx) => this.placeContainerAt(st, 'word', id, trayXs[idx], trayY));
|
||||
}
|
||||
|
||||
buildBonusRow() {
|
||||
const cx = PX + PW / 2;
|
||||
const y = BONUS_ROW_Y;
|
||||
const segments = this.answerSegments(this.puzzle.bonusAnswer);
|
||||
const slotXs = this.segmentedXs(segments, cx, STEP, SEGMENT_GAP);
|
||||
this.bonusRowLayout = { y, cx, slotXs };
|
||||
this.drawSlotOutlines(slotXs, y + SLOT_DY);
|
||||
const label = this.add.text(cx, y + SLOT_DY - 54, 'Bonus answer', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.layer.add(label);
|
||||
this.renderBonusRow();
|
||||
}
|
||||
|
||||
renderBonusRow() {
|
||||
const st = this.bonusState;
|
||||
const { y, cx, slotXs } = this.bonusRowLayout;
|
||||
const slotY = y + SLOT_DY, trayY = y + TRAY_DY;
|
||||
st.slotFill.forEach((id, i) => { if (id != null) this.placeContainerAt(st, 'bonus', id, slotXs[i], slotY); });
|
||||
const trayXs = this.centeredXs(st.trayOrder.length, cx, STEP);
|
||||
st.trayOrder.forEach((id, idx) => this.placeContainerAt(st, 'bonus', id, trayXs[idx], trayY));
|
||||
}
|
||||
|
||||
buildHudButtons() {
|
||||
const cx = PX + PW / 2;
|
||||
const hint = new Button(this, cx - 260, BTN_Y, 'Hint', () => this.useHint(),
|
||||
{ width: 220, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
const levels = new Button(this, cx, BTN_Y, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 220, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
const leave = new Button(this, cx + 260, BTN_Y, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ width: 220, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
this.layer.add([hint, levels, leave]);
|
||||
}
|
||||
|
||||
// ── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
onTileTap(rowKind, wordIdx, id) {
|
||||
if (this.view !== 'play' || this.overlayUp) return;
|
||||
const state = rowKind === 'word' ? this.wordStates[wordIdx] : this.bonusState;
|
||||
if (state.solved) return;
|
||||
if (state.slotFill.includes(id)) this.returnTile(rowKind, wordIdx, id);
|
||||
else this.placeTile(rowKind, wordIdx, id);
|
||||
}
|
||||
|
||||
placeTile(rowKind, wordIdx, id) {
|
||||
const state = rowKind === 'word' ? this.wordStates[wordIdx] : this.bonusState;
|
||||
const slot = state.slotFill.indexOf(null);
|
||||
if (slot === -1) return;
|
||||
state.slotFill[slot] = id;
|
||||
state.trayOrder = state.trayOrder.filter((x) => x !== id);
|
||||
playSound(this, SFX.UI_PLACE);
|
||||
if (rowKind === 'word') { this.renderWordRow(wordIdx); this.afterWordChange(wordIdx); }
|
||||
else { this.renderBonusRow(); this.afterBonusChange(); }
|
||||
}
|
||||
|
||||
returnTile(rowKind, wordIdx, id) {
|
||||
const state = rowKind === 'word' ? this.wordStates[wordIdx] : this.bonusState;
|
||||
const slot = state.slotFill.indexOf(id);
|
||||
if (slot === -1) return;
|
||||
state.slotFill[slot] = null;
|
||||
state.trayOrder.push(id);
|
||||
playSound(this, SFX.UI_PICK);
|
||||
if (rowKind === 'word') { this.renderWordRow(wordIdx); this.afterWordChange(wordIdx); }
|
||||
else this.renderBonusRow();
|
||||
}
|
||||
|
||||
afterWordChange(wordIdx) {
|
||||
const st = this.wordStates[wordIdx];
|
||||
const arrangement = st.slotFill.map((id) => (id == null ? null : { char: st.tileDefs[id].char }));
|
||||
if (!st.solved && checkWordGuess(arrangement, st.solution)) this.onWordSolved(wordIdx);
|
||||
this.updateWordShade(wordIdx);
|
||||
}
|
||||
|
||||
afterBonusChange() {
|
||||
const st = this.bonusState;
|
||||
const arrangement = st.slotFill.map((id) => (id == null ? null : { char: st.tileDefs[id].char }));
|
||||
if (!st.solved && checkBonusGuess(arrangement, st.answer)) this.onBonusSolved();
|
||||
}
|
||||
|
||||
onWordSolved(wordIdx) {
|
||||
const st = this.wordStates[wordIdx];
|
||||
st.solved = true;
|
||||
playSound(this, SFX.UI_CHIME);
|
||||
st.containers.forEach((c) => c.disableInteractive());
|
||||
const ids = this.bonusSubsetsByWord[wordIdx];
|
||||
this.bonusState.trayOrder.push(...ids);
|
||||
this.renderBonusRow();
|
||||
this.checkPuzzleComplete();
|
||||
}
|
||||
|
||||
onBonusSolved() {
|
||||
this.bonusState.solved = true;
|
||||
this.bonusState.containers.forEach((c) => c.disableInteractive());
|
||||
this.checkPuzzleComplete();
|
||||
}
|
||||
|
||||
checkPuzzleComplete() {
|
||||
if (isPuzzleComplete(this.wordStates.map((s) => s.solved), this.bonusState.solved)) this.onPuzzleComplete();
|
||||
}
|
||||
|
||||
// ── Hint ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
useHint() {
|
||||
if (this.view !== 'play' || this.overlayUp) return;
|
||||
const idx = this.wordStates.findIndex((s) => !s.solved);
|
||||
if (idx === -1) this.hintBonus();
|
||||
else this.hintWord(idx);
|
||||
}
|
||||
|
||||
hintWord(wordIdx) {
|
||||
const st = this.wordStates[wordIdx];
|
||||
const arrangement = st.slotFill.map((id) => (id == null ? null : { char: st.tileDefs[id].char }));
|
||||
const slotIdx = applyWordHint(arrangement, st.solution);
|
||||
if (slotIdx === -1) return;
|
||||
const existing = st.slotFill[slotIdx];
|
||||
if (existing != null) { st.slotFill[slotIdx] = null; st.trayOrder.push(existing); }
|
||||
const ch = st.solution[slotIdx];
|
||||
const foundId = st.trayOrder.find((id) => st.tileDefs[id].char === ch);
|
||||
if (foundId == null) return;
|
||||
st.trayOrder = st.trayOrder.filter((x) => x !== foundId);
|
||||
st.slotFill[slotIdx] = foundId;
|
||||
this.hintsUsed.word++;
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
this.renderWordRow(wordIdx);
|
||||
this.updateStarText();
|
||||
this.afterWordChange(wordIdx);
|
||||
}
|
||||
|
||||
hintBonus() {
|
||||
const st = this.bonusState;
|
||||
const target = st.answer.replace(/[^A-Z]/g, '');
|
||||
const arrangement = st.slotFill.map((id) => (id == null ? null : { char: st.tileDefs[id].char }));
|
||||
const slotIdx = applyWordHint(arrangement, target);
|
||||
if (slotIdx === -1) return;
|
||||
const existing = st.slotFill[slotIdx];
|
||||
if (existing != null) { st.slotFill[slotIdx] = null; st.trayOrder.push(existing); }
|
||||
const ch = target[slotIdx];
|
||||
const foundId = st.trayOrder.find((id) => st.tileDefs[id].char === ch);
|
||||
if (foundId == null) return;
|
||||
st.trayOrder = st.trayOrder.filter((x) => x !== foundId);
|
||||
st.slotFill[slotIdx] = foundId;
|
||||
this.hintsUsed.bonus++;
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
this.renderBonusRow();
|
||||
this.updateStarText();
|
||||
this.afterBonusChange();
|
||||
}
|
||||
|
||||
// ── Complete flow ────────────────────────────────────────────────────────────
|
||||
|
||||
onPuzzleComplete() {
|
||||
this.overlayUp = true;
|
||||
const weighted = this.hintsUsed.word * JUMBLE_HINT_WEIGHT.word + this.hintsUsed.bonus * JUMBLE_HINT_WEIGHT.bonus;
|
||||
const stars = starsForHints(weighted);
|
||||
this.saveStars(this.level, stars);
|
||||
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
||||
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
api.post('/puzzles/jumble/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: 'jumble', score: stars * 100, 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 - 440, cy - 270, 880, 540, 20);
|
||||
panel.lineStyle(3, COLORS.accent, 1);
|
||||
panel.strokeRoundedRect(cx - 440, cy - 270, 880, 540, 20);
|
||||
this.layer.add(panel);
|
||||
|
||||
const title = this.add.text(cx, cy - 200, 'Solved!', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const starRow = this.add.text(cx, cy - 126, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), {
|
||||
fontFamily: 'serif', fontSize: '52px', color: '#ffd54a',
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const riddleTxt = this.add.text(cx, cy - 44, this.puzzle.riddle, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '30px', color: COLORS.textHex, align: 'center',
|
||||
lineSpacing: 6, wordWrap: { width: 780 },
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const answerTxt = this.add.text(cx, cy + 60, this.puzzle.bonusAnswer, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '48px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add([title, starRow, riddleTxt, answerTxt]);
|
||||
|
||||
const hasNext = this.level < this.bank.length;
|
||||
const btns = [];
|
||||
if (hasNext) {
|
||||
btns.push(new Button(this, cx, cy + 148, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
|
||||
{ width: 360, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
|
||||
} else {
|
||||
btns.push(this.add.text(cx, cy + 138, 'You solved every puzzle. Bravo!', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
}
|
||||
const levels = new Button(this, cx, cy + 224, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 220, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
btns.push(levels);
|
||||
this.layer.add(btns);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// Jumble — pure puzzle engine (no Phaser). Shared by the Phaser scene, the
|
||||
// offline bank generator (tools/genJumble.js) and the verifier.
|
||||
//
|
||||
// A puzzle has 4 base words, each scrambled into tiles. Some tile letters are
|
||||
// "circled" (per word). Solving all 4 words collects their circled letters
|
||||
// (in solved word order) into a bonus pool; unscrambling that pool reveals
|
||||
// the answer to a riddle shown on the puzzle.
|
||||
|
||||
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
// Fisher-Yates, in place. Returns the (mutated) array.
|
||||
export function shuffle(arr, rng) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// chars: string | string[] -> Map<char, count>
|
||||
export function letterMultiset(chars) {
|
||||
const m = new Map();
|
||||
for (const ch of chars) m.set(ch, (m.get(ch) ?? 0) + 1);
|
||||
return m;
|
||||
}
|
||||
|
||||
// True when every letter in `sub` appears in `sup` at least as many times.
|
||||
export function isSupersetMultiset(sup, sub) {
|
||||
for (const [ch, count] of sub) {
|
||||
if ((sup.get(ch) ?? 0) < count) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Gameplay (browser-side, but pure — no Phaser deps) ───────────────────────
|
||||
|
||||
// arrangement: array (one entry per slot) of tile-like {char}|null.
|
||||
export function checkWordGuess(arrangement, solution) {
|
||||
if (arrangement.length !== solution.length) return false;
|
||||
if (arrangement.some((t) => t == null)) return false;
|
||||
return arrangement.map((t) => t.char).join('') === solution;
|
||||
}
|
||||
|
||||
// `answer` may contain a space (multi-word answer) — arrangement only covers
|
||||
// the letter slots (space slots are static separators, not interactive), so
|
||||
// compare against the answer with spaces stripped.
|
||||
export function checkBonusGuess(arrangement, answer) {
|
||||
const target = answer.replace(/[^A-Z]/g, '');
|
||||
if (arrangement.length !== target.length) return false;
|
||||
if (arrangement.some((t) => t == null)) return false;
|
||||
return arrangement.map((t) => t.char).join('') === target;
|
||||
}
|
||||
|
||||
// Returns the index of the first slot that doesn't yet hold the correct
|
||||
// letter, or -1 if `arrangement` already fully matches `solution`.
|
||||
export function applyWordHint(arrangement, solution) {
|
||||
for (let i = 0; i < solution.length; i++) {
|
||||
if (arrangement[i]?.char !== solution[i]) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Bonus-round hints reveal more of the puzzle at once (a full riddle letter),
|
||||
// so they cost double toward the star penalty.
|
||||
export const JUMBLE_HINT_WEIGHT = { word: 1, bonus: 2 };
|
||||
|
||||
export function starsForHints(weightedHintsUsed) {
|
||||
if (weightedHintsUsed === 0) return 3;
|
||||
if (weightedHintsUsed <= 2) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// wordStates: bool[4] (one per base word), bonusSolved: bool.
|
||||
export function isPuzzleComplete(wordStates, bonusSolved) {
|
||||
return wordStates.every(Boolean) && bonusSolved;
|
||||
}
|
||||
|
|
@ -142,6 +142,8 @@ export default class PaiGowPokerGame extends Phaser.Scene {
|
|||
const g = this.add.graphics().setDepth(D.table + 1);
|
||||
|
||||
for (let seat = 0; seat < SEAT_POS.length; seat++) {
|
||||
const active = seat === 0 ? true : !!(this.opponents[seat - 1] ?? null);
|
||||
if (!active) continue;
|
||||
const pos = SEAT_POS[seat];
|
||||
g.lineStyle(2, 0xffffff, 0.5);
|
||||
g.strokeRoundedRect(pos.x - CARD_W / 2 - 4, pos.y - CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, CARD_R + 2);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ import BookworkGame from './games/bookwork/BookworkGame.js';
|
|||
import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
|
||||
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
|
||||
import AzulGame from './games/azul/AzulGame.js';
|
||||
import JumbleGame from './games/jumble/JumbleGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -179,6 +180,7 @@ const config = {
|
|||
PaiGowPokerGame,
|
||||
SpireClimbGame,
|
||||
AzulGame,
|
||||
JumbleGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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', 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' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('katamino', 'data/katamino.json');
|
||||
this.load.json('bookwork', 'data/bookwork.json');
|
||||
this.load.json('spireclimb-artwork', 'data/spireclimb-artwork.json');
|
||||
this.load.json('jumble', 'data/jumble.json');
|
||||
this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png');
|
||||
this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png');
|
||||
this.load.image('spireclimb-act3', 'assets/images/spireclimb-act3.png');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
// Offline generator for Jumble puzzles.
|
||||
//
|
||||
// Takes curated riddle/answer pairs (data/jumble-riddles.json), partitions
|
||||
// each answer's letters across 4 base words, finds a real common word to
|
||||
// carry each partition (as a superset of its letters), marks which letter
|
||||
// positions are "circled", and scrambles everything. Writes ordered levels
|
||||
// to data/jumble.json.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/genJumble.js [seed] [outFile]
|
||||
//
|
||||
// Deterministic: same seed -> same bank (riddle bank + common word list held
|
||||
// fixed).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeRng, shuffle, letterMultiset, isSupersetMultiset } from '../src/games/jumble/JumbleLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RIDDLES_FILE = path.join(__dirname, '../data/jumble-riddles.json');
|
||||
const WORDS_FILE = path.join(__dirname, '../data/wordlists/common.txt');
|
||||
const OUT_FILE = process.argv[3]
|
||||
? path.resolve(process.argv[3])
|
||||
: path.join(__dirname, '../data/jumble.json');
|
||||
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x4a4d424c; // 'JMBL'
|
||||
|
||||
const MAX_ATTEMPTS = 60;
|
||||
const MIN_ANSWER_LEN = 6;
|
||||
const MAX_ANSWER_LEN = 14;
|
||||
const WORD_LEN_MIN = 4;
|
||||
const WORD_LEN_MAX = 8;
|
||||
|
||||
// Difficulty curve: word length + bonus-answer length both grow with tier.
|
||||
const TIERS = [
|
||||
{ count: 12, wordLenRange: [4, 5], bonusLenRange: [6, 8] },
|
||||
{ count: 12, wordLenRange: [4, 6], bonusLenRange: [7, 9] },
|
||||
{ count: 12, wordLenRange: [5, 6], bonusLenRange: [8, 10] },
|
||||
{ count: 12, wordLenRange: [5, 7], bonusLenRange: [9, 11] },
|
||||
{ count: 10, wordLenRange: [6, 8], bonusLenRange: [10, 14] },
|
||||
];
|
||||
|
||||
function multisetsEqual(a, b) {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [ch, count] of a) if (b.get(ch) !== count) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function candidateLengths(groupSize, tier) {
|
||||
const lo = Math.max(WORD_LEN_MIN, groupSize);
|
||||
const hi = WORD_LEN_MAX;
|
||||
const preferred = [];
|
||||
for (let l = Math.max(lo, tier.wordLenRange[0]); l <= Math.min(hi, tier.wordLenRange[1]); l++) preferred.push(l);
|
||||
const rest = [];
|
||||
for (let l = lo; l <= hi; l++) if (!preferred.includes(l)) rest.push(l);
|
||||
return [...preferred, ...rest];
|
||||
}
|
||||
|
||||
function findWordForGroup(groupMultiset, groupSize, tier, wordsByLength, wordMultisets, usedWords, answerStripped, rng) {
|
||||
for (const len of candidateLengths(groupSize, tier)) {
|
||||
const bucket = wordsByLength.get(len);
|
||||
if (!bucket || !bucket.length) continue;
|
||||
const matches = [];
|
||||
for (const w of bucket) {
|
||||
if (usedWords.has(w) || w === answerStripped) continue;
|
||||
if (isSupersetMultiset(wordMultisets.get(w), groupMultiset)) matches.push(w);
|
||||
}
|
||||
if (matches.length) return matches[Math.floor(rng() * matches.length)];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCircledPositions(word, groupMultiset, rng) {
|
||||
const indices = new Set();
|
||||
for (const [ch, count] of groupMultiset) {
|
||||
const positions = [];
|
||||
for (let i = 0; i < word.length; i++) if (word[i] === ch) positions.push(i);
|
||||
shuffle(positions, rng);
|
||||
for (let i = 0; i < count; i++) indices.add(positions[i]);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Builds scrambled tile objects for `word`. Rerolls (up to 20 tries) if the
|
||||
// shuffle lands on `identityTarget` (default: the word itself) so the display
|
||||
// never shows an already-solved order. Tiles carry only their letter — which
|
||||
// answer-slot positions are "circled" is tracked separately (see
|
||||
// circledSlots below), since the circle marks a fixed spot in the SOLVED
|
||||
// word, not a specific scrambled tile.
|
||||
function buildScrambledTiles(word, rng, identityTarget = word) {
|
||||
const tiles = word.split('').map((ch) => ({ char: ch }));
|
||||
let tries = 0;
|
||||
do {
|
||||
shuffle(tiles, rng);
|
||||
tries++;
|
||||
} while (tiles.map((t) => t.char).join('') === identityTarget && tries < 20);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function buildPuzzle(riddle, tier, wordsByLength, wordMultisets, rng) {
|
||||
const answerStripped = riddle.answer.replace(/[^A-Za-z]/g, '').toUpperCase();
|
||||
const n = answerStripped.length;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const shuffled = shuffle(answerStripped.split(''), rng);
|
||||
const base = Math.floor(n / 4);
|
||||
const rem = n % 4;
|
||||
const sizes = [base, base, base, base];
|
||||
const bumpOrder = shuffle([0, 1, 2, 3], rng);
|
||||
for (let i = 0; i < rem; i++) sizes[bumpOrder[i]]++;
|
||||
|
||||
const groups = [];
|
||||
let p = 0;
|
||||
for (let i = 0; i < 4; i++) { groups.push(shuffled.slice(p, p + sizes[i])); p += sizes[i]; }
|
||||
|
||||
const usedWords = new Set();
|
||||
const words = [];
|
||||
let ok = true;
|
||||
for (const group of groups) {
|
||||
const groupMultiset = letterMultiset(group);
|
||||
const word = findWordForGroup(groupMultiset, group.length, tier, wordsByLength, wordMultisets, usedWords, answerStripped, rng);
|
||||
if (!word) { ok = false; break; }
|
||||
usedWords.add(word);
|
||||
const circledIndices = pickCircledPositions(word, groupMultiset, rng);
|
||||
const tiles = buildScrambledTiles(word, rng);
|
||||
const circledSlots = Array.from({ length: word.length }, (_, i) => circledIndices.has(i));
|
||||
words.push({ solution: word, scrambled: tiles, circledSlots });
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
// Bonus pool: each word's circled letters, in that word's solved
|
||||
// (left-to-right) order, concatenated in word order. Read directly off
|
||||
// the solution string at the circled slot positions — no dependency on
|
||||
// how the tray tiles ended up scrambled.
|
||||
const bonusLetters = [];
|
||||
for (const w of words) {
|
||||
for (let i = 0; i < w.solution.length; i++) {
|
||||
if (w.circledSlots[i]) bonusLetters.push(w.solution[i]);
|
||||
}
|
||||
}
|
||||
const bonusMultiset = letterMultiset(bonusLetters);
|
||||
const answerMultiset = letterMultiset(answerStripped.split(''));
|
||||
if (!multisetsEqual(bonusMultiset, answerMultiset)) continue; // defensive; should never trigger
|
||||
|
||||
const bonusScrambled = buildScrambledTiles(bonusLetters.join(''), rng, answerStripped);
|
||||
|
||||
return {
|
||||
riddleId: riddle.id,
|
||||
riddle: riddle.setup,
|
||||
words: words.map((w) => ({ solution: w.solution, scrambled: w.scrambled, circledSlots: w.circledSlots })),
|
||||
bonusScrambled,
|
||||
bonusAnswer: riddle.answer.toUpperCase(),
|
||||
};
|
||||
}
|
||||
console.warn(`[jumble] could not build puzzle for riddle "${riddle.id}", skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Load inputs ──────────────────────────────────────────────────────────────
|
||||
const riddlesRaw = JSON.parse(fs.readFileSync(RIDDLES_FILE, 'utf8'));
|
||||
const allRiddles = riddlesRaw.riddles ?? [];
|
||||
|
||||
const wordList = fs.readFileSync(WORDS_FILE, 'utf8').split('\n').map((w) => w.trim().toUpperCase()).filter(Boolean);
|
||||
const wordsByLength = new Map();
|
||||
const wordMultisets = new Map();
|
||||
for (const w of wordList) {
|
||||
if (w.length < WORD_LEN_MIN || w.length > WORD_LEN_MAX) continue;
|
||||
if (!/^[A-Z]+$/.test(w)) continue;
|
||||
if (!wordsByLength.has(w.length)) wordsByLength.set(w.length, []);
|
||||
wordsByLength.get(w.length).push(w);
|
||||
wordMultisets.set(w, letterMultiset(w.split('')));
|
||||
}
|
||||
|
||||
// ── Assign riddles to tiers ──────────────────────────────────────────────────
|
||||
// Sort valid-length riddles ascending by answer length, then slice
|
||||
// consecutively into each tier's slot count — since tiers themselves are
|
||||
// ordered by increasing difficulty/length, this naturally buckets shorter
|
||||
// answers into earlier tiers without needing per-tier backfill logic.
|
||||
const validRiddles = [];
|
||||
for (const r of allRiddles) {
|
||||
const len = r.answer.replace(/[^A-Za-z]/g, '').length;
|
||||
if (len < MIN_ANSWER_LEN || len > MAX_ANSWER_LEN) {
|
||||
console.warn(`[jumble] skip "${r.id}": answer length ${len} out of ${MIN_ANSWER_LEN}-${MAX_ANSWER_LEN} range`);
|
||||
continue;
|
||||
}
|
||||
validRiddles.push(r);
|
||||
}
|
||||
validRiddles.sort((a, b) => a.answer.replace(/[^A-Za-z]/g, '').length - b.answer.replace(/[^A-Za-z]/g, '').length);
|
||||
|
||||
const totalSlots = TIERS.reduce((s, t) => s + t.count, 0);
|
||||
if (validRiddles.length < totalSlots) {
|
||||
console.warn(`[jumble] only ${validRiddles.length} valid riddles for ${totalSlots} slots — bank will be shorter than planned`);
|
||||
}
|
||||
|
||||
const rng = makeRng(SEED);
|
||||
console.log(`[jumble] generating with seed 0x${SEED.toString(16)}…`);
|
||||
|
||||
const puzzles = [];
|
||||
let riddleIdx = 0;
|
||||
for (const tier of TIERS) {
|
||||
for (let i = 0; i < tier.count && riddleIdx < validRiddles.length; i++, riddleIdx++) {
|
||||
const riddle = validRiddles[riddleIdx];
|
||||
const puzzle = buildPuzzle(riddle, tier, wordsByLength, wordMultisets, rng);
|
||||
if (!puzzle) continue;
|
||||
puzzles.push({ level: puzzles.length + 1, ...puzzle });
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
seed: SEED,
|
||||
count: puzzles.length,
|
||||
puzzles,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||
console.log(`[jumble] wrote ${puzzles.length} levels -> ${OUT_FILE}`);
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
// Verifier for Jumble (Node only — no browser).
|
||||
//
|
||||
// Asserts every level in data/jumble.json is internally consistent: base
|
||||
// words are real dictionary words whose scrambles are true rearrangements,
|
||||
// circled letters are genuine subsets of their word, and — the critical
|
||||
// invariant — the circled letters collected across all 4 words exactly
|
||||
// anagram to the bonus answer.
|
||||
//
|
||||
// Usage: node tools/verifyJumble.js
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FILE = path.join(__dirname, '../data/jumble.json');
|
||||
const WORDS_FILE = path.join(__dirname, '../data/wordlists/common.txt');
|
||||
|
||||
let failures = 0;
|
||||
const fail = (msg) => { console.error(` ✗ ${msg}`); failures++; };
|
||||
|
||||
const dict = new Set(
|
||||
fs.readFileSync(WORDS_FILE, 'utf8').split('\n').map((w) => w.trim().toUpperCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
function sortedChars(str) {
|
||||
return str.split('').sort().join('');
|
||||
}
|
||||
|
||||
// ── Bank checks ──────────────────────────────────────────────────────────────
|
||||
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
||||
const puzzles = raw.puzzles ?? [];
|
||||
console.log(`[verify] ${FILE}`);
|
||||
console.log(`[verify] ${puzzles.length} levels (seed 0x${(raw.seed >>> 0).toString(16)})`);
|
||||
|
||||
if (puzzles.length < 40) fail(`expected at least 40 levels, found ${puzzles.length}`);
|
||||
|
||||
const seenRiddleIds = new Set();
|
||||
const seenWordSets = new Set();
|
||||
let prevLevel = 0;
|
||||
|
||||
for (const pz of puzzles) {
|
||||
const tag = `L${pz.level}`;
|
||||
|
||||
if (pz.level !== prevLevel + 1) fail(`${tag}: level numbering gap (expected ${prevLevel + 1})`);
|
||||
prevLevel = pz.level;
|
||||
|
||||
if (seenRiddleIds.has(pz.riddleId)) fail(`${tag}: duplicate riddleId "${pz.riddleId}"`);
|
||||
seenRiddleIds.add(pz.riddleId);
|
||||
|
||||
if (!pz.words || pz.words.length !== 4) {
|
||||
fail(`${tag}: expected 4 base words, found ${pz.words?.length}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wordSetKey = pz.words.map((w) => w.solution).slice().sort().join('|');
|
||||
if (seenWordSets.has(wordSetKey)) fail(`${tag}: duplicate 4-word set across levels`);
|
||||
seenWordSets.add(wordSetKey);
|
||||
|
||||
const perWordCircledChars = [];
|
||||
for (const w of pz.words) {
|
||||
if (!dict.has(w.solution)) fail(`${tag}: "${w.solution}" is not a real dictionary word`);
|
||||
|
||||
// Scramble must be a rearrangement of the solution (same multiset).
|
||||
const scrambledStr = w.scrambled.map((t) => t.char).join('');
|
||||
if (sortedChars(scrambledStr) !== sortedChars(w.solution)) {
|
||||
fail(`${tag}: word "${w.solution}" scramble letters don't match solution letters`);
|
||||
}
|
||||
if (scrambledStr === w.solution) {
|
||||
fail(`${tag}: word "${w.solution}" scramble is identity order (not shuffled)`);
|
||||
}
|
||||
|
||||
// circledSlots marks fixed positions in the SOLVED word (not scrambled
|
||||
// tiles) — read the circled letters directly off the solution string in
|
||||
// left-to-right order, which is exactly the order the bonus round
|
||||
// collects them in.
|
||||
if (!w.circledSlots || w.circledSlots.length !== w.solution.length) {
|
||||
fail(`${tag}: word "${w.solution}" circledSlots length doesn't match solution length`);
|
||||
}
|
||||
const circledChars = w.solution.split('').filter((_, i) => w.circledSlots?.[i]);
|
||||
if (circledChars.length === 0) {
|
||||
fail(`${tag}: word "${w.solution}" has no circled slots`);
|
||||
}
|
||||
perWordCircledChars.push(circledChars);
|
||||
}
|
||||
|
||||
// Critical invariant: the full circled-letter multiset across all 4 words
|
||||
// (in word order) must exactly equal the bonus answer's letter multiset.
|
||||
const collected = perWordCircledChars.flat();
|
||||
const bonusAnswerStripped = (pz.bonusAnswer ?? '').replace(/[^A-Z]/g, '');
|
||||
if (sortedChars(collected.join('')) !== sortedChars(bonusAnswerStripped)) {
|
||||
fail(`${tag}: collected circled letters "${sortedChars(collected.join(''))}" != bonus answer letters "${sortedChars(bonusAnswerStripped)}"`);
|
||||
}
|
||||
|
||||
// Bonus scramble must be a rearrangement of the bonus answer, and shuffled.
|
||||
if (!pz.bonusScrambled || !pz.bonusScrambled.length) {
|
||||
fail(`${tag}: missing bonusScrambled`);
|
||||
} else {
|
||||
const bonusScrambledStr = pz.bonusScrambled.map((t) => t.char).join('');
|
||||
if (sortedChars(bonusScrambledStr) !== sortedChars(bonusAnswerStripped)) {
|
||||
fail(`${tag}: bonusScrambled letters don't match bonusAnswer letters`);
|
||||
}
|
||||
if (bonusScrambledStr === bonusAnswerStripped) {
|
||||
fail(`${tag}: bonusScrambled is identity order (not shuffled)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-tier stats ───────────────────────────────────────────────────────────
|
||||
const TIER_COUNTS = [12, 12, 12, 12, 10];
|
||||
let idx = 0;
|
||||
TIER_COUNTS.forEach((count, tierIdx) => {
|
||||
const slice = puzzles.slice(idx, idx + count);
|
||||
idx += count;
|
||||
if (!slice.length) return;
|
||||
const avgWordLen = slice.flatMap((p) => p.words.map((w) => w.solution.length)).reduce((a, b) => a + b, 0) / (slice.length * 4);
|
||||
const avgBonusLen = slice.reduce((a, p) => a + (p.bonusAnswer ?? '').replace(/[^A-Z]/g, '').length, 0) / slice.length;
|
||||
console.log(`[verify] tier ${tierIdx + 1}: ${slice.length} levels, avg word len ${avgWordLen.toFixed(1)}, avg bonus len ${avgBonusLen.toFixed(1)}`);
|
||||
});
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n[verify] FAILED with ${failures} problem(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n[verify] All Jumble checks passed.');
|
||||
Loading…
Reference in New Issue