718 lines
26 KiB
JavaScript
718 lines
26 KiB
JavaScript
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();
|
||
}
|
||
}
|