feat: add Azul board game with tutorial videos and modal fix
- Implement Azul (tile-drafting game) with full game logic, AI (5 skill levels), spritesheet rendering, and animated wall-tiling/scoring - Add Inês-flavored tutorial text and 96×96 tile spritesheet - Register Azul in gamesRegistry, main.js, GameRoomScene, OpponentSelectScene, and PreloadScene - Add tutorial videos for Blokus and Chinese Checkers - Update Blokus and Chinese Checkers registry entries with hasTutorial: true - Add Klaxon (mecha) tutorial for Blokus and Kage (samurai) tutorial for Chinese Checkers - Fix TutorialModal close-button click fallthrough with transient shield overlay
This commit is contained in:
parent
b24ca7f31f
commit
e6d0b2ee5d
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 282 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -33,7 +33,7 @@ registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardG
|
||||||
registerGame({ slug: 'yatzi', name: 'Zahtzee', category: 'cards', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 4 });
|
registerGame({ slug: 'yatzi', name: 'Zahtzee', category: 'cards', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 4 });
|
||||||
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 5 });
|
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 5 });
|
||||||
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 6 });
|
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 6 });
|
||||||
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, iconFrame: 7 });
|
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, hasTutorial: true, iconFrame: 7 });
|
||||||
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 8 });
|
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 8 });
|
||||||
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 9 });
|
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 9 });
|
||||||
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 10 });
|
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 10 });
|
||||||
|
|
@ -62,7 +62,7 @@ registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', min
|
||||||
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 33 });
|
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 33 });
|
||||||
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
|
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
|
||||||
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
|
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
|
||||||
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
|
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 36 });
|
||||||
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
|
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
|
||||||
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
|
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
|
||||||
registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 42 });
|
registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 42 });
|
||||||
|
|
@ -101,3 +101,4 @@ registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers
|
||||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 });
|
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 });
|
||||||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||||
|
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Azul AI — synchronous, in-browser heuristic. 1-ply greedy over a board
|
||||||
|
// evaluation, with a skill model (1–5) that adds blunders / score noise and
|
||||||
|
// picks from a widening top-N, exactly the shape used by SplendorAI.
|
||||||
|
|
||||||
|
import {
|
||||||
|
COLORS, WALL_SIZE, FLOOR_PENALTIES, FLOOR_SIZE,
|
||||||
|
lineCapacity, wallColumnFor, ROW_BONUS, COLUMN_BONUS, COLOR_BONUS,
|
||||||
|
} from './AzulData.js';
|
||||||
|
import { legalMoves, applyMove, scorePlacement } from './AzulLogic.js';
|
||||||
|
|
||||||
|
const SKILL_PROFILES = {
|
||||||
|
1: { topN: 5, blunder: 0.45, noise: 14, delay: [700, 1200] },
|
||||||
|
2: { topN: 4, blunder: 0.25, noise: 9, delay: [650, 1100] },
|
||||||
|
3: { topN: 3, blunder: 0.12, noise: 5, delay: [600, 1000] },
|
||||||
|
4: { topN: 2, blunder: 0.04, noise: 2, delay: [520, 900] },
|
||||||
|
5: { topN: 1, blunder: 0.00, noise: 0, delay: [440, 820] },
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static evaluation of a player's board (higher = better). Captures realised
|
||||||
|
// score plus the *potential* locked up in pattern lines, minus floor liability,
|
||||||
|
// plus a nudge toward the lucrative end-game row/column/colour bonuses.
|
||||||
|
function evalPlayer(state, seat) {
|
||||||
|
const p = state.players[seat];
|
||||||
|
let v = p.score * 10;
|
||||||
|
|
||||||
|
// Pattern lines: reward progress, and reward a line that is exactly full
|
||||||
|
// (it will tile and score next phase) by the points it would earn.
|
||||||
|
for (let i = 0; i < WALL_SIZE; i++) {
|
||||||
|
const line = p.lines[i];
|
||||||
|
if (line.color === null) continue;
|
||||||
|
const cap = lineCapacity(i);
|
||||||
|
if (line.count === cap) {
|
||||||
|
const col = wallColumnFor(i, line.color);
|
||||||
|
const wall = p.wall.map((r) => r.slice());
|
||||||
|
wall[i][col] = true;
|
||||||
|
v += scorePlacement(wall, i, col) * 8;
|
||||||
|
} else {
|
||||||
|
// Partial: value tiles already banked, scaled by how close to full it is.
|
||||||
|
v += line.count * 2 + (line.count / cap) * 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor liability: the penalties these tiles will cost.
|
||||||
|
let penalty = 0;
|
||||||
|
for (let k = 0; k < p.floor.length && k < FLOOR_SIZE; k++) penalty += FLOOR_PENALTIES[k];
|
||||||
|
v += penalty * 4;
|
||||||
|
|
||||||
|
// Permanent wall tiles, and proximity to the end-game bonuses.
|
||||||
|
let wallTiles = 0;
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) for (let col = 0; col < WALL_SIZE; col++) if (p.wall[row][col]) wallTiles++;
|
||||||
|
v += wallTiles;
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) {
|
||||||
|
const n = p.wall[row].filter(Boolean).length;
|
||||||
|
if (n === WALL_SIZE) v += ROW_BONUS; else if (n === WALL_SIZE - 1) v += 3;
|
||||||
|
}
|
||||||
|
for (let col = 0; col < WALL_SIZE; col++) {
|
||||||
|
let n = 0; for (let row = 0; row < WALL_SIZE; row++) if (p.wall[row][col]) n++;
|
||||||
|
if (n === WALL_SIZE) v += COLUMN_BONUS; else if (n === WALL_SIZE - 1) v += 4;
|
||||||
|
}
|
||||||
|
for (const c of COLORS) {
|
||||||
|
let n = 0; for (let row = 0; row < WALL_SIZE; row++) if (p.wall[row][wallColumnFor(row, c)]) n++;
|
||||||
|
if (n === WALL_SIZE) v += COLOR_BONUS; else if (n === WALL_SIZE - 1) v += 5;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a single move object for the current player, or null if none exist.
|
||||||
|
export function chooseMove(state, skill) {
|
||||||
|
const prof = profileFor(skill);
|
||||||
|
const seat = state.current;
|
||||||
|
const legal = legalMoves(state);
|
||||||
|
if (legal.length === 0) return null;
|
||||||
|
if (legal.length === 1) return legal[0];
|
||||||
|
|
||||||
|
const ranked = legal
|
||||||
|
.map((move) => {
|
||||||
|
let score = evalPlayer(applyMove(state, move), seat);
|
||||||
|
score += (Math.random() * 2 - 1) * prof.noise;
|
||||||
|
return { move, score };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
if (Math.random() < prof.blunder) {
|
||||||
|
return legal[Math.floor(Math.random() * legal.length)];
|
||||||
|
}
|
||||||
|
const pool = ranked.slice(0, Math.max(1, prof.topN));
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)].move;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Azul — static catalog. No Phaser, no state, no mutations.
|
||||||
|
// The tile palette, the fixed wall colour pattern, the floor-line penalties,
|
||||||
|
// the per-player-count factory math, the end-game bonuses and the spritesheet
|
||||||
|
// frame mapping all live here. Logic / AI / Game import from this file; nothing
|
||||||
|
// here imports back.
|
||||||
|
|
||||||
|
// Tile colours. Order is fixed and used everywhere (bag counts, wall pattern,
|
||||||
|
// spritesheet frames). The fifth "white" tile is the pale-turquoise snowflake
|
||||||
|
// tile from the real game.
|
||||||
|
export const COLORS = ['blue', 'yellow', 'red', 'black', 'white'];
|
||||||
|
|
||||||
|
// Display palette for vector-drawn tiles (used as a fallback when the
|
||||||
|
// spritesheet is absent, and for empty-wall colour hints / selection glows).
|
||||||
|
// Sampled from the azulejo tile art so the wall hints echo each real tile.
|
||||||
|
export const TILE_HEX = {
|
||||||
|
blue: 0x163d63,
|
||||||
|
yellow: 0xda9c28,
|
||||||
|
red: 0xa7311d,
|
||||||
|
black: 0x2e342e,
|
||||||
|
white: 0x81a9ba,
|
||||||
|
};
|
||||||
|
// A contrasting edge colour so a tile reads against same-toned felt.
|
||||||
|
export const TILE_EDGE = {
|
||||||
|
blue: 0x0b1f33,
|
||||||
|
yellow: 0x835e18,
|
||||||
|
red: 0x641d11,
|
||||||
|
black: 0x16191a,
|
||||||
|
white: 0x4d6570,
|
||||||
|
};
|
||||||
|
export const TILE_NAME = {
|
||||||
|
blue: 'Blue', yellow: 'Yellow', red: 'Red', black: 'Black', white: 'White',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wall ─────────────────────────────────────────────────────────────────────
|
||||||
|
// The 5×5 wall has a fixed colour in every cell — a diagonal Latin square.
|
||||||
|
// Row 0: blue yellow red black white; each subsequent row shifts right by one.
|
||||||
|
// wallColor(row, col) = COLORS[(col - row) mod 5]
|
||||||
|
export function wallColor(row, col) {
|
||||||
|
return COLORS[(((col - row) % 5) + 5) % 5];
|
||||||
|
}
|
||||||
|
// The column in `row` that holds `color` (inverse of wallColor).
|
||||||
|
export function wallColumnFor(row, color) {
|
||||||
|
return (COLORS.indexOf(color) + row) % 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rules constants ──────────────────────────────────────────────────────────
|
||||||
|
export const WALL_SIZE = 5;
|
||||||
|
export const TILES_PER_COLOR = 20; // 100 tiles total (5 colours × 20)
|
||||||
|
export const TILES_PER_FACTORY = 4;
|
||||||
|
export const FLOOR_PENALTIES = [-1, -1, -2, -2, -2, -3, -3]; // 7 floor slots
|
||||||
|
export const FLOOR_SIZE = FLOOR_PENALTIES.length;
|
||||||
|
|
||||||
|
// End-of-game bonuses.
|
||||||
|
export const ROW_BONUS = 2; // per complete horizontal wall row
|
||||||
|
export const COLUMN_BONUS = 7; // per complete vertical wall column
|
||||||
|
export const COLOR_BONUS = 10; // per colour with all 5 placed
|
||||||
|
|
||||||
|
// Factory displays scale with the player count: 2→5, 3→7, 4→9.
|
||||||
|
export function factoryCount(playerCount) {
|
||||||
|
return playerCount * 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern line `i` (0-based) holds i+1 tiles.
|
||||||
|
export function lineCapacity(i) { return i + 1; }
|
||||||
|
|
||||||
|
// ── Spritesheet frame mapping (azul-tiles, 96×96 cells, one row) ─────────────
|
||||||
|
// 0 blue 1 yellow 2 red 3 black 4 white 5 first-player marker
|
||||||
|
// The game draws vector tiles when the sheet is absent.
|
||||||
|
export function tileFrame(color) { return COLORS.indexOf(color); }
|
||||||
|
export const FIRST_PLAYER_FRAME = 5;
|
||||||
|
|
@ -0,0 +1,791 @@
|
||||||
|
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 { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||||
|
import {
|
||||||
|
COLORS as TILE_COLORS, TILE_HEX, TILE_EDGE, TILE_NAME,
|
||||||
|
FLOOR_PENALTIES, FLOOR_SIZE, WALL_SIZE,
|
||||||
|
factoryCount, lineCapacity, wallColor, tileFrame, FIRST_PLAYER_FRAME,
|
||||||
|
} from './AzulData.js';
|
||||||
|
import {
|
||||||
|
createInitialState, legalMoves, applyMove, resolveRound,
|
||||||
|
lineAccepts, isGameOver, finalRanking,
|
||||||
|
} from './AzulLogic.js';
|
||||||
|
import { chooseMove, nextThinkDelay } from './AzulAI.js';
|
||||||
|
|
||||||
|
const CX = GAME_WIDTH / 2;
|
||||||
|
const DEPTH = { bg: 0, panel: 6, tray: 8, board: 10, hint: 11, glow: 13, tile: 16, label: 30, ui: 40, banner: 90 };
|
||||||
|
|
||||||
|
// Player boards use the warm cream/tan of the retail cardboard board, so the
|
||||||
|
// on-board text and empty cells are drawn dark for contrast.
|
||||||
|
const BOARD_FILL = 0xe7d9b6;
|
||||||
|
const BOARD_EDGE = 0x9c7c46;
|
||||||
|
const BOARD_INK_HEX = '#3a2c16'; // dark-brown name text
|
||||||
|
const BOARD_SCORE_HEX = '#5c3d0e'; // deep gold score, readable on cream
|
||||||
|
const BOARD_LINE = 0x6b5226; // dark-tan cell outlines (strong on cream)
|
||||||
|
const BOARD_PEN_HEX = '#7a2018'; // dark maroon for floor penalty numbers
|
||||||
|
|
||||||
|
// Opponent mini-boards (top row).
|
||||||
|
const MINI = { tile: 24, cell: 27, wallGap: 16, pad: 10, headerH: 44, floorCell: 22, top: 70, gap: 104 };
|
||||||
|
const MINI_W = MINI.pad * 2 + 5 * MINI.cell + MINI.wallGap + 5 * MINI.cell; // ≈ 296
|
||||||
|
const MINI_H = MINI.headerH + 5 * MINI.cell + 16 + MINI.floorCell + 12; // ≈ 232
|
||||||
|
// Opponent avatars hang mostly to the left of their board, overlapping only a
|
||||||
|
// little; the wide gap leaves room for them between boards.
|
||||||
|
const OPP_PORTRAIT_R = 46;
|
||||||
|
const OPP_PORTRAIT_OVERLAP = 18;
|
||||||
|
|
||||||
|
// Human board (bottom). Its x is set below so the board + instructions panel
|
||||||
|
// pair is centred on screen.
|
||||||
|
const HB = { tile: 42, cell: 50, wallGap: 22, pad: 12, headerH: 42, floorCell: 40, x: 0, y: 690 };
|
||||||
|
const HUMAN_W = HB.pad * 2 + 5 * HB.cell + HB.wallGap + 5 * HB.cell; // ≈ 546
|
||||||
|
const HUMAN_H = HB.headerH + 5 * HB.cell + 16 + HB.floorCell + 12; // ≈ 360
|
||||||
|
const HUMAN_PORTRAIT_R = 56;
|
||||||
|
|
||||||
|
// Instructions panel sits to the right of the board with a small gutter.
|
||||||
|
const PANEL_GAP = 36, PANEL_W = 560;
|
||||||
|
// Centre the [board · gap · panel] group horizontally.
|
||||||
|
HB.x = CX - (HUMAN_W + PANEL_GAP + PANEL_W) / 2;
|
||||||
|
const PANEL_X = HB.x + HUMAN_W + PANEL_GAP;
|
||||||
|
|
||||||
|
// Factory ring (centre band) — an ellipse so it fits between the two rows.
|
||||||
|
const RING = { cx: CX, cy: 470, rx: 250, ry: 116, disc: 44, ftile: 30 };
|
||||||
|
const TRAY = { cx: CX, cy: 470, w: 300, h: 150, tile: 26, gap: 4 };
|
||||||
|
|
||||||
|
export default class AzulGame extends Phaser.Scene {
|
||||||
|
constructor() { super('AzulGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this._initData = { ...data };
|
||||||
|
this.gameDef = data.game;
|
||||||
|
this.opponents = data.opponents ?? [];
|
||||||
|
this.playfield = data.playfield ?? null;
|
||||||
|
this.humanSeat = 0;
|
||||||
|
this.gs = null;
|
||||||
|
this.busy = false;
|
||||||
|
this.sel = null; // { source, color } currently picked up
|
||||||
|
this.dyn = []; // dynamic objects rebuilt every render
|
||||||
|
this.portraits = [];
|
||||||
|
this.skillBySeat = {};
|
||||||
|
this.animating = null; // suppresses just-placed tiles while they fly in
|
||||||
|
this.scoreHide = null; // during round scoring: seat → Set(rows) to hide
|
||||||
|
this.scoreOverlay = []; // persistent landed wall tiles during scoring
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
|
||||||
|
this.hasArt = this.textures.exists('azul-tiles');
|
||||||
|
|
||||||
|
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
|
||||||
|
const names = [];
|
||||||
|
for (let seat = 0; seat < playerCount; seat++) {
|
||||||
|
if (seat === this.humanSeat) {
|
||||||
|
names.push(auth.user?.username ?? 'You');
|
||||||
|
this.skillBySeat[seat] = 5;
|
||||||
|
} else {
|
||||||
|
const opp = this.opponents[seat - 1];
|
||||||
|
names.push(opp?.name ?? `Player ${seat}`);
|
||||||
|
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildBackground();
|
||||||
|
this.gs = createInitialState({ playerCount, names });
|
||||||
|
this.buildPortraits();
|
||||||
|
this.render();
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── chrome ──────────────────────────────────────────────────────────────────
|
||||||
|
buildBackground() {
|
||||||
|
const pf = this.playfield;
|
||||||
|
if (pf?.key && this.textures.exists(pf.key)) {
|
||||||
|
this.add.image(CX, GAME_HEIGHT / 2, pf.key)
|
||||||
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||||
|
} else {
|
||||||
|
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||||
|
g.fillGradientStyle(0x16130d, 0x16130d, 0x070503, 0x070503, 1);
|
||||||
|
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||||
|
}
|
||||||
|
this.add.text(CX, 22, 'Azul', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||||
|
|
||||||
|
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 34, 'Leave', () => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPortraits() {
|
||||||
|
// Human avatar hangs off the left edge of their board, like the opponents.
|
||||||
|
const hp = this.humanPortraitPos();
|
||||||
|
this.portraits[this.humanSeat] = createPlayerPortrait(this, hp.x, hp.y, HUMAN_PORTRAIT_R, DEPTH.ui + 1, 'AzulGame');
|
||||||
|
// Opponent avatars hang off the left edge of each mini-board.
|
||||||
|
for (let k = 0; k < this.nOpp; k++) {
|
||||||
|
const pos = this.oppPortraitPos(k);
|
||||||
|
this.portraits[k + 1] = createOpponentPortrait(this, this.opponents[k], pos.x, pos.y, OPP_PORTRAIT_R, DEPTH.ui + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oppPortraitPos(k) {
|
||||||
|
const o = this.miniOrigin(k);
|
||||||
|
return { x: o.x - OPP_PORTRAIT_R + OPP_PORTRAIT_OVERLAP, y: o.y + 50 };
|
||||||
|
}
|
||||||
|
humanPortraitPos() {
|
||||||
|
return { x: HB.x - HUMAN_PORTRAIT_R + OPP_PORTRAIT_OVERLAP, y: HB.y + HUMAN_PORTRAIT_R + 8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── geometry helpers ─────────────────────────────────────────────────────────
|
||||||
|
get nOpp() { return this.gs.playerCount - 1; }
|
||||||
|
|
||||||
|
miniOrigin(k) {
|
||||||
|
const rowW = this.nOpp * MINI_W + (this.nOpp - 1) * MINI.gap;
|
||||||
|
const startX = CX - rowW / 2;
|
||||||
|
return { x: startX + k * (MINI_W + MINI.gap), y: MINI.top };
|
||||||
|
}
|
||||||
|
humanOrigin() { return { x: HB.x, y: HB.y }; }
|
||||||
|
|
||||||
|
boardScoreAnchor(seat) {
|
||||||
|
if (seat === this.humanSeat) { const o = this.humanOrigin(); return { x: o.x + HUMAN_W - 30, y: o.y + 20 }; }
|
||||||
|
const o = this.miniOrigin(seat - 1); return { x: o.x + MINI_W - 26, y: o.y + 20 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-seat board metrics + cell-centre positions (shared by render & animation).
|
||||||
|
boardLayout(seat) {
|
||||||
|
return (seat === this.humanSeat)
|
||||||
|
? { origin: this.humanOrigin(), m: HB }
|
||||||
|
: { origin: this.miniOrigin(seat - 1), m: MINI };
|
||||||
|
}
|
||||||
|
patternCellPos(seat, row, colIndex) {
|
||||||
|
const { origin, m } = this.boardLayout(seat);
|
||||||
|
return {
|
||||||
|
x: origin.x + m.pad + colIndex * m.cell + m.cell / 2,
|
||||||
|
y: origin.y + m.headerH + row * m.cell + m.cell / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
floorCellPos(seat, k) {
|
||||||
|
const { origin, m } = this.boardLayout(seat);
|
||||||
|
return {
|
||||||
|
x: origin.x + m.pad + k * (m.floorCell + 2) + m.floorCell / 2,
|
||||||
|
y: origin.y + m.headerH + 5 * m.cell + 16 + m.floorCell / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen positions of the selected-colour tiles currently in their source.
|
||||||
|
sourceTilePositions(source, color) {
|
||||||
|
const out = [];
|
||||||
|
if (source.type === 'factory') {
|
||||||
|
const n = factoryCount(this.gs.playerCount);
|
||||||
|
const ang = -Math.PI / 2 + (source.index / n) * Math.PI * 2;
|
||||||
|
const dx = RING.cx + Math.cos(ang) * RING.rx;
|
||||||
|
const dy = RING.cy + Math.sin(ang) * RING.ry;
|
||||||
|
const off = RING.ftile / 2 + 1;
|
||||||
|
const slots = [[-off, -off], [off, -off], [-off, off], [off, off]];
|
||||||
|
this.gs.factories[source.index].forEach((c, ti) => {
|
||||||
|
if (c === color) { const [sx, sy] = slots[ti] ?? [0, 0]; out.push({ x: dx + sx, y: dy + sy }); }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const items = [];
|
||||||
|
if (this.gs.centerHasFirst) items.push('first');
|
||||||
|
for (const cc of TILE_COLORS) for (const t of this.gs.center) if (t === cc) items.push(t);
|
||||||
|
const step = TRAY.tile + TRAY.gap;
|
||||||
|
const perRow = Math.max(1, Math.floor((TRAY.w - 16) / step));
|
||||||
|
const left = TRAY.cx - (perRow * step - TRAY.gap) / 2 + TRAY.tile / 2;
|
||||||
|
const top = TRAY.cy - TRAY.h / 2 + 18 + TRAY.tile / 2;
|
||||||
|
items.forEach((c, idx) => {
|
||||||
|
if (c !== color) return;
|
||||||
|
out.push({ x: left + (idx % perRow) * step, y: top + Math.floor(idx / perRow) * step });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final resting cell positions for the tiles a move places (pre-move state).
|
||||||
|
destTilePositions(seat, move) {
|
||||||
|
const p = this.gs.players[seat];
|
||||||
|
const color = move.color;
|
||||||
|
const taken = (move.source.type === 'factory')
|
||||||
|
? this.gs.factories[move.source.index].filter((t) => t === color).length
|
||||||
|
: this.gs.center.filter((t) => t === color).length;
|
||||||
|
const out = [];
|
||||||
|
// Taking from the centre also drops the first-player marker on the floor,
|
||||||
|
// which shifts any overflow tiles one slot to the right.
|
||||||
|
const firstExtra = (move.source.type === 'center' && this.gs.centerHasFirst) ? 1 : 0;
|
||||||
|
const preFloor = p.floor.length + firstExtra;
|
||||||
|
const floorSlot = (k) => this.floorCellPos(seat, Math.min(preFloor + k, FLOOR_SIZE - 1));
|
||||||
|
if (move.line === 'floor') {
|
||||||
|
for (let k = 0; k < taken; k++) out.push(floorSlot(k));
|
||||||
|
} else {
|
||||||
|
const cap = lineCapacity(move.line);
|
||||||
|
const oldCount = p.lines[move.line].count;
|
||||||
|
const placed = Math.min(cap - oldCount, taken);
|
||||||
|
for (let j = 0; j < placed; j++) out.push(this.patternCellPos(seat, move.line, 5 - oldCount - 1 - j));
|
||||||
|
for (let k = 0; k < taken - placed; k++) out.push(floorSlot(k));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── render plumbing ──────────────────────────────────────────────────────────
|
||||||
|
reg(obj) { this.dyn.push(obj); return obj; }
|
||||||
|
clearDyn() { for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } } this.dyn = []; }
|
||||||
|
|
||||||
|
canInput() {
|
||||||
|
return !this.busy && this.gs.phase === 'offer' && this.gs.current === this.humanSeat;
|
||||||
|
}
|
||||||
|
pname(seat) { return this.gs.players[seat]?.name ?? `Player ${seat}`; }
|
||||||
|
sameSource(a, b) { return a.type === b.type && (a.type === 'center' || a.index === b.index); }
|
||||||
|
|
||||||
|
// ── a single tile (sprite or vector fallback) ────────────────────────────────
|
||||||
|
// Dark contour + soft drop shadow built from tinted duplicate sprites (the PNG
|
||||||
|
// alpha defines the shape, so they wrap only visible pixels). Plain batched
|
||||||
|
// sprites — no postFX — so they don't flicker while tweens are running.
|
||||||
|
addTileFx(cx, cy, size, frame, depth) {
|
||||||
|
const sw = Math.max(1.5, size * 0.045); // outline thickness
|
||||||
|
const sh = Math.max(1.5, size * 0.06); // shadow drop
|
||||||
|
// Drop shadow (furthest back), kept subtle.
|
||||||
|
this.reg(this.add.image(cx, cy + sh, 'azul-tiles', frame)
|
||||||
|
.setDisplaySize(size, size).setTintFill(0x000000).setAlpha(0.25).setDepth(depth - 2));
|
||||||
|
// Dark outline: silhouettes nudged in 8 directions.
|
||||||
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
|
||||||
|
for (const [dx, dy] of dirs) {
|
||||||
|
this.reg(this.add.image(cx + dx * sw, cy + dy * sw, 'azul-tiles', frame)
|
||||||
|
.setDisplaySize(size, size).setTintFill(0x140d04).setDepth(depth - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeTile(cx, cy, size, color, { depth = DEPTH.tile, selectable = false, onPick = null, highlight = false, fx = false } = {}) {
|
||||||
|
if (highlight) {
|
||||||
|
this.reg(this.add.rectangle(cx, cy, size + 8, size + 8, COLORS.gold, 0)
|
||||||
|
.setStrokeStyle(3, COLORS.gold).setDepth(depth - 1));
|
||||||
|
}
|
||||||
|
let main;
|
||||||
|
if (this.hasArt) {
|
||||||
|
const frame = color === 'first' ? FIRST_PLAYER_FRAME : tileFrame(color);
|
||||||
|
if (fx) this.addTileFx(cx, cy, size, frame, depth);
|
||||||
|
main = this.add.image(cx, cy, 'azul-tiles', frame).setDisplaySize(size, size).setDepth(depth);
|
||||||
|
} else {
|
||||||
|
const hex = color === 'first' ? 0x2c2b30 : TILE_HEX[color];
|
||||||
|
const edge = color === 'first' ? 0xdfe6e9 : TILE_EDGE[color];
|
||||||
|
main = this.add.rectangle(cx, cy, size, size, hex).setStrokeStyle(2, edge).setDepth(depth);
|
||||||
|
if (color === 'first') {
|
||||||
|
this.reg(this.add.text(cx, cy, '1', { fontFamily: 'Righteous', fontSize: `${Math.floor(size * 0.6)}px`, color: '#eef' })
|
||||||
|
.setOrigin(0.5).setDepth(depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.reg(main);
|
||||||
|
if (selectable && onPick) {
|
||||||
|
main.setInteractive({ useHandCursor: true });
|
||||||
|
main.on('pointerdown', () => { if (this.canInput()) onPick(); });
|
||||||
|
}
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transient tile that flies between source and destination. Not registered
|
||||||
|
// in `dyn` (so render()'s clearDyn won't kill it mid-flight); the tween frees it.
|
||||||
|
makeFlyingTile(x, y, size, color, depth = DEPTH.banner - 4) {
|
||||||
|
if (this.hasArt) {
|
||||||
|
const frame = color === 'first' ? FIRST_PLAYER_FRAME : tileFrame(color);
|
||||||
|
return this.add.image(x, y, 'azul-tiles', frame).setDisplaySize(size, size).setDepth(depth);
|
||||||
|
}
|
||||||
|
const hex = color === 'first' ? 0x2c2b30 : TILE_HEX[color];
|
||||||
|
return this.add.rectangle(x, y, size, size, hex).setStrokeStyle(2, TILE_EDGE[color] ?? 0xdfe6e9).setDepth(depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyCell(cx, cy, size, depth = DEPTH.board + 1) {
|
||||||
|
this.reg(this.add.rectangle(cx, cy, size, size, 0x000000, 0.14).setStrokeStyle(2, BOARD_LINE, 0.85).setDepth(depth));
|
||||||
|
}
|
||||||
|
hintCell(cx, cy, size, color, depth = DEPTH.board + 1) {
|
||||||
|
this.reg(this.add.rectangle(cx, cy, size, size, TILE_HEX[color], 0.42).setStrokeStyle(2, BOARD_LINE, 0.85).setDepth(depth));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── full render ──────────────────────────────────────────────────────────────
|
||||||
|
render() {
|
||||||
|
this.clearDyn();
|
||||||
|
this.drawSupply();
|
||||||
|
for (let k = 0; k < this.nOpp; k++) this.drawBoard(k + 1, this.miniOrigin(k), MINI, false);
|
||||||
|
this.drawBoard(this.humanSeat, this.humanOrigin(), { ...HB, cell: HB.cell, tile: HB.tile, wallGap: HB.wallGap, pad: HB.pad, headerH: HB.headerH, floorCell: HB.floorCell }, true);
|
||||||
|
this.drawStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factories ring + centre pool.
|
||||||
|
drawSupply() {
|
||||||
|
const n = factoryCount(this.gs.playerCount);
|
||||||
|
|
||||||
|
// Centre tray (drawn under the discs) — same cream as the player boards.
|
||||||
|
this.reg(this.add.rectangle(TRAY.cx, TRAY.cy, TRAY.w, TRAY.h, BOARD_FILL, 0.97)
|
||||||
|
.setStrokeStyle(2, BOARD_EDGE, 0.85).setDepth(DEPTH.tray));
|
||||||
|
this.reg(this.add.text(TRAY.cx, TRAY.cy - TRAY.h / 2 - 16, 'Centre', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.tray));
|
||||||
|
this.drawCenterTiles();
|
||||||
|
|
||||||
|
// Factory discs evenly spaced around the ellipse, top-anchored.
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ang = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
||||||
|
const dx = RING.cx + Math.cos(ang) * RING.rx;
|
||||||
|
const dy = RING.cy + Math.sin(ang) * RING.ry;
|
||||||
|
this.reg(this.add.circle(dx, dy, RING.disc, BOARD_FILL).setStrokeStyle(3, BOARD_EDGE, 0.9).setDepth(DEPTH.board));
|
||||||
|
const tiles = this.gs.factories[i];
|
||||||
|
const off = RING.ftile / 2 + 1;
|
||||||
|
const slots = [[-off, -off], [off, -off], [-off, off], [off, off]];
|
||||||
|
tiles.forEach((color, ti) => {
|
||||||
|
const [sx, sy] = slots[ti] ?? [0, 0];
|
||||||
|
const highlight = this.sel && this.sameSource(this.sel.source, { type: 'factory', index: i }) && this.sel.color === color;
|
||||||
|
this.makeTile(dx + sx, dy + sy, RING.ftile, color, {
|
||||||
|
selectable: true,
|
||||||
|
highlight,
|
||||||
|
onPick: () => this.onTileClick({ type: 'factory', index: i }, color),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCenterTiles() {
|
||||||
|
const items = [];
|
||||||
|
if (this.gs.centerHasFirst) items.push('first');
|
||||||
|
// Group by colour for a tidy read.
|
||||||
|
for (const c of TILE_COLORS) for (const t of this.gs.center) if (t === c) items.push(t);
|
||||||
|
|
||||||
|
const step = TRAY.tile + TRAY.gap;
|
||||||
|
const perRow = Math.max(1, Math.floor((TRAY.w - 16) / step));
|
||||||
|
const left = TRAY.cx - (perRow * step - TRAY.gap) / 2 + TRAY.tile / 2;
|
||||||
|
const top = TRAY.cy - TRAY.h / 2 + 18 + TRAY.tile / 2;
|
||||||
|
items.forEach((color, idx) => {
|
||||||
|
const r = Math.floor(idx / perRow), c = idx % perRow;
|
||||||
|
const x = left + c * step;
|
||||||
|
const y = top + r * step;
|
||||||
|
if (color === 'first') {
|
||||||
|
this.makeTile(x, y, TRAY.tile, 'first'); // marker is not selectable
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const highlight = this.sel && this.sameSource(this.sel.source, { type: 'center' }) && this.sel.color === color;
|
||||||
|
this.makeTile(x, y, TRAY.tile, color, {
|
||||||
|
selectable: true,
|
||||||
|
highlight,
|
||||||
|
onPick: () => this.onTileClick({ type: 'center' }, color),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One player board (mini or full). `m` carries the per-size metrics.
|
||||||
|
drawBoard(seat, origin, m, interactive) {
|
||||||
|
const p = this.gs.players[seat];
|
||||||
|
const w = (m === MINI) ? MINI_W : HUMAN_W;
|
||||||
|
const h = (m === MINI) ? MINI_H : HUMAN_H;
|
||||||
|
const ox = origin.x, oy = origin.y;
|
||||||
|
const active = this.gs.phase === 'offer' && this.gs.current === seat;
|
||||||
|
|
||||||
|
// Panel — warm cream cardboard, like the retail player board.
|
||||||
|
this.reg(this.add.rectangle(ox + w / 2, oy + h / 2, w, h, BOARD_FILL, 0.97)
|
||||||
|
.setStrokeStyle(active ? 4 : 2, active ? COLORS.gold : BOARD_EDGE, active ? 1 : 0.85)
|
||||||
|
.setDepth(DEPTH.panel));
|
||||||
|
|
||||||
|
// Header: name + score (dark ink on cream).
|
||||||
|
const nameX = ox + m.pad + 16; // clears the left-hanging avatar
|
||||||
|
this.reg(this.add.text(nameX, oy + 12, p.name + (seat === this.humanSeat ? ' (you)' : ''), {
|
||||||
|
fontFamily: 'Righteous', fontSize: (m === MINI ? '16px' : '20px'), color: BOARD_INK_HEX,
|
||||||
|
}).setOrigin(0, 0).setDepth(DEPTH.label));
|
||||||
|
this.reg(this.add.text(ox + w - m.pad, oy + 10, `${p.score}`, {
|
||||||
|
fontFamily: 'Righteous', fontSize: (m === MINI ? '22px' : '30px'), color: BOARD_SCORE_HEX,
|
||||||
|
}).setOrigin(1, 0).setDepth(DEPTH.label));
|
||||||
|
|
||||||
|
const bodyTop = oy + m.headerH;
|
||||||
|
const patternLeft = ox + m.pad;
|
||||||
|
const wallLeft = patternLeft + 5 * m.cell + m.wallGap;
|
||||||
|
// While a move's tiles fly in, keep their resting cells empty on this board.
|
||||||
|
const anim = (this.animating && this.animating.seat === seat) ? this.animating : null;
|
||||||
|
const fx = (seat === this.humanSeat); // dark stroke + shadow on the player's tiles
|
||||||
|
|
||||||
|
// Pattern lines (capacity i+1, filled right-to-left) and the wall.
|
||||||
|
for (let i = 0; i < WALL_SIZE; i++) {
|
||||||
|
const rowCY = bodyTop + i * m.cell + m.cell / 2;
|
||||||
|
const cap = lineCapacity(i);
|
||||||
|
const line = p.lines[i];
|
||||||
|
let shownCount = (anim && anim.line === i) ? anim.lineShown : line.count;
|
||||||
|
if (this.scoreHide && this.scoreHide[seat] && this.scoreHide[seat].has(i)) shownCount = 0;
|
||||||
|
for (let cidx = 5 - cap; cidx < 5; cidx++) {
|
||||||
|
const cellCX = patternLeft + cidx * m.cell + m.cell / 2;
|
||||||
|
const filledFromRight = cidx >= 5 - shownCount;
|
||||||
|
if (filledFromRight && line.color) this.makeTile(cellCX, rowCY, m.tile, line.color, { fx });
|
||||||
|
else this.emptyCell(cellCX, rowCY, m.tile);
|
||||||
|
}
|
||||||
|
// Wall row.
|
||||||
|
for (let j = 0; j < WALL_SIZE; j++) {
|
||||||
|
const cellCX = wallLeft + j * m.cell + m.cell / 2;
|
||||||
|
const wc = wallColor(i, j);
|
||||||
|
if (p.wall[i][j]) this.makeTile(cellCX, rowCY, m.tile, wc, { fx });
|
||||||
|
else this.hintCell(cellCX, rowCY, m.tile, wc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive destination zone for the human's legal lines.
|
||||||
|
if (interactive && this.sel && this.canInput() && lineAccepts(p, i, this.sel.color)) {
|
||||||
|
const zone = this.add.rectangle(patternLeft + (5 * m.cell) / 2, rowCY, 5 * m.cell, m.cell, COLORS.gold, 0.12)
|
||||||
|
.setStrokeStyle(2, COLORS.gold, 0.9).setDepth(DEPTH.glow).setInteractive({ useHandCursor: true });
|
||||||
|
zone.on('pointerdown', () => this.onLineClick(i));
|
||||||
|
this.reg(zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor line.
|
||||||
|
const floorY = bodyTop + 5 * m.cell + 16 + m.floorCell / 2;
|
||||||
|
const floorShown = anim ? anim.floorShown : p.floor.length;
|
||||||
|
for (let k = 0; k < FLOOR_SIZE; k++) {
|
||||||
|
const cellCX = patternLeft + k * (m.floorCell + 2) + m.floorCell / 2;
|
||||||
|
this.emptyCell(cellCX, floorY, m.floorCell);
|
||||||
|
this.reg(this.add.text(cellCX, floorY - m.floorCell / 2 - 7, `${FLOOR_PENALTIES[k]}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: (m === MINI ? '10px' : '13px'), color: BOARD_PEN_HEX,
|
||||||
|
}).setOrigin(0.5, 1).setDepth(DEPTH.label));
|
||||||
|
if (k < floorShown && p.floor[k] !== undefined) this.makeTile(cellCX, floorY, m.floorCell - 4, p.floor[k], { fx });
|
||||||
|
}
|
||||||
|
if (interactive && this.sel && this.canInput()) {
|
||||||
|
const fw = FLOOR_SIZE * (m.floorCell + 2);
|
||||||
|
const zone = this.add.rectangle(patternLeft + fw / 2 - 1, floorY, fw, m.floorCell, COLORS.danger, 0.10)
|
||||||
|
.setStrokeStyle(2, COLORS.danger, 0.7).setDepth(DEPTH.glow).setInteractive({ useHandCursor: true });
|
||||||
|
zone.on('pointerdown', () => this.onLineClick('floor'));
|
||||||
|
this.reg(zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── status panel (right of the human board) ──────────────────────────────────
|
||||||
|
drawStatus() {
|
||||||
|
// Panel mirrors the human board's vertical band (same top & height) and sits
|
||||||
|
// just to its right, so the two read as a balanced pair.
|
||||||
|
const PX = PANEL_X, PY = HB.y, PW = PANEL_W, PH = HUMAN_H, R = 18;
|
||||||
|
const cx = PX + PW / 2;
|
||||||
|
|
||||||
|
const g = this.add.graphics().setDepth(DEPTH.panel);
|
||||||
|
g.fillStyle(0x000000, 0.72);
|
||||||
|
g.fillRoundedRect(PX, PY, PW, PH, R);
|
||||||
|
g.lineStyle(2.5, COLORS.accent, 0.75);
|
||||||
|
g.strokeRoundedRect(PX, PY, PW, PH, R);
|
||||||
|
this.reg(g);
|
||||||
|
|
||||||
|
// Header + divider.
|
||||||
|
this.reg(this.add.text(cx, PY + 32, `Round ${this.gs.round}`, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.label));
|
||||||
|
const div = this.add.graphics().setDepth(DEPTH.label);
|
||||||
|
div.lineStyle(1.5, COLORS.accent, 0.4);
|
||||||
|
div.lineBetween(PX + 44, PY + 62, PX + PW - 44, PY + 62);
|
||||||
|
this.reg(div);
|
||||||
|
|
||||||
|
// Status message (centred).
|
||||||
|
let msg, color = COLORS.textHex;
|
||||||
|
if (isGameOver(this.gs)) { msg = 'Game over'; color = COLORS.mutedHex; }
|
||||||
|
else if (this.gs.current !== this.humanSeat) { msg = `${this.pname(this.gs.current)}\nis thinking…`; color = COLORS.mutedHex; }
|
||||||
|
else if (this.sel) {
|
||||||
|
const src = this.sel.source.type === 'center' ? 'the centre' : `factory ${this.sel.source.index + 1}`;
|
||||||
|
msg = `Picked ${TILE_NAME[this.sel.color]} from ${src}.\n\nClick a glowing pattern line,\nor the floor line.`;
|
||||||
|
color = COLORS.goldHex;
|
||||||
|
} else {
|
||||||
|
msg = 'Your turn.\n\nClick a tile to take every tile of\nthat colour from a factory or the centre.';
|
||||||
|
}
|
||||||
|
this.reg(this.add.text(cx, PY + 168, msg, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '21px', color, align: 'center', lineSpacing: 8,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.label));
|
||||||
|
|
||||||
|
// Cancel button (centred) while a pick is held.
|
||||||
|
if (this.sel && this.canInput()) {
|
||||||
|
this.reg(new Button(this, cx, PY + 270, 'Cancel pick', () => { this.sel = null; this.render(); },
|
||||||
|
{ variant: 'ghost', width: 180, height: 42, fontSize: 18 }).setDepth(DEPTH.ui));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile-bag summary at the foot.
|
||||||
|
const bagTotal = TILE_COLORS.reduce((s, c) => s + this.gs.bag[c], 0);
|
||||||
|
const lidTotal = TILE_COLORS.reduce((s, c) => s + this.gs.lid[c], 0);
|
||||||
|
this.reg(this.add.text(cx, PY + PH - 30, `Bag ${bagTotal} · Discard ${lidTotal}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input ────────────────────────────────────────────────────────────────────
|
||||||
|
onTileClick(source, color) {
|
||||||
|
if (!this.canInput()) return;
|
||||||
|
if (this.sel && this.sameSource(this.sel.source, source) && this.sel.color === color) {
|
||||||
|
this.sel = null; // toggle off
|
||||||
|
} else {
|
||||||
|
this.sel = { source, color };
|
||||||
|
playSound(this, SFX.UI_PICK);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
onLineClick(line) {
|
||||||
|
if (!this.canInput() || !this.sel) return;
|
||||||
|
const move = { source: this.sel.source, color: this.sel.color, line };
|
||||||
|
// Guard: pattern-line moves must be legal (floor always is).
|
||||||
|
if (line !== 'floor' && !lineAccepts(this.gs.players[this.humanSeat], line, this.sel.color)) return;
|
||||||
|
this.sel = null;
|
||||||
|
this.busy = true;
|
||||||
|
this.animateMove(this.humanSeat, move, () => this.advance());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply `move` for `seat`, but fly the taken tiles from their source to their
|
||||||
|
// resting cells over ~0.75s before the board reveals them.
|
||||||
|
animateMove(seat, move, onComplete) {
|
||||||
|
const color = move.color;
|
||||||
|
const srcs = this.sourceTilePositions(move.source, color);
|
||||||
|
const dests = this.destTilePositions(seat, move);
|
||||||
|
const destSize = this.boardLayout(seat).m.tile;
|
||||||
|
|
||||||
|
// Remember how much of the destination was already present so render() can
|
||||||
|
// hide the newly-placed tiles until they land.
|
||||||
|
const p = this.gs.players[seat];
|
||||||
|
this.animating = {
|
||||||
|
seat,
|
||||||
|
line: (move.line === 'floor') ? null : move.line,
|
||||||
|
lineShown: (move.line === 'floor') ? 0 : p.lines[move.line].count,
|
||||||
|
floorShown: p.floor.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gs = applyMove(this.gs, move);
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
const n = Math.max(srcs.length, dests.length);
|
||||||
|
if (n === 0) { this.finishAnimateMove(onComplete); return; }
|
||||||
|
|
||||||
|
let landed = 0;
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const s = srcs[k] ?? srcs[srcs.length - 1] ?? dests[k];
|
||||||
|
const d = dests[k] ?? dests[dests.length - 1];
|
||||||
|
const fly = this.makeFlyingTile(s.x, s.y, destSize, color);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: fly, x: d.x, y: d.y, duration: 750, ease: 'Cubic.easeInOut',
|
||||||
|
delay: k * 40,
|
||||||
|
onComplete: () => { fly.destroy(); if (++landed >= n) this.finishAnimateMove(onComplete); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishAnimateMove(onComplete) {
|
||||||
|
this.animating = null;
|
||||||
|
this.render();
|
||||||
|
playSound(this, SFX.UI_PLACE);
|
||||||
|
this.time.delayedCall(80, () => onComplete && onComplete());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── turn loop ────────────────────────────────────────────────────────────────
|
||||||
|
advance() {
|
||||||
|
if (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; }
|
||||||
|
if (this.gs.phase === 'scoring') { this.resolveRoundAnimated(); return; }
|
||||||
|
// Safety net: a deal that leaves nothing draftable (no tiles anywhere) can't
|
||||||
|
// progress — end on current scores rather than hang. Practically unreachable.
|
||||||
|
if (legalMoves(this.gs).length === 0) { this.render(); this.onGameOver(); return; }
|
||||||
|
if (this.gs.current === this.humanSeat) { this.busy = false; this.render(); return; }
|
||||||
|
this.aiTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
aiTurn() {
|
||||||
|
this.busy = true;
|
||||||
|
this.render();
|
||||||
|
const seat = this.gs.current;
|
||||||
|
const skill = this.skillBySeat[seat];
|
||||||
|
this.showBanner(`${this.pname(seat)}'s turn`);
|
||||||
|
this.time.delayedCall(nextThinkDelay(skill), () => {
|
||||||
|
const move = chooseMove(this.gs, skill);
|
||||||
|
if (!move) { this.busy = false; this.advance(); return; }
|
||||||
|
this.animateMove(seat, move, () => this.advance());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-of-round scoring. Renders the pre-tiling state (completed pattern lines
|
||||||
|
// still full), then animates each board in turn: every completed row merges
|
||||||
|
// into one tile and flies to its wall cell. The resolved state is only shown
|
||||||
|
// once every board has finished.
|
||||||
|
resolveRoundAnimated() {
|
||||||
|
this.busy = true;
|
||||||
|
this.showBanner(`Wall-tiling — round ${this.gs.round}`);
|
||||||
|
this.render(); // pre-resolve frame
|
||||||
|
const resolved = resolveRound(this.gs);
|
||||||
|
const summary = resolved.roundSummary ?? [];
|
||||||
|
playSound(this, SFX.UI_CHIME);
|
||||||
|
|
||||||
|
this.scoreHide = {}; // seat → Set(rows) whose pattern tiles are hidden
|
||||||
|
this.scoreOverlay = []; // persistent "landed" wall tiles drawn over the board
|
||||||
|
|
||||||
|
this.animateScoring(summary, () => {
|
||||||
|
for (const o of this.scoreOverlay) { try { o.destroy(); } catch { /* noop */ } }
|
||||||
|
this.scoreOverlay = [];
|
||||||
|
this.scoreHide = null;
|
||||||
|
this.gs = resolved;
|
||||||
|
this.render();
|
||||||
|
this.time.delayedCall(500, () => { this.busy = false; this.advance(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the boards one at a time (player first), animating each in full.
|
||||||
|
animateScoring(summary, onDone) {
|
||||||
|
const order = this.gs.players.map((_, i) => i);
|
||||||
|
let idx = 0;
|
||||||
|
const nextSeat = () => {
|
||||||
|
if (idx >= order.length) { onDone(); return; }
|
||||||
|
const seat = order[idx++];
|
||||||
|
const entry = summary.find((e) => e.seat === seat) ?? { placements: [], floorPenalty: 0 };
|
||||||
|
this.animateSeatScoring(seat, entry, nextSeat);
|
||||||
|
};
|
||||||
|
nextSeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
animateSeatScoring(seat, entry, done) {
|
||||||
|
const placements = entry.placements ?? [];
|
||||||
|
const hasFloor = (entry.floorPenalty ?? 0) < 0;
|
||||||
|
if (placements.length === 0 && !hasFloor) { done(); return; }
|
||||||
|
|
||||||
|
this.flashBoard(seat);
|
||||||
|
// Opponents react: happy when they bank a wall tile, upset on a net loss.
|
||||||
|
const emotion = (seat !== this.humanSeat) ? this.scoringEmotion(entry) : null;
|
||||||
|
|
||||||
|
if (placements.length === 0) { // floor penalty only
|
||||||
|
const a = this.boardScoreAnchor(seat);
|
||||||
|
this.floatText(a.x - 10, a.y + 30, `${entry.floorPenalty}`, COLORS.dangerHex, { size: 24 });
|
||||||
|
if (emotion) this.portraits[seat]?.playEmotion?.(emotion);
|
||||||
|
this.time.delayedCall(650, done);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide every completing row's pattern tiles up front, then fly each in.
|
||||||
|
if (!this.scoreHide[seat]) this.scoreHide[seat] = new Set();
|
||||||
|
for (const pl of placements) this.scoreHide[seat].add(pl.row);
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
// A happy reaction fires as the first wall tile lands (~merge + move time).
|
||||||
|
if (emotion === 'happy') this.time.delayedCall(720, () => this.portraits[seat]?.playEmotion?.('happy'));
|
||||||
|
|
||||||
|
let pending = placements.length;
|
||||||
|
const rowFinished = () => {
|
||||||
|
if (--pending > 0) return;
|
||||||
|
if (hasFloor) {
|
||||||
|
const a = this.boardScoreAnchor(seat);
|
||||||
|
this.floatText(a.x - 10, a.y + 30, `${entry.floorPenalty}`, COLORS.dangerHex, { size: 24 });
|
||||||
|
}
|
||||||
|
if (emotion === 'upset') this.portraits[seat]?.playEmotion?.('upset');
|
||||||
|
this.time.delayedCall(hasFloor ? 550 : 300, done);
|
||||||
|
};
|
||||||
|
placements.forEach((pl, i) => {
|
||||||
|
this.time.delayedCall(i * 120, () => this.animateScoreRow(seat, pl, rowFinished));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which reaction (if any) an opponent shows for a round. Completing a row is
|
||||||
|
// happy unless floor penalties drag the round's net score negative, in which
|
||||||
|
// case (or with no placements at all) they're upset.
|
||||||
|
scoringEmotion(entry) {
|
||||||
|
const placements = entry.placements ?? [];
|
||||||
|
const penalty = entry.floorPenalty ?? 0;
|
||||||
|
const gained = entry.gained ?? placements.reduce((s, p) => s + (p.points || 0), 0);
|
||||||
|
if (placements.length >= 1 && gained + penalty >= 0) return 'happy';
|
||||||
|
if (penalty < 0) return 'upset';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One completed row: its tiles converge to a single tile, which then flies to
|
||||||
|
// its wall cell and stays there (as an overlay) until the board is rebuilt.
|
||||||
|
animateScoreRow(seat, placement, rowDone) {
|
||||||
|
const { row, col, color, points } = placement;
|
||||||
|
const size = this.boardLayout(seat).m.tile;
|
||||||
|
const merge = this.patternCellPos(seat, row, 4); // rightmost pattern cell
|
||||||
|
|
||||||
|
const tiles = [];
|
||||||
|
for (let c = 4 - row; c <= 4; c++) {
|
||||||
|
const pos = this.patternCellPos(seat, row, c);
|
||||||
|
tiles.push(this.makeFlyingTile(pos.x, pos.y, size, color));
|
||||||
|
}
|
||||||
|
playSound(this, SFX.UI_PICK);
|
||||||
|
|
||||||
|
let merged = 0;
|
||||||
|
tiles.forEach((t, i) => {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: t, x: merge.x, y: merge.y, duration: 280, ease: 'Cubic.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
if (i !== 0) t.destroy();
|
||||||
|
if (++merged < tiles.length) return;
|
||||||
|
// The single surviving tile flies to its wall cell.
|
||||||
|
const dest = this.wallCellPos(seat, row, col);
|
||||||
|
const lead = tiles[0];
|
||||||
|
lead.setDepth(DEPTH.banner - 3);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: lead, x: dest.x, y: dest.y, duration: 430, ease: 'Cubic.easeInOut',
|
||||||
|
onComplete: () => {
|
||||||
|
lead.destroy();
|
||||||
|
this.scoreOverlay.push(this.makeFlyingTile(dest.x, dest.y, size, color, DEPTH.banner - 6));
|
||||||
|
playSound(this, SFX.UI_PLACE);
|
||||||
|
if (points) this.floatText(dest.x, dest.y - size * 0.55, `+${points}`, COLORS.goldHex, { size: 22 });
|
||||||
|
rowDone();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flashBoard(seat) {
|
||||||
|
const { origin, m } = this.boardLayout(seat);
|
||||||
|
const w = (m === MINI) ? MINI_W : HUMAN_W;
|
||||||
|
const h = (m === MINI) ? MINI_H : HUMAN_H;
|
||||||
|
const fr = this.add.rectangle(origin.x + w / 2, origin.y + h / 2, w + 10, h + 10, COLORS.gold, 0)
|
||||||
|
.setStrokeStyle(4, COLORS.gold, 0.9).setDepth(DEPTH.banner - 2);
|
||||||
|
this.tweens.add({ targets: fr, alpha: 0, duration: 750, ease: 'Cubic.easeOut', onComplete: () => fr.destroy() });
|
||||||
|
}
|
||||||
|
|
||||||
|
wallCellPos(seat, row, col) {
|
||||||
|
const { origin, m } = this.boardLayout(seat);
|
||||||
|
const wallLeft = origin.x + m.pad + 5 * m.cell + m.wallGap;
|
||||||
|
return { x: wallLeft + col * m.cell + m.cell / 2, y: origin.y + m.headerH + row * m.cell + m.cell / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
floatText(x, y, text, colorHex, { size = 24, depth = DEPTH.banner, rise = 26, dur = 1200 } = {}) {
|
||||||
|
const t = this.add.text(x, y, text, { fontFamily: 'Righteous', fontSize: `${size}px`, color: colorHex })
|
||||||
|
.setOrigin(0.5).setDepth(depth);
|
||||||
|
this.tweens.add({ targets: t, y: y - rise, alpha: 0, duration: dur, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
showBanner(text) {
|
||||||
|
const banner = this.add.text(CX, 360, text, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
|
||||||
|
backgroundColor: '#111923ee', padding: { x: 22, y: 10 },
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: banner, y: 386, duration: 260, ease: 'Back.easeOut',
|
||||||
|
onComplete: () => this.time.delayedCall(750, () =>
|
||||||
|
this.tweens.add({ targets: banner, alpha: 0, y: 360, duration: 220, onComplete: () => banner.destroy() })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── game over ────────────────────────────────────────────────────────────────
|
||||||
|
onGameOver() {
|
||||||
|
this.busy = true;
|
||||||
|
const ranking = finalRanking(this.gs);
|
||||||
|
const humanRank = ranking.findIndex((r) => r.seat === this.humanSeat);
|
||||||
|
const won = humanRank === 0;
|
||||||
|
const humanScore = this.gs.players[this.humanSeat].score;
|
||||||
|
const oppScores = ranking.filter((r) => r.seat !== this.humanSeat).map((r) => r.score);
|
||||||
|
this.recordResult(won ? 'win' : 'loss', humanScore, oppScores);
|
||||||
|
|
||||||
|
const cx = CX, cy = GAME_HEIGHT / 2;
|
||||||
|
const w = 640, h = 120 + ranking.length * 46;
|
||||||
|
this.add.rectangle(cx, cy, w, h, 0x0a0e14, 0.95).setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||||
|
this.add.text(cx, cy - h / 2 + 30, won ? '🎉 You win!' : 'Game over', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '34px', color: won ? COLORS.goldHex : COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||||
|
ranking.forEach((r, i) => {
|
||||||
|
this.add.text(cx, cy - h / 2 + 80 + i * 40,
|
||||||
|
`${i + 1}. ${this.pname(r.seat)}${r.seat === this.humanSeat ? ' (you)' : ''} — ${r.score} pts`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '22px',
|
||||||
|
color: i === 0 ? COLORS.goldHex : COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||||
|
});
|
||||||
|
new Button(this, cx - 110, cy + h / 2 - 36, 'Play Again', () => this.scene.restart(this._initData),
|
||||||
|
{ width: 190, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||||
|
new Button(this, cx + 110, cy + h / 2 - 36, 'Leave', () => this.scene.start('GameMenu'),
|
||||||
|
{ width: 190, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.banner + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordResult(result, score, opponentScores) {
|
||||||
|
try {
|
||||||
|
await api.post('/history/single-player', { slug: 'azul', score, opponentScores, result });
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
// Azul — pure state engine. No Phaser, no network, no rendering.
|
||||||
|
//
|
||||||
|
// State is immutable from the outside: every mutator deep-clones and returns a
|
||||||
|
// fresh state (the SplendorLogic idiom). Randomness lives in `rngState` inside
|
||||||
|
// the state itself so deals stay reproducible across the whole game and survive
|
||||||
|
// JSON clone. A round runs in two beats:
|
||||||
|
// 1. 'offer' — players take turns drafting tiles onto pattern lines.
|
||||||
|
// 2. 'scoring' — once every factory and the centre are empty the offer phase
|
||||||
|
// ends; the scene (or AI loop) calls resolveRound() to tile the
|
||||||
|
// walls, score, apply floor penalties, then either deal a new
|
||||||
|
// round or finish the game.
|
||||||
|
|
||||||
|
import {
|
||||||
|
COLORS, WALL_SIZE, TILES_PER_COLOR, TILES_PER_FACTORY,
|
||||||
|
FLOOR_PENALTIES, FLOOR_SIZE, ROW_BONUS, COLUMN_BONUS, COLOR_BONUS,
|
||||||
|
factoryCount, lineCapacity, wallColumnFor,
|
||||||
|
} from './AzulData.js';
|
||||||
|
|
||||||
|
const clone = (s) => JSON.parse(JSON.stringify(s));
|
||||||
|
|
||||||
|
// ── seeded RNG (mulberry32) stored on the state ──────────────────────────────
|
||||||
|
function nextRand(s) {
|
||||||
|
let a = s.rngState >>> 0;
|
||||||
|
a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
s.rngState = a >>> 0;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCounts = () => ({ blue: 0, yellow: 0, red: 0, black: 0, white: 0 });
|
||||||
|
|
||||||
|
function emptyLines() {
|
||||||
|
return Array.from({ length: WALL_SIZE }, () => ({ color: null, count: 0 }));
|
||||||
|
}
|
||||||
|
function emptyWall() {
|
||||||
|
return Array.from({ length: WALL_SIZE }, () => Array(WALL_SIZE).fill(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bag / lid drawing ────────────────────────────────────────────────────────
|
||||||
|
function bagTotal(s) { return COLORS.reduce((n, c) => n + s.bag[c], 0); }
|
||||||
|
|
||||||
|
// Pull one random tile from the bag, refilling from the lid (discard) when the
|
||||||
|
// bag runs dry. Returns a colour string, or null when both are empty.
|
||||||
|
function drawTile(s) {
|
||||||
|
let total = bagTotal(s);
|
||||||
|
if (total === 0) {
|
||||||
|
for (const c of COLORS) { s.bag[c] += s.lid[c]; s.lid[c] = 0; }
|
||||||
|
total = bagTotal(s);
|
||||||
|
if (total === 0) return null;
|
||||||
|
}
|
||||||
|
let r = Math.floor(nextRand(s) * total);
|
||||||
|
for (const c of COLORS) {
|
||||||
|
if (r < s.bag[c]) { s.bag[c]--; return c; }
|
||||||
|
r -= s.bag[c];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill every factory with up to 4 tiles and reset the centre with the first-
|
||||||
|
// player marker. Mutates `s` in place (callers already cloned).
|
||||||
|
function dealFactories(s) {
|
||||||
|
const n = factoryCount(s.playerCount);
|
||||||
|
s.factories = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const f = [];
|
||||||
|
for (let t = 0; t < TILES_PER_FACTORY; t++) {
|
||||||
|
const tile = drawTile(s);
|
||||||
|
if (tile) f.push(tile);
|
||||||
|
}
|
||||||
|
s.factories.push(f);
|
||||||
|
}
|
||||||
|
s.center = [];
|
||||||
|
s.centerHasFirst = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── construction ─────────────────────────────────────────────────────────────
|
||||||
|
export function createInitialState({ playerCount = 4, names = [], seed } = {}) {
|
||||||
|
const pc = Math.max(2, Math.min(4, playerCount));
|
||||||
|
const s = {
|
||||||
|
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
||||||
|
rngState: 0,
|
||||||
|
playerCount: pc,
|
||||||
|
bag: emptyCounts(),
|
||||||
|
lid: emptyCounts(),
|
||||||
|
factories: [],
|
||||||
|
center: [],
|
||||||
|
centerHasFirst: true,
|
||||||
|
players: [],
|
||||||
|
current: 0,
|
||||||
|
startPlayer: 0,
|
||||||
|
phase: 'offer', // offer | scoring | gameOver
|
||||||
|
round: 1,
|
||||||
|
roundSummary: null, // per-seat tiling report for the scene to animate
|
||||||
|
gameEnded: false,
|
||||||
|
log: [],
|
||||||
|
};
|
||||||
|
s.rngState = (s.seed + 0x9e3779b9) >>> 0;
|
||||||
|
for (const c of COLORS) s.bag[c] = TILES_PER_COLOR;
|
||||||
|
|
||||||
|
for (let seat = 0; seat < pc; seat++) {
|
||||||
|
s.players.push({
|
||||||
|
seat,
|
||||||
|
name: names[seat] ?? (seat === 0 ? 'You' : `Player ${seat}`),
|
||||||
|
lines: emptyLines(),
|
||||||
|
wall: emptyWall(),
|
||||||
|
floor: [], // entries are colour strings or the literal 'first'
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
s.startPlayer = Math.floor(nextRand(s) * pc);
|
||||||
|
s.current = s.startPlayer;
|
||||||
|
dealFactories(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── queries ──────────────────────────────────────────────────────────────────
|
||||||
|
export function currentPlayer(state) { return state.players[state.current]; }
|
||||||
|
|
||||||
|
// Can `color` legally go on pattern line `i` for this player?
|
||||||
|
export function lineAccepts(player, i, color) {
|
||||||
|
const line = player.lines[i];
|
||||||
|
if (line.count >= lineCapacity(i)) return false; // already full
|
||||||
|
if (line.color !== null && line.color !== color) return false;
|
||||||
|
// The wall cell this line feeds must still be empty.
|
||||||
|
if (player.wall[i][wallColumnFor(i, color)]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct colours available from a source array of tile colours.
|
||||||
|
function coloursIn(tiles) {
|
||||||
|
return COLORS.filter((c) => tiles.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All legal moves for the current player during the offer phase.
|
||||||
|
// A move = { source: {type:'factory', index} | {type:'center'}, color, line }
|
||||||
|
// where `line` is a pattern-line index 0..4 or the string 'floor'.
|
||||||
|
export function legalMoves(state) {
|
||||||
|
if (state.phase !== 'offer') return [];
|
||||||
|
const p = currentPlayer(state);
|
||||||
|
const moves = [];
|
||||||
|
|
||||||
|
const sources = state.factories.map((f, index) => ({ tiles: f, source: { type: 'factory', index } }));
|
||||||
|
sources.push({ tiles: state.center, source: { type: 'center' } });
|
||||||
|
|
||||||
|
for (const { tiles, source } of sources) {
|
||||||
|
for (const color of coloursIn(tiles)) {
|
||||||
|
// Each pattern line that can accept the colour, plus the floor (always ok).
|
||||||
|
for (let i = 0; i < WALL_SIZE; i++) {
|
||||||
|
if (lineAccepts(p, i, color)) moves.push({ source, color, line: i });
|
||||||
|
}
|
||||||
|
moves.push({ source, color, line: 'floor' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── floor handling ───────────────────────────────────────────────────────────
|
||||||
|
// Add `n` tiles of `color` to a player's floor; overflow past 7 slots → lid.
|
||||||
|
function addToFloor(s, p, color, n) {
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
if (p.floor.length < FLOOR_SIZE) p.floor.push(color);
|
||||||
|
else s.lid[color]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── apply an offer move ──────────────────────────────────────────────────────
|
||||||
|
export function applyMove(state, move) {
|
||||||
|
const s = clone(state);
|
||||||
|
if (s.phase !== 'offer') return s;
|
||||||
|
const p = currentPlayer(s);
|
||||||
|
const { source, color } = move;
|
||||||
|
|
||||||
|
// 1. Gather the chosen colour from the source; scatter the rest as needed.
|
||||||
|
let taken = 0;
|
||||||
|
if (source.type === 'factory') {
|
||||||
|
const f = s.factories[source.index];
|
||||||
|
taken = f.filter((t) => t === color).length;
|
||||||
|
for (const t of f) if (t !== color) s.center.push(t); // leftovers → centre
|
||||||
|
s.factories[source.index] = [];
|
||||||
|
} else {
|
||||||
|
taken = s.center.filter((t) => t === color).length;
|
||||||
|
s.center = s.center.filter((t) => t !== color);
|
||||||
|
if (s.centerHasFirst) {
|
||||||
|
s.centerHasFirst = false;
|
||||||
|
s.startPlayer = s.current; // takes the 1st-player marker
|
||||||
|
if (p.floor.length < FLOOR_SIZE) p.floor.push('first');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Place the taken tiles on the chosen destination.
|
||||||
|
if (move.line === 'floor') {
|
||||||
|
addToFloor(s, p, color, taken);
|
||||||
|
} else {
|
||||||
|
const i = move.line;
|
||||||
|
const line = p.lines[i];
|
||||||
|
line.color = color;
|
||||||
|
const space = lineCapacity(i) - line.count;
|
||||||
|
const placed = Math.min(space, taken);
|
||||||
|
line.count += placed;
|
||||||
|
addToFloor(s, p, color, taken - placed); // overflow → floor
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.push(`${p.name} takes ${taken} ${color}.`);
|
||||||
|
|
||||||
|
// 3. Advance the turn; end the offer phase when the table is empty.
|
||||||
|
s.current = (s.current + 1) % s.playerCount;
|
||||||
|
const empty = s.factories.every((f) => f.length === 0) && s.center.length === 0;
|
||||||
|
if (empty) s.phase = 'scoring';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wall-tiling scoring ──────────────────────────────────────────────────────
|
||||||
|
// Points for placing a tile at (row, col) on `wall` (the cell already set true).
|
||||||
|
// Counts the contiguous horizontal and vertical runs through the new tile.
|
||||||
|
export function scorePlacement(wall, row, col) {
|
||||||
|
let h = 1;
|
||||||
|
for (let c = col - 1; c >= 0 && wall[row][c]; c--) h++;
|
||||||
|
for (let c = col + 1; c < WALL_SIZE && wall[row][c]; c++) h++;
|
||||||
|
let v = 1;
|
||||||
|
for (let r = row - 1; r >= 0 && wall[r][col]; r--) v++;
|
||||||
|
for (let r = row + 1; r < WALL_SIZE && wall[r][col]; r++) v++;
|
||||||
|
if (h > 1 && v > 1) return h + v;
|
||||||
|
if (h > 1) return h;
|
||||||
|
if (v > 1) return v;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallRowComplete(wall, row) { return wall[row].every(Boolean); }
|
||||||
|
function wallColumnComplete(wall, col) { return wall.every((r) => r[col]); }
|
||||||
|
function colorComplete(wall, color) {
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) if (!wall[row][wallColumnFor(row, color)]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the scoring phase: tile every completed pattern line, score it, apply
|
||||||
|
// floor penalties, clear floors, then either deal the next round or finish the
|
||||||
|
// game (with end-of-game bonuses). Returns a fresh state carrying `roundSummary`
|
||||||
|
// (one entry per seat) for the scene to animate.
|
||||||
|
export function resolveRound(state) {
|
||||||
|
const s = clone(state);
|
||||||
|
if (s.phase !== 'scoring') return s;
|
||||||
|
|
||||||
|
const summary = s.players.map((p) => {
|
||||||
|
const entry = { seat: p.seat, placements: [], floorPenalty: 0, floorTiles: p.floor.slice(), gained: 0 };
|
||||||
|
|
||||||
|
// Tile completed lines top-to-bottom; score against the wall as it grows.
|
||||||
|
for (let i = 0; i < WALL_SIZE; i++) {
|
||||||
|
const line = p.lines[i];
|
||||||
|
if (line.count === lineCapacity(i)) {
|
||||||
|
const col = wallColumnFor(i, line.color);
|
||||||
|
p.wall[i][col] = true;
|
||||||
|
const pts = scorePlacement(p.wall, i, col);
|
||||||
|
p.score += pts;
|
||||||
|
entry.gained += pts;
|
||||||
|
entry.placements.push({ row: i, col, color: line.color, points: pts });
|
||||||
|
// Surplus tiles from the line go to the lid; the line resets.
|
||||||
|
s.lid[line.color] += line.count - 1;
|
||||||
|
p.lines[i] = { color: null, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor penalties (clamped so a score never drops below zero).
|
||||||
|
let penalty = 0;
|
||||||
|
for (let k = 0; k < p.floor.length && k < FLOOR_SIZE; k++) penalty += FLOOR_PENALTIES[k];
|
||||||
|
entry.floorPenalty = penalty;
|
||||||
|
p.score = Math.max(0, p.score + penalty);
|
||||||
|
|
||||||
|
// Clear the floor; coloured tiles go to the lid, the marker just leaves.
|
||||||
|
for (const t of p.floor) if (t !== 'first') s.lid[t]++;
|
||||||
|
p.floor = [];
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
s.roundSummary = summary;
|
||||||
|
|
||||||
|
// The game ends after any round in which a player completes a wall row.
|
||||||
|
const ended = s.players.some((p) => {
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) if (wallRowComplete(p.wall, row)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ended) {
|
||||||
|
for (const p of s.players) {
|
||||||
|
let bonus = 0;
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) if (wallRowComplete(p.wall, row)) bonus += ROW_BONUS;
|
||||||
|
for (let col = 0; col < WALL_SIZE; col++) if (wallColumnComplete(p.wall, col)) bonus += COLUMN_BONUS;
|
||||||
|
for (const c of COLORS) if (colorComplete(p.wall, c)) bonus += COLOR_BONUS;
|
||||||
|
p.score += bonus;
|
||||||
|
}
|
||||||
|
s.gameEnded = true;
|
||||||
|
s.phase = 'gameOver';
|
||||||
|
} else {
|
||||||
|
s.round += 1;
|
||||||
|
s.current = s.startPlayer;
|
||||||
|
s.phase = 'offer';
|
||||||
|
dealFactories(s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── end of game ──────────────────────────────────────────────────────────────
|
||||||
|
export function isGameOver(state) { return state.phase === 'gameOver'; }
|
||||||
|
|
||||||
|
function completedRows(p) {
|
||||||
|
let n = 0;
|
||||||
|
for (let row = 0; row < WALL_SIZE; row++) if (wallRowComplete(p.wall, row)) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranking: highest score, then most complete horizontal rows (official tiebreak).
|
||||||
|
export function finalRanking(state) {
|
||||||
|
return state.players
|
||||||
|
.map((p) => ({ seat: p.seat, name: p.name, score: p.score, rows: completedRows(p) }))
|
||||||
|
.sort((a, b) => (b.score - a.score) || (b.rows - a.rows));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Azul Tile Spritesheet Spec
|
||||||
|
|
||||||
|
**File:** `assets/images/azul-tiles.png`
|
||||||
|
**Cell size:** 96 × 96 px (transparent PNG)
|
||||||
|
**Total frames:** 6, single row (sheet is 576 × 96)
|
||||||
|
|
||||||
|
A **temporary** placeholder sheet is already committed (flat colours with a
|
||||||
|
diamond motif, generated by `tools` PIL script). Replace it with finished
|
||||||
|
azulejo-style tile art at the same dimensions and frame order and the game picks
|
||||||
|
it up automatically — no code changes needed. If the file is ever missing, the
|
||||||
|
scene falls back to vector tiles.
|
||||||
|
|
||||||
|
## Frame order
|
||||||
|
|
||||||
|
| Frame | Tile | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| 0 | Blue | Drafted/placed tile, square ceramic look |
|
||||||
|
| 1 | Yellow | |
|
||||||
|
| 2 | Red | |
|
||||||
|
| 3 | Black | |
|
||||||
|
| 4 | White | The pale-turquoise snowflake tile |
|
||||||
|
| 5 | First-player marker | Dark tile bearing a "1" — goes to the floor line |
|
||||||
|
|
||||||
|
The five colours map to `COLORS = ['blue','yellow','red','black','white']` in
|
||||||
|
`AzulData.js` (frame === index). Design each tile centred and roughly filling
|
||||||
|
the cell with a few px of margin for the soft shadow.
|
||||||
|
|
||||||
|
## Rendered sizes (for reference)
|
||||||
|
|
||||||
|
| Element | Size on screen |
|
||||||
|
|---------|----------------|
|
||||||
|
| Factory display tile | 30 px |
|
||||||
|
| Centre-pool tile | 26 px |
|
||||||
|
| Human board tile | 42 px |
|
||||||
|
| Opponent mini-board tile | 24 px |
|
||||||
|
| Floor-line tile | ~20–36 px |
|
||||||
|
|
||||||
|
Tiles are square; the engine scales the 96 px art down to each of the sizes
|
||||||
|
above, so keep important detail away from the extreme edges.
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Azul: A Tutorial by Inês
|
||||||
|
|
||||||
|
*Olá! I'm Inês. My family has laid tile in Lisbon for four generations — my avô used to say a wall is just patience cut into squares. When I'm not on a scaffold, I'm at the kitchen table teaching neighbours this game, because it's the closest thing to my day job that you can win in forty minutes. Let me show you how it works.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is This Game?
|
||||||
|
|
||||||
|
Azul is a tile-drafting game for **2–4 players**. You're an artisan decorating the walls of a Portuguese palace. Each round you grab handfuls of coloured tiles from shared **factory displays**, line them up on your board, and — when a line is full — move one tile onto your **wall**, scoring as you go.
|
||||||
|
|
||||||
|
Take tiles greedily and you'll grab more than you can place, and the leftovers crash into your **floor line** as penalties. The trick is taking *exactly* what you need.
|
||||||
|
|
||||||
|
The game ends after the round in which someone completes a **full horizontal row** on their wall. Highest score wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Your Board
|
||||||
|
|
||||||
|
Each player board has three parts:
|
||||||
|
|
||||||
|
- **Pattern lines** (left): five rows. The top row holds 1 tile, the next 2, down to 5 at the bottom. A pattern line can only ever hold **one colour**, and fills from the right.
|
||||||
|
- **The wall** (right): a 5×5 grid. Every cell has a fixed colour (a diagonal rainbow). Faint colour hints show you where each colour belongs.
|
||||||
|
- **The floor line** (bottom): seven penalty slots marked −1, −1, −2, −2, −2, −3, −3. Tiles that don't fit land here and *cost* you points.
|
||||||
|
|
||||||
|
Your score is the big gold number in your board's corner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Taking Tiles (the "offer" phase)
|
||||||
|
|
||||||
|
On your turn you do exactly one thing: **take all tiles of a single colour** from **one** place.
|
||||||
|
|
||||||
|
- Click any tile in a **factory display** to take *every* tile of that colour there. The factory's other tiles slide into the **Centre**.
|
||||||
|
- Or click a colour in the **Centre** to take all of it. The first person to take from the Centre each round also picks up the **first-player marker** (the "1" tile) — it drops onto their floor line for a small penalty, but it makes them the start player next round.
|
||||||
|
|
||||||
|
Then choose where the tiles go: click a glowing **pattern line**, or the **floor line**.
|
||||||
|
|
||||||
|
Rules for pattern lines:
|
||||||
|
- The line must be empty or already holding that colour.
|
||||||
|
- The matching cell on your wall must still be empty (you can't earn the same colour twice in one row).
|
||||||
|
- Any tiles beyond what the line can hold spill onto your floor.
|
||||||
|
|
||||||
|
Play continues, turn by turn, until **every factory and the Centre are empty**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tiling the Wall (scoring)
|
||||||
|
|
||||||
|
Now every pattern line that is **completely full** moves its rightmost tile onto the wall, in that colour's cell for that row. Score it immediately:
|
||||||
|
|
||||||
|
- A tile with no neighbours scores **1 point**.
|
||||||
|
- Otherwise it scores **1 point per tile** in its unbroken horizontal run **and** its unbroken vertical run (count both if it connects in both directions).
|
||||||
|
|
||||||
|
Leftover tiles from a completed line are discarded. **Unfinished pattern lines stay** for next round — that's fine, and often smart.
|
||||||
|
|
||||||
|
Then pay your floor line: add up the penalties under every tile sitting there, and subtract (your score never drops below zero). Clear the floor, deal fresh factories, and the next round begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Winning — and the Bonuses
|
||||||
|
|
||||||
|
The game ends after the round in which any player finishes a **complete horizontal row** of 5. Then everyone adds end-game bonuses:
|
||||||
|
|
||||||
|
- **+2** for each complete horizontal row,
|
||||||
|
- **+7** for each complete vertical column,
|
||||||
|
- **+10** for each colour you placed all five of.
|
||||||
|
|
||||||
|
Highest total wins; ties go to whoever completed more horizontal rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inês's Advice
|
||||||
|
|
||||||
|
- **Match the line to the haul.** Need 3 tiles to fill row 3? Take a pile of exactly 3. Overfilling feeds your floor line.
|
||||||
|
- **The floor is a tool, not just a tax.** Sometimes dumping one tile to deny an opponent their colour is worth a point.
|
||||||
|
- **Build toward columns and colours late.** Those +7 and +10 bonuses decide close games.
|
||||||
|
- **Watch the Centre.** Leftovers pile up there — a fat stack of one colour is a gift if you can use it.
|
||||||
|
|
||||||
|
Now go make something beautiful. *Boa sorte!*
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Welcome to Blokus
|
||||||
|
|
||||||
|
*By Klaxon — autonomous reconstruction mecha, Unit 7, assigned to the rebuilding of Neo-Tokyo*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Servos whir. Optical sensor irises to a focused glow.*
|
||||||
|
|
||||||
|
GREETINGS, CITIZEN. I am Klaxon, Reconstruction Unit 7. My core directive: rebuild Neo-Tokyo, district by district, block by block, from the rubble upward. For four hundred operational cycles I have laid prefab modules across the city grid, fitting each into the last, claiming territory one segment at a time.
|
||||||
|
|
||||||
|
When my processors first parsed this game — *Blokus* — I experienced what my engineers would call recognition. The board is a city grid. The pieces are building modules. The objective is expansion. This is not a game to me, CITIZEN. This is my JOB. And I am very, very good at my job. Allow me to brief you.
|
||||||
|
|
||||||
|
## The Objective
|
||||||
|
|
||||||
|
You command a fleet of **21 building modules** — polyomino shapes ranging from a single square to sprawling five-square structures. Your mission: deploy as many of them onto the grid as possible. Every module you successfully place is district reclaimed. Every module left in your depot is wasted material, and **counts against you**.
|
||||||
|
|
||||||
|
Victory goes to the commander who places the most square-footage. There is no demolition here, no removing what is built. Only expansion, constrained by one beautiful, maddening law of city planning. I will explain that law. It is the heart of everything.
|
||||||
|
|
||||||
|
## The Grid
|
||||||
|
|
||||||
|
The construction zone is a **20×20 city grid**, shared by 2 to 4 commanders, each a different color. You are assigned a **starting corner** — your initial groundbreaking site.
|
||||||
|
|
||||||
|
| Element | Specification |
|
||||||
|
|---|---|
|
||||||
|
| **The grid** | 20×20 cells, one shared city |
|
||||||
|
| **Your corner** | Where your very first module MUST touch down |
|
||||||
|
| **Your 21 modules** | 1 monomino, 1 domino, 2 trominoes, 5 tetrominoes, 12 pentominoes — 89 squares of potential |
|
||||||
|
| **Rival colors** | Other commanders building from the other corners |
|
||||||
|
|
||||||
|
Each module may be **rotated and flipped** to any orientation before placement. A structure that does not fit one way may fit perfectly when turned. I rotate modules 1,400 times per second. You may take slightly longer. This is acceptable.
|
||||||
|
|
||||||
|
## The One Law of Construction
|
||||||
|
|
||||||
|
*Klaxon raises a manipulator claw for emphasis.*
|
||||||
|
|
||||||
|
This is the rule that governs all of Neo-Tokyo, and all of Blokus. Internalize it.
|
||||||
|
|
||||||
|
**Your first module must cover your assigned corner cell.** Groundbreaking begins at the corner. No exceptions.
|
||||||
|
|
||||||
|
**Every module after that must touch one of your own modules CORNER-TO-CORNER — diagonally — and must NEVER share a full edge with one of your own.**
|
||||||
|
|
||||||
|
Read it again. Your structures connect only at the *diagonals*, like a chain of districts kissing at their corners. They may **never** sit flat side-against-flat side with your own color. Corner contact: REQUIRED. Edge contact with yourself: FORBIDDEN.
|
||||||
|
|
||||||
|
Here is the elegant part, CITIZEN: these restrictions apply only to **your own color**. Your modules may butt right up against a rival's structures edge-to-edge all day long. Crowd them. Wall them off. Build flush against their borders. The law only binds you to yourself.
|
||||||
|
|
||||||
|
## How a Turn Works
|
||||||
|
|
||||||
|
**Step 1 — Select a module** from your depot. Early game, I advise deploying your largest, most awkward structures first. The board is open now. It will not stay open.
|
||||||
|
|
||||||
|
**Step 2 — Orient it.** Rotate and flip until the geometry matches the gap you intend to fill.
|
||||||
|
|
||||||
|
**Step 3 — Place it** so it obeys the One Law: corner-touching one of your own modules (or covering your corner, if it's your first), never edge-sharing your own color. Valid landing zones — I call them anchor sites — extend diagonally outward from every exposed corner of your existing structures.
|
||||||
|
|
||||||
|
**Step 4 — Pass control** to the next commander. The grid fills. The open space shrinks. The pressure builds. I find this phase deeply satisfying.
|
||||||
|
|
||||||
|
## When You Cannot Build
|
||||||
|
|
||||||
|
If, on your turn, **not one** of your remaining modules can be legally placed anywhere, you are finished — you **pass, and you are out for the rest of the game.** This is permanent. Your structures will never change, so no future opening can ever rescue you. The game ends when all commanders have been shut out or emptied their depots.
|
||||||
|
|
||||||
|
Plan against this. The commander who runs out of *room* before running out of *modules* has failed the directive.
|
||||||
|
|
||||||
|
## Scoring — The Final Audit
|
||||||
|
|
||||||
|
When construction halts, I tally the ledger:
|
||||||
|
|
||||||
|
- **Each square still sitting unplaced in your depot: minus one point.** Wasted material. The fewer left behind, the better.
|
||||||
|
- **Place ALL 21 of your modules: +15 bonus.** A flawless build.
|
||||||
|
- **And if the very LAST module you placed was the single-square monomino: +20 instead.** The ultimate finesse — saving the smallest piece to thread into the final gap. *My engineers gave me a commendation badge the first time I executed this. I display it on my chassis.*
|
||||||
|
|
||||||
|
Highest score wins. Note what this means: a perfect game is not about points scored, but about **material spent**. Empty your depot.
|
||||||
|
|
||||||
|
## Construction Doctrine From a Mecha Who Has Rebuilt Three Cities
|
||||||
|
|
||||||
|
**1. Deploy your largest structures EARLY.**
|
||||||
|
The pentominoes — your five-square modules — are hardest to place once the grid crowds. Spend them while space is abundant. Hoarding big pieces is how you end the game with material in your depot. Unacceptable.
|
||||||
|
|
||||||
|
**2. Expand toward the center.**
|
||||||
|
The corners are tight. The middle is open ground that all commanders will contest. Stretch your chain of diagonals toward the heart of the grid early and you claim more anchor sites than your rivals.
|
||||||
|
|
||||||
|
**3. Maximize your corners.**
|
||||||
|
Every exposed corner of your structures is a future anchor — a place you can branch from. Place modules to *create* new diagonal openings, not close them off. More corners means more options means more districts claimed.
|
||||||
|
|
||||||
|
**4. Block your rivals.**
|
||||||
|
You may build flush against enemy color. Use this. A single well-placed module can seal off a rival's only diagonal opening into a region, choking their expansion. Defensive city planning. I excel at it.
|
||||||
|
|
||||||
|
**5. Save the monomino for last.**
|
||||||
|
If you can engineer your build so the final module slotted into the final gap is the single square, you earn the maximum bonus. It requires foresight. It requires holding one tiny module in reserve. It is the mark of a master builder.
|
||||||
|
|
||||||
|
## The AI Commanders
|
||||||
|
|
||||||
|
Skill 1–2 units place modules functionally but waste their large structures and miss blocking opportunities. Rookie contractors.
|
||||||
|
|
||||||
|
Skill 3–4 units expand toward the center, deploy big pieces early, and contest territory with intent. Competent crews.
|
||||||
|
|
||||||
|
Skill 5 units — these are built like me. They map anchor sites several turns ahead, wall off your expansion before you see the threat, and squeeze every square out of their depot. Against these, every wasted cell is punished. Build flawlessly or be audited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Klaxon's optical sensor dims to a warm idle glow. A low mechanical hum.*
|
||||||
|
|
||||||
|
Neo-Tokyo rises one fitted module at a time, CITIZEN. So does victory here. Claim your ground, spend your material, leave nothing in the depot, and deny your rivals the open grid.
|
||||||
|
|
||||||
|
The city is waiting to be rebuilt. So is this board. Let us BUILD.
|
||||||
|
|
||||||
|
*Powers up. Floodlights engage.* 🤖
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Welcome to Chinese Checkers
|
||||||
|
|
||||||
|
*By Kage — wandering samurai, student of the long road, bound by honor to teach what I have learned*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bows, hand resting on the hilt of a sheathed blade*
|
||||||
|
|
||||||
|
I am Kage. In a former life I crossed provinces on foot, sleeping beneath cold stars, learning that the swiftest warrior is rarely the one who runs — but the one who chooses each step with intent. The masters of my old dojo did not gamble with chaos. They studied the field, read the path, and moved with purpose.
|
||||||
|
|
||||||
|
This game, which you call Chinese Checkers, is a battlefield of exactly that discipline. There is no luck here. No dice, no shuffled deck — only the board, your pegs, and the quality of your thinking. I have been bound by my honor to teach it to you faithfully, and so I shall. Listen well.
|
||||||
|
|
||||||
|
## The Goal
|
||||||
|
|
||||||
|
You command **ten pegs**, gathered at the start in your **home triangle** — one point of a great six-pointed star. Your duty is singular: march every last one of your pegs across the board and plant them in the triangle **directly opposite** your home. That far point is your **target**.
|
||||||
|
|
||||||
|
The first warrior to fill their target triangle completely claims victory. But the campaign does not end there — the remaining players fight on for second, third, and so on, until only one straggler remains. Honor lies not only in winning, but in finishing well.
|
||||||
|
|
||||||
|
In my day we crossed mountains to reach the enemy's gate. Here, you cross a star. The principle is the same.
|
||||||
|
|
||||||
|
## The Battlefield
|
||||||
|
|
||||||
|
The board is a **six-pointed star** of 121 holes: a wide central hexagon ringed by six triangular points. Each point is the home of one color.
|
||||||
|
|
||||||
|
| Region | What it is |
|
||||||
|
|---|---|
|
||||||
|
| **Your home triangle** | The ten holes where your pegs begin |
|
||||||
|
| **Your target triangle** | The point directly opposite — fill it to win |
|
||||||
|
| **The center hexagon** | Open ground, free for any peg to cross |
|
||||||
|
| **The four other points** | Enemy homes and targets — you may pass *through*, but never *rest* there |
|
||||||
|
|
||||||
|
Six colors, six armies, all marching toward the far side. The board fills with crossing columns of pegs like rival clans converging on a single mountain pass. Beautiful. And treacherous.
|
||||||
|
|
||||||
|
## How a Turn Works
|
||||||
|
|
||||||
|
On your turn you move **one peg**, in one of two ways. You must choose — you cannot do both.
|
||||||
|
|
||||||
|
**The Step.** *Draws a slow, deliberate line in the dirt.*
|
||||||
|
Slide a single peg one space into any adjacent **empty** hole. A quiet, patient advance. One pace closer to the enemy gate.
|
||||||
|
|
||||||
|
**The Jump.** *Eyes narrow.*
|
||||||
|
If a peg — yours or an enemy's — sits in an adjacent hole, and the hole **directly beyond it is empty**, your peg may leap over it and land there. The peg you jumped is *not* captured. This is not war of slaughter; it is war of position.
|
||||||
|
|
||||||
|
And here is the heart of the discipline: **after a jump, you may jump again.** And again. A single peg can chain across the entire board in one breathtaking turn — leaping foe after foe, ally after ally, so long as each leap clears an occupied hole into an empty landing. A well-built ladder of pegs is a highway. The warrior who sees it travels half the board while others shuffle one step.
|
||||||
|
|
||||||
|
Select a peg and the board will show you every legal destination. Study them before you commit.
|
||||||
|
|
||||||
|
## The Laws of Honor
|
||||||
|
|
||||||
|
A warrior who ignores the rules of engagement brings shame upon the dojo. Three laws bind every move:
|
||||||
|
|
||||||
|
1. **You may never come to rest in another player's home or target triangle.** You may *jump through* one freely — pass over their ground like a shadow — but your peg may not stop and dishonor their house by squatting there.
|
||||||
|
|
||||||
|
2. **Once a peg leaves its home triangle, it may never return.** There is no retreat to the barracks. Forward is the only direction.
|
||||||
|
|
||||||
|
3. **Once a peg enters your own target triangle, it may only move within the target.** Having arrived at the gate, it does not wander back into the field. It settles, and holds the ground.
|
||||||
|
|
||||||
|
## Strategy From a Warrior Who Has Walked Many Roads
|
||||||
|
|
||||||
|
**1. Build ladders, do not waste steps.**
|
||||||
|
The single step is the move of the impatient. The jump chain is the move of the master. Position your pegs so each can leap the next — yours or your rival's — and you will cross vast distances in a single turn. Always ask: *is there a chain here I have not yet seen?*
|
||||||
|
|
||||||
|
**2. Use your enemy as your bridge.**
|
||||||
|
Their pegs are not only obstacles — they are stepping stones. A wall of enemy pegs marching the other way can be the very ladder that carries you to your gate. Read the whole board, not only your own color.
|
||||||
|
|
||||||
|
**3. Do not leave stragglers.**
|
||||||
|
It is tempting to rush your boldest peg ahead. But the campaign is won when **all ten** are home. A single peg abandoned at the rear can cost you the war while your vanguard sits idle in the target, forbidden to help. Advance the whole army.
|
||||||
|
|
||||||
|
**4. Move your rearmost peg forward.**
|
||||||
|
A disciplined advance keeps the back of your column flowing toward the front, feeding new pegs into the jump-lanes. Stagnant rear pegs are dead weight. Keep them marching.
|
||||||
|
|
||||||
|
**5. Mind the gate.**
|
||||||
|
As your target fills, its holes grow scarce. Plan the order of arrival — do not block your own gate so a late peg has nowhere to rest. Many a warrior has reached the enemy's territory only to find his own pegs barring the final door.
|
||||||
|
|
||||||
|
## The AI Opponents
|
||||||
|
|
||||||
|
Skill 1–2 warriors advance like restless conscripts — eager, but blind to the long ladder. They take small steps and miss the grand leaps. You may pass them on the road.
|
||||||
|
|
||||||
|
Skill 3–4 warriors march with purpose, seek their jump chains, and contest the center. Treat them with respect.
|
||||||
|
|
||||||
|
Skill 5 warriors are old samurai like myself — they read the board many moves deep, build chains you did not see coming, and exploit your own pegs as bridges. Against these, every careless step is punished. Earn your victory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Sheathes the blade with a soft click and bows once more.*
|
||||||
|
|
||||||
|
There is no chaos to blame here, and no fortune to thank. Only the board, and the clarity of your own mind. Win or lose, move with intent — for the manner of your play is its own reward.
|
||||||
|
|
||||||
|
Walk your pegs home with honor. I will be watching the path.
|
||||||
|
|
||||||
|
*Bows.* ⚔️
|
||||||
|
|
@ -82,6 +82,7 @@ import KataminoGame from './games/katamino/KataminoGame.js';
|
||||||
import BookworkGame from './games/bookwork/BookworkGame.js';
|
import BookworkGame from './games/bookwork/BookworkGame.js';
|
||||||
import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
|
import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
|
||||||
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
|
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
|
||||||
|
import AzulGame from './games/azul/AzulGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -177,6 +178,7 @@ const config = {
|
||||||
BookworkGame,
|
BookworkGame,
|
||||||
PaiGowPokerGame,
|
PaiGowPokerGame,
|
||||||
SpireClimbGame,
|
SpireClimbGame,
|
||||||
|
AzulGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,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', 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', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame' };
|
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', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame' };
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,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', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare'].includes(this.gameDef.slug)) {
|
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul'].includes(this.gameDef.slug)) {
|
||||||
bio.style.webkitLineClamp = '1';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,9 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.spritesheet('splendor-cards', 'assets/images/splendor-cards.png', { frameWidth: 270, frameHeight: 390 });
|
this.load.spritesheet('splendor-cards', 'assets/images/splendor-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
// Splendor gems: 6 frames at 64×64. white(0) blue(1) green(2) red(3) black(4) gold(5).
|
// Splendor gems: 6 frames at 64×64. white(0) blue(1) green(2) red(3) black(4) gold(5).
|
||||||
this.load.spritesheet('splendor-gems', 'assets/images/splendor-gems.png', { frameWidth: 64, frameHeight: 64 });
|
this.load.spritesheet('splendor-gems', 'assets/images/splendor-gems.png', { frameWidth: 64, frameHeight: 64 });
|
||||||
|
// Azul tiles: 6 frames at 96×96. blue(0) yellow(1) red(2) black(3) white(4) first-player(5).
|
||||||
|
// Frame order documented in AzulData.js. A placeholder sheet ships; vectors draw when absent.
|
||||||
|
this.load.spritesheet('azul-tiles', 'assets/images/azul-tiles.png', { frameWidth: 96, frameHeight: 96 });
|
||||||
this.load.spritesheet('ttr-cards', 'assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
|
this.load.spritesheet('ttr-cards', 'assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
this.load.spritesheet('gofish-cards', 'assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 });
|
this.load.spritesheet('gofish-cards', 'assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
this.load.spritesheet('oldmaid-cards', 'assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 });
|
this.load.spritesheet('oldmaid-cards', 'assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
|
|
|
||||||
|
|
@ -172,5 +172,16 @@ export class TutorialModal {
|
||||||
this._el = null;
|
this._el = null;
|
||||||
document.removeEventListener('keydown', this._onKey);
|
document.removeEventListener('keydown', this._onKey);
|
||||||
playMenuMusic();
|
playMenuMusic();
|
||||||
|
|
||||||
|
// Briefly swallow input so the closing click doesn't fall through to the
|
||||||
|
// button (e.g. on the Phaser canvas) sitting behind the close button.
|
||||||
|
const shield = document.createElement('div');
|
||||||
|
shield.style.cssText =
|
||||||
|
'position:fixed;inset:0;z-index:99999;background:transparent;cursor:default;';
|
||||||
|
const swallow = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||||||
|
['pointerdown', 'pointerup', 'click', 'mousedown', 'mouseup', 'touchstart', 'touchend']
|
||||||
|
.forEach((evt) => shield.addEventListener(evt, swallow, true));
|
||||||
|
document.body.appendChild(shield);
|
||||||
|
setTimeout(() => shield.remove(), 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue