fertig-classic-games/src/games/wordle/WordleGame.js

718 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, evaluateGuess, submitGuess, isGameOver, getLetterStatuses,
} from './WordleLogic.js';
import { createAIState, chooseGuess, nextThinkDelay } from './WordleAI.js';
// ── Layout constants ───────────────────────────────────────────────────────────
const TW = 74; // tile width
const TH = 74; // tile height
const TG = 8; // tile gap
const BOARD_W = 5 * TW + 4 * TG; // 402
const BOARD_H = 6 * TH + 5 * TG; // 484
const PLR_CX = 490; // player board center X
const AI_CX = 1430; // AI board center X
const BOARD_Y = 165; // board top Y
const KEY_W = 52;
const KEY_H = 62;
const KEY_WW = 82; // wide key (Enter / ⌫)
const KEY_G = 7;
const KBD_Y = BOARD_Y + BOARD_H + 24;
// Score display: in the gap between each board's inner edge and the center divider,
// vertically halfway between the board top and the VS badge (y = BOARD_Y + BOARD_H/2).
const VS_Y = BOARD_Y + BOARD_H / 2; // 407
const SCORE_Y = Math.round((BOARD_Y + VS_Y) / 2); // 286
const PLR_SCORE_X = Math.round((PLR_CX + BOARD_W / 2 + GAME_WIDTH / 2) / 2); // 826
const AI_SCORE_X = Math.round((GAME_WIDTH / 2 + AI_CX - BOARD_W / 2) / 2); // 1095
const DEPTH = { bg: 0, board: 1, tile: 2, ui: 10 };
const VD = DEPTH.ui + 20; // victory screen depth base
// Tile colors
const C = {
empty: 0x121213,
border: 0x565758,
active: 0x818384,
filled: 0x1a1a1b,
correct: 0x538d4e,
present: 0xb59f3b,
absent: 0x3a3a3c,
key: 0x818384,
keyBg: 0x1e1e1f,
};
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','⌫'],
];
// ── Scene ──────────────────────────────────────────────────────────────────────
export default class WordleGame extends Phaser.Scene {
constructor() { super('WordleGame'); }
init(data) {
this._initData = { ...data }; // saved for round restarts
this.gameDef = data.game;
this.opponent = data.opponents?.[0] ?? null;
this.skill = this.opponent?.skill ?? 3;
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;
this.aiGs = null;
this.wordPool = null;
this.currentInput = '';
this.animating = false;
this.playerDone = false;
this.aiDone = false;
this.gameEnded = false;
this.aiTimer = null;
this.opponentPortrait = null;
}
async create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
let answer, validWords;
try {
const res = await api.get('/words/wordle/start');
answer = res.answer;
validWords = res.validWords;
} catch (err) {
console.error('[wordle] failed to fetch word:', err);
answer = 'CRANE';
validWords = ['CRANE'];
}
this.wordPool = new Set(validWords);
this.gs = createInitialState(answer);
this.aiGs = createAIState(answer, validWords);
this.buildBackground();
this.buildPortraits();
this.buildBoards();
this.buildKeyboard();
this.buildStatusText();
this.buildScoreDisplay();
this.buildControls();
this._startCountdown();
}
// ── Build ──────────────────────────────────────────────────────────────────
buildParticleTexture() {
if (this.textures.exists('wordleParticle')) return;
const g = this.make.graphics({ add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('wordleParticle', 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);
// Divider line
const g = this.add.graphics().setDepth(DEPTH.bg);
g.lineStyle(1, 0x2a2a2c, 1);
g.lineBetween(cx, 0, cx, GAME_HEIGHT);
// Section labels
const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex };
this.add.text(PLR_CX, BOARD_Y - 32, 'YOU', labelStyle).setOrigin(0.5, 1).setDepth(DEPTH.ui);
this.add.text(AI_CX, BOARD_Y - 32, this.opponent?.name?.toUpperCase() ?? 'CPU', labelStyle)
.setOrigin(0.5, 1).setDepth(DEPTH.ui);
// "VS" badge
this.add.text(cx, VS_Y, 'VS', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildPortraits() {
const r = 52;
const depth = DEPTH.ui;
const py = BOARD_Y + BOARD_H / 2; // vertically centered with the playfield
const ppx = 120;
createPlayerPortrait(this, ppx, py, r, depth, 'WordleGame');
this.add.text(ppx, py + r + 10, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).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.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);
this.add.text(GAME_WIDTH / 2, 55, 'WORDLE', {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildScoreDisplay() {
this.playerScoreText = this.add.text(PLR_SCORE_X, SCORE_Y, String(this.playerWins), {
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.aiScoreText = this.add.text(AI_SCORE_X, SCORE_Y, String(this.aiWins), {
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(GAME_WIDTH / 2, SCORE_Y + 48, 'first to 3', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
updateScoreDisplay() {
this.playerScoreText?.setText(String(this.playerWins));
this.aiScoreText?.setText(String(this.aiWins));
}
buildBoards() {
this.playerTiles = this.createTileGrid(PLR_CX - BOARD_W / 2, BOARD_Y);
this.aiTiles = this.createTileGrid(AI_CX - BOARD_W / 2, BOARD_Y);
}
createTileGrid(startX, startY) {
const grid = [];
for (let r = 0; r < 6; r++) {
const row = [];
for (let c = 0; c < 5; c++) {
const x = startX + c * (TW + TG) + TW / 2;
const y = startY + r * (TH + TG) + TH / 2;
const container = this.add.container(x, y).setDepth(DEPTH.tile);
const bg = this.add.rectangle(0, 0, TW, TH, C.empty)
.setStrokeStyle(2, C.border);
const label = this.add.text(0, 0, '', {
fontFamily: 'Righteous',
fontSize: '36px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, label]);
row.push({ container, bg, label });
}
grid.push(row);
}
return grid;
}
buildKeyboard() {
this.keyObjs = {};
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 = PLR_CX - rowW / 2;
keys.forEach(key => {
const kw = isWide(key) ? KEY_WW : KEY_W;
const cx = x + kw / 2;
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
x += kw + KEY_G;
const container = this.add.container(cx, cy).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 ? '16px' : '22px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, lbl]);
this.keyObjs[key] = { container, bg, lbl };
bg.on('pointerdown', () => {
if (key === 'ENTER') this.submitGuess();
else if (key === '⌫') this.removeLetter();
else this.addLetter(key);
});
bg.on('pointerover', () => {
if (!this.keyObjs[key]._locked) bg.setFillStyle(0x9a9b9c);
});
bg.on('pointerout', () => {
if (!this.keyObjs[key]._locked) bg.setFillStyle(this.keyObjs[key]._color ?? C.key);
});
});
});
}
buildStatusText() {
this.statusText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 36, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.aiThinkText = this.add.text(AI_CX, KBD_Y + 31, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
}).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);
}
// ── Countdown ─────────────────────────────────────────────────────────────
_startCountdown() {
const steps = ['3', '2', '1', 'GO!'];
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const OVERLAY_D = DEPTH.ui + 5;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.50)
.setDepth(OVERLAY_D);
const label = this.add.text(cx, cy, '', {
fontFamily: 'Righteous',
fontSize: '240px',
color: '#f7c948',
stroke: '#b08800',
strokeThickness: 8,
}).setOrigin(0.5).setDepth(OVERLAY_D + 1).setAlpha(0);
const runStep = (i) => {
if (i >= steps.length) {
dim.destroy();
label.destroy();
this.setupInput();
this.scheduleAITurn();
return;
}
label.setText(steps[i]).setScale(1.6).setAlpha(1);
playSound(this, steps[i] === 'GO!' ? SFX.COUNTDOWN_GO : SFX.COUNTDOWN_TICK);
this.tweens.add({
targets: label, scaleX: 1, scaleY: 1,
duration: 260, ease: 'Back.easeOut',
});
this.time.delayedCall(steps[i] === 'GO!' ? 560 : 740, () => {
this.tweens.add({
targets: label, alpha: 0, duration: 180,
onComplete: () => runStep(i + 1),
});
});
};
runStep(0);
}
// ── Input ──────────────────────────────────────────────────────────────────
setupInput() {
this.input.keyboard.on('keydown', (evt) => {
if (this.animating || this.playerDone) 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.submitGuess();
});
}
addLetter(l) {
if (this.currentInput.length >= 5 || this.playerDone || this.animating) return;
this.currentInput += l;
const row = this.gs.currentRow;
const col = this.currentInput.length - 1;
const tile = this.playerTiles[row][col];
tile.label.setText(l);
tile.bg.setFillStyle(C.filled);
tile.bg.setStrokeStyle(2, C.active);
this.bounceTile(tile);
}
removeLetter() {
if (this.currentInput.length === 0 || this.playerDone || this.animating) return;
const row = this.gs.currentRow;
const col = this.currentInput.length - 1;
const tile = this.playerTiles[row][col];
tile.label.setText('');
tile.bg.setFillStyle(C.empty);
tile.bg.setStrokeStyle(2, C.border);
this.currentInput = this.currentInput.slice(0, -1);
}
async submitGuess() {
if (this.animating || this.playerDone) return;
if (this.currentInput.length < 5) {
this.shakeRow(this.playerTiles[this.gs.currentRow]);
this.statusText.setText('Not enough letters');
this.time.delayedCall(1200, () => this.statusText.setText(''));
return;
}
if (!this.wordPool.has(this.currentInput)) {
this.shakeRow(this.playerTiles[this.gs.currentRow]);
this.statusText.setText('Not in word list');
this.time.delayedCall(1200, () => this.statusText.setText(''));
return;
}
this.animating = true;
const word = this.currentInput;
this.currentInput = '';
const row = this.gs.currentRow;
this.gs = submitGuess(this.gs, word);
await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation);
this.updateKeyboardColors(this.gs);
playSound(this, SFX.UI_ACTIVATE);
const { over } = isGameOver(this.gs);
if (over) {
this.playerDone = true;
this.animating = false;
this.checkAndHandleGameOver();
} else {
this.animating = false;
}
}
// ── AI ────────────────────────────────────────────────────────────────────
scheduleAITurn() {
if (this.aiDone) return;
const delay = nextThinkDelay(this.skill, this.aiGs.guesses.length);
this.aiThinkText.setText('...');
this.aiTimer = this.time.delayedCall(delay, () => this.doAITurn());
}
async doAITurn() {
if (this.aiDone || this.gameEnded) return;
this.aiThinkText.setText('');
const guess = chooseGuess(this.aiGs, this.skill);
const row = this.aiGs.currentRow;
const evaluation = evaluateGuess(guess, this.aiGs.target);
for (let c = 0; c < 5; c++) {
this.aiTiles[row][c].label.setText(guess[c]);
this.aiTiles[row][c].bg.setFillStyle(C.filled);
}
await this.delay(350);
this.aiGs = submitGuess(this.aiGs, guess);
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
playSound(this, SFX.PIECE_CLICK);
const { over } = isGameOver(this.aiGs);
if (over) {
this.aiDone = true;
this.checkAndHandleGameOver();
} else {
this.scheduleAITurn();
}
}
// ── Animations ────────────────────────────────────────────────────────────
animateTileFlip(rowTiles, word, evaluation) {
return new Promise(resolve => {
const STAGGER = 100;
const HALF = 200;
rowTiles.forEach((tile, i) => {
this.time.delayedCall(i * STAGGER, () => {
this.tweens.add({
targets: tile.container,
scaleY: 0,
duration: HALF,
ease: 'Linear',
onComplete: () => {
const status = evaluation[i];
const color = status === 'correct' ? C.correct
: status === 'present' ? C.present
: C.absent;
tile.bg.setFillStyle(color);
tile.bg.setStrokeStyle(0);
tile.label.setText(word[i]);
this.tweens.add({
targets: tile.container,
scaleY: 1,
duration: HALF,
ease: 'Linear',
onComplete: i === 4 ? resolve : undefined,
});
},
});
});
});
});
}
shakeRow(rowTiles) {
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, _key, _value, index) => startXs[index] + 8,
duration: 50,
yoyo: true,
repeat: 3,
ease: 'Sine.easeInOut',
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
});
}
bounceTile(tile) {
this.tweens.add({
targets: tile.container,
scaleY: 1.08,
duration: 80,
yoyo: true,
ease: 'Sine.easeOut',
});
}
// ── Keyboard color tracking ───────────────────────────────────────────────
updateKeyboardColors(state) {
const statuses = getLetterStatuses(state);
for (const [letter, status] of Object.entries(statuses)) {
const keyObj = this.keyObjs[letter];
if (!keyObj) continue;
const color = status === 'correct' ? C.correct
: status === 'present' ? C.present
: C.absent;
keyObj.bg.setFillStyle(color);
keyObj._color = color;
keyObj._locked = true;
}
}
// ── Game over → series logic ───────────────────────────────────────────────
checkAndHandleGameOver() {
if (this.gameEnded) return;
const { over: pOver, won: pWon } = isGameOver(this.gs);
const { over: aOver, won: aWon } = isGameOver(this.aiGs);
const shouldEnd = pWon || aWon || (pOver && aOver);
if (!shouldEnd) return;
this.gameEnded = true;
this.playerDone = true;
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
// Determine round outcome
let roundWon, isDraw;
if (pWon && aWon) {
const pg = this.gs.guesses.length;
const ag = this.aiGs.guesses.length;
if (pg < ag) { roundWon = true; isDraw = false; }
else if (pg > ag) { roundWon = false; isDraw = false; }
else { isDraw = true; }
} else if (pWon) {
roundWon = true; isDraw = false;
} else if (aWon) {
roundWon = false; isDraw = false;
} else {
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++;
else this.aiWins++;
this.updateScoreDisplay();
}
// Animate score bump for non-draw rounds
if (!isDraw) {
const scoreObj = roundWon ? this.playerScoreText : this.aiScoreText;
this.tweens.add({ targets: scoreObj, scaleX: 1.4, scaleY: 1.4, duration: 160, yoyo: true, ease: 'Back.easeOut' });
}
this.recordResult(isDraw ? 'draw' : roundWon ? 'win' : 'loss');
this.time.delayedCall(700, () => {
if (this.playerWins >= 3 || this.aiWins >= 3) {
const playerWon = this.playerWins >= 3;
playSound(this, playerWon ? SFX.VICTORY_SHORT : SFX.SCIFI_RISER);
this.showVictoryScreen(playerWon);
} else {
if (!isDraw) playSound(this, roundWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.showRoundResult(roundWon, isDraw);
}
});
}
// ── Round result overlay (auto-dismissing) ────────────────────────────────
showRoundResult(roundWon, isDraw) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const oppName = this.opponent?.name ?? 'CPU';
const headline = isDraw
? 'Draw — no point awarded'
: roundWon ? 'Round won!' : 'Round lost';
const seriesLine = `Series ${this.playerWins} ${this.aiWins} ${oppName}`;
const wordLine = `The word was ${this.gs.target}`;
const panel = this.add.rectangle(cx, cy, 640, 200, 0x0a0e14, 0.92)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
const t1 = this.add.text(cx, cy - 56, headline, {
fontFamily: 'Righteous', fontSize: '38px',
color: isDraw ? COLORS.mutedHex : roundWon ? '#6aff88' : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t2 = this.add.text(cx, cy + 2, wordLine, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t3 = this.add.text(cx, cy + 44, seriesLine, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
this.time.delayedCall(2400, () => {
panel.destroy(); t1.destroy(); t2.destroy(); t3.destroy();
this.startNextRound();
});
}
startNextRound() {
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins, skipIntro: true });
}
// ── Fireworks victory screen ───────────────────────────────────────────────
showVictoryScreen(playerWon) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const PW = 840;
const PH = 560;
const top = cy - PH / 2; // 260
const bot = cy + PH / 2; // 820
// ── Fireworks ──────────────────────────────────────────────────────────
const fwEmitter = this.add.particles(cx, cy, 'wordleParticle', {
speed: { min: 80, max: 480 },
lifespan: 1400,
scale: { start: 1.2, end: 0 },
alpha: { start: 1, end: 0 },
quantity: 3,
frequency: 35,
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 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(VD - 1);
this.time.delayedCall(3200, () => {
fwEmitter.stop();
this.time.delayedCall(1400, () => fwEmitter.destroy());
});
// ── Panel ──────────────────────────────────────────────────────────────
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94)
.setStrokeStyle(3, COLORS.accent).setDepth(VD);
// ── Title ──────────────────────────────────────────────────────────────
this.add.text(cx, top + 62, playerWon ? 'You Win the Series!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
fontFamily: 'Righteous', fontSize: '44px',
color: playerWon ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(VD + 1);
// ── Portraits ──────────────────────────────────────────────────────────
const WINNER_R = 88;
const LOSER_R = 62;
const portY = top + 210; // 470
const plrX = cx - 195; // 765
const oppX = cx + 195; // 1155
const plrR = playerWon ? WINNER_R : LOSER_R;
const oppR = playerWon ? LOSER_R : WINNER_R;
// Player portrait (left)
const plrPortrait = createPlayerPortrait(this, plrX, portY, plrR, VD + 2, 'WordleGame');
this.add.text(plrX, portY + plrR + 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);
// Opponent portrait (right)
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2, { playIntro: false });
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);
if (playerWon) oppPortrait.fadeToEliminated(900);
else oppPortrait.playEmotion('happy');
// ── Series score ───────────────────────────────────────────────────────
this.add.text(cx, portY + WINNER_R + 68, `${this.playerWins} ${this.aiWins}`, {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(VD + 1);
// ── Buttons ────────────────────────────────────────────────────────────
const btnsY = bot - 64;
new Button(this, cx - 130, btnsY, 'Play Again', () => {
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0, 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 recording ──────────────────────────────────────────────────────
async recordResult(result) {
try {
const guessesUsed = this.gs.guesses.length;
const score = result === 'win' ? Math.max(17, Math.round(100 - (guessesUsed - 1) * 14)) : 0;
await api.post('/history/single-player', {
slug: 'wordle',
score,
opponentScores: [0],
result,
});
} catch { /* best effort */ }
}
// ── Utility ───────────────────────────────────────────────────────────────
delay(ms) {
return new Promise(resolve => this.time.delayedCall(ms, resolve));
}
shutdown() {
this.opponentPortrait?.destroy();
}
}