feat: add Spelling Bee solo word puzzle game
- Implement client-side Phaser scene and pure scoring/rank logic for the honeycomb-style word game. - Add server-side puzzle generation engine with difficulty bands (easy/normal/hard) and dictionary pre-filtering. - Register game metadata, frontend scene routing, and backend API endpoint (`/words/spellingbee/start`). - Implements NYT-style rules: 7 letters (1 required center), 4+ length words, pangram bonus, and tiered rank progression.
This commit is contained in:
parent
d6d7bc818b
commit
50292b4a69
|
|
@ -0,0 +1,467 @@
|
|||
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 { scoreWord, isPangram, buildTiers, rankFor } from './SpellingBeeLogic.js';
|
||||
|
||||
// ── Palette / styling ────────────────────────────────────────────────────────
|
||||
const FELT = 0x12100a; // deep background
|
||||
const HONEY = 0xf2c14e; // center hex / accents (honey gold)
|
||||
const HONEY_DARK = 0xb8860b;
|
||||
const COMB = 0x2a2418; // outer hex fill
|
||||
const COMB_EDGE = 0x4a4030;
|
||||
const INK = '#f2ead8'; // light letter text on combs
|
||||
const INK_DARK = '#1a1208'; // dark letter on the honey center hex
|
||||
const TITLE_GOLD = '#f2c14e';
|
||||
const PAPER = 0x1e1a12; // panels
|
||||
|
||||
const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
|
||||
|
||||
// ── Honeycomb geometry ─────────────────────────────────────────────────────────
|
||||
const HIVE_X = 620, HIVE_Y = 600; // center of the flower
|
||||
const HEX_R = 96; // center-to-vertex radius
|
||||
const HEX_D = Math.round(HEX_R * Math.sqrt(3)); // flush edge-to-edge distance ≈ 166
|
||||
|
||||
export default class SpellingBeeGame extends Phaser.Scene {
|
||||
constructor() { super('SpellingBeeGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
|
||||
this.center = '';
|
||||
this.outer = [];
|
||||
this.letters = [];
|
||||
this.validWords = new Set();
|
||||
this.pangramSet = new Set();
|
||||
this.maxScore = 0;
|
||||
this.tiers = [];
|
||||
|
||||
this.current = ''; // word being built
|
||||
this.found = new Set();
|
||||
this.score = 0;
|
||||
this.ended = false;
|
||||
|
||||
this.startObjs = [];
|
||||
this.curLetters = []; // per-char text objects for the current word
|
||||
this.foundTexts = [];
|
||||
this.hexes = {}; // letter -> { container, gfx, isCenter }
|
||||
}
|
||||
|
||||
create() {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
|
||||
.setDepth(DEPTH.bg);
|
||||
|
||||
this.add.text(40, 36, 'SPELLING BEE', {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
this.events.once('shutdown', () => this.cleanup());
|
||||
this.showStartPanel();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.keyHandler) {
|
||||
this.input.keyboard.off('keydown', this.keyHandler);
|
||||
this.keyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
|
||||
|
||||
showStartPanel() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
|
||||
const sheet = this.add.graphics().setDepth(DEPTH.panel);
|
||||
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
|
||||
sheet.fillStyle(PAPER, 1);
|
||||
sheet.fillRoundedRect(cx - 480, cy - 280, 960, 560, 20);
|
||||
sheet.lineStyle(3, HONEY_DARK, 1);
|
||||
sheet.strokeRoundedRect(cx - 480, cy - 280, 960, 560, 20);
|
||||
this.startObjs.push(sheet);
|
||||
|
||||
this.startObjs.push(this.add.text(cx, cy - 190, 'Spelling Bee', {
|
||||
fontFamily: 'Righteous', fontSize: '88px', color: TITLE_GOLD,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||
|
||||
this.startObjs.push(this.add.text(cx, cy - 90,
|
||||
'Make words of 4+ letters using the given letters.\nEvery word must include the center letter.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex,
|
||||
align: 'center', lineSpacing: 8,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||
|
||||
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose difficulty', {
|
||||
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||
|
||||
[['Easy', 'easy'], ['Normal', 'normal'], ['Hard', 'hard']].forEach(([label, id], i) => {
|
||||
const b = new Button(this, cx - 270 + i * 270, cy + 110, label,
|
||||
() => this.startPuzzle(id),
|
||||
{ width: 230, height: 68, fontSize: 28, bgHover: HONEY });
|
||||
b.setDepth(DEPTH.ui);
|
||||
this.startObjs.push(b);
|
||||
});
|
||||
}
|
||||
|
||||
async startPuzzle(difficulty) {
|
||||
let data;
|
||||
try {
|
||||
data = await api.get(`/words/spellingbee/start?difficulty=${difficulty}`);
|
||||
} catch (err) {
|
||||
console.error('[spellingbee] failed to fetch puzzle:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.startObjs.forEach((o) => o.destroy());
|
||||
this.startObjs = [];
|
||||
|
||||
this.center = data.center;
|
||||
this.outer = data.outer ?? [];
|
||||
this.letters = data.letters ?? [];
|
||||
this.validWords = new Set((data.validWords ?? []).map((w) => w.toUpperCase()));
|
||||
this.pangramSet = new Set((data.pangrams ?? []).map((w) => w.toUpperCase()));
|
||||
this.maxScore = data.maxScore ?? 0;
|
||||
this.tiers = buildTiers(this.maxScore);
|
||||
|
||||
this.buildBoard();
|
||||
}
|
||||
|
||||
// ── Board ──────────────────────────────────────────────────────────────────
|
||||
|
||||
buildBoard() {
|
||||
this.buildCurrentWord();
|
||||
this.buildHive();
|
||||
this.buildControls();
|
||||
this.buildFoundPanel();
|
||||
this.buildRankBar();
|
||||
this.bindKeyboard();
|
||||
this.refreshRank();
|
||||
}
|
||||
|
||||
buildCurrentWord() {
|
||||
// Feedback banner sits just above the current-word row.
|
||||
this.feedback = this.add.text(HIVE_X, HIVE_Y - 370, '', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.word).setAlpha(0);
|
||||
|
||||
this.curRowY = HIVE_Y - 325;
|
||||
this.renderCurrentWord();
|
||||
}
|
||||
|
||||
hexPoints(r) {
|
||||
// Pointy-top hexagon: first vertex at the top, stepping 60°.
|
||||
const pts = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = Phaser.Math.DegToRad(60 * i - 90);
|
||||
pts.push(Math.cos(a) * r, Math.sin(a) * r);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
drawHex(g, fill, stroke) {
|
||||
const p = this.hexPoints(HEX_R);
|
||||
g.clear();
|
||||
g.fillStyle(fill, 1);
|
||||
g.lineStyle(4, stroke, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(p[0], p[1]);
|
||||
for (let i = 2; i < p.length; i += 2) g.lineTo(p[i], p[i + 1]);
|
||||
g.closePath();
|
||||
g.fillPath();
|
||||
g.strokePath();
|
||||
}
|
||||
|
||||
makeHex(letter, x, y, isCenter) {
|
||||
const cont = this.add.container(x, y).setDepth(DEPTH.comb);
|
||||
const gfx = this.add.graphics();
|
||||
this.drawHex(gfx, isCenter ? HONEY : COMB, isCenter ? HONEY_DARK : COMB_EDGE);
|
||||
const txt = this.add.text(0, 2, letter, {
|
||||
fontFamily: 'Righteous', fontSize: '58px',
|
||||
color: isCenter ? INK_DARK : INK,
|
||||
}).setOrigin(0.5);
|
||||
cont.add([gfx, txt]);
|
||||
|
||||
cont.setInteractive(new Phaser.Geom.Circle(0, 0, HEX_R * 0.9), Phaser.Geom.Circle.Contains);
|
||||
cont.on('pointerdown', () => {
|
||||
this.typeLetter(letter);
|
||||
this.tweens.add({ targets: cont, scale: { from: 0.88, to: 1 }, duration: 160, ease: 'Back.easeOut' });
|
||||
});
|
||||
|
||||
this.hexes[letter] = { container: cont, gfx, txt, isCenter };
|
||||
return cont;
|
||||
}
|
||||
|
||||
buildHive() {
|
||||
this.makeHex(this.center, HIVE_X, HIVE_Y, true);
|
||||
// Outer six placed at edge-midpoint directions (0°, 60°, 120°…) for flush contact.
|
||||
this.outer.forEach((letter, i) => {
|
||||
const a = Phaser.Math.DegToRad(60 * i);
|
||||
const x = HIVE_X + Math.cos(a) * HEX_D;
|
||||
const y = HIVE_Y + Math.sin(a) * HEX_D;
|
||||
this.makeHex(letter, x, y, false);
|
||||
});
|
||||
}
|
||||
|
||||
buildControls() {
|
||||
const y = HIVE_Y + 300;
|
||||
this.delBtn = new Button(this, HIVE_X - 230, y, 'Delete',
|
||||
() => this.deleteLetter(), { width: 180, height: 64, fontSize: 26 });
|
||||
this.shuffleBtn = new Button(this, HIVE_X, y, 'Shuffle',
|
||||
() => this.shuffleHive(), { width: 180, height: 64, fontSize: 26 });
|
||||
this.enterBtn = new Button(this, HIVE_X + 230, y, 'Enter',
|
||||
() => this.submitWord(), { width: 180, height: 64, fontSize: 26, bgHover: HONEY });
|
||||
[this.delBtn, this.shuffleBtn, this.enterBtn].forEach((b) => b.setDepth(DEPTH.ui));
|
||||
|
||||
const leaveBtn = new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 44, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 140, height: 52, fontSize: 20 });
|
||||
leaveBtn.setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Current word rendering (center letter highlighted) ──────────────────────
|
||||
|
||||
renderCurrentWord() {
|
||||
this.curLetters.forEach((t) => t.destroy());
|
||||
this.curLetters = [];
|
||||
if (!this.current) {
|
||||
this.curLetters.push(this.add.text(HIVE_X, this.curRowY, 'Type a word…', {
|
||||
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.word));
|
||||
return;
|
||||
}
|
||||
const chars = this.current.split('');
|
||||
const widths = chars.map((c) => (c === 'M' || c === 'W' ? 50 : 40));
|
||||
const total = widths.reduce((a, b) => a + b, 0);
|
||||
let x = HIVE_X - total / 2;
|
||||
chars.forEach((c, i) => {
|
||||
const inPuzzle = this.letters.includes(c);
|
||||
const color = c === this.center ? TITLE_GOLD : (inPuzzle ? INK : COLORS.dangerHex);
|
||||
const t = this.add.text(x + widths[i] / 2, this.curRowY, c, {
|
||||
fontFamily: 'Righteous', fontSize: '56px', color,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.word);
|
||||
this.curLetters.push(t);
|
||||
x += widths[i];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
bindKeyboard() {
|
||||
this.keyHandler = (event) => {
|
||||
if (this.ended) return;
|
||||
const key = event.key;
|
||||
if (key === 'Enter') { this.submitWord(); }
|
||||
else if (key === 'Backspace') { event.preventDefault(); this.deleteLetter(); }
|
||||
else if (/^[a-zA-Z]$/.test(key)) { this.typeLetter(key.toUpperCase()); }
|
||||
};
|
||||
this.input.keyboard.on('keydown', this.keyHandler);
|
||||
}
|
||||
|
||||
typeLetter(letter) {
|
||||
if (this.ended) return;
|
||||
this.current += letter.toUpperCase();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.renderCurrentWord();
|
||||
}
|
||||
|
||||
deleteLetter() {
|
||||
if (this.ended || !this.current) return;
|
||||
this.current = this.current.slice(0, -1);
|
||||
this.renderCurrentWord();
|
||||
}
|
||||
|
||||
shuffleHive() {
|
||||
if (this.ended) return;
|
||||
Phaser.Utils.Array.Shuffle(this.outer);
|
||||
// Reassign the shuffled letters onto the six fixed outer-hex containers.
|
||||
const centerEntry = Object.values(this.hexes).find((h) => h.isCenter);
|
||||
const outerHexes = Object.values(this.hexes).filter((h) => !h.isCenter);
|
||||
this.hexes = { [this.center]: centerEntry };
|
||||
outerHexes.forEach((h, i) => {
|
||||
const letter = this.outer[i];
|
||||
h.txt.setText(letter);
|
||||
h.container.removeAllListeners('pointerdown');
|
||||
h.container.on('pointerdown', () => {
|
||||
this.typeLetter(letter);
|
||||
this.tweens.add({ targets: h.container, scale: { from: 0.88, to: 1 }, duration: 160, ease: 'Back.easeOut' });
|
||||
});
|
||||
this.hexes[letter] = h;
|
||||
});
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
}
|
||||
|
||||
// ── Submission ───────────────────────────────────────────────────────────────
|
||||
|
||||
submitWord() {
|
||||
if (this.ended) return;
|
||||
const w = this.current.toUpperCase();
|
||||
this.clearCurrent();
|
||||
|
||||
if (w.length < 4) { return this.flash('Too short', COLORS.dangerHex); }
|
||||
if (!w.includes(this.center)) { return this.flash('Missing center letter', COLORS.dangerHex); }
|
||||
if ([...w].some((c) => !this.letters.includes(c))) { return this.flash('Bad letters', COLORS.dangerHex); }
|
||||
if (this.found.has(w)) { return this.flash('Already found', COLORS.mutedHex); }
|
||||
if (!this.validWords.has(w)) { return this.flash('Not in word list', COLORS.dangerHex); }
|
||||
|
||||
this.found.add(w);
|
||||
const pts = scoreWord(w);
|
||||
this.score += pts;
|
||||
this.addFoundWord(w);
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
if (isPangram(w)) {
|
||||
this.flash(`Pangram! +${pts}`, COLORS.goldHex);
|
||||
this.pulseHive();
|
||||
} else {
|
||||
this.flash(`+${pts} ${w}`, '#7ad17a');
|
||||
}
|
||||
this.refreshRank();
|
||||
}
|
||||
|
||||
clearCurrent() {
|
||||
this.current = '';
|
||||
this.renderCurrentWord();
|
||||
}
|
||||
|
||||
flash(msg, colorHex) {
|
||||
this.feedback.setText(msg).setColor(colorHex).setAlpha(1).setScale(1.2);
|
||||
this.tweens.killTweensOf(this.feedback);
|
||||
this.tweens.add({ targets: this.feedback, scale: 1, duration: 160, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: this.feedback, alpha: 0, delay: 1000, duration: 400 });
|
||||
}
|
||||
|
||||
pulseHive() {
|
||||
Object.values(this.hexes).forEach((h) => {
|
||||
this.tweens.add({ targets: h.container, scale: { from: 1, to: 1.12 }, duration: 180, yoyo: true, ease: 'Sine.easeInOut' });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Found-words panel ──────────────────────────────────────────────────────
|
||||
|
||||
buildFoundPanel() {
|
||||
this.FP = { x: 1240, y: 150, w: 620, h: 800, perCol: 18 };
|
||||
const g = this.add.graphics().setDepth(DEPTH.panel);
|
||||
g.fillStyle(PAPER, 1);
|
||||
g.fillRoundedRect(this.FP.x, this.FP.y, this.FP.w, this.FP.h, 18);
|
||||
g.lineStyle(3, COMB_EDGE, 1);
|
||||
g.strokeRoundedRect(this.FP.x, this.FP.y, this.FP.w, this.FP.h, 18);
|
||||
|
||||
this.foundHeader = this.add.text(this.FP.x + this.FP.w / 2, this.FP.y + 44,
|
||||
'Found: 0', {
|
||||
fontFamily: 'Righteous', fontSize: '38px', color: TITLE_GOLD,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.combTxt);
|
||||
}
|
||||
|
||||
addFoundWord(word) {
|
||||
const idx = this.foundTexts.length;
|
||||
const col = Math.floor(idx / this.FP.perCol);
|
||||
const row = idx % this.FP.perCol;
|
||||
const x = this.FP.x + 40 + col * ((this.FP.w - 80) / 3);
|
||||
const y = this.FP.y + 110 + row * 36;
|
||||
const pangram = isPangram(word);
|
||||
const t = this.add.text(x, y, word, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px',
|
||||
color: pangram ? TITLE_GOLD : INK,
|
||||
fontStyle: pangram ? 'bold' : 'normal',
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.combTxt);
|
||||
t.setScale(1.4).setAlpha(0);
|
||||
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
|
||||
this.foundTexts.push(t);
|
||||
this.foundHeader.setText(`Found: ${this.found.size}`);
|
||||
}
|
||||
|
||||
// ── Rank bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
buildRankBar() {
|
||||
this.RB = { x: 360, y: 150, w: 760 };
|
||||
this.rankLabel = this.add.text(this.RB.x, this.RB.y - 50, '', {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: TITLE_GOLD,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
|
||||
this.scoreLabel = this.add.text(this.RB.x + this.RB.w, this.RB.y - 50, '', {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||
}).setOrigin(1, 0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.rankGfx = this.add.graphics().setDepth(DEPTH.ui);
|
||||
this.rankDots = this.tiers.map((tier, i) => {
|
||||
const x = this.RB.x + (this.RB.w * i) / (this.tiers.length - 1);
|
||||
return { x, tier };
|
||||
});
|
||||
}
|
||||
|
||||
refreshRank() {
|
||||
const { current, isGenius } = rankFor(this.score, this.maxScore);
|
||||
this.rankLabel.setText(current.name);
|
||||
this.scoreLabel.setText(`${this.score}`);
|
||||
|
||||
const g = this.rankGfx;
|
||||
g.clear();
|
||||
// Track line.
|
||||
g.lineStyle(4, COMB_EDGE, 1);
|
||||
g.lineBetween(this.RB.x, this.RB.y, this.RB.x + this.RB.w, this.RB.y);
|
||||
// Tier dots — filled up through the current rank.
|
||||
let reachedIdx = 0;
|
||||
this.rankDots.forEach((d, i) => { if (this.score >= d.tier.threshold) reachedIdx = i; });
|
||||
this.rankDots.forEach((d, i) => {
|
||||
const reached = i <= reachedIdx;
|
||||
const isCur = i === reachedIdx;
|
||||
g.fillStyle(reached ? HONEY : COMB, 1);
|
||||
g.lineStyle(3, reached ? HONEY_DARK : COMB_EDGE, 1);
|
||||
g.fillCircle(d.x, this.RB.y, isCur ? 16 : 10);
|
||||
g.strokeCircle(d.x, this.RB.y, isCur ? 16 : 10);
|
||||
});
|
||||
|
||||
if (isGenius && !this.ended) this.handleGenius();
|
||||
}
|
||||
|
||||
// ── Genius (win) ─────────────────────────────────────────────────────────────
|
||||
|
||||
handleGenius() {
|
||||
this.ended = true;
|
||||
this.cleanup();
|
||||
playSound(this, SFX.CASINO_WIN);
|
||||
this.recordResult('win');
|
||||
this.time.delayedCall(400, () => this.showWin());
|
||||
}
|
||||
|
||||
showWin() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(DEPTH.overlay);
|
||||
|
||||
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
|
||||
panel.fillStyle(PAPER, 1);
|
||||
panel.fillRoundedRect(cx - 400, cy - 220, 800, 440, 20);
|
||||
panel.lineStyle(3, HONEY_DARK, 1);
|
||||
panel.strokeRoundedRect(cx - 400, cy - 220, 800, 440, 20);
|
||||
|
||||
this.add.text(cx, cy - 120, 'Genius!', {
|
||||
fontFamily: 'Righteous', fontSize: '96px', color: TITLE_GOLD,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
|
||||
|
||||
this.add.text(cx, cy - 10,
|
||||
`${this.found.size} words · ${this.score} points`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '36px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
|
||||
|
||||
new Button(this, cx - 160, cy + 120, 'New Puzzle',
|
||||
() => this.scene.restart(this._initData),
|
||||
{ width: 280, height: 64, fontSize: 26, bgHover: HONEY }).setDepth(DEPTH.overlayUI);
|
||||
new Button(this, cx + 160, cy + 120, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 280, height: 64, fontSize: 26 }).setDepth(DEPTH.overlayUI);
|
||||
}
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'spellingbee', score: this.score, opponentScores: [], result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Spelling Bee logic: scoring and rank tiers. Pure — no Phaser.
|
||||
|
||||
// Rank tiers as a fraction of the puzzle's maximum possible score (NYT scale).
|
||||
// Queen Bee (100%) is intentionally omitted — Genius is the achievable goal.
|
||||
const TIER_TABLE = [
|
||||
{ name: 'Beginner', pct: 0.00 },
|
||||
{ name: 'Good Start', pct: 0.02 },
|
||||
{ name: 'Moving Up', pct: 0.05 },
|
||||
{ name: 'Good', pct: 0.08 },
|
||||
{ name: 'Solid', pct: 0.15 },
|
||||
{ name: 'Nice', pct: 0.25 },
|
||||
{ name: 'Great', pct: 0.40 },
|
||||
{ name: 'Amazing', pct: 0.50 },
|
||||
{ name: 'Genius', pct: 0.70 },
|
||||
];
|
||||
|
||||
// Number of distinct letters in a word.
|
||||
function distinctCount(word) {
|
||||
return new Set(word.toUpperCase()).size;
|
||||
}
|
||||
|
||||
// A pangram uses all 7 distinct puzzle letters.
|
||||
export function isPangram(word) {
|
||||
return distinctCount(word) === 7;
|
||||
}
|
||||
|
||||
// 4-letter word = 1 pt; longer words = 1 pt per letter; pangram earns +7.
|
||||
export function scoreWord(word) {
|
||||
const w = String(word);
|
||||
let s = w.length === 4 ? 1 : w.length;
|
||||
if (isPangram(w)) s += 7;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Concrete tier thresholds (rounded score needed to reach each tier).
|
||||
export function buildTiers(maxScore) {
|
||||
return TIER_TABLE.map((t) => ({
|
||||
name: t.name,
|
||||
threshold: Math.round(t.pct * maxScore),
|
||||
}));
|
||||
}
|
||||
|
||||
// Current tier for a score plus the next tier (null once Genius is reached).
|
||||
export function rankFor(score, maxScore) {
|
||||
const tiers = buildTiers(maxScore);
|
||||
let currentIdx = 0;
|
||||
for (let i = 0; i < tiers.length; i++) {
|
||||
if (score >= tiers[i].threshold) currentIdx = i;
|
||||
}
|
||||
return {
|
||||
current: tiers[currentIdx],
|
||||
next: tiers[currentIdx + 1] ?? null,
|
||||
isGenius: currentIdx === tiers.length - 1,
|
||||
};
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import Connect4Game from './games/connect4/Connect4Game.js';
|
|||
import BoggleGame from './games/boggle/BoggleGame.js';
|
||||
import OldMaidGame from './games/oldmaid/OldMaidGame.js';
|
||||
import BlokusGame from './games/blokus/BlokusGame.js';
|
||||
import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -107,6 +108,7 @@ const config = {
|
|||
BoggleGame,
|
||||
OldMaidGame,
|
||||
BlokusGame,
|
||||
SpellingBeeGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,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' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ registerGame({ slug: 'connect4', name: 'Connect 4', category: 'ta
|
|||
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
|
||||
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
|
||||
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
|
||||
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
// Spelling Bee engine: NYT-style honeycomb puzzle generation.
|
||||
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
|
||||
//
|
||||
// A puzzle is 7 distinct letters (one "center" required in every word) built
|
||||
// around a pangram (a word using all 7 letters). Valid words are length >= 4,
|
||||
// contain the center letter, and use only the 7 puzzle letters (repeats allowed).
|
||||
// The letter S is excluded (NYT convention) to avoid trivial plurals.
|
||||
|
||||
const MIN_LEN = 4;
|
||||
const A_CODE = 'A'.charCodeAt(0);
|
||||
const S_BIT = 1 << ('S'.charCodeAt(0) - A_CODE);
|
||||
|
||||
// Difficulty target bands for the valid-word count (smaller pool = harder).
|
||||
const BANDS = {
|
||||
easy: { min: 40, max: 90 },
|
||||
normal: { min: 20, max: 45 },
|
||||
hard: { min: 10, max: 25 },
|
||||
};
|
||||
|
||||
let words = []; // [{ w, mask }] — candidate words (len>=4, no S, <=7 distinct)
|
||||
let pangramMasks = []; // distinct 7-letter masks that have at least one pangram
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function wordMask(w) {
|
||||
let mask = 0;
|
||||
for (let i = 0; i < w.length; i++) {
|
||||
mask |= 1 << (w.charCodeAt(i) - A_CODE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function popcount(n) {
|
||||
let c = 0;
|
||||
while (n) { n &= n - 1; c++; }
|
||||
return c;
|
||||
}
|
||||
|
||||
function maskLetters(mask) {
|
||||
const letters = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (mask & (1 << i)) letters.push(String.fromCharCode(A_CODE + i));
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Scoring (mirrors the client SpellingBeeLogic): 4 letters = 1 pt; longer words
|
||||
// = 1 pt per letter; pangram (uses all 7 distinct letters) earns a +7 bonus.
|
||||
function scoreWord(w, isPangram) {
|
||||
let s = w.length === 4 ? 1 : w.length;
|
||||
if (isPangram) s += 7;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function initSpellingBeeDictionary(rawWords) {
|
||||
words = [];
|
||||
const seenPangram = new Set();
|
||||
pangramMasks = [];
|
||||
|
||||
for (const raw of rawWords) {
|
||||
const w = String(raw).toUpperCase();
|
||||
if (w.length < MIN_LEN || !/^[A-Z]+$/.test(w)) continue;
|
||||
const mask = wordMask(w);
|
||||
if (mask & S_BIT) continue; // S is excluded from puzzles entirely
|
||||
const distinct = popcount(mask);
|
||||
if (distinct > 7) continue; // can never fit a 7-letter puzzle
|
||||
words.push({ w, mask });
|
||||
if (distinct === 7 && !seenPangram.has(mask)) {
|
||||
seenPangram.add(mask);
|
||||
pangramMasks.push(mask);
|
||||
}
|
||||
}
|
||||
return { words: words.length, pangrams: pangramMasks.length };
|
||||
}
|
||||
|
||||
// ── Puzzle generation ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildPuzzle(puzzleMask, centerBit) {
|
||||
const validWords = [];
|
||||
const pangrams = [];
|
||||
let maxScore = 0;
|
||||
const notMask = ~puzzleMask;
|
||||
for (const { w, mask } of words) {
|
||||
if ((mask & notMask) !== 0) continue; // uses a letter outside the puzzle
|
||||
if ((mask & centerBit) === 0) continue; // missing the center letter
|
||||
const isPangram = mask === puzzleMask; // a <=7-distinct word filling all 7
|
||||
validWords.push(w);
|
||||
if (isPangram) pangrams.push(w);
|
||||
maxScore += scoreWord(w, isPangram);
|
||||
}
|
||||
return { validWords, pangrams, maxScore };
|
||||
}
|
||||
|
||||
export function generatePuzzle(difficulty = 'normal') {
|
||||
const band = BANDS[difficulty] ?? BANDS.normal;
|
||||
if (!pangramMasks.length) {
|
||||
return { center: '', outer: [], letters: [], validWords: [], pangrams: [], maxScore: 0 };
|
||||
}
|
||||
|
||||
let best = null;
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const puzzleMask = pangramMasks[Math.floor(Math.random() * pangramMasks.length)];
|
||||
const letters = maskLetters(puzzleMask);
|
||||
const center = letters[Math.floor(Math.random() * letters.length)];
|
||||
const centerBit = 1 << (center.charCodeAt(0) - A_CODE);
|
||||
|
||||
const { validWords, pangrams, maxScore } = buildPuzzle(puzzleMask, centerBit);
|
||||
const candidate = { center, letters, validWords, pangrams, maxScore };
|
||||
|
||||
// Track the best-so-far by closeness to the band midpoint, as a fallback.
|
||||
if (!best || Math.abs(validWords.length - (band.min + band.max) / 2)
|
||||
< Math.abs(best.validWords.length - (band.min + band.max) / 2)) {
|
||||
best = candidate;
|
||||
}
|
||||
if (validWords.length >= band.min && validWords.length <= band.max) {
|
||||
best = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const outer = shuffle(best.letters.filter((l) => l !== best.center));
|
||||
return {
|
||||
center: best.center,
|
||||
outer,
|
||||
letters: best.letters,
|
||||
validWords: best.validWords,
|
||||
pangrams: best.pangrams,
|
||||
maxScore: best.maxScore,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from './wordSearchEngine.js';
|
||||
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
|
||||
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
|
||||
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
|
@ -150,6 +151,10 @@ function loadWordLists() {
|
|||
initBoggleDictionary(boggleWords);
|
||||
console.log(`[words] loaded ${boggleWords.length} Boggle words (3–16 letters)`);
|
||||
|
||||
// Spelling Bee dictionary: ENABLE words of length 4+, no S, ≤7 distinct letters.
|
||||
const beeStats = initSpellingBeeDictionary(allWords);
|
||||
console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`);
|
||||
|
||||
// Answer pool: prefer curated common words that are also in ENABLE;
|
||||
// supplement with additional ENABLE words up to a healthy pool size.
|
||||
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
|
||||
|
|
@ -192,6 +197,17 @@ router.get('/boggle/start', (_req, res) => {
|
|||
res.json({ board, solutions: solveBoard(board) });
|
||||
});
|
||||
|
||||
// ── Spelling Bee ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/words/spellingbee/start?difficulty=easy|normal|hard
|
||||
// Builds a honeycomb puzzle (7 letters incl. a required center) and returns it
|
||||
// with the full valid-word list so the client validates locally.
|
||||
router.get('/spellingbee/start', (req, res) => {
|
||||
const VALID = ['easy', 'normal', 'hard'];
|
||||
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'normal';
|
||||
res.json(spellingBeeGenerate(difficulty));
|
||||
});
|
||||
|
||||
// ── Scrabble ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/scrabble/validate { words: string[] }
|
||||
|
|
|
|||
Loading…
Reference in New Issue