feat: add Word Search game and improve solo game flow
- Integrate Word Search game into client and server registries - Add API endpoints for puzzle generation and theme listing - Update GameMenuScene to skip opponent selection and stop menu music for solo-only games
This commit is contained in:
parent
47885bab01
commit
57eeb3bfee
|
|
@ -0,0 +1,436 @@
|
||||||
|
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 { snapSelection, lettersAt, matchWord } from './WordSearchLogic.js';
|
||||||
|
|
||||||
|
// ── Palette / styling ────────────────────────────────────────────────────────
|
||||||
|
const PAPER = 0xf6efdd; // cream sheet
|
||||||
|
const PAPER_EDGE = 0xe6dcc0;
|
||||||
|
const INK = '#3a2a18'; // pencil-dark letters
|
||||||
|
const TITLE_RED = '#b03a2e';
|
||||||
|
const PENCIL = 0x6b6256; // preview loop color
|
||||||
|
const RULE = 0x8a7a5a; // faint grid guide lines
|
||||||
|
|
||||||
|
// Rotating ink colors for found-word loops.
|
||||||
|
const LOOP_COLORS = [0xb03a2e, 0x2e6da4, 0x3c8a4e, 0x8a5cb8, 0xd08020, 0xc02e7a];
|
||||||
|
|
||||||
|
const DEPTH = { bg: 0, paper: 1, lines: 2, loop: 5, letter: 7, ui: 20, overlay: 40 };
|
||||||
|
|
||||||
|
// ── Play-area geometry (within the 1920×1080 paper sheet) ─────────────────────
|
||||||
|
const PX = 110, PY = 64, PW = 1700, PH = 956; // paper sheet
|
||||||
|
const GRID_AX = 196, GRID_AY = 236, GRID_AW = 1060, GRID_AH = 740; // grid region
|
||||||
|
const WORD_LEFT = 1360, WORD_HDR_X = 1560, WORD_TOP = 250; // checklist
|
||||||
|
|
||||||
|
export default class WordSearchGame extends Phaser.Scene {
|
||||||
|
constructor() { super('WordSearchGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this._initData = { ...data };
|
||||||
|
this.gameDef = data.game;
|
||||||
|
|
||||||
|
this.puzzle = null;
|
||||||
|
this.gridRows = []; // array of row strings
|
||||||
|
this.size = 0;
|
||||||
|
this.cell = 0;
|
||||||
|
this.gridX = 0;
|
||||||
|
this.gridY = 0;
|
||||||
|
|
||||||
|
this.remaining = [];
|
||||||
|
this.wordEntries = {}; // word -> { text, strike }
|
||||||
|
this.anchor = null; // {r,c}
|
||||||
|
this.previewCells = [];
|
||||||
|
this.previewLoop = null;
|
||||||
|
this.loopColorIdx = 0;
|
||||||
|
this.gameEnded = false;
|
||||||
|
|
||||||
|
this.selectedTheme = 'random';
|
||||||
|
this.startObjs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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, COLORS.bg)
|
||||||
|
.setDepth(DEPTH.bg);
|
||||||
|
|
||||||
|
await this.showStartPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
|
||||||
|
|
||||||
|
async showStartPanel() {
|
||||||
|
let themes;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/words/wordsearch/themes');
|
||||||
|
themes = res.themes ?? [];
|
||||||
|
} catch {
|
||||||
|
themes = [
|
||||||
|
{ id: 'space', label: 'Space' }, { id: 'animals', label: 'Animals' },
|
||||||
|
{ id: 'food', label: 'Food' }, { id: 'ocean', label: 'Ocean' },
|
||||||
|
{ id: 'sports', label: 'Sports' }, { id: 'music', label: 'Music' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
||||||
|
const sheet = this.add.graphics().setDepth(DEPTH.paper);
|
||||||
|
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
|
||||||
|
sheet.fillStyle(PAPER, 1);
|
||||||
|
sheet.fillRoundedRect(cx - 560, cy - 330, 1120, 660, 18);
|
||||||
|
sheet.lineStyle(3, PAPER_EDGE, 1);
|
||||||
|
sheet.strokeRoundedRect(cx - 560, cy - 330, 1120, 660, 18);
|
||||||
|
this.startObjs.push(sheet);
|
||||||
|
|
||||||
|
this.startObjs.push(this.add.text(cx, cy - 250, 'Word Search', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '92px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||||
|
|
||||||
|
this.startObjs.push(this.add.text(cx, cy - 170, 'Pick a theme', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '44px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||||
|
|
||||||
|
// Theme chips: "Surprise me" (random) + each theme.
|
||||||
|
const chips = [{ id: 'random', label: 'Surprise me' }, ...themes];
|
||||||
|
this.chipRefs = [];
|
||||||
|
const chipW = 235, step = 255;
|
||||||
|
const layoutRow = (items, y) => {
|
||||||
|
const total = items.length * chipW + (items.length - 1) * (step - chipW);
|
||||||
|
const startX = cx - total / 2 + chipW / 2;
|
||||||
|
items.forEach((t, i) => this.makeChip(startX + i * step, y, t.label, t.id));
|
||||||
|
};
|
||||||
|
layoutRow(chips.slice(0, 4), cy - 90);
|
||||||
|
layoutRow(chips.slice(4), cy - 26);
|
||||||
|
this.refreshChips();
|
||||||
|
|
||||||
|
this.startObjs.push(this.add.text(cx, cy + 50, 'Choose difficulty', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '40px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||||
|
|
||||||
|
const diffY = cy + 140;
|
||||||
|
[['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']].forEach(([label, id], i) => {
|
||||||
|
const b = new Button(this, cx - 260 + i * 260, diffY, label,
|
||||||
|
() => this.startPuzzle(id, this.selectedTheme),
|
||||||
|
{ width: 220, height: 64, fontSize: 26 });
|
||||||
|
b.setDepth(DEPTH.ui);
|
||||||
|
this.startObjs.push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const leave = new Button(this, cx, cy + 250, 'Leave', () => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
|
||||||
|
leave.setDepth(DEPTH.ui);
|
||||||
|
this.startObjs.push(leave);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeChip(x, y, label, value) {
|
||||||
|
const w = 218, h = 52;
|
||||||
|
const bg = this.add.graphics().setDepth(DEPTH.ui);
|
||||||
|
const text = this.add.text(x, y, label, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '30px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui + 1);
|
||||||
|
const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001)
|
||||||
|
.setDepth(DEPTH.ui + 1).setInteractive({ useHandCursor: true });
|
||||||
|
hit.on('pointerdown', () => { this.selectedTheme = value; this.refreshChips(); playSound(this, SFX.PIECE_CLICK); });
|
||||||
|
|
||||||
|
const ref = { value, x, y, w, h, bg, text };
|
||||||
|
this.chipRefs.push(ref);
|
||||||
|
this.startObjs.push(bg, text, hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshChips() {
|
||||||
|
this.chipRefs.forEach((c) => {
|
||||||
|
const sel = c.value === this.selectedTheme;
|
||||||
|
c.bg.clear();
|
||||||
|
if (sel) {
|
||||||
|
c.bg.fillStyle(0xb03a2e, 0.9);
|
||||||
|
c.bg.fillRoundedRect(c.x - c.w / 2, c.y - c.h / 2, c.w, c.h, 12);
|
||||||
|
}
|
||||||
|
c.bg.lineStyle(2, 0x9a7a4a, 0.9);
|
||||||
|
c.bg.strokeRoundedRect(c.x - c.w / 2, c.y - c.h / 2, c.w, c.h, 12);
|
||||||
|
c.text.setColor(sel ? '#fff6e6' : INK);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyStart() {
|
||||||
|
this.startObjs.forEach((o) => o.destroy());
|
||||||
|
this.startObjs = [];
|
||||||
|
this.chipRefs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build the puzzle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async startPuzzle(difficulty, theme) {
|
||||||
|
this.destroyStart();
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.puzzle = await api.get(`/words/wordsearch/start?difficulty=${difficulty}&theme=${theme}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wordsearch] failed to fetch puzzle:', err);
|
||||||
|
this.showStartPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gridRows = this.puzzle.grid;
|
||||||
|
this.size = this.puzzle.size;
|
||||||
|
this.remaining = [...this.puzzle.words];
|
||||||
|
|
||||||
|
this.cell = Math.min(
|
||||||
|
Math.floor(GRID_AW / this.size),
|
||||||
|
Math.floor(GRID_AH / this.size),
|
||||||
|
66,
|
||||||
|
);
|
||||||
|
const span = this.cell * this.size;
|
||||||
|
this.gridX = GRID_AX + (GRID_AW - span) / 2;
|
||||||
|
this.gridY = GRID_AY + (GRID_AH - span) / 2;
|
||||||
|
|
||||||
|
this.buildPaper();
|
||||||
|
this.buildGrid();
|
||||||
|
this.buildWordList();
|
||||||
|
this.buildControls();
|
||||||
|
this.setupInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPaper() {
|
||||||
|
const g = this.add.graphics().setDepth(DEPTH.paper);
|
||||||
|
g.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
|
||||||
|
g.fillStyle(PAPER, 1);
|
||||||
|
g.fillRoundedRect(PX, PY, PW, PH, 20);
|
||||||
|
g.lineStyle(3, PAPER_EDGE, 1);
|
||||||
|
g.strokeRoundedRect(PX, PY, PW, PH, 20);
|
||||||
|
|
||||||
|
// faint ruled lines across the sheet for paper texture
|
||||||
|
const lines = this.add.graphics().setDepth(DEPTH.lines);
|
||||||
|
lines.lineStyle(1, 0xbcd0e0, 0.35);
|
||||||
|
for (let y = PY + 150; y < PY + PH - 30; y += 44) {
|
||||||
|
lines.lineBetween(PX + 30, y, PX + PW - 30, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.add.text(GAME_WIDTH / 2, PY + 70, `${this.puzzle.themeLabel} Word Search`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '78px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildGrid() {
|
||||||
|
// faint cell guide lines
|
||||||
|
const guide = this.add.graphics().setDepth(DEPTH.lines);
|
||||||
|
guide.lineStyle(1, RULE, 0.22);
|
||||||
|
for (let i = 0; i <= this.size; i++) {
|
||||||
|
const x = this.gridX + i * this.cell;
|
||||||
|
const y = this.gridY + i * this.cell;
|
||||||
|
guide.lineBetween(x, this.gridY, x, this.gridY + this.cell * this.size);
|
||||||
|
guide.lineBetween(this.gridX, y, this.gridX + this.cell * this.size, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = `${Math.floor(this.cell * 0.5)}px`;
|
||||||
|
for (let r = 0; r < this.size; r++) {
|
||||||
|
for (let c = 0; c < this.size; c++) {
|
||||||
|
const { x, y } = this.cellCenter(r, c);
|
||||||
|
this.add.text(x, y, this.gridRows[r][c], {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: fs, color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewLoop = this.add.graphics().setDepth(DEPTH.loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWordList() {
|
||||||
|
this.add.text(WORD_HDR_X, WORD_TOP, 'Find these words', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '46px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||||
|
|
||||||
|
const words = this.puzzle.words;
|
||||||
|
const lineH = Math.min(56, Math.floor(640 / Math.max(8, words.length)));
|
||||||
|
const startY = WORD_TOP + 90;
|
||||||
|
|
||||||
|
words.forEach((word, i) => {
|
||||||
|
const y = startY + i * lineH;
|
||||||
|
const text = this.add.text(WORD_LEFT, y, word, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '38px', color: INK,
|
||||||
|
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
|
||||||
|
const strike = this.add.graphics().setDepth(DEPTH.ui + 1);
|
||||||
|
this.wordEntries[word] = { text, strike };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildControls() {
|
||||||
|
new Button(this, PX + 130, PY + PH - 56, 'New puzzle',
|
||||||
|
() => this.scene.restart(this._initData),
|
||||||
|
{ variant: 'ghost', width: 220, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
|
||||||
|
|
||||||
|
new Button(this, PX + PW - 110, PY + PH - 56, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 180, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection input ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
setupInput() {
|
||||||
|
this.input.on('pointerdown', (p) => this.onPointerDown(p));
|
||||||
|
this.input.on('pointermove', (p) => { if (this.anchor) this.updatePreview(p); });
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(p) {
|
||||||
|
if (this.gameEnded || !this.puzzle) return;
|
||||||
|
|
||||||
|
if (!this.anchor) {
|
||||||
|
const cell = this.cellAt(p.x, p.y);
|
||||||
|
if (!cell) return;
|
||||||
|
this.anchor = cell;
|
||||||
|
this.previewCells = [[cell.r, cell.c]];
|
||||||
|
this.drawLoop(this.previewLoop, this.previewCells, PENCIL, 0.85, 6);
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second click commits the current preview.
|
||||||
|
this.updatePreview(p);
|
||||||
|
this.commitSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview(p) {
|
||||||
|
if (!this.anchor) return;
|
||||||
|
const t = this.clampedCell(p.x, p.y);
|
||||||
|
const { cells } = snapSelection(this.size, this.anchor.r, this.anchor.c, t.r, t.c);
|
||||||
|
this.previewCells = cells;
|
||||||
|
this.drawLoop(this.previewLoop, cells, PENCIL, 0.85, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSelection() {
|
||||||
|
const letters = lettersAt(this.gridRows, this.previewCells);
|
||||||
|
const matched = matchWord(letters, this.remaining);
|
||||||
|
if (matched) {
|
||||||
|
this.acceptWord(matched, this.previewCells);
|
||||||
|
} else {
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
}
|
||||||
|
this.clearAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptWord(word, cells) {
|
||||||
|
const color = LOOP_COLORS[this.loopColorIdx % LOOP_COLORS.length];
|
||||||
|
this.loopColorIdx++;
|
||||||
|
|
||||||
|
const gfx = this.add.graphics().setDepth(DEPTH.loop);
|
||||||
|
this.drawLoop(gfx, cells, color, 0.95, 6);
|
||||||
|
|
||||||
|
this.remaining = this.remaining.filter((w) => w !== word);
|
||||||
|
this.crossOff(word, color);
|
||||||
|
playSound(this, SFX.PENCIL_WRITE);
|
||||||
|
|
||||||
|
if (this.remaining.length === 0) this.handleWin();
|
||||||
|
}
|
||||||
|
|
||||||
|
crossOff(word, color) {
|
||||||
|
const entry = this.wordEntries[word];
|
||||||
|
if (!entry) return;
|
||||||
|
entry.text.setAlpha(0.5);
|
||||||
|
const b = entry.text.getBounds();
|
||||||
|
entry.strike.clear();
|
||||||
|
entry.strike.lineStyle(4, color, 0.9);
|
||||||
|
entry.strike.lineBetween(b.left - 6, b.centerY + 3, b.right + 6, b.centerY - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAnchor() {
|
||||||
|
this.anchor = null;
|
||||||
|
this.previewCells = [];
|
||||||
|
this.previewLoop?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loop drawing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Draws a rotated capsule outline wrapping the line from the first to the last
|
||||||
|
// cell. A second offset pass gives it a hand-drawn wobble.
|
||||||
|
drawLoop(gfx, cells, color, alpha, lineW) {
|
||||||
|
gfx.clear();
|
||||||
|
const a = this.cellCenter(cells[0][0], cells[0][1]);
|
||||||
|
const b = this.cellCenter(cells[cells.length - 1][0], cells[cells.length - 1][1]);
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const len = dist + this.cell * 0.92;
|
||||||
|
const h = this.cell * 0.82;
|
||||||
|
|
||||||
|
gfx.setPosition((a.x + b.x) / 2, (a.y + b.y) / 2);
|
||||||
|
gfx.setRotation(Math.atan2(dy, dx));
|
||||||
|
gfx.lineStyle(lineW, color, alpha);
|
||||||
|
gfx.strokeRoundedRect(-len / 2, -h / 2, len, h, h / 2);
|
||||||
|
gfx.lineStyle(Math.max(1, lineW - 3), color, alpha * 0.45);
|
||||||
|
gfx.strokeRoundedRect(-len / 2 + 2, -h / 2 - 2, len, h, h / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Win ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
handleWin() {
|
||||||
|
this.gameEnded = true;
|
||||||
|
this.clearAnchor();
|
||||||
|
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.45).setDepth(DEPTH.overlay);
|
||||||
|
|
||||||
|
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
|
||||||
|
panel.fillStyle(PAPER, 1);
|
||||||
|
panel.fillRoundedRect(cx - 380, cy - 200, 760, 400, 18);
|
||||||
|
panel.lineStyle(3, PAPER_EDGE, 1);
|
||||||
|
panel.strokeRoundedRect(cx - 380, cy - 200, 760, 400, 18);
|
||||||
|
|
||||||
|
this.add.text(cx, cy - 110, 'Solved!', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '90px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
|
||||||
|
|
||||||
|
this.add.text(cx, cy - 20, `You found all ${this.puzzle.words.length} words!`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '40px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
|
||||||
|
|
||||||
|
new Button(this, cx - 150, cy + 110, 'New puzzle',
|
||||||
|
() => this.scene.restart(this._initData),
|
||||||
|
{ width: 250, height: 56, fontSize: 24 }).setDepth(DEPTH.overlay + 2);
|
||||||
|
|
||||||
|
new Button(this, cx + 150, cy + 110, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 250, height: 56, fontSize: 24 }).setDepth(DEPTH.overlay + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordResult(result) {
|
||||||
|
try {
|
||||||
|
const score = (this.puzzle?.words.length ?? 0) * 10;
|
||||||
|
await api.post('/history/single-player', {
|
||||||
|
slug: 'wordsearch', score, opponentScores: [], result,
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cell helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cellCenter(r, c) {
|
||||||
|
return {
|
||||||
|
x: this.gridX + c * this.cell + this.cell / 2,
|
||||||
|
y: this.gridY + r * this.cell + this.cell / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
cellAt(x, y) {
|
||||||
|
const c = Math.floor((x - this.gridX) / this.cell);
|
||||||
|
const r = Math.floor((y - this.gridY) / this.cell);
|
||||||
|
if (r < 0 || c < 0 || r >= this.size || c >= this.size) return null;
|
||||||
|
return { r, c };
|
||||||
|
}
|
||||||
|
|
||||||
|
clampedCell(x, y) {
|
||||||
|
const clamp = (v) => Math.max(0, Math.min(this.size - 1, v));
|
||||||
|
return {
|
||||||
|
r: clamp(Math.floor((y - this.gridY) / this.cell)),
|
||||||
|
c: clamp(Math.floor((x - this.gridX) / this.cell)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Pure helpers for Word Search selection — no Phaser, easy to reason about/test.
|
||||||
|
|
||||||
|
// All 8 directions as [rowDelta, colDelta]. Selection always snaps to one of
|
||||||
|
// these regardless of difficulty; matchWord checks both orientations, so a word
|
||||||
|
// can be traced from either end.
|
||||||
|
export const DIRECTIONS = [
|
||||||
|
[0, 1], [0, -1], [1, 0], [-1, 0],
|
||||||
|
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||||
|
];
|
||||||
|
|
||||||
|
// How many steps we can take from (br,bc) along (dr,dc) before leaving the grid.
|
||||||
|
function maxStepsInBounds(size, br, bc, dr, dc) {
|
||||||
|
let steps = Infinity;
|
||||||
|
if (dr > 0) steps = Math.min(steps, size - 1 - br);
|
||||||
|
else if (dr < 0) steps = Math.min(steps, br);
|
||||||
|
if (dc > 0) steps = Math.min(steps, size - 1 - bc);
|
||||||
|
else if (dc < 0) steps = Math.min(steps, bc);
|
||||||
|
return steps === Infinity ? 0 : steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given an anchor cell (br,bc) and a target cell (tr,tc), pick the straight line
|
||||||
|
// that best matches the drag and return its cells. The chosen direction is the
|
||||||
|
// one whose unit vector is most aligned with the drag; the length is the drag's
|
||||||
|
// projection onto that direction, clamped to the grid.
|
||||||
|
export function snapSelection(size, br, bc, tr, tc, allowedDirs = DIRECTIONS) {
|
||||||
|
const dR = tr - br;
|
||||||
|
const dC = tc - bc;
|
||||||
|
|
||||||
|
if (dR === 0 && dC === 0) {
|
||||||
|
return { dir: [0, 0], cells: [[br, bc]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const [dr, dc] of allowedDirs) {
|
||||||
|
const denom = dr * dr + dc * dc; // 1 (orthogonal) or 2 (diagonal)
|
||||||
|
const proj = (dR * dr + dC * dc) / denom; // steps along this direction
|
||||||
|
if (proj <= 0) continue; // wrong way — skip
|
||||||
|
const maxN = maxStepsInBounds(size, br, bc, dr, dc);
|
||||||
|
const n = Math.max(1, Math.min(Math.round(proj), maxN));
|
||||||
|
if (n < 1) continue;
|
||||||
|
|
||||||
|
// Landing point if we step n times; error vs. the actual target.
|
||||||
|
const lr = br + dr * n;
|
||||||
|
const lc = bc + dc * n;
|
||||||
|
const err = (lr - tr) * (lr - tr) + (lc - tc) * (lc - tc);
|
||||||
|
|
||||||
|
// Prefer the direction with the better unit-vector alignment, breaking ties
|
||||||
|
// by the smaller landing error.
|
||||||
|
const align = (dR * dr + dC * dc) / (Math.sqrt(dR * dR + dC * dC) * Math.sqrt(denom));
|
||||||
|
if (!best || align > best.align + 1e-9 || (Math.abs(align - best.align) <= 1e-9 && err < best.err)) {
|
||||||
|
best = { dr, dc, n, align, err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best) return { dir: [0, 0], cells: [[br, bc]] };
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i <= best.n; i++) {
|
||||||
|
cells.push([br + best.dr * i, bc + best.dc * i]);
|
||||||
|
}
|
||||||
|
return { dir: [best.dr, best.dc], cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters under a list of [r,c] cells, joined into a string. `grid` is an array
|
||||||
|
// of row strings (grid[r][c] indexing works on strings).
|
||||||
|
export function lettersAt(grid, cells) {
|
||||||
|
return cells.map(([r, c]) => grid[r][c]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If `letters` (or its reverse) is one of `words`, return the matched word;
|
||||||
|
// otherwise null.
|
||||||
|
export function matchWord(letters, words) {
|
||||||
|
const rev = [...letters].reverse().join('');
|
||||||
|
if (words.includes(letters)) return letters;
|
||||||
|
if (words.includes(rev)) return rev;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ import WordleGame from './games/wordle/WordleGame.js';
|
||||||
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
|
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
|
||||||
import GhostGame from './games/ghost/GhostGame.js';
|
import GhostGame from './games/ghost/GhostGame.js';
|
||||||
import WordLadderGame from './games/wordladder/WordLadderGame.js';
|
import WordLadderGame from './games/wordladder/WordLadderGame.js';
|
||||||
|
import WordSearchGame from './games/wordsearch/WordSearchGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -83,6 +84,7 @@ const config = {
|
||||||
ScrabbleGame,
|
ScrabbleGame,
|
||||||
GhostGame,
|
GhostGame,
|
||||||
WordLadderGame,
|
WordLadderGame,
|
||||||
|
WordSearchGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||||
import { api } from '../services/api.js';
|
import { api } from '../services/api.js';
|
||||||
import { Button } from '../ui/Button.js';
|
import { Button } from '../ui/Button.js';
|
||||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||||
|
|
||||||
export default class GameMenuScene extends Phaser.Scene {
|
export default class GameMenuScene extends Phaser.Scene {
|
||||||
constructor() { super('GameMenu'); }
|
constructor() { super('GameMenu'); }
|
||||||
|
|
@ -74,6 +74,13 @@ export default class GameMenuScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
openGame(game) {
|
openGame(game) {
|
||||||
|
// Solo-only games (maxOpponents === 0) skip the opponent-selection screen,
|
||||||
|
// so stop the menu music here just as OpponentSelectScene does on start.
|
||||||
|
if (game.maxOpponents === 0) {
|
||||||
|
stopMenuMusic();
|
||||||
|
this.scene.start('GameRoom', { game, opponents: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.scene.start('OpponentSelect', { game });
|
this.scene.start('OpponentSelect', { game });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame' };
|
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,4 @@ registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2,
|
||||||
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||||
registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 });
|
registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 });
|
||||||
|
registerGame({ slug: 'wordsearch', name: 'Word Search', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import {
|
||||||
hintMove as ladderHintMove,
|
hintMove as ladderHintMove,
|
||||||
validWordsOfLength as ladderValidWords,
|
validWordsOfLength as ladderValidWords,
|
||||||
} from './wordLadderEngine.js';
|
} from './wordLadderEngine.js';
|
||||||
|
import {
|
||||||
|
generatePuzzle as wordSearchGenerate,
|
||||||
|
listThemes as wordSearchThemes,
|
||||||
|
} from './wordSearchEngine.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||||
|
|
@ -258,4 +262,19 @@ router.post('/wordladder/hint', (req, res) => {
|
||||||
res.json({ word: ladderHintMove(current, target) });
|
res.json({ word: ladderHintMove(current, target) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Word Search ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/words/wordsearch/start?difficulty=easy|medium|hard&theme=random|space|...
|
||||||
|
// Returns a generated puzzle: { difficulty, theme, themeLabel, size, words, placements, grid }.
|
||||||
|
router.get('/wordsearch/start', (req, res) => {
|
||||||
|
const difficulty = String(req.query.difficulty ?? 'medium').toLowerCase();
|
||||||
|
const theme = req.query.theme ? String(req.query.theme).toLowerCase() : 'random';
|
||||||
|
res.json(wordSearchGenerate({ difficulty, theme }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/words/wordsearch/themes — list of { id, label } for the start panel.
|
||||||
|
router.get('/wordsearch/themes', (_req, res) => {
|
||||||
|
res.json({ themes: wordSearchThemes() });
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
// Server-side Word Search puzzle generator.
|
||||||
|
//
|
||||||
|
// A puzzle is a square letter grid with a set of theme words hidden inside it,
|
||||||
|
// placed along straight lines in one of 8 directions. Difficulty controls grid
|
||||||
|
// size, how many words are hidden, and which directions are allowed (harder
|
||||||
|
// puzzles add diagonals and backwards placement).
|
||||||
|
//
|
||||||
|
// Self-contained: theme word lists live here, no dependency on the ENABLE list.
|
||||||
|
|
||||||
|
const A = 'A'.charCodeAt(0);
|
||||||
|
|
||||||
|
// [rowDelta, colDelta] for each direction.
|
||||||
|
const DIR = {
|
||||||
|
E: [0, 1],
|
||||||
|
W: [0, -1],
|
||||||
|
S: [1, 0],
|
||||||
|
N: [-1, 0],
|
||||||
|
SE: [1, 1],
|
||||||
|
SW: [1, -1],
|
||||||
|
NE: [-1, 1],
|
||||||
|
NW: [-1, -1],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY = {
|
||||||
|
easy: { id: 'easy', size: 10, count: 8, dirs: [DIR.E, DIR.S] },
|
||||||
|
medium: { id: 'medium', size: 12, count: 10, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE] },
|
||||||
|
hard: { id: 'hard', size: 14, count: 12, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE, DIR.W, DIR.N, DIR.SW, DIR.NW] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Curated themes. Each entry: display label + uppercase A–Z words (3–9 letters).
|
||||||
|
const THEMES = {
|
||||||
|
space: {
|
||||||
|
label: 'Space',
|
||||||
|
words: ['COMET', 'PLANET', 'ROCKET', 'GALAXY', 'ORBIT', 'NEBULA', 'METEOR',
|
||||||
|
'SOLAR', 'COSMOS', 'SATURN', 'VENUS', 'PLUTO', 'GRAVITY', 'ASTEROID',
|
||||||
|
'MARS', 'MOON'],
|
||||||
|
},
|
||||||
|
animals: {
|
||||||
|
label: 'Animals',
|
||||||
|
words: ['TIGER', 'ELEPHANT', 'GIRAFFE', 'MONKEY', 'ZEBRA', 'KANGAROO',
|
||||||
|
'PENGUIN', 'DOLPHIN', 'LEOPARD', 'PANDA', 'RABBIT', 'FALCON',
|
||||||
|
'OTTER', 'BEAVER', 'COBRA', 'WALRUS'],
|
||||||
|
},
|
||||||
|
food: {
|
||||||
|
label: 'Food',
|
||||||
|
words: ['PIZZA', 'BURGER', 'PASTA', 'SALAD', 'CHEESE', 'TOMATO', 'BANANA',
|
||||||
|
'COOKIE', 'WAFFLE', 'PRETZEL', 'MUFFIN', 'NOODLE', 'PEPPER',
|
||||||
|
'CARROT', 'YOGURT', 'PANCAKE'],
|
||||||
|
},
|
||||||
|
ocean: {
|
||||||
|
label: 'Ocean',
|
||||||
|
words: ['CORAL', 'SHARK', 'WHALE', 'OCTOPUS', 'DOLPHIN', 'LOBSTER',
|
||||||
|
'STARFISH', 'SEAWEED', 'JELLYFISH', 'URCHIN', 'OYSTER', 'MARLIN',
|
||||||
|
'SHRIMP', 'ANCHOR', 'LAGOON', 'CURRENT'],
|
||||||
|
},
|
||||||
|
sports: {
|
||||||
|
label: 'Sports',
|
||||||
|
words: ['SOCCER', 'TENNIS', 'HOCKEY', 'BOXING', 'RUGBY', 'SKIING',
|
||||||
|
'CYCLING', 'ARCHERY', 'BOWLING', 'CRICKET', 'DIVING', 'FENCING',
|
||||||
|
'KARATE', 'ROWING', 'SAILING', 'SURFING'],
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
label: 'Music',
|
||||||
|
words: ['GUITAR', 'PIANO', 'VIOLIN', 'DRUMS', 'TRUMPET', 'FLUTE', 'CELLO',
|
||||||
|
'HARP', 'BANJO', 'MELODY', 'RHYTHM', 'TEMPO', 'CHORD', 'OCTAVE',
|
||||||
|
'SONATA', 'BALLAD'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function randomFrom(arr) {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(arr) {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDifficulty(id) {
|
||||||
|
return DIFFICULTY[String(id).toLowerCase()] ?? DIFFICULTY.medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(id) {
|
||||||
|
const key = String(id).toLowerCase();
|
||||||
|
if (key === 'random' || !THEMES[key]) {
|
||||||
|
const keys = Object.keys(THEMES);
|
||||||
|
const pick = randomFrom(keys);
|
||||||
|
return { key: pick, ...THEMES[pick] };
|
||||||
|
}
|
||||||
|
return { key, ...THEMES[key] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to place `word` into `grid` along one of `dirs`. Accepts a placement when
|
||||||
|
// every target cell is empty or already holds the matching letter (crossings).
|
||||||
|
// Returns { row, col, dir } on success, or null after exhausting attempts.
|
||||||
|
function tryPlace(grid, word, dirs, size, attempts = 150) {
|
||||||
|
for (let a = 0; a < attempts; a++) {
|
||||||
|
const [dr, dc] = randomFrom(dirs);
|
||||||
|
const len = word.length;
|
||||||
|
|
||||||
|
// Valid start range so the whole word stays in bounds.
|
||||||
|
const rowMin = dr < 0 ? (len - 1) : 0;
|
||||||
|
const rowMax = dr > 0 ? (size - len) : (size - 1);
|
||||||
|
const colMin = dc < 0 ? (len - 1) : 0;
|
||||||
|
const colMax = dc > 0 ? (size - len) : (size - 1);
|
||||||
|
if (rowMin > rowMax || colMin > colMax) continue;
|
||||||
|
|
||||||
|
const row = rowMin + Math.floor(Math.random() * (rowMax - rowMin + 1));
|
||||||
|
const col = colMin + Math.floor(Math.random() * (colMax - colMin + 1));
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const r = row + dr * i;
|
||||||
|
const c = col + dc * i;
|
||||||
|
const cur = grid[r][c];
|
||||||
|
if (cur !== '' && cur !== word[i]) { ok = false; break; }
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
grid[row + dr * i][col + dc * i] = word[i];
|
||||||
|
}
|
||||||
|
return { row, col, dir: [dr, dc] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a single puzzle. Returns the grid as an array of row strings plus the
|
||||||
|
// list of words actually placed and their placements.
|
||||||
|
export function generatePuzzle({ difficulty, theme } = {}) {
|
||||||
|
const cfg = resolveDifficulty(difficulty);
|
||||||
|
const themeInfo = resolveTheme(theme ?? 'random');
|
||||||
|
const { size, count, dirs } = cfg;
|
||||||
|
|
||||||
|
// Candidate words that fit the grid, longest first so the hard ones place
|
||||||
|
// before the grid fills up.
|
||||||
|
const candidates = shuffle(themeInfo.words.filter(w => w.length <= size))
|
||||||
|
.sort((x, y) => y.length - x.length);
|
||||||
|
|
||||||
|
const grid = Array.from({ length: size }, () => Array(size).fill(''));
|
||||||
|
const placedWords = [];
|
||||||
|
const placements = [];
|
||||||
|
|
||||||
|
for (const word of candidates) {
|
||||||
|
if (placedWords.length >= count) break;
|
||||||
|
const placed = tryPlace(grid, word, dirs, size);
|
||||||
|
if (placed) {
|
||||||
|
placedWords.push(word);
|
||||||
|
placements.push({ word, row: placed.row, col: placed.col, dir: placed.dir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the remaining empties with random letters.
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) {
|
||||||
|
if (grid[r][c] === '') {
|
||||||
|
grid[r][c] = String.fromCharCode(A + Math.floor(Math.random() * 26));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
difficulty: cfg.id,
|
||||||
|
theme: themeInfo.key,
|
||||||
|
themeLabel: themeInfo.label,
|
||||||
|
size,
|
||||||
|
words: placedWords,
|
||||||
|
placements,
|
||||||
|
grid: grid.map(row => row.join('')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThemes() {
|
||||||
|
return Object.entries(THEMES).map(([id, t]) => ({ id, label: t.label }));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue