Compare commits
No commits in common. "47885bab01c7e5cc68e82e40a19f0e3216d7b7b4" and "dd384d6c154be157caafaf03fffa1c15df94b130" have entirely different histories.
47885bab01
...
dd384d6c15
|
|
@ -1,52 +0,0 @@
|
|||
// Thin client wrapper around the server Ghost solver. The trie + perfect-play
|
||||
// search live in server/words/ghostEngine.js; here we just ship the fragment and
|
||||
// pace the AI so turns feel deliberate.
|
||||
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
// Ask the server for the AI's next letter. Resolves to:
|
||||
// { letter, isWord, isPrefix } (letter is null when the AI is at a dead end)
|
||||
export async function requestAILetter({ fragment, skill }) {
|
||||
try {
|
||||
return await api.post('/words/ghost/ai-move', { fragment, skill });
|
||||
} catch (err) {
|
||||
console.error('[ghost] ai-move request failed:', err);
|
||||
return { letter: null, isWord: false, isPrefix: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Example safe words the loser could have headed toward from `prefix`.
|
||||
// Resolves to a string[] (empty when no safe move existed).
|
||||
export async function requestSuggestions(prefix, max = 3) {
|
||||
try {
|
||||
const res = await api.post('/words/ghost/suggest', { prefix, max });
|
||||
return res.words ?? [];
|
||||
} catch (err) {
|
||||
console.error('[ghost] suggest request failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Grade a fragment a player just formed: { isWord, isPrefix }.
|
||||
export async function judgeFragment(fragment) {
|
||||
try {
|
||||
return await api.post('/words/ghost/judge', { fragment });
|
||||
} catch (err) {
|
||||
console.error('[ghost] judge request failed:', err);
|
||||
return { isWord: false, isPrefix: true }; // fail open: treat as a safe continuation
|
||||
}
|
||||
}
|
||||
|
||||
// "Thinking" pause before the AI commits, in ms. Stronger players act faster.
|
||||
const THINK_DELAY = {
|
||||
1: [1600, 2600],
|
||||
2: [1300, 2200],
|
||||
3: [1000, 1800],
|
||||
4: [800, 1400],
|
||||
5: [550, 1100],
|
||||
};
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [min, max] = THINK_DELAY[skill] ?? THINK_DELAY[3];
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
|
@ -1,477 +0,0 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
GHOST_LETTERS, createInitialState, appendLetter, concedeRound, other,
|
||||
} from './GhostLogic.js';
|
||||
import { requestAILetter, judgeFragment, requestSuggestions, nextThinkDelay } from './GhostAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const TILE_W = 84;
|
||||
const TILE_H = 96;
|
||||
const GAP = 10;
|
||||
const ROW_Y = 440;
|
||||
|
||||
const PLR_X = 200;
|
||||
const AI_X = GAME_WIDTH - 200;
|
||||
const PORT_Y = 300;
|
||||
const PORT_R = 78;
|
||||
|
||||
const KEY_W = 64;
|
||||
const KEY_H = 74;
|
||||
const KEY_G = 8;
|
||||
const KBD_Y = 660;
|
||||
|
||||
const DEPTH = { bg: 0, tile: 2, ui: 10 };
|
||||
const VD = DEPTH.ui + 20;
|
||||
|
||||
const C = {
|
||||
tileBg: 0x1a1a1b,
|
||||
border: 0x565758,
|
||||
player: 0x538d4e, // green — letters you played
|
||||
ai: 0xb59f3b, // gold — letters the AI played
|
||||
key: 0x818384,
|
||||
keyDim: 0x3a3a3c,
|
||||
};
|
||||
|
||||
const KEYBOARD_ROWS = [
|
||||
['Q','W','E','R','T','Y','U','I','O','P'],
|
||||
['A','S','D','F','G','H','J','K','L'],
|
||||
['Z','X','C','V','B','N','M'],
|
||||
];
|
||||
|
||||
const TARGET = GHOST_LETTERS.length; // 5 — spelling GHOST loses the match
|
||||
|
||||
// ── Scene ──────────────────────────────────────────────────────────────────────
|
||||
export default class GhostGame extends Phaser.Scene {
|
||||
constructor() { super('GhostGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
this.opponent = data.opponents?.[0] ?? null;
|
||||
this.skill = this.opponent?.skill ?? 3;
|
||||
this.playerGhost = data.playerGhost ?? 0; // letters earned (losses)
|
||||
this.aiGhost = data.aiGhost ?? 0;
|
||||
this.startingPlayer = data.startingPlayer ?? 'player';
|
||||
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
|
||||
|
||||
this.gs = null;
|
||||
this.tileObjs = [];
|
||||
this.busy = false; // an async judge / AI turn is in flight
|
||||
this.roundEnded = false;
|
||||
this.aiTimer = null;
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildParticleTexture();
|
||||
|
||||
this.gs = createInitialState({ startingPlayer: this.startingPlayer });
|
||||
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildMeters();
|
||||
this.buildKeyboard();
|
||||
this.buildTexts();
|
||||
this.buildControls();
|
||||
this.setupInput();
|
||||
|
||||
if (this.gs.turn === 'ai') this.scheduleAITurn();
|
||||
else this.setTurnIndicator('player');
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
if (this.textures.exists('ghostParticle')) return;
|
||||
const g = this.make.graphics({ add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('ghostParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
|
||||
.setDepth(DEPTH.bg);
|
||||
this.add.text(GAME_WIDTH / 2, 70, 'GHOST', {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
this.add.text(GAME_WIDTH / 2, 116, 'add a letter — don’t finish a word', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
createPlayerPortrait(this, PLR_X, PORT_Y, PORT_R, DEPTH.ui, 'GhostGame');
|
||||
this.add.text(PLR_X, PORT_Y + PORT_R + 12, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
|
||||
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, AI_X, PORT_Y, PORT_R, DEPTH.ui, { playIntro: !this.skipIntro });
|
||||
this.add.text(AI_X, PORT_Y + PORT_R + 12, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
|
||||
}
|
||||
|
||||
buildMeters() {
|
||||
const my = PORT_Y + PORT_R + 56;
|
||||
this.playerMeter = this.createMeter(PLR_X, my);
|
||||
this.aiMeter = this.createMeter(AI_X, my);
|
||||
this.updateMeter(this.playerMeter, this.playerGhost);
|
||||
this.updateMeter(this.aiMeter, this.aiGhost);
|
||||
}
|
||||
|
||||
createMeter(cx, y) {
|
||||
const SP = 40;
|
||||
const startX = cx - (TARGET - 1) * SP / 2;
|
||||
return GHOST_LETTERS.map((ch, i) =>
|
||||
this.add.text(startX + i * SP, y, ch, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui).setAlpha(0.25),
|
||||
);
|
||||
}
|
||||
|
||||
updateMeter(meter, count) {
|
||||
meter.forEach((t, i) => {
|
||||
const lit = i < count;
|
||||
t.setColor(lit ? COLORS.dangerHex : COLORS.mutedHex);
|
||||
t.setAlpha(lit ? 1 : 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
buildKeyboard() {
|
||||
this.keyObjs = {};
|
||||
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
|
||||
const rowW = keys.length * (KEY_W + KEY_G) - KEY_G;
|
||||
let x = GAME_WIDTH / 2 - rowW / 2;
|
||||
keys.forEach(key => {
|
||||
const cx = x + KEY_W / 2;
|
||||
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
|
||||
x += KEY_W + KEY_G;
|
||||
|
||||
const container = this.add.container(cx, cy).setDepth(DEPTH.ui);
|
||||
const bg = this.add.rectangle(0, 0, KEY_W, KEY_H, C.key);
|
||||
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
|
||||
const lbl = this.add.text(0, 0, key, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, lbl]);
|
||||
this.keyObjs[key] = { container, bg, lbl };
|
||||
|
||||
bg.on('pointerdown', () => this.playLetter(key));
|
||||
bg.on('pointerover', () => { if (this.canPlay()) bg.setFillStyle(0x9a9b9c); });
|
||||
bg.on('pointerout', () => bg.setFillStyle(this.canPlay() ? C.key : C.keyDim));
|
||||
});
|
||||
});
|
||||
this.refreshKeyboardEnabled();
|
||||
}
|
||||
|
||||
buildTexts() {
|
||||
this.turnText = this.add.text(GAME_WIDTH / 2, 210, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, ROW_Y + TILE_H / 2 + 60, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildControls() {
|
||||
new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 160, height: 44, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
setupInput() {
|
||||
this.input.keyboard.on('keydown', (evt) => {
|
||||
if (/^[a-zA-Z]$/.test(evt.key)) this.playLetter(evt.key.toUpperCase());
|
||||
});
|
||||
}
|
||||
|
||||
canPlay() {
|
||||
return !this.busy && !this.roundEnded && this.gs?.status === 'playing' && this.gs.turn === 'player';
|
||||
}
|
||||
|
||||
refreshKeyboardEnabled() {
|
||||
const on = this.canPlay();
|
||||
for (const key in this.keyObjs) {
|
||||
this.keyObjs[key].bg.setFillStyle(on ? C.key : C.keyDim);
|
||||
}
|
||||
}
|
||||
|
||||
setTurnIndicator(who) {
|
||||
if (who === 'player') {
|
||||
this.turnText.setText('Your turn');
|
||||
this.statusText.setText('');
|
||||
} else {
|
||||
this.turnText.setText(`${this.opponent?.name ?? 'CPU'} is thinking…`);
|
||||
}
|
||||
this.refreshKeyboardEnabled();
|
||||
}
|
||||
|
||||
// ── Turn flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async playLetter(letter) {
|
||||
if (!this.canPlay()) return;
|
||||
this.busy = true;
|
||||
this.refreshKeyboardEnabled();
|
||||
|
||||
this.gs = appendLetter(this.gs, letter, 'player');
|
||||
this.addTile(letter, 'player');
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
const { isWord, isPrefix } = await judgeFragment(this.gs.fragment);
|
||||
if (!this.scene.isActive()) return;
|
||||
|
||||
if (isWord) return this.endRound('player', 'word');
|
||||
if (!isPrefix) return this.endRound('player', 'off-dictionary');
|
||||
|
||||
this.busy = false;
|
||||
this.scheduleAITurn();
|
||||
}
|
||||
|
||||
scheduleAITurn() {
|
||||
if (this.roundEnded) return;
|
||||
this.busy = true;
|
||||
this.setTurnIndicator('ai');
|
||||
this.aiTimer = this.time.delayedCall(nextThinkDelay(this.skill), () => this.doAITurn());
|
||||
}
|
||||
|
||||
async doAITurn() {
|
||||
if (this.roundEnded) return;
|
||||
|
||||
const { letter, isWord, isPrefix } = await requestAILetter({ fragment: this.gs.fragment, skill: this.skill });
|
||||
if (!this.scene.isActive()) return;
|
||||
|
||||
if (letter == null) return this.endRound('ai', 'stumped');
|
||||
|
||||
this.gs = appendLetter(this.gs, letter, 'ai');
|
||||
this.addTile(letter, 'ai');
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
if (isWord) return this.endRound('ai', 'word');
|
||||
if (!isPrefix) return this.endRound('ai', 'off-dictionary');
|
||||
|
||||
this.busy = false;
|
||||
this.setTurnIndicator('player');
|
||||
}
|
||||
|
||||
// ── Fragment tiles ───────────────────────────────────────────────────────────
|
||||
|
||||
addTile(letter, who) {
|
||||
const container = this.add.container(0, ROW_Y).setDepth(DEPTH.tile);
|
||||
const bg = this.add.rectangle(0, 0, TILE_W, TILE_H, who === 'player' ? C.player : C.ai)
|
||||
.setStrokeStyle(2, C.border);
|
||||
const label = this.add.text(0, 0, letter, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, label]);
|
||||
this.tileObjs.push({ container });
|
||||
this.layoutFragment();
|
||||
|
||||
container.setScale(0);
|
||||
this.tweens.add({ targets: container, scale: 1, duration: 170, ease: 'Back.easeOut' });
|
||||
}
|
||||
|
||||
layoutFragment() {
|
||||
const n = this.tileObjs.length;
|
||||
const totalW = n * TILE_W + (n - 1) * GAP;
|
||||
const startX = GAME_WIDTH / 2 - totalW / 2 + TILE_W / 2;
|
||||
this.tileObjs.forEach((t, i) => { t.container.x = startX + i * (TILE_W + GAP); });
|
||||
}
|
||||
|
||||
shakeFragment() {
|
||||
const containers = this.tileObjs.map(t => t.container);
|
||||
const startXs = containers.map(c => c.x);
|
||||
this.tweens.add({
|
||||
targets: containers, x: (_t, _k, _v, i) => startXs[i] + 8,
|
||||
duration: 50, yoyo: true, repeat: 3, ease: 'Sine.easeInOut',
|
||||
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Round end → G-H-O-S-T scoring ─────────────────────────────────────────────
|
||||
|
||||
endRound(loser, reason) {
|
||||
if (this.roundEnded) return;
|
||||
this.roundEnded = true;
|
||||
this.busy = true;
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.gs = concedeRound(this.gs, loser, reason);
|
||||
this.refreshKeyboardEnabled();
|
||||
this.shakeFragment();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
if (loser === 'player') this.playerGhost++;
|
||||
else this.aiGhost++;
|
||||
|
||||
const meter = loser === 'player' ? this.playerMeter : this.aiMeter;
|
||||
const count = loser === 'player' ? this.playerGhost : this.aiGhost;
|
||||
this.updateMeter(meter, count);
|
||||
const lit = meter[count - 1];
|
||||
if (lit) this.tweens.add({ targets: lit, scale: 1.6, duration: 180, yoyo: true, ease: 'Back.easeOut' });
|
||||
|
||||
this.opponentPortrait?.playEmotion(loser === 'player' ? 'happy' : 'upset');
|
||||
|
||||
const matchOver = this.playerGhost >= TARGET || this.aiGhost >= TARGET;
|
||||
if (matchOver) this.recordResult(this.aiGhost >= TARGET ? 'win' : 'loss');
|
||||
|
||||
// The prefix the loser faced before their losing letter (the AI didn't append
|
||||
// one when stumped, so the whole fragment is what it faced).
|
||||
const faced = reason === 'stumped' ? this.gs.fragment : this.gs.fragment.slice(0, -1);
|
||||
|
||||
this.time.delayedCall(800, async () => {
|
||||
if (matchOver) return this.showVictoryScreen(this.aiGhost >= TARGET);
|
||||
const words = await requestSuggestions(faced);
|
||||
if (!this.scene.isActive()) return;
|
||||
this.showRoundResult(loser, reason, words);
|
||||
});
|
||||
}
|
||||
|
||||
ghostStr(count) {
|
||||
return GHOST_LETTERS.map((l, i) => (i < count ? l : '·')).join(' ');
|
||||
}
|
||||
|
||||
showRoundResult(loser, reason, words = []) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const oppName = this.opponent?.name ?? 'CPU';
|
||||
const loserName = loser === 'player' ? 'You' : oppName;
|
||||
const verb = loser === 'player' ? 'earn' : 'earns';
|
||||
|
||||
let headline, detail;
|
||||
if (reason === 'word') {
|
||||
headline = `${loserName} completed a word`;
|
||||
detail = `“${this.gs.fragment}” is a word`;
|
||||
} else if (reason === 'off-dictionary') {
|
||||
headline = `${loserName} ran off the dictionary`;
|
||||
detail = `“${this.gs.fragment}” starts no valid word`;
|
||||
} else {
|
||||
headline = `${loserName} got stuck`;
|
||||
detail = `nothing safe follows “${this.gs.fragment}”`;
|
||||
}
|
||||
|
||||
const earnedLetter = GHOST_LETTERS[(loser === 'player' ? this.playerGhost : this.aiGhost) - 1];
|
||||
const earnLine = `${loserName} ${verb} a “${earnedLetter}”`;
|
||||
const seriesLine = `You ${this.ghostStr(this.playerGhost)} ${oppName} ${this.ghostStr(this.aiGhost)}`;
|
||||
|
||||
const hasSafe = words.length > 0;
|
||||
const suggestLine = hasSafe
|
||||
? `Safe play would lead to: ${words.join(' · ')}`
|
||||
: 'No safe move was possible';
|
||||
|
||||
const panel = this.add.rectangle(cx, cy, 780, 300, 0x0a0e14, 0.94)
|
||||
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
|
||||
const t1 = this.add.text(cx, cy - 100, headline, {
|
||||
fontFamily: 'Righteous', fontSize: '36px',
|
||||
color: loser === 'player' ? COLORS.dangerHex : '#6aff88',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t2 = this.add.text(cx, cy - 54, detail, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t3 = this.add.text(cx, cy - 12, earnLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t4 = this.add.text(cx, cy + 32, suggestLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px',
|
||||
color: hasSafe ? '#6aff88' : COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t5 = this.add.text(cx, cy + 80, seriesLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
|
||||
this.time.delayedCall(3000, () => {
|
||||
[panel, t1, t2, t3, t4, t5].forEach(o => o.destroy());
|
||||
this.startNextRound();
|
||||
});
|
||||
}
|
||||
|
||||
startNextRound() {
|
||||
this.scene.restart({
|
||||
...this._initData,
|
||||
playerGhost: this.playerGhost,
|
||||
aiGhost: this.aiGhost,
|
||||
startingPlayer: other(this.startingPlayer),
|
||||
skipIntro: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Victory ────────────────────────────────────────────────────────────────
|
||||
|
||||
showVictoryScreen(playerWon) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const PW = 840, PH = 560;
|
||||
const top = cy - PH / 2, bot = cy + PH / 2;
|
||||
|
||||
const fw = this.add.particles(cx, cy, 'ghostParticle', {
|
||||
speed: { min: 80, max: 480 }, lifespan: 1400,
|
||||
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
|
||||
quantity: 3, frequency: 35, angle: { min: 0, max: 360 },
|
||||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
|
||||
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT) },
|
||||
}).setDepth(VD - 1);
|
||||
this.time.delayedCall(3200, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
|
||||
|
||||
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(VD);
|
||||
this.add.text(cx, top + 62, playerWon ? 'You Win!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: playerWon ? '#ffd700' : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
const WIN_R = 92, LOSE_R = 64;
|
||||
const portY = top + 215;
|
||||
const plrX = cx - 195, oppX = cx + 195;
|
||||
|
||||
const plrPortrait = createPlayerPortrait(this, plrX, portY, playerWon ? WIN_R : LOSE_R, VD + 2, 'GhostGame');
|
||||
this.add.text(plrX, portY + (playerWon ? WIN_R : LOSE_R) + 12, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(VD + 2);
|
||||
if (!playerWon) plrPortrait.fadeToEliminated(900);
|
||||
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, playerWon ? LOSE_R : WIN_R, VD + 2, { playIntro: false });
|
||||
this.add.text(oppX, portY + (playerWon ? LOSE_R : WIN_R) + 12, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(VD + 2);
|
||||
if (playerWon) oppPortrait.fadeToEliminated(900); else oppPortrait.playEmotion('happy');
|
||||
|
||||
this.add.text(cx, portY + WIN_R + 64,
|
||||
`You ${this.ghostStr(this.playerGhost)} ${this.opponent?.name ?? 'CPU'} ${this.ghostStr(this.aiGhost)}`, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
const btnsY = bot - 64;
|
||||
new Button(this, cx - 130, btnsY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, playerGhost: 0, aiGhost: 0, startingPlayer: 'player', skipIntro: false });
|
||||
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
}
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'ghost',
|
||||
score: result === 'win' ? 100 : 0,
|
||||
opponentScores: [0],
|
||||
result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.opponentPortrait?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Pure Ghost logic — no Phaser, no network. Word judging (did a letter complete a
|
||||
// word / go off-dictionary) is decided by the server, then recorded here via
|
||||
// concedeRound; this module only tracks the fragment, whose turn it is, and the
|
||||
// running G-H-O-S-T score.
|
||||
|
||||
export const MIN_LEN = 4;
|
||||
export const GHOST_LETTERS = ['G', 'H', 'O', 'S', 'T'];
|
||||
|
||||
export function other(who) {
|
||||
return who === 'player' ? 'ai' : 'player';
|
||||
}
|
||||
|
||||
export function createInitialState({ startingPlayer = 'player' } = {}) {
|
||||
return {
|
||||
fragment: '',
|
||||
turn: startingPlayer, // 'player' | 'ai'
|
||||
history: [], // [{ who, letter }]
|
||||
status: 'playing', // 'playing' | 'over'
|
||||
loser: null, // 'player' | 'ai'
|
||||
reason: null, // 'word' | 'off-dictionary' | 'stumped'
|
||||
};
|
||||
}
|
||||
|
||||
// Append a letter played by `who`, then hand the turn to the other player.
|
||||
export function appendLetter(state, letter, who) {
|
||||
if (state.status !== 'playing') return state;
|
||||
const L = letter.toUpperCase();
|
||||
return {
|
||||
...state,
|
||||
fragment: state.fragment + L,
|
||||
history: [...state.history, { who, letter: L }],
|
||||
turn: other(who),
|
||||
};
|
||||
}
|
||||
|
||||
// End the round with a loser and a reason.
|
||||
export function concedeRound(state, loser, reason) {
|
||||
return { ...state, status: 'over', loser, reason };
|
||||
}
|
||||
|
||||
export function isRoundOver(state) {
|
||||
return state.status === 'over';
|
||||
}
|
||||
|
|
@ -293,20 +293,14 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
|
||||
// ── Rack (human) ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Shared tray geometry: x of slot 0's center, and center-to-center slot pitch.
|
||||
rackLayout() {
|
||||
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
|
||||
return { startX: GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2, slot: RACK_TILE + RACK_GAP };
|
||||
}
|
||||
|
||||
renderRack() {
|
||||
for (const o of this.rackObjs) o.destroy();
|
||||
this.rackObjs = [];
|
||||
this._dragInsertIndex = null;
|
||||
const rack = this.players[0].rack;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
|
||||
const startX = GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2;
|
||||
rack.forEach((token, i) => {
|
||||
const x = startX + i * slot;
|
||||
const x = startX + i * (RACK_TILE + RACK_GAP);
|
||||
const letter = token === BLANK ? '' : token;
|
||||
const value = token === BLANK ? 0 : (LETTER_VALUES[token] ?? 0);
|
||||
const tile = this.makeTile(RACK_TILE, letter, value);
|
||||
|
|
@ -332,28 +326,14 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
if (!this.canHumanAct() || this.exchangeMode || obj._rackIndex === undefined) return;
|
||||
obj.setDepth(DEPTH.drag);
|
||||
obj._dragging = true;
|
||||
obj._fromIndex = obj._rackIndex;
|
||||
this._dragInsertIndex = obj._fromIndex;
|
||||
});
|
||||
this.input.on('drag', (_p, obj, dx, dy) => {
|
||||
if (!obj._dragging) return;
|
||||
obj.setPosition(dx, dy);
|
||||
const sq = this.squareAt(obj.x, obj.y);
|
||||
if (sq && this.squareFree(sq.row, sq.col)) {
|
||||
// Pointer is over a free board square — close any tray gap we opened.
|
||||
this.clearRackGap(obj);
|
||||
} else if (this.inRackZone(obj.y)) {
|
||||
// Tray-reorder mode — slide neighbours aside to reveal the drop slot.
|
||||
this.updateRackGap(obj, this.rackInsertIndexAt(obj.x));
|
||||
} else {
|
||||
// Dead zone (e.g. over an occupied square) — no gap.
|
||||
this.clearRackGap(obj);
|
||||
}
|
||||
});
|
||||
this.input.on('dragend', (_p, obj) => {
|
||||
if (!obj._dragging) return;
|
||||
obj._dragging = false;
|
||||
const insert = this._dragInsertIndex;
|
||||
const sq = this.squareAt(obj.x, obj.y);
|
||||
if (sq && this.squareFree(sq.row, sq.col)) {
|
||||
// Defer: placeTentative rebuilds the rack and destroys this very tile,
|
||||
|
|
@ -361,73 +341,13 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
obj.setVisible(false);
|
||||
const token = obj._token, row = sq.row, col = sq.col;
|
||||
this.time.delayedCall(0, () => this.placeTentative(token, row, col));
|
||||
} else if (this.inRackZone(obj.y) && insert !== null && insert !== obj._fromIndex) {
|
||||
this.reorderRack(obj._fromIndex, insert);
|
||||
} else {
|
||||
// No move — snap the dragged tile home and close the gap.
|
||||
obj.setDepth(DEPTH.tile);
|
||||
this.clearRackGap(obj);
|
||||
this.tweens.add({ targets: obj, x: obj._homeX, y: obj._homeY, duration: 140, ease: 'Back.easeOut' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// True when y is below the board, i.e. in the tray region (not over the grid).
|
||||
inRackZone(y) {
|
||||
return y > BOARD_Y0 + BOARD_PX;
|
||||
}
|
||||
|
||||
// Which rack slot would the dragged tile land on, given its current x.
|
||||
rackInsertIndexAt(x) {
|
||||
const { startX, slot } = this.rackLayout();
|
||||
const max = this.players[0].rack.length - 1;
|
||||
const i = Math.round((x - startX) / slot);
|
||||
return Math.max(0, Math.min(max, i));
|
||||
}
|
||||
|
||||
// Lay out every non-dragged tile, leaving slot `insertIndex` empty for the drop.
|
||||
updateRackGap(draggedObj, insertIndex) {
|
||||
this._dragInsertIndex = insertIndex;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
// Tiles in their natural rack order, excluding the one being dragged.
|
||||
const others = this.rackObjs.filter(t => t !== draggedObj);
|
||||
let s = 0;
|
||||
for (const tile of others) {
|
||||
if (s === insertIndex) s++; // reserve the gap slot
|
||||
const tx = startX + s * slot;
|
||||
if (tile._homeX !== tx) { // only retween when the target changes
|
||||
tile._homeX = tx;
|
||||
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
|
||||
}
|
||||
s++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore all non-dragged tiles to their natural slots (gap closed).
|
||||
clearRackGap(draggedObj) {
|
||||
if (this._dragInsertIndex === null) return;
|
||||
this._dragInsertIndex = null;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
for (const tile of this.rackObjs) {
|
||||
if (tile === draggedObj) continue;
|
||||
const tx = startX + tile._rackIndex * slot;
|
||||
if (tile._homeX !== tx) {
|
||||
tile._homeX = tx;
|
||||
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reorderRack(fromIndex, toIndex) {
|
||||
const rack = this.players[0].rack;
|
||||
const [tok] = rack.splice(fromIndex, 1);
|
||||
// `toIndex` is the desired final-array slot for the tile; updateRackGap laid the
|
||||
// remaining tiles out around that slot, so insert directly there.
|
||||
rack.splice(toIndex, 0, tok);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.renderRack();
|
||||
}
|
||||
|
||||
squareAt(x, y) {
|
||||
const col = Math.floor((x - BOARD_X0) / SQ);
|
||||
const row = Math.floor((y - BOARD_Y0) / SQ);
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
// Word Ladder AI — a thin client wrapper. The intelligence (graph BFS + skill
|
||||
// model) lives server-side in wordLadderEngine.js, like Scrabble/Ghost; this
|
||||
// just advances the AI's own ladder one rung per request and tracks its path.
|
||||
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
export function createAIState({ start, target, skill }) {
|
||||
const S = String(start).toUpperCase();
|
||||
return {
|
||||
current: S,
|
||||
target: String(target).toUpperCase(),
|
||||
skill: skill ?? 3,
|
||||
path: [S],
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Ask the server for the AI's next rung. Updates aiState and returns
|
||||
// { word, delayMs, done } — delayMs is the suggested pause before the next move.
|
||||
export async function requestNextMove(aiState) {
|
||||
let res;
|
||||
try {
|
||||
res = await api.post('/words/wordladder/ai-move', {
|
||||
current: aiState.current,
|
||||
target: aiState.target,
|
||||
skill: aiState.skill,
|
||||
});
|
||||
} catch {
|
||||
return { word: aiState.current, delayMs: 2000, done: aiState.done };
|
||||
}
|
||||
|
||||
const word = String(res.word ?? aiState.current).toUpperCase();
|
||||
if (word !== aiState.current) {
|
||||
aiState.current = word;
|
||||
aiState.path.push(word);
|
||||
}
|
||||
aiState.done = !!res.done || word === aiState.target;
|
||||
return { word, delayMs: res.delayMs ?? 2000, done: aiState.done };
|
||||
}
|
||||
|
|
@ -1,600 +0,0 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
createInitialState, lastRung, tryAddRung, undoRung, isSolved, stepsTaken, changedIndex,
|
||||
} from './WordLadderLogic.js';
|
||||
import { createAIState, requestNextMove } from './WordLadderAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const TILE = 58;
|
||||
const TGAP = 8;
|
||||
const N_ROWS = 7; // visible working-board rows (sliding window)
|
||||
const ROW_PITCH = TILE + 8; // 66
|
||||
|
||||
const ROW0_Y = 140; // first working-board row center (board sits at top)
|
||||
const GOAL_ROW_Y = 644; // goal row sits BELOW the working board
|
||||
|
||||
const KEY_W = 50, KEY_H = 56, KEY_WW = 78, KEY_G = 7;
|
||||
const KBD_TOP = 724;
|
||||
|
||||
const PLR_CX = 560, AI_CX = 1360, SOLO_CX = GAME_WIDTH / 2;
|
||||
|
||||
const DEPTH = { bg: 0, board: 1, tile: 2, ui: 10, overlay: 40 };
|
||||
|
||||
const C = {
|
||||
empty: 0x121213,
|
||||
border: 0x565758,
|
||||
active: 0x818384,
|
||||
filled: 0x1a1a1b,
|
||||
start: 0x2a3550,
|
||||
target: 0x538d4e,
|
||||
key: 0x818384,
|
||||
};
|
||||
|
||||
const KEYBOARD_ROWS = [
|
||||
['Q','W','E','R','T','Y','U','I','O','P'],
|
||||
['A','S','D','F','G','H','J','K','L'],
|
||||
['ENTER','Z','X','C','V','B','N','M','⌫'],
|
||||
];
|
||||
|
||||
export default class WordLadderGame extends Phaser.Scene {
|
||||
constructor() { super('WordLadderGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
this.opponent = data.opponents?.[0] ?? null;
|
||||
this.versus = !!this.opponent;
|
||||
this.skill = this.opponent?.skill ?? 3;
|
||||
this.length = data.wordLength ?? 4;
|
||||
this.skipIntro = data.skipIntro ?? false;
|
||||
|
||||
this.gs = null;
|
||||
this.aiState = null;
|
||||
this.validSet = null;
|
||||
this.currentInput = '';
|
||||
this.activeRow = 1;
|
||||
this.animating = false;
|
||||
this.playerDone = false;
|
||||
this.aiDone = false;
|
||||
this.gameEnded = false;
|
||||
this.hintsUsed = 0;
|
||||
this.aiTimer = null;
|
||||
this.aiNextDelay = 1500;
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
this.buildParticleTexture();
|
||||
|
||||
let player, opponent, validWords;
|
||||
try {
|
||||
const res = await api.get(`/words/wordladder/start?length=${this.length}&versus=${this.versus}`);
|
||||
player = res.player;
|
||||
opponent = res.opponent;
|
||||
validWords = res.validWords;
|
||||
} catch (err) {
|
||||
console.error('[wordladder] failed to fetch puzzle:', err);
|
||||
player = { start: 'COLD', target: 'WARM', par: 4 };
|
||||
validWords = ['COLD', 'CORD', 'CARD', 'WARD', 'WARM'];
|
||||
}
|
||||
|
||||
this.validSet = new Set((validWords ?? []).map(w => w.toUpperCase()));
|
||||
this.gs = createInitialState({ ...player, length: this.length });
|
||||
if (this.versus && opponent) {
|
||||
this.aiState = createAIState({ start: opponent.start, target: opponent.target, skill: this.skill });
|
||||
}
|
||||
|
||||
this.playerCX = this.versus ? PLR_CX : SOLO_CX;
|
||||
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildBoards();
|
||||
this.buildKeyboard();
|
||||
this.buildStatusText();
|
||||
this.buildControls();
|
||||
this.setupInput();
|
||||
|
||||
this.renderPlayerBoard();
|
||||
if (this.aiState) {
|
||||
this.renderAIBoard();
|
||||
this.scheduleAIMove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ────────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
if (this.textures.exists('ladderParticle')) return;
|
||||
const g = this.make.graphics({ add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('ladderParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
|
||||
|
||||
this.add.text(cx, 40, 'WORD LADDER', {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex };
|
||||
this.add.text(this.playerCX, 90, 'YOU', labelStyle).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
if (this.versus) {
|
||||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||
g.lineStyle(1, 0x2a2a2c, 1);
|
||||
g.lineBetween(cx, 30, cx, 690);
|
||||
|
||||
this.add.text(AI_CX, 90, this.opponent?.name?.toUpperCase() ?? 'CPU', labelStyle)
|
||||
.setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.add.text(cx, 400, 'VS', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
const r = 58;
|
||||
const py = 250;
|
||||
const ppx = 96;
|
||||
createPlayerPortrait(this, ppx, py, r, DEPTH.ui, 'WordLadderGame');
|
||||
this.add.text(ppx, py + r + 10, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
|
||||
if (this.versus) {
|
||||
const opx = GAME_WIDTH - 96;
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, DEPTH.ui, { playIntro: !this.skipIntro });
|
||||
this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
}
|
||||
}
|
||||
|
||||
buildBoards() {
|
||||
this.playerGoalTiles = this.createGoalRow(this.playerCX, this.gs.target);
|
||||
this.playerTiles = this.createWorkingGrid(this.playerCX);
|
||||
if (this.aiState) {
|
||||
this.aiGoalTiles = this.createGoalRow(AI_CX, this.aiState.target);
|
||||
this.aiTiles = this.createWorkingGrid(AI_CX);
|
||||
}
|
||||
}
|
||||
|
||||
boardLeft(centerX) {
|
||||
const boardW = this.length * TILE + (this.length - 1) * TGAP;
|
||||
return centerX - boardW / 2;
|
||||
}
|
||||
|
||||
tileX(centerX, col) {
|
||||
return this.boardLeft(centerX) + col * (TILE + TGAP) + TILE / 2;
|
||||
}
|
||||
|
||||
createGoalRow(centerX, target) {
|
||||
this.add.text(centerX, GOAL_ROW_Y - TILE / 2 - 18, 'GOAL', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
const tiles = [];
|
||||
for (let c = 0; c < this.length; c++) {
|
||||
const x = this.tileX(centerX, c);
|
||||
const container = this.add.container(x, GOAL_ROW_Y).setDepth(DEPTH.tile);
|
||||
const bg = this.add.rectangle(0, 0, TILE, TILE, C.filled).setStrokeStyle(2, C.target);
|
||||
const label = this.add.text(0, 0, target[c] ?? '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: '#6aff88', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, label]);
|
||||
tiles.push({ container, bg, label });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
createWorkingGrid(centerX) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < N_ROWS; r++) {
|
||||
const row = [];
|
||||
const y = ROW0_Y + r * ROW_PITCH;
|
||||
for (let c = 0; c < this.length; c++) {
|
||||
const x = this.tileX(centerX, c);
|
||||
const container = this.add.container(x, y).setDepth(DEPTH.tile);
|
||||
const bg = this.add.rectangle(0, 0, TILE, TILE, C.empty).setStrokeStyle(2, C.border);
|
||||
const label = this.add.text(0, 0, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, label]);
|
||||
row.push({ container, bg, label });
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
buildKeyboard() {
|
||||
this.keyObjs = {};
|
||||
const cx = this.playerCX;
|
||||
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
|
||||
const isWide = (k) => k === 'ENTER' || k === '⌫';
|
||||
const rowW = keys.reduce((s, k) => s + (isWide(k) ? KEY_WW : KEY_W) + KEY_G, -KEY_G);
|
||||
let x = cx - rowW / 2;
|
||||
keys.forEach(key => {
|
||||
const kw = isWide(key) ? KEY_WW : KEY_W;
|
||||
const kcx = x + kw / 2;
|
||||
const kcy = KBD_TOP + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
|
||||
x += kw + KEY_G;
|
||||
|
||||
const container = this.add.container(kcx, kcy).setDepth(DEPTH.ui);
|
||||
const bg = this.add.rectangle(0, 0, kw, KEY_H, C.key).setStrokeStyle(0);
|
||||
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
|
||||
const lbl = this.add.text(0, 0, key, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: key.length > 1 ? '15px' : '21px',
|
||||
color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, lbl]);
|
||||
this.keyObjs[key] = { container, bg, lbl };
|
||||
|
||||
bg.on('pointerdown', () => {
|
||||
if (key === 'ENTER') this.submitWord();
|
||||
else if (key === '⌫') this.removeLetter();
|
||||
else this.addLetter(key);
|
||||
});
|
||||
bg.on('pointerover', () => bg.setFillStyle(0x9a9b9c));
|
||||
bg.on('pointerout', () => bg.setFillStyle(C.key));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildStatusText() {
|
||||
this.statusText = this.add.text(this.playerCX, KBD_TOP - 26, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
if (this.versus) {
|
||||
this.aiThinkText = this.add.text(AI_CX, KBD_TOP - 26, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
const parLine = this.gs.par ? `Par: ${this.gs.par} steps` : '';
|
||||
this.parText = this.add.text(this.playerCX, GAME_HEIGHT - 30, parLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildControls() {
|
||||
const y = 940;
|
||||
this.undoBtn = new Button(this, this.playerCX - 130, y, 'Undo', () => this.undo(), {
|
||||
variant: 'ghost', width: 150, height: 48, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
this.hintBtn = new Button(this, this.playerCX + 130, y, 'Hint', () => this.hint(), {
|
||||
variant: 'ghost', width: 150, height: 48, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 160, height: 44, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
renderPlayerBoard() {
|
||||
const showActive = !this.gameEnded && this.gs.status === 'playing';
|
||||
this.activeRow = this.renderWorkingBoard(this.playerTiles, this.gs.rungs, {
|
||||
showActive,
|
||||
target: this.gs.target,
|
||||
});
|
||||
if (showActive) this.updateActiveRow();
|
||||
}
|
||||
|
||||
renderAIBoard() {
|
||||
this.renderWorkingBoard(this.aiTiles, this.aiState.path, {
|
||||
showActive: false,
|
||||
target: this.aiState.target,
|
||||
});
|
||||
}
|
||||
|
||||
// Paints a sliding window of `rungs` (plus an active input row when showActive).
|
||||
// Returns the screen-row index of the active row (or -1).
|
||||
renderWorkingBoard(tiles, rungs, { showActive, target }) {
|
||||
const used = rungs.length + (showActive ? 1 : 0);
|
||||
const windowStart = Math.max(0, used - N_ROWS);
|
||||
let activeScreenRow = -1;
|
||||
|
||||
for (let r = 0; r < N_ROWS; r++) {
|
||||
const gi = windowStart + r;
|
||||
const rowTiles = tiles[r];
|
||||
if (gi < rungs.length) {
|
||||
const prev = gi > 0 ? rungs[gi - 1] : null;
|
||||
this.paintRung(rowTiles, rungs[gi], {
|
||||
isStart: gi === 0,
|
||||
isTarget: rungs[gi] === target,
|
||||
changed: changedIndex(prev, rungs[gi]),
|
||||
});
|
||||
} else if (showActive && gi === rungs.length) {
|
||||
activeScreenRow = r;
|
||||
this.paintActive(rowTiles, '');
|
||||
} else {
|
||||
this.paintEmpty(rowTiles);
|
||||
}
|
||||
}
|
||||
return activeScreenRow;
|
||||
}
|
||||
|
||||
paintRung(rowTiles, word, { isStart, isTarget, changed }) {
|
||||
const fill = isTarget ? C.target : isStart ? C.start : C.filled;
|
||||
rowTiles.forEach((tile, i) => {
|
||||
tile.label.setText(word[i] ?? '');
|
||||
tile.label.setColor(i === changed ? COLORS.goldHex : '#ffffff');
|
||||
tile.bg.setFillStyle(fill);
|
||||
tile.bg.setStrokeStyle(2, isStart ? COLORS.accent : isTarget ? C.target : C.border);
|
||||
tile.container.setScale(1);
|
||||
});
|
||||
}
|
||||
|
||||
paintActive(rowTiles, input) {
|
||||
rowTiles.forEach((tile, i) => {
|
||||
const ch = input[i] ?? '';
|
||||
tile.label.setText(ch);
|
||||
tile.label.setColor('#ffffff');
|
||||
tile.bg.setFillStyle(ch ? C.filled : C.empty);
|
||||
tile.bg.setStrokeStyle(2, ch ? C.active : C.border);
|
||||
tile.container.setScale(1);
|
||||
});
|
||||
}
|
||||
|
||||
paintEmpty(rowTiles) {
|
||||
rowTiles.forEach((tile) => {
|
||||
tile.label.setText('');
|
||||
tile.bg.setFillStyle(C.empty);
|
||||
tile.bg.setStrokeStyle(2, 0x2a2a2c);
|
||||
tile.container.setScale(1);
|
||||
});
|
||||
}
|
||||
|
||||
updateActiveRow() {
|
||||
if (this.activeRow < 0) return;
|
||||
this.paintActive(this.playerTiles[this.activeRow], this.currentInput);
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
setupInput() {
|
||||
this.input.keyboard.on('keydown', (evt) => {
|
||||
if (this.animating || this.playerDone || this.gameEnded) return;
|
||||
if (/^[a-zA-Z]$/.test(evt.key)) this.addLetter(evt.key.toUpperCase());
|
||||
else if (evt.key === 'Backspace') this.removeLetter();
|
||||
else if (evt.key === 'Enter') this.submitWord();
|
||||
});
|
||||
}
|
||||
|
||||
addLetter(l) {
|
||||
if (this.animating || this.playerDone || this.gameEnded) return;
|
||||
if (this.currentInput.length >= this.length) return;
|
||||
this.currentInput += l;
|
||||
this.updateActiveRow();
|
||||
const tile = this.playerTiles[this.activeRow]?.[this.currentInput.length - 1];
|
||||
if (tile) this.bounceTile(tile);
|
||||
}
|
||||
|
||||
removeLetter() {
|
||||
if (this.currentInput.length === 0 || this.playerDone || this.gameEnded) return;
|
||||
this.currentInput = this.currentInput.slice(0, -1);
|
||||
this.updateActiveRow();
|
||||
}
|
||||
|
||||
submitWord() {
|
||||
if (this.animating || this.playerDone || this.gameEnded) return;
|
||||
|
||||
const result = tryAddRung(this.gs, this.currentInput, this.validSet);
|
||||
if (!result.ok) {
|
||||
this.shakeRow(this.playerTiles[this.activeRow]);
|
||||
this.flashStatus(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.gs = result.state;
|
||||
this.currentInput = '';
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
this.renderPlayerBoard();
|
||||
this.popRow(this.committedScreenRow());
|
||||
|
||||
if (isSolved(this.gs)) {
|
||||
this.playerDone = true;
|
||||
this.handleFinish('player');
|
||||
}
|
||||
}
|
||||
|
||||
// Screen row of the most recently committed rung (for the pop animation).
|
||||
committedScreenRow() {
|
||||
const used = this.gs.rungs.length + (this.gs.status === 'playing' && !this.gameEnded ? 1 : 0);
|
||||
const windowStart = Math.max(0, used - N_ROWS);
|
||||
return (this.gs.rungs.length - 1) - windowStart;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.playerDone || this.gameEnded || this.animating) return;
|
||||
if (this.gs.rungs.length <= 1) { this.flashStatus('Nothing to undo'); return; }
|
||||
this.gs = undoRung(this.gs);
|
||||
this.currentInput = '';
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.renderPlayerBoard();
|
||||
}
|
||||
|
||||
async hint() {
|
||||
if (this.playerDone || this.gameEnded || this.animating) return;
|
||||
if (this.hintsUsed >= 1) { this.flashStatus('No hints left'); return; }
|
||||
this.hintsUsed = 1;
|
||||
this.hintBtn.setEnabled(false).setLabel('Hint used');
|
||||
try {
|
||||
const res = await api.post('/words/wordladder/hint', {
|
||||
current: lastRung(this.gs), target: this.gs.target,
|
||||
});
|
||||
if (res.word) {
|
||||
this.currentInput = String(res.word).toUpperCase().slice(0, this.length);
|
||||
this.updateActiveRow();
|
||||
this.flashStatus('Hint filled — press Enter', COLORS.accentHex);
|
||||
} else {
|
||||
this.flashStatus('No hint available');
|
||||
}
|
||||
} catch {
|
||||
this.flashStatus('Hint unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
flashStatus(msg, color = COLORS.dangerHex) {
|
||||
this.statusText.setColor(color).setText(msg);
|
||||
this.time.delayedCall(1400, () => { if (this.statusText) this.statusText.setText(''); });
|
||||
}
|
||||
|
||||
// ── AI race loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
scheduleAIMove() {
|
||||
if (this.aiDone || this.gameEnded) return;
|
||||
this.aiThinkText?.setText('thinking…');
|
||||
this.aiTimer = this.time.delayedCall(this.aiNextDelay, () => this.doAIMove());
|
||||
}
|
||||
|
||||
async doAIMove() {
|
||||
if (this.aiDone || this.gameEnded) return;
|
||||
this.aiThinkText?.setText('');
|
||||
|
||||
const { delayMs, done } = await requestNextMove(this.aiState);
|
||||
if (this.gameEnded) return;
|
||||
|
||||
this.aiNextDelay = delayMs;
|
||||
this.renderAIBoard();
|
||||
this.popRow(this.aiCommittedScreenRow(), this.aiTiles);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
if (done) {
|
||||
this.aiDone = true;
|
||||
this.handleFinish('ai');
|
||||
} else {
|
||||
this.scheduleAIMove();
|
||||
}
|
||||
}
|
||||
|
||||
aiCommittedScreenRow() {
|
||||
const used = this.aiState.path.length;
|
||||
const windowStart = Math.max(0, used - N_ROWS);
|
||||
return (this.aiState.path.length - 1) - windowStart;
|
||||
}
|
||||
|
||||
// ── End of game ──────────────────────────────────────────────────────────────
|
||||
|
||||
handleFinish(winner) {
|
||||
if (this.gameEnded) return;
|
||||
this.gameEnded = true;
|
||||
this.playerDone = true;
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.aiThinkText?.setText('');
|
||||
|
||||
const playerWon = winner === 'player';
|
||||
if (this.versus) this.opponentPortrait?.playEmotion(playerWon ? 'upset' : 'happy');
|
||||
this.recordResult(playerWon ? 'win' : 'loss');
|
||||
|
||||
this.time.delayedCall(600, () => this.showResult(playerWon));
|
||||
}
|
||||
|
||||
showResult(playerWon) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
|
||||
if (playerWon) {
|
||||
const fw = this.add.particles(cx, cy, 'ladderParticle', {
|
||||
speed: { min: 80, max: 420 }, lifespan: 1300, scale: { start: 1.1, end: 0 },
|
||||
alpha: { start: 1, end: 0 }, quantity: 3, frequency: 40,
|
||||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0x88ff44], angle: { min: 0, max: 360 },
|
||||
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT) },
|
||||
}).setDepth(DEPTH.overlay - 1);
|
||||
this.time.delayedCall(2600, () => { fw.stop(); this.time.delayedCall(1300, () => fw.destroy()); });
|
||||
}
|
||||
|
||||
this.add.rectangle(cx, cy, 720, 360, 0x0a0e14, 0.95)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.overlay);
|
||||
|
||||
const headline = this.versus
|
||||
? (playerWon ? 'You win the race!' : `${this.opponent?.name ?? 'CPU'} wins!`)
|
||||
: 'Solved!';
|
||||
this.add.text(cx, cy - 110, headline, {
|
||||
fontFamily: 'Righteous', fontSize: '44px',
|
||||
color: playerWon ? '#ffd700' : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlay + 1);
|
||||
|
||||
const ladderLine = this.gs.rungs.join(' → ');
|
||||
this.add.text(cx, cy - 40, ladderLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
align: 'center', wordWrap: { width: 660 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlay + 1);
|
||||
|
||||
const parStr = this.gs.par ? ` · Par: ${this.gs.par}` : '';
|
||||
this.add.text(cx, cy + 30, `Your steps: ${stepsTaken(this.gs)}${parStr}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlay + 1);
|
||||
|
||||
const btnY = cy + 120;
|
||||
new Button(this, cx - 130, btnY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, skipIntro: true });
|
||||
}, { width: 230, height: 52, fontSize: 22 }).setDepth(DEPTH.overlay + 1);
|
||||
|
||||
new Button(this, cx + 130, btnY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(DEPTH.overlay + 1);
|
||||
}
|
||||
|
||||
// ── Animations ───────────────────────────────────────────────────────────────
|
||||
|
||||
popRow(screenRow, tiles = this.playerTiles) {
|
||||
if (screenRow < 0 || screenRow >= N_ROWS) return;
|
||||
const row = tiles[screenRow];
|
||||
row.forEach((tile, i) => {
|
||||
this.time.delayedCall(i * 40, () => {
|
||||
this.tweens.add({ targets: tile.container, scale: 1.12, duration: 90, yoyo: true, ease: 'Sine.easeOut' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bounceTile(tile) {
|
||||
this.tweens.add({ targets: tile.container, scaleY: 1.08, duration: 80, yoyo: true, ease: 'Sine.easeOut' });
|
||||
}
|
||||
|
||||
shakeRow(rowTiles) {
|
||||
if (!rowTiles) return;
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
const containers = rowTiles.map(t => t.container);
|
||||
const startXs = containers.map(c => c.x);
|
||||
this.tweens.add({
|
||||
targets: containers,
|
||||
x: (target, _k, _v, index) => startXs[index] + 8,
|
||||
duration: 50, yoyo: true, repeat: 3, ease: 'Sine.easeInOut',
|
||||
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
const score = result === 'win' ? Math.max(20, 100 - Math.max(0, stepsTaken(this.gs) - (this.gs.par ?? stepsTaken(this.gs))) * 12) : 0;
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'wordladder', score, opponentScores: [0], result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.opponentPortrait?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Pure Word Ladder logic — no Phaser dependency.
|
||||
//
|
||||
// A ladder transforms START into TARGET by changing exactly one letter per rung,
|
||||
// where every rung is a real word of the same length. State tracks the rungs
|
||||
// played so far (rungs[0] is always the start word).
|
||||
|
||||
export function createInitialState({ start, target, par, length }) {
|
||||
const S = String(start).toUpperCase();
|
||||
return {
|
||||
start: S,
|
||||
target: String(target).toUpperCase(),
|
||||
par: par ?? null,
|
||||
length: length ?? S.length,
|
||||
rungs: [S],
|
||||
status: 'playing', // 'playing' | 'won'
|
||||
};
|
||||
}
|
||||
|
||||
export function lastRung(state) {
|
||||
return state.rungs[state.rungs.length - 1];
|
||||
}
|
||||
|
||||
// True when a and b are the same length and differ in exactly one position.
|
||||
export function isOneLetterDifferent(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) diff++;
|
||||
if (diff > 1) return false;
|
||||
}
|
||||
return diff === 1;
|
||||
}
|
||||
|
||||
// Index of the single differing position between two equal-length words, or -1.
|
||||
export function changedIndex(prev, word) {
|
||||
if (!prev || prev.length !== word.length) return -1;
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
if (prev[i] !== word[i]) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Attempt to append `word` as the next rung. Returns { ok, state } on success or
|
||||
// { ok: false, reason } with a short message suitable for display.
|
||||
export function tryAddRung(state, word, validSet) {
|
||||
if (state.status !== 'playing') return { ok: false, reason: 'Round over' };
|
||||
const W = String(word).toUpperCase();
|
||||
|
||||
if (W.length !== state.length) return { ok: false, reason: 'Not enough letters' };
|
||||
if (!isOneLetterDifferent(lastRung(state), W)) return { ok: false, reason: 'Change exactly one letter' };
|
||||
if (!validSet.has(W)) return { ok: false, reason: 'Not in word list' };
|
||||
if (state.rungs.includes(W)) return { ok: false, reason: 'Word already used' };
|
||||
|
||||
const rungs = [...state.rungs, W];
|
||||
const status = W === state.target ? 'won' : 'playing';
|
||||
return { ok: true, state: { ...state, rungs, status } };
|
||||
}
|
||||
|
||||
// Remove the most recent rung (never the start word).
|
||||
export function undoRung(state) {
|
||||
if (state.rungs.length <= 1) return state;
|
||||
return { ...state, rungs: state.rungs.slice(0, -1), status: 'playing' };
|
||||
}
|
||||
|
||||
export function isSolved(state) {
|
||||
return lastRung(state) === state.target;
|
||||
}
|
||||
|
||||
// Steps taken so far (rungs beyond the start word).
|
||||
export function stepsTaken(state) {
|
||||
return state.rungs.length - 1;
|
||||
}
|
||||
|
|
@ -69,7 +69,6 @@ export default class WordleGame extends Phaser.Scene {
|
|||
this.playfield = data.playfield ?? null;
|
||||
this.playerWins = data.playerWins ?? 0; // series score
|
||||
this.aiWins = data.aiWins ?? 0;
|
||||
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
|
||||
|
||||
// round-level state
|
||||
this.gs = null;
|
||||
|
|
@ -160,7 +159,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5, 0).setDepth(depth + 1);
|
||||
|
||||
const opx = GAME_WIDTH - 120;
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth, { playIntro: !this.skipIntro });
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth);
|
||||
this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 1);
|
||||
|
|
@ -338,9 +337,12 @@ export default class WordleGame extends Phaser.Scene {
|
|||
this.updateKeyboardColors(this.gs);
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
const { over } = isGameOver(this.gs);
|
||||
const { over, won } = isGameOver(this.gs);
|
||||
if (over) {
|
||||
this.playerDone = true;
|
||||
if (!this.aiDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'upset' : 'happy');
|
||||
}
|
||||
this.animating = false;
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
|
|
@ -375,9 +377,12 @@ export default class WordleGame extends Phaser.Scene {
|
|||
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
const { over } = isGameOver(this.aiGs);
|
||||
const { over, won } = isGameOver(this.aiGs);
|
||||
if (over) {
|
||||
this.aiDone = true;
|
||||
if (!this.playerDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'happy' : 'upset');
|
||||
}
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
this.scheduleAITurn();
|
||||
|
|
@ -493,9 +498,6 @@ export default class WordleGame extends Phaser.Scene {
|
|||
isDraw = true;
|
||||
}
|
||||
|
||||
// Opponent reacts to the finished round: happy if the AI won, upset if it lost.
|
||||
if (!isDraw) this.opponentPortrait?.playEmotion(roundWon ? 'upset' : 'happy');
|
||||
|
||||
// Update series scores (draws give no point)
|
||||
if (!isDraw) {
|
||||
if (roundWon) this.playerWins++;
|
||||
|
|
@ -557,7 +559,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
startNextRound() {
|
||||
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins, skipIntro: true });
|
||||
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins });
|
||||
}
|
||||
|
||||
// ── Fireworks victory screen ───────────────────────────────────────────────
|
||||
|
|
@ -619,7 +621,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
if (!playerWon) plrPortrait.fadeToEliminated(900);
|
||||
|
||||
// Opponent portrait (right)
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2, { playIntro: false });
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2);
|
||||
this.add.text(oppX, portY + oppR + 12, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(VD + 2);
|
||||
|
|
@ -634,7 +636,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
// ── Buttons ────────────────────────────────────────────────────────────
|
||||
const btnsY = bot - 64;
|
||||
new Button(this, cx - 130, btnsY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0, skipIntro: false });
|
||||
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0 });
|
||||
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
|
||||
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ import CheckersGame from './games/checkers/CheckersGame.js';
|
|||
import ChessGame from './games/chess/ChessGame.js';
|
||||
import WordleGame from './games/wordle/WordleGame.js';
|
||||
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
|
||||
import GhostGame from './games/ghost/GhostGame.js';
|
||||
import WordLadderGame from './games/wordladder/WordLadderGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -81,8 +79,6 @@ const config = {
|
|||
ChessGame,
|
||||
WordleGame,
|
||||
ScrabbleGame,
|
||||
GhostGame,
|
||||
WordLadderGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
this.cardBack = data.cardBack ?? null;
|
||||
this.tilePlacement = data.tilePlacement ?? 'standard';
|
||||
this.deckMode = data.deckMode ?? 'standard';
|
||||
this.wordLength = data.wordLength ?? 4;
|
||||
}
|
||||
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
@ -27,7 +26,6 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
cardBack: this.cardBack,
|
||||
tilePlacement: this.tilePlacement,
|
||||
deckMode: this.deckMode,
|
||||
wordLength: this.wordLength,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.selectedTilePlacement = 'standard';
|
||||
this.selectedMatchVariant = 4;
|
||||
this.selectedDeckMode = 'standard';
|
||||
this.selectedWordLength = 4;
|
||||
this._initializing = false;
|
||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||
}
|
||||
|
|
@ -122,8 +121,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
if (this.gameDef.slug === 'dominion') this.buildDeckModeSection(340, 1013);
|
||||
|
||||
if (this.gameDef.slug === 'wordladder') this.buildWordLengthSection(340, 1013);
|
||||
|
||||
if (!isWordGame) {
|
||||
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
|
||||
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
|
||||
|
|
@ -369,7 +366,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
// Skill control: pips always show the level; the +/- buttons appear only
|
||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder'].includes(this.gameDef.slug)) {
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
@ -642,52 +639,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Word Ladder: word-length toggle ───────────────────────────────────────
|
||||
buildWordLengthSection(centerX, centerY) {
|
||||
const options = [
|
||||
{ id: 3, label: '3-Letter' },
|
||||
{ id: 4, label: '4-Letter' },
|
||||
];
|
||||
const pillW = 150, pillH = 40, pillGap = 12;
|
||||
const totalW = options.length * pillW + (options.length - 1) * pillGap;
|
||||
const labelY = centerY - 28;
|
||||
const pillY = centerY + 10;
|
||||
|
||||
const labelText = this.add.text(centerX, labelY, 'Word Length', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '20px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
|
||||
this.children.moveBelow(labelBg, labelText);
|
||||
|
||||
this._wordLengthBtns = [];
|
||||
options.forEach((opt, i) => {
|
||||
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
|
||||
const isSelected = this.selectedWordLength === opt.id;
|
||||
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
|
||||
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
|
||||
this.children.moveBelow(pillBg, bg);
|
||||
this.add.text(x, pillY, opt.label, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '16px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const refresh = () => {
|
||||
this._wordLengthBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedWordLength ? COLORS.accent : COLORS.muted)
|
||||
);
|
||||
};
|
||||
bg.on('pointerup', () => { this.selectedWordLength = opt.id; refresh(); });
|
||||
bg.on('pointerover', () => { if (this.selectedWordLength !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
|
||||
bg.on('pointerout', () => { if (this.selectedWordLength !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
|
||||
this._wordLengthBtns.push({ bg, id: opt.id });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generic option section builder ─────────────────────────────────────────
|
||||
|
||||
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
|
||||
|
|
@ -796,7 +747,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
// ── Start game ─────────────────────────────────────────────────────────────
|
||||
|
||||
startGame() {
|
||||
if (this.selected.size < (this.gameDef.minOpponents ?? 1)) return;
|
||||
if (this.selected.size === 0) return;
|
||||
this._startingGame = true;
|
||||
stopMenuMusic();
|
||||
const opponents = this.cards
|
||||
|
|
@ -810,7 +761,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
tilePlacement: this.selectedTilePlacement,
|
||||
matchVariant: this.selectedMatchVariant,
|
||||
deckMode: this.selectedDeckMode,
|
||||
wordLength: this.selectedWordLength,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function drawBacking(scene, x, y, radius, depth) {
|
|||
|
||||
// ── Opponent portrait (video + sprite fallback) ───────────────────────────────
|
||||
// Returns { playEmotion(emotion), destroy() }
|
||||
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth, { playIntro = true } = {}) {
|
||||
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) {
|
||||
injectEmotionStyles();
|
||||
const size = radius * 2;
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
|
|||
|
||||
scene.events.once('shutdown', destroy);
|
||||
|
||||
if (playIntro && opponent?.speech?.intro?.length) {
|
||||
if (opponent?.speech?.intro?.length) {
|
||||
const clips = opponent.speech.intro;
|
||||
enqueueSpeech(clips[Math.floor(Math.random() * clips.length)], {
|
||||
onStart: () => startVisualizer('intro'),
|
||||
|
|
|
|||
|
|
@ -46,5 +46,3 @@ registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlay
|
|||
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
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: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 });
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
// Server-side Ghost dictionary + perfect-play AI.
|
||||
//
|
||||
// Ghost: players alternate appending one letter to a shared fragment. The player
|
||||
// who appends a letter LOSES the round if that letter completes a valid word
|
||||
// (length >= MIN_LEN) or makes the fragment no longer a prefix of any valid word.
|
||||
//
|
||||
// We build a trie of every ENABLE word of length >= MIN_LEN and precompute, once,
|
||||
// a game-theoretic value per node: `node.loss` is true when the player whose turn
|
||||
// it is to move FROM that fragment loses under optimal play. With that table every
|
||||
// AI move is O(26): walk to the fragment's node and read its children's values.
|
||||
|
||||
const MIN_LEN = 4;
|
||||
|
||||
let TRIE = null;
|
||||
|
||||
export function initGhostDictionary(words) {
|
||||
TRIE = { children: Object.create(null), terminal: false, loss: false };
|
||||
for (const w of words) {
|
||||
if (w.length < MIN_LEN) continue;
|
||||
let node = TRIE;
|
||||
for (const ch of w) {
|
||||
node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false });
|
||||
}
|
||||
node.terminal = true;
|
||||
}
|
||||
computeLoss(TRIE);
|
||||
}
|
||||
|
||||
export function dictionaryReady() {
|
||||
return TRIE !== null;
|
||||
}
|
||||
|
||||
// Post-order: a node is a LOSS for the player to move iff it has no "winning" move.
|
||||
// A winning move is appending a letter L whose child is NOT a word (a safe
|
||||
// continuation) AND hands the opponent a losing position. Letters whose child is
|
||||
// terminal complete a word and so lose for the mover — never winning moves.
|
||||
function computeLoss(node) {
|
||||
let win = false;
|
||||
for (const L in node.children) {
|
||||
const child = node.children[L];
|
||||
computeLoss(child);
|
||||
if (!child.terminal && child.loss) win = true;
|
||||
}
|
||||
node.loss = !win;
|
||||
}
|
||||
|
||||
function nodeFor(fragment) {
|
||||
let node = TRIE;
|
||||
for (const ch of fragment) {
|
||||
node = node.children[ch];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Public: judge a completed fragment (used to grade the human's letter) ──────
|
||||
|
||||
// Returns { isWord, isPrefix }:
|
||||
// isWord — fragment is a complete valid word (length >= MIN_LEN) -> mover loses
|
||||
// isPrefix — fragment is a prefix of some valid word; if false the mover went
|
||||
// "off-dictionary" and loses.
|
||||
export function judge(fragment) {
|
||||
const F = String(fragment).toUpperCase();
|
||||
const node = nodeFor(F);
|
||||
if (!node) return { isWord: false, isPrefix: false };
|
||||
return { isWord: !!node.terminal, isPrefix: true };
|
||||
}
|
||||
|
||||
// ── Public: choose the AI's letter ─────────────────────────────────────────────
|
||||
|
||||
// Skill profiles. `blunder` = chance of a careless pick from ALL legal letters
|
||||
// (which may complete a word and lose). `foresight` = chance, on a careful turn,
|
||||
// of choosing a move that provably hands the opponent a losing position.
|
||||
const SKILL = {
|
||||
5: { blunder: 0.00, foresight: 1.0 },
|
||||
4: { blunder: 0.05, foresight: 1.0 },
|
||||
3: { blunder: 0.18, foresight: 0.5 },
|
||||
2: { blunder: 0.40, foresight: 0.0 },
|
||||
1: { blunder: 0.65, foresight: 0.0 },
|
||||
};
|
||||
|
||||
// Returns { letter, isWord, isPrefix } for the resulting fragment, mirroring judge().
|
||||
// letter is null only when the AI is at a dead end (should already have ended).
|
||||
export function chooseLetter(fragment, skill) {
|
||||
const F = String(fragment).toUpperCase();
|
||||
const node = nodeFor(F);
|
||||
if (!node) return { letter: null, isWord: false, isPrefix: false };
|
||||
|
||||
const legal = Object.keys(node.children); // every child is a valid prefix
|
||||
if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false };
|
||||
|
||||
const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
|
||||
const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word
|
||||
|
||||
let letter;
|
||||
if (safe.length === 0) {
|
||||
// Forced: every legal letter completes a word -> the AI self-loses this turn.
|
||||
letter = randomFrom(legal);
|
||||
} else if (Math.random() < profile.blunder) {
|
||||
// Careless: pick any legal letter, which may accidentally complete a word.
|
||||
letter = randomFrom(legal);
|
||||
} else {
|
||||
// Careful: never complete a word; use foresight to hand off a losing position.
|
||||
const winning = safe.filter(L => node.children[L].loss);
|
||||
letter = (winning.length && Math.random() < profile.foresight)
|
||||
? randomFrom(winning)
|
||||
: randomFrom(safe);
|
||||
}
|
||||
|
||||
const child = node.children[letter];
|
||||
return { letter, isWord: !!child.terminal, isPrefix: true };
|
||||
}
|
||||
|
||||
// ── Public: example safe words from a faced prefix ─────────────────────────────
|
||||
|
||||
// Given the fragment a player FACED (before their losing letter), return up to
|
||||
// `max` complete words they could have headed toward via a safe move — i.e. a
|
||||
// letter that does not itself complete a word. Returns [] when no safe move
|
||||
// existed (the player was genuinely cornered).
|
||||
export function suggestWords(prefix, max = 3) {
|
||||
const P = String(prefix).toUpperCase();
|
||||
const node = nodeFor(P);
|
||||
if (!node) return [];
|
||||
|
||||
const safe = Object.keys(node.children).filter(L => !node.children[L].terminal);
|
||||
shuffleInPlace(safe);
|
||||
|
||||
const words = [];
|
||||
for (const L of safe) {
|
||||
const w = shortestWord(node.children[L], P + L);
|
||||
if (w && !words.includes(w)) words.push(w);
|
||||
if (words.length >= max) break;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
// Shortest complete word at or below `node` (BFS), prefixed by `prefix`.
|
||||
function shortestWord(node, prefix) {
|
||||
const queue = [[node, prefix]];
|
||||
let i = 0;
|
||||
while (i < queue.length) {
|
||||
const [n, p] = queue[i++];
|
||||
if (n.terminal) return p;
|
||||
for (const L in n.children) queue.push([n.children[L], p + L]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shuffleInPlace(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;
|
||||
}
|
||||
|
||||
function randomFrom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
// Server-side Word Ladder dictionary + puzzle generator + skill-based AI.
|
||||
//
|
||||
// Word Ladder: transform a START word into a TARGET word by changing exactly one
|
||||
// letter at a time, where every intermediate rung is a real word of the same
|
||||
// length (COLD → CORD → CARD → WARD → WARM).
|
||||
//
|
||||
// The word graph (one node per word, an edge between words differing in exactly
|
||||
// one position) is implicit: neighbours are generated on demand by trying all 25
|
||||
// substitutions at each position and testing membership in a Set. The graphs are
|
||||
// small (a few thousand 3/4-letter words), so BFS over them is cheap.
|
||||
|
||||
const LENGTHS = [3, 4];
|
||||
const A = 'A'.charCodeAt(0);
|
||||
|
||||
// Optimal-path length ranges that make a satisfying puzzle, per word length.
|
||||
const PAR_RANGE = {
|
||||
3: [3, 5],
|
||||
4: [4, 6],
|
||||
};
|
||||
|
||||
// Skill profiles for the AI opponent. `wander` = chance of stepping onto a
|
||||
// non-progress neighbour (a detour it must later recover from). `delay` =
|
||||
// "thinking" time [lo, hi] ms before each rung (pacing, not intelligence).
|
||||
const SKILL = {
|
||||
1: { wander: 0.70, delay: [9000, 15000] },
|
||||
2: { wander: 0.55, delay: [7000, 11000] },
|
||||
3: { wander: 0.42, delay: [5000, 8000] },
|
||||
4: { wander: 0.28, delay: [3500, 6000] },
|
||||
5: { wander: 0.15, delay: [2500, 4500] },
|
||||
};
|
||||
|
||||
const wordSets = {}; // length → Set<string>
|
||||
const wordArrays = {}; // length → string[]
|
||||
|
||||
export function initWordLadderDictionary(words3, words4) {
|
||||
setFor(3, words3);
|
||||
setFor(4, words4);
|
||||
}
|
||||
|
||||
function setFor(length, words) {
|
||||
const arr = (words ?? []).map(w => String(w).toUpperCase()).filter(w => w.length === length);
|
||||
wordSets[length] = new Set(arr);
|
||||
wordArrays[length] = [...wordSets[length]];
|
||||
}
|
||||
|
||||
export function dictionaryReady() {
|
||||
return LENGTHS.every(L => wordArrays[L]?.length > 0);
|
||||
}
|
||||
|
||||
// ── Graph ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Valid words differing from `word` in exactly one position.
|
||||
export function neighbors(word) {
|
||||
const W = String(word).toUpperCase();
|
||||
const set = wordSets[W.length];
|
||||
if (!set) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < W.length; i++) {
|
||||
const before = W.slice(0, i);
|
||||
const after = W.slice(i + 1);
|
||||
const orig = W[i];
|
||||
for (let c = 0; c < 26; c++) {
|
||||
const ch = String.fromCharCode(A + c);
|
||||
if (ch === orig) continue;
|
||||
const cand = before + ch + after;
|
||||
if (set.has(cand)) out.push(cand);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// BFS from `start`; returns Map<word, distance> over the connected component.
|
||||
// `maxDepth` (optional) bounds the search for puzzle generation.
|
||||
function bfsDistances(start, maxDepth = Infinity) {
|
||||
const dist = new Map([[start, 0]]);
|
||||
const queue = [start];
|
||||
let i = 0;
|
||||
while (i < queue.length) {
|
||||
const w = queue[i++];
|
||||
const d = dist.get(w);
|
||||
if (d >= maxDepth) continue;
|
||||
for (const n of neighbors(w)) {
|
||||
if (!dist.has(n)) {
|
||||
dist.set(n, d + 1);
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
// Shortest path between two words (inclusive of both ends), or null.
|
||||
export function shortestPath(from, to) {
|
||||
const start = String(from).toUpperCase();
|
||||
const goal = String(to).toUpperCase();
|
||||
if (start === goal) return [start];
|
||||
if (start.length !== goal.length) return null;
|
||||
|
||||
const prev = new Map([[start, null]]);
|
||||
const queue = [start];
|
||||
let i = 0;
|
||||
while (i < queue.length) {
|
||||
const w = queue[i++];
|
||||
for (const n of neighbors(w)) {
|
||||
if (prev.has(n)) continue;
|
||||
prev.set(n, w);
|
||||
if (n === goal) {
|
||||
const path = [];
|
||||
for (let cur = goal; cur !== null; cur = prev.get(cur)) path.unshift(cur);
|
||||
return path;
|
||||
}
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Puzzle generation ────────────────────────────────────────────────────────
|
||||
|
||||
// Build a puzzle whose optimal solution is exactly `par` steps. Retries with
|
||||
// fresh random starts; relaxes to the deepest reachable target if `par` proves
|
||||
// hard to hit for the chosen start.
|
||||
function generatePuzzleWithPar(length, par, attempts = 300) {
|
||||
const pool = wordArrays[length] ?? [];
|
||||
if (pool.length === 0) return null;
|
||||
|
||||
for (let a = 0; a < attempts; a++) {
|
||||
const start = randomFrom(pool);
|
||||
const dist = bfsDistances(start, par);
|
||||
const exact = [];
|
||||
let deepest = null, deepestD = 0;
|
||||
for (const [word, d] of dist) {
|
||||
if (word === start) continue;
|
||||
if (d === par) exact.push(word);
|
||||
if (d > deepestD) { deepest = word; deepestD = d; }
|
||||
}
|
||||
if (exact.length > 0) {
|
||||
const target = randomFrom(exact);
|
||||
return { start, target, par };
|
||||
}
|
||||
// Last few attempts: accept the deepest target we can reach (>= 2 steps).
|
||||
if (a >= attempts - 5 && deepest && deepestD >= 2) {
|
||||
return { start, target: deepest, par: deepestD };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickPar(length) {
|
||||
const [lo, hi] = PAR_RANGE[length] ?? [3, 5];
|
||||
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
||||
}
|
||||
|
||||
// Single puzzle (solo mode).
|
||||
export function generatePuzzle(length) {
|
||||
const L = LENGTHS.includes(length) ? length : 4;
|
||||
return generatePuzzleWithPar(L, pickPar(L)) ?? fallbackPuzzle(L);
|
||||
}
|
||||
|
||||
// Two distinct puzzles of equal par (versus mode — fair race).
|
||||
export function generateVersusPuzzles(length) {
|
||||
const L = LENGTHS.includes(length) ? length : 4;
|
||||
const par = pickPar(L);
|
||||
const player = generatePuzzleWithPar(L, par) ?? fallbackPuzzle(L);
|
||||
let opponent = null;
|
||||
for (let a = 0; a < 20; a++) {
|
||||
const cand = generatePuzzleWithPar(L, player.par);
|
||||
if (cand && cand.start !== player.start && cand.target !== player.target) {
|
||||
opponent = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { player, opponent: opponent ?? generatePuzzleWithPar(L, player.par) ?? fallbackPuzzle(L) };
|
||||
}
|
||||
|
||||
// Degenerate safety net: a start word and one neighbour (par 1). Only reached if
|
||||
// the dictionary is empty/broken.
|
||||
function fallbackPuzzle(length) {
|
||||
const pool = wordArrays[length] ?? [];
|
||||
const start = pool[0] ?? 'CATS'.slice(0, length);
|
||||
const ns = neighbors(start);
|
||||
return { start, target: ns[0] ?? start, par: ns.length ? 1 : 0 };
|
||||
}
|
||||
|
||||
// ── AI opponent ────────────────────────────────────────────────────────────────
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
|
||||
}
|
||||
|
||||
// Advance the AI one rung from `current` toward `target`. Skill sets both pace
|
||||
// (delayMs) and accuracy (chance of a detour that lengthens its path).
|
||||
export function chooseAIMove(current, target, skill) {
|
||||
const cur = String(current).toUpperCase();
|
||||
const goal = String(target).toUpperCase();
|
||||
if (cur === goal) return { word: cur, delayMs: 0, done: true };
|
||||
|
||||
const profile = profileFor(skill);
|
||||
const ns = neighbors(cur);
|
||||
if (ns.length === 0) return { word: cur, delayMs: 0, done: false };
|
||||
|
||||
// Distances to the goal, so we know which neighbours make progress.
|
||||
const dist = bfsDistances(goal);
|
||||
const d = dist.get(cur);
|
||||
const progress = ns.filter(n => dist.get(n) === d - 1);
|
||||
const detour = ns.filter(n => dist.get(n) !== d - 1);
|
||||
|
||||
let word;
|
||||
if (detour.length > 0 && Math.random() < profile.wander) {
|
||||
word = randomFrom(detour);
|
||||
} else if (progress.length > 0) {
|
||||
word = randomFrom(progress);
|
||||
} else {
|
||||
word = randomFrom(ns);
|
||||
}
|
||||
|
||||
const [lo, hi] = profile.delay;
|
||||
const delayMs = Math.round(lo + Math.random() * (hi - lo));
|
||||
return { word, delayMs, done: word === goal };
|
||||
}
|
||||
|
||||
// ── Hint ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The next word on a shortest path from `current` to `target`.
|
||||
export function hintMove(current, target) {
|
||||
const path = shortestPath(current, target);
|
||||
return path && path.length > 1 ? path[1] : null;
|
||||
}
|
||||
|
||||
// ── Misc ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function validWordsOfLength(length) {
|
||||
return [...(wordArrays[length] ?? [])];
|
||||
}
|
||||
|
||||
function randomFrom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
|
@ -3,15 +3,6 @@ import fs from 'node:fs';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
|
||||
import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js';
|
||||
import {
|
||||
initWordLadderDictionary,
|
||||
generatePuzzle as ladderGeneratePuzzle,
|
||||
generateVersusPuzzles as ladderGenerateVersus,
|
||||
chooseAIMove as ladderChooseAIMove,
|
||||
hintMove as ladderHintMove,
|
||||
validWordsOfLength as ladderValidWords,
|
||||
} from './wordLadderEngine.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
|
@ -128,17 +119,6 @@ function loadWordLists() {
|
|||
initScrabbleDictionary(scrabbleWords);
|
||||
console.log(`[words] loaded ${scrabbleWords.size} Scrabble words (2–15 letters)`);
|
||||
|
||||
// Ghost dictionary: every ENABLE word of length >= 4 (shorter words don't count).
|
||||
const ghostWords = allWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w));
|
||||
initGhostDictionary(ghostWords);
|
||||
console.log(`[words] loaded ${ghostWords.length} Ghost words (4+ letters)`);
|
||||
|
||||
// Word Ladder dictionaries: 3- and 4-letter words for the two game variants.
|
||||
const ladderThree = allWords.filter(w => /^[A-Z]{3}$/.test(w));
|
||||
const ladderFour = allWords.filter(w => /^[A-Z]{4}$/.test(w));
|
||||
initWordLadderDictionary(ladderThree, ladderFour);
|
||||
console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`);
|
||||
|
||||
// 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));
|
||||
|
|
@ -199,63 +179,4 @@ router.post('/scrabble/ai-move', (req, res) => {
|
|||
res.json(move);
|
||||
});
|
||||
|
||||
// ── Ghost ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/ghost/judge { fragment: string }
|
||||
// Grades the fragment a player just formed: a completed word, or off-dictionary,
|
||||
// both lose the round for whoever made the move.
|
||||
router.post('/ghost/judge', (req, res) => {
|
||||
const fragment = String(req.body?.fragment ?? '');
|
||||
res.json(ghostJudge(fragment));
|
||||
});
|
||||
|
||||
// POST /api/words/ghost/ai-move { fragment: string, skill: number }
|
||||
// Returns the AI's letter and how the resulting fragment grades.
|
||||
router.post('/ghost/ai-move', (req, res) => {
|
||||
const fragment = String(req.body?.fragment ?? '');
|
||||
const skill = Number(req.body?.skill) || 3;
|
||||
res.json(ghostChooseLetter(fragment, skill));
|
||||
});
|
||||
|
||||
// POST /api/words/ghost/suggest { prefix: string, max?: number }
|
||||
// Example safe words the loser could have headed toward from the faced prefix.
|
||||
router.post('/ghost/suggest', (req, res) => {
|
||||
const prefix = String(req.body?.prefix ?? '');
|
||||
const max = Number(req.body?.max) || 3;
|
||||
res.json({ words: ghostSuggestWords(prefix, max) });
|
||||
});
|
||||
|
||||
// ── Word Ladder ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/words/wordladder/start?length=3|4&versus=true|false
|
||||
// Solo: { player, opponent: null, validWords }.
|
||||
// Versus: { player, opponent, validWords } — two puzzles of equal par.
|
||||
router.get('/wordladder/start', (req, res) => {
|
||||
const length = Number(req.query.length) === 3 ? 3 : 4;
|
||||
const versus = String(req.query.versus) === 'true';
|
||||
const validWords = ladderValidWords(length);
|
||||
if (versus) {
|
||||
const { player, opponent } = ladderGenerateVersus(length);
|
||||
return res.json({ player, opponent, validWords });
|
||||
}
|
||||
res.json({ player: ladderGeneratePuzzle(length), opponent: null, validWords });
|
||||
});
|
||||
|
||||
// POST /api/words/wordladder/ai-move { current, target, skill }
|
||||
// Advances the AI opponent one rung toward its target.
|
||||
router.post('/wordladder/ai-move', (req, res) => {
|
||||
const current = String(req.body?.current ?? '');
|
||||
const target = String(req.body?.target ?? '');
|
||||
const skill = Number(req.body?.skill) || 3;
|
||||
res.json(ladderChooseAIMove(current, target, skill));
|
||||
});
|
||||
|
||||
// POST /api/words/wordladder/hint { current, target }
|
||||
// One valid next step on a shortest path toward the target.
|
||||
router.post('/wordladder/hint', (req, res) => {
|
||||
const current = String(req.body?.current ?? '');
|
||||
const target = String(req.body?.target ?? '');
|
||||
res.json({ word: ladderHintMove(current, target) });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
Loading…
Reference in New Issue