feat: add Connect 4 game and image support to tutorial modal
- Register Connect 4 in server registry and frontend game config - Add Connect 4 to slug dispatch and opponent select skill controls - Update TutorialModal markdown parser to render embedded images
This commit is contained in:
parent
f83b8e72e9
commit
588925e564
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Connect 4 AI — alpha-beta minimax with a window-of-four heuristic.
|
||||||
|
import {
|
||||||
|
ROWS, COLS, CONNECT, other, getValidColumns, applyMove, dropRow, findWinLine, isBoardFull,
|
||||||
|
} from './Connect4Logic.js';
|
||||||
|
|
||||||
|
const SKILL_PROFILES = {
|
||||||
|
1: { depth: 1, blunder: 0.55, noise: 60, delay: [700, 1200] },
|
||||||
|
2: { depth: 2, blunder: 0.30, noise: 35, delay: [650, 1100] },
|
||||||
|
3: { depth: 4, blunder: 0.12, noise: 15, delay: [550, 1000] },
|
||||||
|
4: { depth: 6, blunder: 0.04, noise: 5, delay: [450, 900] },
|
||||||
|
5: { depth: 7, blunder: 0.00, noise: 0, delay: [400, 850] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CENTER = Math.floor(COLS / 2);
|
||||||
|
// Try the centre column first — it maximises alpha-beta pruning.
|
||||||
|
const COL_ORDER = (() => {
|
||||||
|
const order = [];
|
||||||
|
for (let d = 0; d < COLS; d++) {
|
||||||
|
const left = CENTER - d, right = CENTER + d;
|
||||||
|
if (d === 0) order.push(CENTER);
|
||||||
|
else {
|
||||||
|
if (left >= 0) order.push(left);
|
||||||
|
if (right < COLS) order.push(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function profileFor(skill) {
|
||||||
|
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextThinkDelay(skill) {
|
||||||
|
const [lo, hi] = profileFor(skill).delay;
|
||||||
|
return lo + Math.random() * (hi - lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score a single 4-cell window from the AI's perspective.
|
||||||
|
function scoreWindow(cells, ai, human) {
|
||||||
|
let a = 0, h = 0, empty = 0;
|
||||||
|
for (const v of cells) {
|
||||||
|
if (v === ai) a++;
|
||||||
|
else if (v === human) h++;
|
||||||
|
else empty++;
|
||||||
|
}
|
||||||
|
if (a > 0 && h > 0) return 0; // mixed window — dead
|
||||||
|
if (a === 4) return 100000;
|
||||||
|
if (h === 4) return -100000;
|
||||||
|
if (a === 3 && empty === 1) return 60;
|
||||||
|
if (a === 2 && empty === 2) return 12;
|
||||||
|
if (a === 1 && empty === 3) return 1;
|
||||||
|
if (h === 3 && empty === 1) return -80; // weight threats heavier than our own
|
||||||
|
if (h === 2 && empty === 2) return -12;
|
||||||
|
if (h === 1 && empty === 3) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(board, ai) {
|
||||||
|
const human = other(ai);
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Centre-column control
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
if (board[r][CENTER] === ai) score += 6;
|
||||||
|
else if (board[r][CENTER] === human) score -= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal windows
|
||||||
|
for (let r = 0; r < ROWS; r++)
|
||||||
|
for (let c = 0; c <= COLS - CONNECT; c++)
|
||||||
|
score += scoreWindow([board[r][c], board[r][c+1], board[r][c+2], board[r][c+3]], ai, human);
|
||||||
|
|
||||||
|
// Vertical windows
|
||||||
|
for (let c = 0; c < COLS; c++)
|
||||||
|
for (let r = 0; r <= ROWS - CONNECT; r++)
|
||||||
|
score += scoreWindow([board[r][c], board[r+1][c], board[r+2][c], board[r+3][c]], ai, human);
|
||||||
|
|
||||||
|
// Diagonal ↘ windows
|
||||||
|
for (let r = 0; r <= ROWS - CONNECT; r++)
|
||||||
|
for (let c = 0; c <= COLS - CONNECT; c++)
|
||||||
|
score += scoreWindow([board[r][c], board[r+1][c+1], board[r+2][c+2], board[r+3][c+3]], ai, human);
|
||||||
|
|
||||||
|
// Diagonal ↙ windows
|
||||||
|
for (let r = 0; r <= ROWS - CONNECT; r++)
|
||||||
|
for (let c = CONNECT - 1; c < COLS; c++)
|
||||||
|
score += scoreWindow([board[r][c], board[r+1][c-1], board[r+2][c-2], board[r+3][c-3]], ai, human);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedValidColumns(state) {
|
||||||
|
const valid = new Set(getValidColumns(state));
|
||||||
|
return COL_ORDER.filter(c => valid.has(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(state, depth, alpha, beta, ai) {
|
||||||
|
if (state.phase === 'game_over') {
|
||||||
|
if (state.winner === ai) return 100000 + depth; // prefer faster wins
|
||||||
|
if (state.winner === 'draw') return 0;
|
||||||
|
return -100000 - depth; // delay losses
|
||||||
|
}
|
||||||
|
if (depth <= 0) return evaluate(state.board, ai);
|
||||||
|
|
||||||
|
const cols = orderedValidColumns(state);
|
||||||
|
if (cols.length === 0) return 0;
|
||||||
|
|
||||||
|
if (state.turn === ai) {
|
||||||
|
let value = -Infinity;
|
||||||
|
for (const c of cols) {
|
||||||
|
value = Math.max(value, search(applyMove(state, c), depth - 1, alpha, beta, ai));
|
||||||
|
alpha = Math.max(alpha, value);
|
||||||
|
if (alpha >= beta) break;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
let value = Infinity;
|
||||||
|
for (const c of cols) {
|
||||||
|
value = Math.min(value, search(applyMove(state, c), depth - 1, alpha, beta, ai));
|
||||||
|
beta = Math.min(beta, value);
|
||||||
|
if (beta <= alpha) break;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a column index for the AI to play, or null if no move is possible.
|
||||||
|
export function chooseMove(state, ai, skill) {
|
||||||
|
const prof = profileFor(skill);
|
||||||
|
const cols = orderedValidColumns(state);
|
||||||
|
if (cols.length === 0) return null;
|
||||||
|
if (cols.length === 1) return cols[0];
|
||||||
|
|
||||||
|
// Always take an immediate win, and always block an immediate loss —
|
||||||
|
// even at low skill these are too glaring to fumble (above the blunder roll
|
||||||
|
// for wins; blocks still respect the blunder chance to keep weak AI beatable).
|
||||||
|
for (const c of cols) {
|
||||||
|
const r = dropRow(state.board, c);
|
||||||
|
if (r >= 0) {
|
||||||
|
const test = state.board.map(row => [...row]);
|
||||||
|
test[r][c] = ai;
|
||||||
|
if (findWinLine(test, r, c, ai)) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.random() < prof.blunder) return cols[Math.floor(Math.random() * cols.length)];
|
||||||
|
|
||||||
|
let best = cols[0], bestScore = -Infinity;
|
||||||
|
for (const c of cols) {
|
||||||
|
let val = search(applyMove(state, c), prof.depth - 1, -Infinity, Infinity, ai);
|
||||||
|
val += (Math.random() * 2 - 1) * prof.noise;
|
||||||
|
if (val > bestScore) { bestScore = val; best = c; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,457 @@
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { auth } from '../../services/auth.js';
|
||||||
|
import { api } from '../../services/api.js';
|
||||||
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||||
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
|
import {
|
||||||
|
ROWS, COLS, other, createInitialState, getValidColumns, dropRow,
|
||||||
|
applyMove, isGameOver, getWinner,
|
||||||
|
} from './Connect4Logic.js';
|
||||||
|
import { chooseMove, nextThinkDelay } from './Connect4AI.js';
|
||||||
|
|
||||||
|
// ── Layout ─────────────────────────────────────────────────────────────────────
|
||||||
|
const CELL = 120;
|
||||||
|
const BOARD_W = CELL * COLS; // 840
|
||||||
|
const BOARD_H = CELL * ROWS; // 720
|
||||||
|
const BX = Math.round(GAME_WIDTH / 2 - BOARD_W / 2); // 540
|
||||||
|
const BY = 232; // leaves room for the falling disc above
|
||||||
|
const HOLE_R = Math.round(CELL * 0.40); // 48
|
||||||
|
const DISC_R = Math.round(CELL * 0.43); // 52 — slightly larger so holes fill cleanly
|
||||||
|
const FRAME = 26;
|
||||||
|
|
||||||
|
const DEPTH = { board: 0, piece: 10, boardFront: 14, ghost: 16, win: 18, ui: 50, banner: 60 };
|
||||||
|
|
||||||
|
// ── Colors ─────────────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
boardFill: 0x1f5fd0, // classic Connect 4 blue
|
||||||
|
boardEdge: 0x14409a,
|
||||||
|
boardHi: 0x3f86ff,
|
||||||
|
socket: 0x0a1424, // empty hole — dark, reads as "see-through"
|
||||||
|
redFill: 0xe23b3b, redRing: 0xff6b6b, redEdge: 0x9c1414,
|
||||||
|
yelFill: 0xf6c83a, yelRing: 0xffe27a, yelEdge: 0xb98908,
|
||||||
|
ghostRed: 0xe23b3b,
|
||||||
|
ghostYel: 0xf6c83a,
|
||||||
|
winGlow: 0xffffff,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Connect4Game extends Phaser.Scene {
|
||||||
|
constructor() { super('Connect4Game'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this._initData = { ...data };
|
||||||
|
this.gameDef = data.game;
|
||||||
|
this.opponents = data.opponents ?? [];
|
||||||
|
this.playfield = data.playfield ?? null;
|
||||||
|
this.gs = null;
|
||||||
|
this.animating = false;
|
||||||
|
this.discObjs = []; // discObjs[r][c] = Graphics | null
|
||||||
|
this.colZones = [];
|
||||||
|
this.ghost = null;
|
||||||
|
this.hoverCol = -1;
|
||||||
|
this.winFx = [];
|
||||||
|
this.opponentPortrait = null;
|
||||||
|
this.turnText = null;
|
||||||
|
this.scoreText = { player: null, opp: null };
|
||||||
|
this.wins = { player: 0, opp: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||||
|
this.buildTextures();
|
||||||
|
this.buildPlayfield();
|
||||||
|
this.buildBoard();
|
||||||
|
this.buildUI();
|
||||||
|
this.buildPlayerCards();
|
||||||
|
this.initGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildTextures() {
|
||||||
|
const p = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
p.fillStyle(0xffffff, 1);
|
||||||
|
p.fillCircle(6, 6, 6);
|
||||||
|
p.generateTexture('c4Particle', 12, 12);
|
||||||
|
p.destroy();
|
||||||
|
|
||||||
|
// White circle used to erase holes out of the front panel.
|
||||||
|
const h = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
h.fillStyle(0xffffff, 1);
|
||||||
|
h.fillCircle(HOLE_R, HOLE_R, HOLE_R);
|
||||||
|
h.generateTexture('c4Hole', HOLE_R * 2, HOLE_R * 2);
|
||||||
|
h.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPlayfield() {
|
||||||
|
const pf = this.playfield;
|
||||||
|
if (pf?.key && this.textures.exists(pf.key)) {
|
||||||
|
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||||
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.board - 2);
|
||||||
|
} else if (pf?.fallbackColor) {
|
||||||
|
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
|
||||||
|
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
|
||||||
|
.setDepth(DEPTH.board - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBoard() {
|
||||||
|
// Drop shadow behind the whole board
|
||||||
|
const shadow = this.add.graphics().setDepth(DEPTH.board);
|
||||||
|
shadow.fillStyle(0x000000, 0.35);
|
||||||
|
shadow.fillRoundedRect(BX - FRAME + 8, BY - FRAME + 12, BOARD_W + FRAME * 2, BOARD_H + FRAME * 2, 28);
|
||||||
|
|
||||||
|
// Dark recess so empty sockets read as deep holes (drawn under the discs)
|
||||||
|
const recess = this.add.graphics().setDepth(DEPTH.board);
|
||||||
|
recess.fillStyle(C.socket, 1);
|
||||||
|
recess.fillRoundedRect(BX, BY, BOARD_W, BOARD_H, 14);
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const { x, y } = this.cellToWorld(r, c);
|
||||||
|
recess.fillStyle(0x000000, 0.45);
|
||||||
|
recess.fillCircle(x, y, HOLE_R);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front blue panel with holes punched through, so discs show through the holes
|
||||||
|
// and empty cells reveal the dark recess behind. This is the classic look.
|
||||||
|
const rt = this.add.renderTexture(BX, BY, BOARD_W, BOARD_H)
|
||||||
|
.setOrigin(0, 0).setDepth(DEPTH.boardFront);
|
||||||
|
|
||||||
|
const panel = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
panel.fillStyle(C.boardFill, 1);
|
||||||
|
panel.fillRoundedRect(0, 0, BOARD_W, BOARD_H, 14);
|
||||||
|
// Bevel highlight + edge
|
||||||
|
panel.lineStyle(6, C.boardHi, 0.5);
|
||||||
|
panel.strokeRoundedRect(4, 4, BOARD_W - 8, BOARD_H - 8, 12);
|
||||||
|
panel.lineStyle(4, C.boardEdge, 0.9);
|
||||||
|
panel.strokeRoundedRect(2, 2, BOARD_W - 4, BOARD_H - 4, 13);
|
||||||
|
rt.draw(panel, 0, 0);
|
||||||
|
panel.destroy();
|
||||||
|
|
||||||
|
// Erase a ring of slightly darker blue around each hole, then the hole itself,
|
||||||
|
// to give each socket a beveled rim.
|
||||||
|
const rim = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const lx = c * CELL + CELL / 2;
|
||||||
|
const ly = r * CELL + CELL / 2;
|
||||||
|
rim.fillStyle(C.boardEdge, 1);
|
||||||
|
rim.fillCircle(lx, ly, HOLE_R + 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt.draw(rim, 0, 0);
|
||||||
|
rim.destroy();
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const lx = c * CELL + CELL / 2 - HOLE_R;
|
||||||
|
const ly = r * CELL + CELL / 2 - HOLE_R;
|
||||||
|
rt.erase('c4Hole', lx, ly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer frame ring on top of everything for a finished edge
|
||||||
|
const frame = this.add.graphics().setDepth(DEPTH.boardFront + 1);
|
||||||
|
frame.lineStyle(5, C.boardEdge, 1);
|
||||||
|
frame.strokeRoundedRect(BX - 2, BY - 2, BOARD_W + 4, BOARD_H + 4, 16);
|
||||||
|
|
||||||
|
// Ghost disc that hovers above the hovered column
|
||||||
|
this.ghost = this.add.graphics().setDepth(DEPTH.ghost).setVisible(false);
|
||||||
|
|
||||||
|
// Interactive column zones spanning the full board height
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const zx = BX + c * CELL + CELL / 2;
|
||||||
|
const zone = this.add.zone(zx, BY + BOARD_H / 2, CELL, BOARD_H + CELL)
|
||||||
|
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ghost + 1);
|
||||||
|
zone.on('pointerover', () => this.onColHover(c));
|
||||||
|
zone.on('pointerdown', () => this.onColClick(c));
|
||||||
|
this.colZones.push(zone);
|
||||||
|
}
|
||||||
|
this.input.on('pointerout', () => this.clearGhost());
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUI() {
|
||||||
|
const cx = BX + BOARD_W / 2;
|
||||||
|
this.turnText = this.add.text(cx, BY + BOARD_H + 36, '', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||||
|
|
||||||
|
new Button(this, BX + BOARD_W + FRAME + 92, BY + 60, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
|
||||||
|
|
||||||
|
new Button(this, BX + BOARD_W + FRAME + 92, BY + 124, 'New',
|
||||||
|
() => this.initGame(),
|
||||||
|
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPlayerCards() {
|
||||||
|
const opp = this.opponents[0];
|
||||||
|
const r = 78;
|
||||||
|
const depth = DEPTH.ui;
|
||||||
|
const avatarX = BX / 2;
|
||||||
|
|
||||||
|
// Opponent (yellow) — top card
|
||||||
|
const oppAY = BY + r + 10;
|
||||||
|
this.add.circle(avatarX, oppAY, r + 6, C.yelEdge, 0.85).setDepth(depth);
|
||||||
|
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
|
||||||
|
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '18px',
|
||||||
|
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
|
||||||
|
}).setOrigin(0.5, 0).setDepth(depth + 2);
|
||||||
|
this.scoreText.opp = this.add.text(avatarX, oppAY + r + 56, 'Wins: 0', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(depth + 2);
|
||||||
|
|
||||||
|
// Player (red) — bottom card
|
||||||
|
const plrAY = BY + BOARD_H - r - 10;
|
||||||
|
this.add.circle(avatarX, plrAY, r + 6, C.redEdge, 0.7).setDepth(depth);
|
||||||
|
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Connect 4');
|
||||||
|
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '18px',
|
||||||
|
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
|
||||||
|
}).setOrigin(0.5, 1).setDepth(depth + 2);
|
||||||
|
this.scoreText.player = this.add.text(avatarX, plrAY - r - 56, 'Wins: 0', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(depth + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
|
||||||
|
|
||||||
|
// ── Geometry ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cellToWorld(r, c) {
|
||||||
|
return { x: BX + c * CELL + CELL / 2, y: BY + r * CELL + CELL / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game flow ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
initGame() {
|
||||||
|
this.clearDiscs();
|
||||||
|
this.clearWinFx();
|
||||||
|
this.clearGhost();
|
||||||
|
this.animating = false;
|
||||||
|
this.gs = createInitialState();
|
||||||
|
this.updateScores();
|
||||||
|
this.updateTurnText();
|
||||||
|
this.showTurnBanner('Your Turn — Red');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTurnText() {
|
||||||
|
if (!this.turnText) return;
|
||||||
|
if (this.gs.phase === 'game_over') { this.turnText.setText(''); return; }
|
||||||
|
this.turnText.setText(this.gs.turn === 'r' ? 'Drop a disc' : 'Opponent thinking…');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScores() {
|
||||||
|
this.scoreText.player?.setText(`Wins: ${this.wins.player}`);
|
||||||
|
this.scoreText.opp?.setText(`Wins: ${this.wins.opp}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disc rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
clearDiscs() {
|
||||||
|
if (this.discObjs.length) {
|
||||||
|
for (const row of this.discObjs)
|
||||||
|
for (const g of row) { if (g) { this.tweens.killTweensOf(g); g.destroy(); } }
|
||||||
|
}
|
||||||
|
this.discObjs = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDisc(g, color) {
|
||||||
|
g.clear();
|
||||||
|
const [fill, ring, edge, hi] = color === 'r'
|
||||||
|
? [C.redFill, C.redRing, C.redEdge, 0xffd0d0]
|
||||||
|
: [C.yelFill, C.yelRing, C.yelEdge, 0xfff6c8];
|
||||||
|
g.fillStyle(edge, 1);
|
||||||
|
g.fillCircle(0, 0, DISC_R);
|
||||||
|
g.fillStyle(ring, 1);
|
||||||
|
g.fillCircle(0, 0, DISC_R - 3);
|
||||||
|
g.fillStyle(fill, 1);
|
||||||
|
g.fillCircle(0, 0, DISC_R - 7);
|
||||||
|
// inner recessed ring (the classic checker look)
|
||||||
|
g.lineStyle(3, edge, 0.55);
|
||||||
|
g.strokeCircle(0, 0, DISC_R - 16);
|
||||||
|
// top-left sheen
|
||||||
|
g.lineStyle(4, hi, 0.6);
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(0, 0, DISC_R - 13, Phaser.Math.DegToRad(195), Phaser.Math.DegToRad(310));
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column hover / ghost ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onColHover(c) {
|
||||||
|
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'r') { this.clearGhost(); return; }
|
||||||
|
if (dropRow(this.gs.board, c) < 0) { this.clearGhost(); return; }
|
||||||
|
this.hoverCol = c;
|
||||||
|
const x = BX + c * CELL + CELL / 2;
|
||||||
|
this.ghost.clear();
|
||||||
|
this.drawDisc(this.ghost, 'r');
|
||||||
|
this.ghost.setPosition(x, BY - CELL / 2 - 4).setAlpha(0.55).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGhost() {
|
||||||
|
this.hoverCol = -1;
|
||||||
|
if (this.ghost) this.ghost.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onColClick(c) {
|
||||||
|
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'r') return;
|
||||||
|
if (dropRow(this.gs.board, c) < 0) return;
|
||||||
|
this.clearGhost();
|
||||||
|
this.dropAndAdvance(c, 'r');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Move execution ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
dropAndAdvance(col, color) {
|
||||||
|
this.animating = true;
|
||||||
|
this.updateTurnText();
|
||||||
|
|
||||||
|
const r = dropRow(this.gs.board, col);
|
||||||
|
const { x, y } = this.cellToWorld(r, col);
|
||||||
|
|
||||||
|
// Create the disc above the board and drop it with a bounce.
|
||||||
|
const g = this.add.graphics().setDepth(DEPTH.piece).setPosition(x, BY - CELL / 2 - 4);
|
||||||
|
this.drawDisc(g, color);
|
||||||
|
this.discObjs[r][col] = g;
|
||||||
|
|
||||||
|
const fallDist = (y - (BY - CELL / 2 - 4));
|
||||||
|
const duration = Phaser.Math.Clamp(150 + fallDist * 0.7, 220, 620);
|
||||||
|
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: g, y, duration, ease: 'Bounce.easeOut',
|
||||||
|
onComplete: () => {
|
||||||
|
// tiny settle squash
|
||||||
|
this.tweens.add({ targets: g, scaleY: 0.86, scaleX: 1.12, duration: 70, yoyo: true, ease: 'Quad.easeOut' });
|
||||||
|
this.afterDrop(col, color);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterDrop(col, color) {
|
||||||
|
this.gs = applyMove(this.gs, col);
|
||||||
|
this.animating = false;
|
||||||
|
this.updateTurnText();
|
||||||
|
|
||||||
|
if (isGameOver(this.gs)) { this.onGameOver(); return; }
|
||||||
|
|
||||||
|
if (this.gs.turn === 'y') {
|
||||||
|
this.startAITurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI turn ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
startAITurn() {
|
||||||
|
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||||
|
this.showTurnBanner(`${name}'s Turn`);
|
||||||
|
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
aiStep() {
|
||||||
|
if (this.gs.phase !== 'playing' || this.gs.turn !== 'y') return;
|
||||||
|
const skill = this.opponents[0]?.skill ?? 3;
|
||||||
|
const col = chooseMove(this.gs, 'y', skill);
|
||||||
|
if (col === null || col === undefined) return;
|
||||||
|
this.dropAndAdvance(col, 'y');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banners ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
showTurnBanner(text) {
|
||||||
|
const cx = BX + BOARD_W / 2;
|
||||||
|
const banner = this.add.text(cx, BY - 150, text, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||||||
|
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: banner, y: BY - 120, duration: 300, ease: 'Back.easeOut',
|
||||||
|
onComplete: () => {
|
||||||
|
this.time.delayedCall(1100, () => {
|
||||||
|
this.tweens.add({ targets: banner, y: BY - 150, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Win effects ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
clearWinFx() {
|
||||||
|
for (const o of this.winFx) { this.tweens.killTweensOf(o); o.destroy(); }
|
||||||
|
this.winFx = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightWin(line) {
|
||||||
|
// Pulse the four winning discs and ring them with a glow.
|
||||||
|
for (const { r, c } of line) {
|
||||||
|
const g = this.discObjs[r][c];
|
||||||
|
if (g) this.tweens.add({ targets: g, scaleX: 1.12, scaleY: 1.12, duration: 380, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||||
|
const { x, y } = this.cellToWorld(r, c);
|
||||||
|
const ring = this.add.graphics().setDepth(DEPTH.win);
|
||||||
|
ring.lineStyle(5, C.winGlow, 0.9);
|
||||||
|
ring.strokeCircle(x, y, DISC_R + 2);
|
||||||
|
this.tweens.add({ targets: ring, alpha: { from: 0.9, to: 0.25 }, duration: 420, yoyo: true, repeat: -1 });
|
||||||
|
this.winFx.push(ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game over ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onGameOver() {
|
||||||
|
const winner = getWinner(this.gs);
|
||||||
|
const isHuman = winner === 'r';
|
||||||
|
const isDraw = winner === 'draw';
|
||||||
|
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||||
|
|
||||||
|
if (this.gs.winLine) this.highlightWin(this.gs.winLine);
|
||||||
|
if (isHuman) this.wins.player++; else if (!isDraw) this.wins.opp++;
|
||||||
|
this.updateScores();
|
||||||
|
|
||||||
|
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
|
||||||
|
this.recordResult(isHuman ? 'win' : isDraw ? 'draw' : 'loss');
|
||||||
|
|
||||||
|
const cx = BX + BOARD_W / 2, cy = BY + BOARD_H / 2;
|
||||||
|
|
||||||
|
if (isHuman) {
|
||||||
|
const emitter = this.add.particles(cx, BY - 40, 'c4Particle', {
|
||||||
|
speed: { min: 180, max: 520 }, lifespan: 1500,
|
||||||
|
scale: { start: 1.4, end: 0 }, alpha: { start: 1, end: 0 },
|
||||||
|
quantity: 6, frequency: 25, angle: { min: 60, max: 120 }, gravityY: 420,
|
||||||
|
tint: [C.redFill, C.yelFill, 0xffffff, COLORS.accent],
|
||||||
|
}).setDepth(DEPTH.banner);
|
||||||
|
this.time.delayedCall(2200, () => emitter.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.time.delayedCall(650, () => {
|
||||||
|
const msg = isDraw
|
||||||
|
? "It's a draw!\nThe board is full."
|
||||||
|
: isHuman
|
||||||
|
? '🎉 You win!\nFour in a row!'
|
||||||
|
: `${name} wins!\nFour in a row.`;
|
||||||
|
|
||||||
|
const overlay = this.add.rectangle(cx, cy, 720, 300, 0x0a0e14, 0.9)
|
||||||
|
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||||
|
const txt = this.add.text(cx, cy - 44, msg, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '30px',
|
||||||
|
color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center',
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||||
|
const playAgain = new Button(this, cx - 90, cy + 84, 'Play Again', () => {
|
||||||
|
overlay.destroy(); txt.destroy(); playAgain.destroy(); leave.destroy(); this.initGame();
|
||||||
|
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||||
|
const leave = new Button(this, cx + 90, cy + 84, 'Leave', () => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordResult(result) {
|
||||||
|
try {
|
||||||
|
const score = result === 'win' ? 100 : result === 'draw' ? 25 : 0;
|
||||||
|
await api.post('/history/single-player', { slug: 'connect4', score, opponentScores: [], result });
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Pure game logic for Connect 4.
|
||||||
|
// Board: 6 rows × 7 columns, each cell = null | 'r' (red) | 'y' (yellow).
|
||||||
|
// Red is the human and drops first; Yellow is the AI.
|
||||||
|
// Row 0 is the top of the grid; pieces fall to the lowest empty row in a column.
|
||||||
|
|
||||||
|
export const ROWS = 6;
|
||||||
|
export const COLS = 7;
|
||||||
|
export const CONNECT = 4;
|
||||||
|
|
||||||
|
const DIRS = [[0, 1], [1, 0], [1, 1], [1, -1]]; // →, ↓, ↘, ↙
|
||||||
|
|
||||||
|
export function other(color) { return color === 'r' ? 'y' : 'r'; }
|
||||||
|
|
||||||
|
function inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
|
||||||
|
|
||||||
|
export function cloneState(state) {
|
||||||
|
return {
|
||||||
|
board: state.board.map(row => [...row]),
|
||||||
|
turn: state.turn,
|
||||||
|
phase: state.phase,
|
||||||
|
winner: state.winner,
|
||||||
|
winLine: state.winLine ? state.winLine.map(p => ({ ...p })) : null,
|
||||||
|
lastDrop: state.lastDrop ? { ...state.lastDrop } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState() {
|
||||||
|
const board = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||||
|
return { board, turn: 'r', phase: 'playing', winner: null, winLine: null, lastDrop: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row a piece would land in for the given column, or -1 if the column is full.
|
||||||
|
export function dropRow(board, col) {
|
||||||
|
for (let r = ROWS - 1; r >= 0; r--) {
|
||||||
|
if (board[r][col] === null) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns that still have room for a piece.
|
||||||
|
export function getValidColumns(state) {
|
||||||
|
if (state.phase === 'game_over') return [];
|
||||||
|
const cols = [];
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
if (state.board[0][c] === null) cols.push(c);
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the four-in-a-row line through (r,c) for `color`, or null.
|
||||||
|
export function findWinLine(board, r, c, color) {
|
||||||
|
for (const [dr, dc] of DIRS) {
|
||||||
|
const line = [{ r, c }];
|
||||||
|
// Extend forward
|
||||||
|
let nr = r + dr, nc = c + dc;
|
||||||
|
while (inBounds(nr, nc) && board[nr][nc] === color) { line.push({ r: nr, c: nc }); nr += dr; nc += dc; }
|
||||||
|
// Extend backward
|
||||||
|
nr = r - dr; nc = c - dc;
|
||||||
|
while (inBounds(nr, nc) && board[nr][nc] === color) { line.unshift({ r: nr, c: nc }); nr -= dr; nc -= dc; }
|
||||||
|
if (line.length >= CONNECT) return line.slice(0, CONNECT);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBoardFull(board) {
|
||||||
|
for (let c = 0; c < COLS; c++) if (board[0][c] === null) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops the current player's piece into `col`. Returns the new state.
|
||||||
|
// Assumes the column is valid; callers should check getValidColumns first.
|
||||||
|
export function applyMove(state, col) {
|
||||||
|
const s = cloneState(state);
|
||||||
|
const color = s.turn;
|
||||||
|
const r = dropRow(s.board, col);
|
||||||
|
if (r < 0) return s; // column full — no-op
|
||||||
|
s.board[r][col] = color;
|
||||||
|
s.lastDrop = { r, c: col, color };
|
||||||
|
|
||||||
|
const line = findWinLine(s.board, r, col, color);
|
||||||
|
if (line) {
|
||||||
|
s.phase = 'game_over';
|
||||||
|
s.winner = color;
|
||||||
|
s.winLine = line;
|
||||||
|
} else if (isBoardFull(s.board)) {
|
||||||
|
s.phase = 'game_over';
|
||||||
|
s.winner = 'draw';
|
||||||
|
} else {
|
||||||
|
s.turn = other(color);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGameOver(state) { return state.phase === 'game_over'; }
|
||||||
|
|
||||||
|
export function getWinner(state) {
|
||||||
|
return state.phase === 'game_over' ? state.winner : null;
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ import OthelloGame from './games/othello/OthelloGame.js';
|
||||||
import GoGame from './games/go/GoGame.js';
|
import GoGame from './games/go/GoGame.js';
|
||||||
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
||||||
import MastermindGame from './games/mastermind/MastermindGame.js';
|
import MastermindGame from './games/mastermind/MastermindGame.js';
|
||||||
|
import Connect4Game from './games/connect4/Connect4Game.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -99,6 +100,7 @@ const config = {
|
||||||
GoGame,
|
GoGame,
|
||||||
BattleshipGame,
|
BattleshipGame,
|
||||||
MastermindGame,
|
MastermindGame,
|
||||||
|
Connect4Game,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
|
|
||||||
// Skill control: pips always show the level; the +/- buttons appear only
|
// 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.
|
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind'].includes(this.gameDef.slug)) {
|
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4'].includes(this.gameDef.slug)) {
|
||||||
bio.style.webkitLineClamp = '1';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ function mdToHtml(md) {
|
||||||
|
|
||||||
const inline = (t) =>
|
const inline = (t) =>
|
||||||
t
|
t
|
||||||
|
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:4px;">')
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,4 @@ registerGame({ slug: 'othello', name: 'Othello', category: 'tabletop', minPlayer
|
||||||
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
|
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue