diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 60c1597..56c1268 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 038def1..24bb994 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/images/originals/beth.png b/public/assets/images/originals/beth.png new file mode 100644 index 0000000..1e0483f Binary files /dev/null and b/public/assets/images/originals/beth.png differ diff --git a/public/assets/images/originals/blackwind.png b/public/assets/images/originals/blackwind.png new file mode 100644 index 0000000..3c24037 Binary files /dev/null and b/public/assets/images/originals/blackwind.png differ diff --git a/public/src/games/triominoes/TriominoesAI.js b/public/src/games/triominoes/TriominoesAI.js new file mode 100644 index 0000000..46827e1 --- /dev/null +++ b/public/src/games/triominoes/TriominoesAI.js @@ -0,0 +1,45 @@ +// Heuristic Tri-Ominoes AI. Stateless; consumed by TriominoesGame. +// +// chooseMove(state, idx, skill) -> a move from getLegalMoves(), or null. +// The scene handles the draw/pass flow when no legal move exists. Skill 1..5 +// scales how reliably the AI takes the highest-scoring placement vs. a random +// legal one, giving weaker bots a human-ish wobble. + +import { getLegalMoves, HEXAGON_BONUS } from './TriominoesLogic.js'; +import { cellVertices, vertKey, tileValue } from './TriominoesData.js'; + +const SKILL_GREED = { 1: 0.15, 2: 0.4, 3: 0.65, 4: 0.85, 5: 1 }; + +export function nextThinkDelay(skill = 3) { + const base = 520 - skill * 40; + return base + Math.floor(Math.random() * 260); +} + +function scorePlacement(state, move, tile) { + // Shed heavy tiles first (they hurt most if we're caught holding them) and + // chase hexagon closes. + let score = tileValue(tile); + const verts = cellVertices(move.r, move.c); + for (let i = 0; i < 3; i++) { + const [vr, vx] = verts[i]; + if (state.board.vertCount[vertKey(vr, vx)] === 5) score += HEXAGON_BONUS; + } + return score; +} + +export function chooseMove(state, idx, skill = 3) { + const moves = getLegalMoves(state, idx); + if (moves.length === 0) return null; + + const hand = state.players[idx].hand; + const ranked = moves + .map((m) => ({ m, s: scorePlacement(state, m, hand[m.tileIndex]) })) + .sort((a, b) => b.s - a.s); + + const greed = SKILL_GREED[Math.max(1, Math.min(5, skill | 0))] ?? 0.65; + if (Math.random() < greed) return ranked[0].m; + + // Off-greed: pick from the better half so weaker play still isn't terrible. + const half = Math.max(1, Math.ceil(ranked.length / 2)); + return ranked[Math.floor(Math.random() * half)].m; +} diff --git a/public/src/games/triominoes/TriominoesData.js b/public/src/games/triominoes/TriominoesData.js new file mode 100644 index 0000000..bb752b4 --- /dev/null +++ b/public/src/games/triominoes/TriominoesData.js @@ -0,0 +1,97 @@ +// Tri-Ominoes — static data and triangular-grid geometry. No Phaser, no state. +// +// The board is a triangular grid. Each cell is an equilateral triangle keyed by +// (r, c). A cell "points up" when (r + c) is even and "points down" otherwise. +// Up and down triangles interlock to tile the plane. +// +// Every cell owns three CORNER VERTICES, listed clockwise (screen coords, y +// down). Vertices are shared between neighbouring cells, so a vertex's number is +// shared by every tile that touches it — which is exactly the Tri-Ominoes +// matching rule (touching corners must agree). We identify a vertex by lattice +// coords (vr, vx): horizontal line index vr and half-column vx. +// +// up (r,c): top=(r, c+1) bottom-right=(r+1, c+2) bottom-left=(r+1, c) +// down (r,c): top-left=(r,c) top-right=(r, c+2) bottom=(r+1, c+1) +// +// Both windings are clockwise, so the three placement rotations of a tile are +// just cyclic shifts of its corner triple — reflections (which a physical tile +// can't do) never sneak in. + +export const VALUE_MAX = 5; // double-five set: corner pips 0..5 + +// All 56 distinct triangles (multisets of 3 values 0..5). The stored triple is +// sorted ascending and treated as the tile's CLOCKWISE corner order. +export function buildTileSet() { + const tiles = []; + let id = 0; + for (let a = 0; a <= VALUE_MAX; a++) { + for (let b = a; b <= VALUE_MAX; b++) { + for (let c = b; c <= VALUE_MAX; c++) { + tiles.push({ id: id++, v: [a, b, c] }); + } + } + } + return tiles; // 56 tiles +} + +export const tileValue = (t) => t.v[0] + t.v[1] + t.v[2]; +export const isTripleTile = (t) => t.v[0] === t.v[1] && t.v[1] === t.v[2]; + +// Standard hand sizes: 2 players draw 9, 3–4 draw 7. +export function handSizeFor(n) { + return n === 2 ? 9 : 7; +} + +// ── Geometry ───────────────────────────────────────────────────────────────── +export function isUp(r, c) { + return (((r + c) % 2) + 2) % 2 === 0; +} + +export const cellKey = (r, c) => `${r},${c}`; +export const vertKey = (vr, vx) => `${vr},${vx}`; + +// Three corner vertices in clockwise order. Order matters: a placed tile maps +// corner i ← tileTriple[(i + rot) % 3]. +export function cellVertices(r, c) { + if (isUp(r, c)) { + return [ + [r, c + 1], // top + [r + 1, c + 2], // bottom-right + [r + 1, c], // bottom-left + ]; + } + return [ + [r, c], // top-left + [r, c + 2], // top-right + [r + 1, c + 1], // bottom + ]; +} + +// The three edge-adjacent cells. The slanted-edge neighbours sit in the same +// row (c±1); the flat-edge neighbour is one row away depending on orientation. +export function neighborCells(r, c) { + return isUp(r, c) + ? [[r, c - 1], [r, c + 1], [r + 1, c]] + : [[r, c - 1], [r, c + 1], [r - 1, c]]; +} + +// ── Pixel mapping ───────────────────────────────────────────────────────────── +// A vertex (vr, vx) maps to a board-space pixel. HALF_W is half the triangle +// base; row height keeps the triangles equilateral. +export const HALF_W = 46; +export const ROW_H = Math.round(HALF_W * 2 * 0.8660254); // ≈ 80 + +export function vertPixel(vr, vx) { + return { x: vx * HALF_W, y: vr * ROW_H }; +} + +export function cellCentroid(r, c) { + const vs = cellVertices(r, c); + let x = 0, y = 0; + for (const [vr, vx] of vs) { const p = vertPixel(vr, vx); x += p.x; y += p.y; } + return { x: x / 3, y: y / 3 }; +} + +// ── Players ───────────────────────────────────────────────────────────────── +export const PLAYER_COLORS = [0xc8a84b, 0x4a90d9, 0x49a25a, 0xd0473a]; +export const PLAYER_COLOR_HEX = ['#c8a84b', '#4a90d9', '#49a25a', '#d0473a']; diff --git a/public/src/games/triominoes/TriominoesGame.js b/public/src/games/triominoes/TriominoesGame.js new file mode 100644 index 0000000..c3a7812 --- /dev/null +++ b/public/src/games/triominoes/TriominoesGame.js @@ -0,0 +1,759 @@ +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 { + createInitialState, getLegalMoves, playTile, drawTile, passTurn, + canDraw, getWinners, MAX_DRAWS, +} from './TriominoesLogic.js'; +import { chooseMove, nextThinkDelay } from './TriominoesAI.js'; +import { + cellVertices, cellCentroid, vertPixel, isUp, HALF_W, + PLAYER_COLORS, PLAYER_COLOR_HEX, +} from './TriominoesData.js'; + +// ─── Layout ────────────────────────────────────────────────────────────── +const CX = GAME_WIDTH / 2; +const VIEW_L = 330, VIEW_R = 1590, VIEW_T = 178, VIEW_B = 858; +const VIEW_CX = (VIEW_L + VIEW_R) / 2; +const VIEW_CY = (VIEW_T + VIEW_B) / 2; +const VIEW_W = VIEW_R - VIEW_L; +const VIEW_H = VIEW_B - VIEW_T; + +const COL_X = 96; // left portrait column +const LABEL_X = 150; +const POOL_X = 1758, POOL_Y = 322; +const HAND_Y = 972; + +const PAN_STEP = 240; +const SAFE_MARGIN = 130; // keep newest tile this far inside the viewport + +const HAND_BASE = 120; // hand-tile triangle base width +const HAND_H = Math.round(HAND_BASE * 0.8660254); +// While hovering a legal cell the dragged tile scales down to the board cell's +// size so the preview matches the real footprint. +const PREVIEW_SCALE = (HALF_W * 2) / HAND_BASE; + +const DEPTH = { + bg: -1, frame: 0, board: 2, arrows: 30, + col: 20, pool: 20, ui: 25, hand: 22, drag: 60, toast: 70, modal: 80, +}; + +export default class TriominoesGame extends Phaser.Scene { + constructor() { super('TriominoesGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + + this.gs = null; + this.inputLocked = true; + this.gameOverShown = false; + + this.portraitCtrls = []; + this.cellObjs = []; // board-tile containers (rebuilt each refresh) + this.handObjs = []; // hand-tile containers + this.labelTexts = []; + this._dragLegal = null; + this._poolActive = false; + } + + create() { + new MusicPlayer(this, this.cache.json.get('music').tracks); + // Diagonal tri-gradient backdrop — deep teal → warm amber → slate blue. + const bg = this.add.graphics().setDepth(DEPTH.bg); + const C1 = Phaser.Display.Color.ValueToColor(0x0c2d3a); // deep teal + const C2 = Phaser.Display.Color.ValueToColor(0x3a2d1a); // warm amber + const C3 = Phaser.Display.Color.ValueToColor(0x1a2040); // slate blue + for (let i = 0; i < GAME_HEIGHT; i += 2) { + const yNorm = i / GAME_HEIGHT; + // Blend three colors along a diagonal: teal (top-left) → amber (center) → blue (bottom-right) + let r, g, b; + if (yNorm < 0.5) { + const t = yNorm * 2; + const c = Phaser.Display.Color.Interpolate.ColorWithColor(C1, C2, 100, Math.floor(t * 100)); + r = c.r; g = c.g; b = c.b; + } else { + const t = (yNorm - 0.5) * 2; + const c = Phaser.Display.Color.Interpolate.ColorWithColor(C2, C3, 100, Math.floor(t * 100)); + r = c.r; g = c.g; b = c.b; + } + bg.fillStyle(Phaser.Display.Color.GetColor(r, g, b), 1); + bg.fillRect(0, i, GAME_WIDTH, 2); + } + if (this.playfield?.key && this.textures.exists(this.playfield.key)) { + this.add.image(CX, GAME_HEIGHT / 2, this.playfield.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); + } + + const skills = [0, ...this.opponents.map((o) => Math.max(1, Math.min(5, o?.skill ?? 3)))]; + this.skillBySeat = skills; + + const playerNames = [ + { name: auth.user?.username ?? 'You', isAI: false }, + ...this.opponents.map((o) => ({ name: o.name ?? o.id ?? 'Bot', isAI: true, avatar: o })), + ]; + this.gs = createInitialState({ playerNames }); + + this.buildViewport(); + this.buildHeader(); + this.buildLeftColumn(); + this.buildPool(); + this.buildArrows(); + this.setupDrag(); + + new Button(this, 92, GAME_HEIGHT - 40, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 150, fontSize: 20, + }).setDepth(DEPTH.ui); + new Button(this, GAME_WIDTH - 120, GAME_HEIGHT - 40, 'Re-center', () => this.recenterBoard(), { + variant: 'ghost', width: 180, fontSize: 20, + }).setDepth(DEPTH.ui); + + // Centre the board origin in the viewport to start. + const c0 = cellCentroid(0, 0); + this.boardLayer.setPosition(VIEW_CX - c0.x, VIEW_CY - c0.y); + + this.refresh(); + this.time.delayedCall(500, () => this.nextTurn()); + } + + // ─── Static structure ────────────────────────────────────────────────── + buildViewport() { + // Felt panel + frame for the play window. + this.add.rectangle(VIEW_CX, VIEW_CY, VIEW_W, VIEW_H, 0x123022, 1) + .setStrokeStyle(3, COLORS.accent, 0.8).setDepth(DEPTH.frame); + + this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board); + const maskShape = this.make.graphics(); + maskShape.fillStyle(0xffffff); + maskShape.fillRect(VIEW_L, VIEW_T, VIEW_W, VIEW_H); + this.boardLayer.setMask(maskShape.createGeometryMask()); + + this.ghostGfx = this.add.graphics(); + this.boardLayer.add(this.ghostGfx); + } + + buildHeader() { + this.add.text(CX, 40, 'Tri-Ominoes', { + fontFamily: 'Righteous', fontSize: '44px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(DEPTH.ui) + .setBackgroundColor('rgba(0,0,0,0.55)').setPadding(14, 6); + + this.statusText = this.add.text(CX, 110, '', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex, + }).setOrigin(0.5).setDepth(DEPTH.ui) + .setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 5); + } + + buildLeftColumn() { + const n = this.gs.players.length; + const top = 230, gap = Math.min(168, (820 - top) / Math.max(1, n - 1) || 168); + for (let i = 0; i < n; i++) { + const y = top + i * gap; + const ring = this.add.graphics().setDepth(DEPTH.col); + let controller; + if (i === 0) { + controller = createPlayerPortrait(this, COL_X, y, 44, DEPTH.col, 'TriominoesGame'); + } else { + const opp = this.opponents[i - 1] ?? { id: 'bot', spriteIndex: 0 }; + controller = createOpponentPortrait(this, opp, COL_X, y, 44, DEPTH.col); + } + this.portraitCtrls.push({ ring, controller, x: COL_X, y }); + + const label = this.add.text(LABEL_X, y, '', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, + align: 'left', lineSpacing: 3, + }).setOrigin(0, 0.5).setDepth(DEPTH.ui) + .setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5); + this.labelTexts.push(label); + } + } + + buildPool() { + this.add.text(POOL_X, POOL_Y - 96, 'POOL', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.ui) + .setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4); + + // A little scatter of face-down triangles. + const offs = [[0, 0, 8], [-14, 10, -12], [12, -8, 16], [-8, -12, 24], [10, 12, -18]]; + this._poolTiles = offs.map(([dx, dy, a]) => { + const c = this.add.container(POOL_X + dx, POOL_Y + dy).setAngle(a).setDepth(DEPTH.pool); + const g = this.add.graphics(); + this.paintFaceDownTri(g, HAND_BASE * 0.62); + c.add(g); + return c; + }); + + this.poolText = this.add.text(POOL_X, POOL_Y + 84, '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.ui) + .setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4); + + this._poolZone = this.add.zone(POOL_X, POOL_Y, 200, 200).setOrigin(0.5).setDepth(DEPTH.pool); + this._poolZone.on('pointerup', () => this.onPoolClick()); + } + + buildArrows() { + const mk = (x, y, dir) => this.makeArrow(x, y, dir); + this._arrows = [ + mk(VIEW_L + 34, VIEW_CY, 'left'), + mk(VIEW_R - 34, VIEW_CY, 'right'), + mk(VIEW_CX, VIEW_T + 34, 'up'), + mk(VIEW_CX, VIEW_B - 34, 'down'), + ]; + } + + makeArrow(x, y, dir) { + const r = 26; + const c = this.add.container(x, y).setDepth(DEPTH.arrows); + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.5); g.fillCircle(0, 0, r); + g.lineStyle(2, COLORS.accent, 0.9); g.strokeCircle(0, 0, r); + g.fillStyle(COLORS.accent, 1); + const s = 11; + const tri = { + left: [[-s, 0], [s, -s], [s, s]], + right: [[s, 0], [-s, -s], [-s, s]], + up: [[0, -s], [-s, s], [s, s]], + down: [[0, s], [-s, -s], [s, -s]], + }[dir]; + g.fillTriangle(tri[0][0], tri[0][1], tri[1][0], tri[1][1], tri[2][0], tri[2][1]); + c.add(g); + c.setSize(r * 2, r * 2).setInteractive({ useHandCursor: true }); + c.on('pointerover', () => c.setScale(1.12)); + c.on('pointerout', () => c.setScale(1)); + c.on('pointerup', () => this.pan(dir)); + return c; + } + + pan(dir) { + const d = { left: [PAN_STEP, 0], right: [-PAN_STEP, 0], up: [0, PAN_STEP], down: [0, -PAN_STEP] }[dir]; + this.tweens.add({ + targets: this.boardLayer, + x: this.boardLayer.x + d[0], + y: this.boardLayer.y + d[1], + duration: 220, ease: 'Cubic.easeOut', + }); + } + + recenterBoard() { + // Centre on the bounding box of all placed tiles (or the origin if empty). + const keys = Object.keys(this.gs.board.cells); + let cx, cy; + if (keys.length === 0) { + const c0 = cellCentroid(0, 0); cx = c0.x; cy = c0.y; + } else { + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const k of keys) { + const { r, c } = this.gs.board.cells[k]; + const ct = cellCentroid(r, c); + minX = Math.min(minX, ct.x); maxX = Math.max(maxX, ct.x); + minY = Math.min(minY, ct.y); maxY = Math.max(maxY, ct.y); + } + cx = (minX + maxX) / 2; cy = (minY + maxY) / 2; + } + this.tweens.add({ + targets: this.boardLayer, + x: VIEW_CX - cx, y: VIEW_CY - cy, + duration: 320, ease: 'Cubic.easeOut', + }); + } + + // Nudge the board so a given cell sits comfortably inside the viewport. + ensureCellVisible(r, c) { + const ct = cellCentroid(r, c); + const sx = ct.x + this.boardLayer.x; + const sy = ct.y + this.boardLayer.y; + let dx = 0, dy = 0; + if (sx < VIEW_L + SAFE_MARGIN) dx = (VIEW_L + SAFE_MARGIN) - sx; + else if (sx > VIEW_R - SAFE_MARGIN) dx = (VIEW_R - SAFE_MARGIN) - sx; + if (sy < VIEW_T + SAFE_MARGIN) dy = (VIEW_T + SAFE_MARGIN) - sy; + else if (sy > VIEW_B - SAFE_MARGIN) dy = (VIEW_B - SAFE_MARGIN) - sy; + if (dx || dy) { + this.tweens.add({ + targets: this.boardLayer, + x: this.boardLayer.x + dx, y: this.boardLayer.y + dy, + duration: 300, ease: 'Cubic.easeOut', + }); + } + } + + // ─── Render ──────────────────────────────────────────────────────────── + refresh() { + this.rebuildBoard(); + this.rebuildHand(); + this.updatePortraits(); + this.updateLabels(); + this.updateStatus(); + this.updatePool(); + } + + rebuildBoard() { + for (const o of this.cellObjs) o.destroy(); + this.cellObjs = []; + for (const key of Object.keys(this.gs.board.cells)) { + const cell = this.gs.board.cells[key]; + const ct = cellCentroid(cell.r, cell.c); + const pts = cellVertices(cell.r, cell.c).map(([vr, vx]) => { + const p = vertPixel(vr, vx); return [p.x - ct.x, p.y - ct.y]; + }); + const container = this.add.container(ct.x, ct.y); + const border = PLAYER_COLORS[cell.owner % PLAYER_COLORS.length]; + this.paintTriangle(container, pts, cell.vals, { + fill: COLORS.text, border, lineW: 3, fontSize: 26, + }); + this.boardLayer.add(container); + this.cellObjs.push(container); + } + // keep ghosts above tiles + this.boardLayer.bringToTop(this.ghostGfx); + } + + // Draw a triangle (local pts) with the three corner numbers into a container. + paintTriangle(container, pts, vals, opts) { + const { fill, border, lineW = 3, fontSize = 26 } = opts; + const g = this.add.graphics(); + g.fillStyle(fill, 1); + g.fillTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]); + g.lineStyle(lineW, border, 1); + g.strokeTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]); + container.add(g); + for (let i = 0; i < 3; i++) { + const tx = pts[i][0] * 0.62, ty = pts[i][1] * 0.62; + const t = this.add.text(tx, ty, String(vals[i]), { + fontFamily: 'Righteous', fontSize: `${fontSize}px`, color: COLORS.textDarkHex, + }).setOrigin(0.5); + container.add(t); + } + } + + // Local corners of an upward-pointing hand triangle (clockwise top,BR,BL). + handPts() { + return [[0, -HAND_H * 2 / 3], [HAND_BASE / 2, HAND_H / 3], [-HAND_BASE / 2, HAND_H / 3]]; + } + + rebuildHand() { + for (const o of this.handObjs) o.destroy(); + this.handObjs = []; + const hand = this.gs.players[0].hand; + const humanTurn = this.gs.current === 0 && this.gs.phase === 'playing'; + const legalTiles = humanTurn + ? new Set(getLegalMoves(this.gs, 0).map((m) => m.tileIndex)) + : new Set(); + + const pitch = HAND_BASE + 22; + const startX = CX - ((hand.length - 1) * pitch) / 2; + const pts = this.handPts(); + + hand.forEach((tile, idx) => { + const x = startX + idx * pitch; + const playable = humanTurn && legalTiles.has(idx); + const container = this.add.container(x, HAND_Y).setDepth(DEPTH.hand); + const border = playable ? COLORS.accent : COLORS.muted; + this.paintTriangle(container, pts, tile.v, { fill: COLORS.text, border, lineW: 3, fontSize: 30 }); + container.setAlpha(humanTurn ? (playable ? 1 : 0.42) : 0.7); + + container._tileIndex = idx; + container._homeX = x; + container._homeY = HAND_Y; + container._playable = playable; + container.setSize(HAND_BASE, HAND_H); + // setSize gives the container displayOrigin (w/2, h/2), which Phaser ADDS + // to the local pointer before the hit test — so the hit area must be + // expressed in that origin-shifted space, i.e. our centred triangle moved + // by (+w/2, +h/2). Skipping this leaves the hitbox up-and-left of the tile. + const ox = HAND_BASE / 2, oy = HAND_H / 2; + const hit = new Phaser.Geom.Triangle( + pts[0][0] + ox, pts[0][1] + oy, + pts[1][0] + ox, pts[1][1] + oy, + pts[2][0] + ox, pts[2][1] + oy, + ); + container.setInteractive(hit, Phaser.Geom.Triangle.Contains, { useHandCursor: playable }); + if (playable) this.input.setDraggable(container); + + this.handObjs.push(container); + }); + } + + updatePortraits() { + for (let i = 0; i < this.portraitCtrls.length; i++) { + const { ring, x, y } = this.portraitCtrls[i]; + ring.clear(); + if (i === this.gs.current && this.gs.phase === 'playing') { + ring.lineStyle(4, COLORS.gold, 1); + ring.strokeCircle(x, y, 50); + } + } + } + + updateLabels() { + for (let i = 0; i < this.gs.players.length; i++) { + const p = this.gs.players[i]; + this.labelTexts[i].setText(`${p.name}\n${p.hand.length} tiles · ${p.score} pts`); + this.labelTexts[i].setColor(i === 0 ? PLAYER_COLOR_HEX[0] : COLORS.textHex); + } + } + + updateStatus() { + if (this.gs.phase === 'gameover') { this.statusText.setText('Game over'); return; } + const cur = this.gs.players[this.gs.current]; + this.statusText.setText(this.gs.current === 0 ? 'Your turn — drag a tile onto the board' : `${cur.name}'s turn`); + } + + updatePool() { + const count = this.gs.pool.length; + const shown = Math.min(count, this._poolTiles.length); + this._poolTiles.forEach((t, i) => t.setVisible(i < shown)); + this.poolText.setText(count > 0 ? `${count} left` : 'Empty'); + } + + paintFaceDownTri(g, size) { + const h = Math.round(size * 0.8660254); + const pts = [[0, -h * 2 / 3], [size / 2, h / 3], [-size / 2, h / 3]]; + g.fillStyle(COLORS.panel, 1); + g.fillTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]); + g.lineStyle(2, COLORS.accent, 0.9); + g.strokeTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]); + g.fillStyle(COLORS.accent, 0.35); + g.fillCircle(0, 0, Math.max(3, size * 0.06)); + } + + // ─── Drag & drop ─────────────────────────────────────────────────────── + setupDrag() { + this.input.on('dragstart', (_p, obj) => { + if (this.inputLocked || !obj._playable) return; + obj._dragging = true; + obj.setDepth(DEPTH.drag).setAngle(0).setScale(1); + this._previewKey = 'none'; + this._dragLegal = getLegalMoves(this.gs, 0) + .filter((m) => m.tileIndex === obj._tileIndex) + .map((m) => { const ct = cellCentroid(m.r, m.c); return { ...m, cx: ct.x, cy: ct.y }; }); + this.drawGhosts(); + }); + + this.input.on('drag', (_p, obj, dx, dy) => { + if (!obj._dragging) return; + obj.setPosition(dx, dy); + this._hoverMove = this.dropTargetAt(dx, dy); + this.updateDragPreview(obj, this._hoverMove); + this.drawGhosts(); + }); + + this.input.on('dragend', (_p, obj) => { + if (!obj._dragging) return; + obj._dragging = false; + const target = this.dropTargetAt(obj.x, obj.y); + this.ghostGfx.clear(); + this._dragLegal = null; + this._hoverMove = null; + this._previewKey = 'none'; + this.tweens.killTweensOf(obj); + if (target) { + // Already rotated/scaled into the fit by the hover preview — drop it. + obj.setVisible(false); + const move = { tileIndex: target.tileIndex, r: target.r, c: target.c, rot: target.rot }; + this.time.delayedCall(0, () => this.applyMove(move)); + } else { + obj.setDepth(DEPTH.hand); + const back = Phaser.Math.Angle.ShortestBetween(obj.angle, 0); + this.tweens.add({ + targets: obj, x: obj._homeX, y: obj._homeY, + angle: obj.angle + back, scaleX: 1, scaleY: 1, + duration: 160, ease: 'Back.easeOut', + }); + } + }); + } + + // The on-screen rotation (deg, clockwise) that makes the upward hand tile — + // drawn v0=top, v1=BR, v2=BL — land exactly as it would once placed. Derived + // from the cell's corner directions: an up cell keeps the tile upward (0/120/ + // 240 by rotation), a down cell flips it (300/180/60). + targetAngleFor(move) { + return isUp(move.r, move.c) + ? [0, 240, 120][move.rot] + : [300, 180, 60][move.rot]; + } + + // Animate the dragged tile to preview the fit: rotate to the placed + // orientation and shrink to the board cell's size while hovering a legal cell; + // ease back to the upright hand size when over open space. + updateDragPreview(obj, target) { + const key = target ? `${target.r},${target.c},${target.rot}` : 'none'; + if (key === this._previewKey) return; + this._previewKey = key; + this.tweens.killTweensOf(obj); + const angle = target ? this.targetAngleFor(target) : 0; + const scale = target ? PREVIEW_SCALE : 1; + const delta = Phaser.Math.Angle.ShortestBetween(obj.angle, angle); + this.tweens.add({ + targets: obj, + angle: obj.angle + delta, + scaleX: scale, scaleY: scale, + duration: 170, ease: 'Cubic.easeOut', + }); + } + + // Which legal placement (if any) the pointer is over. Requires the pointer to + // be inside the viewport and near a legal cell centroid. + dropTargetAt(px, py) { + if (!this._dragLegal || px < VIEW_L || px > VIEW_R || py < VIEW_T || py > VIEW_B) return null; + const bx = px - this.boardLayer.x, by = py - this.boardLayer.y; + let best = null, bestD = 70 * 70; // snap radius² + for (const m of this._dragLegal) { + const d = (m.cx - bx) ** 2 + (m.cy - by) ** 2; + if (d < bestD) { bestD = d; best = m; } + } + return best; + } + + drawGhosts() { + const g = this.ghostGfx; + g.clear(); + if (!this._dragLegal) return; + for (const m of this._dragLegal) { + const pts = cellVertices(m.r, m.c).map(([vr, vx]) => vertPixel(vr, vx)); + const hot = this._hoverMove && this._hoverMove.r === m.r && this._hoverMove.c === m.c; + g.fillStyle(COLORS.gold, hot ? 0.42 : 0.16); + g.fillTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y); + g.lineStyle(hot ? 4 : 2, COLORS.gold, hot ? 1 : 0.6); + g.strokeTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y); + } + } + + // ─── AI tile animation ────────────────────────────────────────────────── + // Create a small triangle at the opponent's portrait and tween it to the + // board cell over 1.2 s, rotating and scaling as it goes. Returns a promise + // that resolves when the animation completes. + animateAITile(move, seat) { + return new Promise((resolve) => { + const tile = this.gs.players[seat].hand[move.tileIndex]; + if (!tile) { resolve(); return; } + + const portrait = this.portraitCtrls[seat]; + if (!portrait) { resolve(); return; } + const px = portrait.x; + const py = portrait.y; + + const ct = cellCentroid(move.r, move.c); + const finalAngle = this.targetAngleFor(move); + const startSize = HAND_BASE * 0.33; + const endSize = HALF_W * 2; + + // Build the triangle tile as a container (graphics + text children). + const h = Math.round(startSize * 0.8660254); + const triPts = [[0, -h * 2 / 3], [startSize / 2, h / 3], [-startSize / 2, h / 3]]; + const container = this.add.container(px, py).setDepth(DEPTH.drag); + const gfx = this.add.graphics(); + gfx.fillStyle(COLORS.text, 1); + gfx.fillTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]); + gfx.lineStyle(2, COLORS.muted, 1); + gfx.strokeTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]); + container.add(gfx); + + // Corner numbers. + for (let i = 0; i < 3; i++) { + const tx = triPts[i][0] * 0.62, ty = triPts[i][1] * 0.62; + const t = this.add.text(tx, ty, String(tile.v[(i + move.rot) % 3]), { + fontFamily: 'Righteous', fontSize: '14px', color: COLORS.textDarkHex, + }).setOrigin(0.5); + container.add(t); + } + + container.setScale(1).setAngle(0); + + // Tween from portrait → board cell. + this.tweens.add({ + targets: container, + x: ct.x + this.boardLayer.x, + y: ct.y + this.boardLayer.y, + scaleX: endSize / startSize, + scaleY: endSize / startSize, + angle: finalAngle, + duration: 1200, + ease: 'Cubic.easeInOut', + onComplete: () => { + container.destroy(); + resolve(); + }, + }); + }); + } + + // ─── Turn flow ───────────────────────────────────────────────────────── + nextTurn() { + this.showPoolClickable(false); + if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; } + this.refresh(); + if (this.gs.players[this.gs.current].isAI) this.runAITurn(); + else this.beginHumanTurn(); + } + + beginHumanTurn() { + this.inputLocked = false; + this.refresh(); + const moves = getLegalMoves(this.gs, 0); + if (moves.length > 0) return; + // No play — must draw (if possible) or pass. + this.inputLocked = true; + if (canDraw(this.gs) && this.gs.players[0].draws < MAX_DRAWS) { + this.statusText.setText('No legal play — click the POOL to draw (−5)'); + this.showPoolClickable(true); + } else { + this.statusText.setText('No play — passing'); + this.time.delayedCall(800, () => { this.gs = passTurn(this.gs); this.nextTurn(); }); + } + } + + applyMove(move) { + this.inputLocked = true; + this.gs = playTile(this.gs, move); + playSound(this, SFX.PIECE_CLICK); + this.refresh(); + this.flashCell(move.r, move.c); + this.ensureCellVisible(move.r, move.c); + this.time.delayedCall(360, () => this.nextTurn()); + } + + onPoolClick() { + if (!this._poolActive) return; + this.showPoolClickable(false); + playSound(this, SFX.CARD_DEAL); + this.gs = drawTile(this.gs); + this.refresh(); + // Re-evaluate: a drawn tile may now be playable. + this.time.delayedCall(260, () => this.beginHumanTurn()); + } + + showPoolClickable(on) { + this._poolActive = on; + if (!this._poolZone) return; + if (on) { + this._poolZone.setInteractive({ useHandCursor: true }); + for (const c of this._poolTiles) { + if (c.visible) this.tweens.add({ targets: c, scale: 1.12, duration: 340, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + } else { + this._poolZone.disableInteractive(); + for (const c of this._poolTiles) { this.tweens.killTweensOf(c); c.setScale(1); } + } + } + + async runAITurn() { + this.inputLocked = true; + const seat = this.gs.current; + const skill = this.skillBySeat[seat] ?? 3; + let guard = 0; + while (this.gs.phase === 'playing' && this.gs.current === seat && guard++ < 80) { + let moves = getLegalMoves(this.gs, seat); + if (moves.length === 0) { + if (canDraw(this.gs) && this.gs.players[seat].draws < MAX_DRAWS) { + await this.delay(360); + playSound(this, SFX.CARD_DEAL); + this.gs = drawTile(this.gs); + this.refresh(); + continue; + } + await this.delay(420); + this.gs = passTurn(this.gs); + break; + } + await this.delay(nextThinkDelay(skill)); + const move = chooseMove(this.gs, seat, skill); + // Animate the tile from the opponent's portrait to the board. + await this.animateAITile(move, seat); + this.gs = playTile(this.gs, move); + playSound(this, SFX.PIECE_CLICK); + this.refresh(); + this.flashCell(move.r, move.c); + this.ensureCellVisible(move.r, move.c); + break; + } + await this.delay(380); + this.nextTurn(); + } + + flashCell(r, c) { + const pts = cellVertices(r, c).map(([vr, vx]) => vertPixel(vr, vx)); + const fx = this.add.graphics(); + fx.lineStyle(4, COLORS.gold, 1); + fx.strokeTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y); + this.boardLayer.add(fx); + this.tweens.add({ targets: fx, alpha: { from: 1, to: 0 }, duration: 520, onComplete: () => fx.destroy() }); + } + + // ─── Game over ───────────────────────────────────────────────────────── + showGameOverModal() { + if (this.gameOverShown) return; + this.gameOverShown = true; + this.refresh(); + this.postHistory().catch(() => {}); + + const winners = new Set(getWinners(this.gs)); + const humanWon = winners.has(0); + if (winners.size === 1 && humanWon) playSound(this, SFX.CASINO_WIN); + else if (!humanWon) playSound(this, SFX.CASINO_LOSE); + for (let i = 1; i < this.gs.players.length; i++) { + this.portraitCtrls[i]?.controller?.playEmotion?.(winners.has(i) ? 'happy' : 'upset'); + } + + this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68) + .setInteractive().setDepth(DEPTH.modal); + const n = this.gs.players.length; + const panelW = 720, panelH = 220 + n * 52; + this.add.rectangle(CX, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1) + .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal); + + const top = GAME_HEIGHT / 2 - panelH / 2; + const heading = winners.size === 1 && humanWon ? 'You win!' + : humanWon ? 'Tied for the win' : `${this.gs.players[[...winners][0]].name} wins`; + this.add.text(CX, top + 50, heading, { + fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.modal); + this.add.text(CX, top + 92, 'Final scores (highest wins)', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(DEPTH.modal); + + const order = this.gs.players.map((p, i) => ({ i, p })).sort((a, b) => b.p.score - a.p.score); + let rowY = top + 134; + for (const { i, p } of order) { + const win = winners.has(i); + const color = win ? COLORS.goldHex : COLORS.textHex; + this.add.text(CX - panelW / 2 + 40, rowY, `${win ? '★ ' : ' '}${p.name}`, { + fontFamily: 'Righteous', fontSize: '24px', color, + }).setOrigin(0, 0.5).setDepth(DEPTH.modal); + this.add.text(CX + panelW / 2 - 40, rowY, String(p.score), { + fontFamily: 'Righteous', fontSize: '26px', color, + }).setOrigin(1, 0.5).setDepth(DEPTH.modal); + rowY += 48; + } + + new Button(this, CX, GAME_HEIGHT / 2 + panelH / 2 - 46, 'Back to Menu', + () => this.scene.start('GameMenu'), { width: 280, fontSize: 24 }).setDepth(DEPTH.modal); + } + + async postHistory() { + const totals = this.gs.players.map((p) => p.score); + const winners = new Set(getWinners(this.gs)); + let result; + if (winners.has(0) && winners.size === 1) result = 'win'; + else if (winners.has(0)) result = 'draw'; + else result = 'loss'; + await api.post('/history/single-player', { + slug: 'triominoes', + score: totals[0], + opponentScores: totals.slice(1), + result, + }); + } + + delay(ms) { + return new Promise((resolve) => this.time.delayedCall(ms, resolve)); + } +} diff --git a/public/src/games/triominoes/TriominoesLogic.js b/public/src/games/triominoes/TriominoesLogic.js new file mode 100644 index 0000000..6556e7b --- /dev/null +++ b/public/src/games/triominoes/TriominoesLogic.js @@ -0,0 +1,242 @@ +// Tri-Ominoes — pure rules engine. No Phaser, no rendering, no timers. Every +// mutator deep-clones and returns the next state so the scene and AI can look +// ahead freely. +// +// Single-deal match: place tiles edge-to-edge on the triangular grid (touching +// corners must match). Placing a tile scores its pip-sum plus bonuses; drawing +// from the pool costs 5. The deal ends when a player empties their hand (they +// score a going-out bonus of opponents' leftover pips) or the game is blocked +// (lowest leftover pips wins that bonus). Highest score wins. + +import { + buildTileSet, handSizeFor, tileValue, isTripleTile, + cellKey, vertKey, cellVertices, neighborCells, +} from './TriominoesData.js'; + +export const HEXAGON_BONUS = 50; // closing a six-tile hexagon +export const BRIDGE_BONUS = 0; // bridges not scored in this implementation +export const GO_OUT_BONUS = 25; // flat bonus for emptying your hand +export const DRAW_PENALTY = 5; +export const MAX_DRAWS = 3; // per turn before a forced pass + +function shuffle(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; +} + +export function cloneState(s) { + return JSON.parse(JSON.stringify(s)); +} + +export function handPips(hand) { + return hand.reduce((a, t) => a + tileValue(t), 0); +} + +export function createInitialState({ playerNames }) { + const n = playerNames.length; + const tiles = shuffle(buildTileSet()); + const hs = handSizeFor(n); + + const players = playerNames.map((p) => ({ + name: p.name, + isAI: !!p.isAI, + avatar: p.avatar ?? null, + hand: tiles.splice(0, hs), + score: 0, + draws: 0, + })); + + // The opener is whoever holds the highest tile (triple-fives ideal); ties fall + // to the earliest seat. This mirrors the real "highest tile starts" rule. + let opener = 0, openerVal = -1; + players.forEach((p, i) => { + const best = Math.max(...p.hand.map(tileValue)); + if (best > openerVal) { openerVal = best; opener = i; } + }); + + return { + players, + current: opener, + startPlayer: opener, + pool: tiles, // remaining draw pile + board: { cells: {}, verts: {}, vertCount: {} }, + phase: 'playing', // playing -> gameover + consecutivePasses: 0, + placedCount: 0, + winner: null, + lastEvent: null, // { type:'place'|'draw'|'pass'|'go-out'|'blocked', ... } + }; +} + +// ── queries ────────────────────────────────────────────────────────────────── +export const isGameOver = (s) => s.phase === 'gameover'; +export const canDraw = (s) => s.pool.length > 0; +export const isBoardEmpty = (s) => s.placedCount === 0; + +// Empty cells that border at least one placed cell — the only places a tile may +// go. On an empty board the sole frontier cell is the origin (0,0). +export function frontierCells(s) { + if (isBoardEmpty(s)) return [[0, 0]]; + const seen = new Set(); + const out = []; + for (const key of Object.keys(s.board.cells)) { + const { r, c } = s.board.cells[key]; + for (const [nr, nc] of neighborCells(r, c)) { + const k = cellKey(nr, nc); + if (s.board.cells[k] || seen.has(k)) continue; + seen.add(k); + out.push([nr, nc]); + } + } + return out; +} + +// Can tile `t` (triple) be placed at cell (r,c) with the given rotation, i.e. +// every already-fixed touching corner agrees? Returns the assigned corner-value +// triple (clockwise) when legal, else null. +export function tryPlacement(s, t, r, c, rot) { + const verts = cellVertices(r, c); + const vals = [0, 1, 2].map((i) => t.v[(i + rot) % 3]); + for (let i = 0; i < 3; i++) { + const [vr, vx] = verts[i]; + const fixed = s.board.verts[vertKey(vr, vx)]; + if (fixed !== undefined && fixed !== vals[i]) return null; + } + return vals; +} + +// All legal placements for one player: [{ tileIndex, r, c, rot, vals }]. +// Distinct rotations that yield the same corner-values are de-duplicated per cell +// so symmetric tiles don't flood the list. +export function getLegalMoves(s, idx) { + if (s.phase !== 'playing') return []; + const hand = s.players[idx].hand; + const frontier = frontierCells(s); + const moves = []; + hand.forEach((t, tileIndex) => { + for (const [r, c] of frontier) { + const seenVals = new Set(); + for (let rot = 0; rot < 3; rot++) { + const vals = tryPlacement(s, t, r, c, rot); + if (!vals) continue; + const sig = vals.join('-'); + if (seenVals.has(sig)) continue; + seenVals.add(sig); + moves.push({ tileIndex, r, c, rot, vals }); + } + } + }); + return moves; +} + +export const hasLegalMove = (s, idx) => getLegalMoves(s, idx).length > 0; + +// ── mutators ───────────────────────────────────────────────────────────────── +function advance(s) { + s.current = (s.current + 1) % s.players.length; +} + +function endGame(s) { + s.phase = 'gameover'; + let best = -Infinity, w = 0; + s.players.forEach((p, i) => { if (p.score > best) { best = p.score; w = i; } }); + s.winner = w; +} + +// Place a tile. `move` is one of getLegalMoves(). Returns the next state. +export function playTile(s0, move) { + if (s0.phase !== 'playing') return s0; + const s = cloneState(s0); + const player = s.players[s.current]; + const tile = player.hand[move.tileIndex]; + if (!tile) return s0; + + const vals = tryPlacement(s, tile, move.r, move.c, move.rot); + if (!vals) return s0; // illegal — ignore + + const verts = cellVertices(move.r, move.c); + let hexBonus = 0; + for (let i = 0; i < 3; i++) { + const [vr, vx] = verts[i]; + const vk = vertKey(vr, vx); + s.board.verts[vk] = vals[i]; + s.board.vertCount[vk] = (s.board.vertCount[vk] ?? 0) + 1; + if (s.board.vertCount[vk] === 6) hexBonus += HEXAGON_BONUS; + } + s.board.cells[cellKey(move.r, move.c)] = { + r: move.r, c: move.c, vals, owner: s.current, + triple: isTripleTile(tile), + }; + s.placedCount += 1; + + player.hand.splice(move.tileIndex, 1); + const gained = tileValue(tile) + hexBonus; + player.score += gained; + player.draws = 0; + s.consecutivePasses = 0; + s.lastEvent = { type: 'place', seat: s.current, r: move.r, c: move.c, gained, hexBonus }; + + if (player.hand.length === 0) { + // Went out: collect everyone else's leftover pips as a bonus. + const bonus = GO_OUT_BONUS + s.players.reduce( + (a, p, i) => a + (i === s.current ? 0 : handPips(p.hand)), 0, + ); + player.score += bonus; + s.lastEvent = { type: 'go-out', seat: s.current, bonus }; + endGame(s); + return s; + } + + advance(s); + return s; +} + +// Draw one tile from the pool into the current player's hand (costs 5). +export function drawTile(s0) { + if (s0.phase !== 'playing' || s0.pool.length === 0) return s0; + const s = cloneState(s0); + const player = s.players[s.current]; + player.hand.push(s.pool.pop()); + player.draws += 1; + player.score -= DRAW_PENALTY; + s.lastEvent = { type: 'draw', seat: s.current }; + return s; +} + +// Current player gives up the turn. If everyone passes in a row with no pool +// left, the deal is blocked. +export function passTurn(s0) { + if (s0.phase !== 'playing') return s0; + const s = cloneState(s0); + s.players[s.current].draws = 0; + s.consecutivePasses += 1; + s.lastEvent = { type: 'pass', seat: s.current }; + + if (s.pool.length === 0 && s.consecutivePasses >= s.players.length) { + // Blocked: lightest hand wins the leftover-pip bonus. + let lightest = 0, lightPips = Infinity; + s.players.forEach((p, i) => { + const pips = handPips(p.hand); + if (pips < lightPips) { lightPips = pips; lightest = i; } + }); + const bonus = s.players.reduce( + (a, p, i) => a + (i === lightest ? 0 : handPips(p.hand)), 0, + ); + s.players[lightest].score += bonus; + s.lastEvent = { type: 'blocked', seat: lightest, bonus }; + endGame(s); + return s; + } + + advance(s); + return s; +} + +export function getWinners(s) { + const max = Math.max(...s.players.map((p) => p.score)); + return s.players.map((p, i) => ({ i, sc: p.score })) + .filter((x) => x.sc === max).map((x) => x.i); +} diff --git a/public/src/main.js b/public/src/main.js index a7a8b11..ef452c0 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -59,6 +59,7 @@ import FarkelGame from './games/farkel/FarkelGame.js'; import StrategoGame from './games/stratego/StrategoGame.js'; import KiitosGame from './games/kiitos/KiitosGame.js'; import MonopolyGame from './games/monopoly/MonopolyGame.js'; +import TriominoesGame from './games/triominoes/TriominoesGame.js'; const config = { type: Phaser.AUTO, @@ -131,6 +132,7 @@ const config = { StrategoGame, KiitosGame, MonopolyGame, + TriominoesGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 62f221d..3c2d278 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } 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', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame' }; + 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', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 755cdf6..3f7bd4d 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -384,7 +384,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', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego'].includes(this.gameDef.slug)) { + if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes'].includes(this.gameDef.slug)) { bio.style.webkitLineClamp = '1'; const skillRow = document.createElement('div'); diff --git a/server/games/registry.js b/server/games/registry.js index acaf991..88efabb 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -74,3 +74,4 @@ registerGame({ slug: 'farkel', name: 'Farkle', category: ' registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 }); registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 }); registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 }); +registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 49 });