feat: add tutorial modal system and register battleship game
- Introduce `hasTutorial` flag to game registry - Add "?" button on game menu to open tutorial modals - Implement shared hover tooltip on game menu - Add tutorial modal CSS styling - Register Battleship game with tutorial flag - Load Battleship sound effects
This commit is contained in:
parent
84cf864fd9
commit
98e799bf25
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,163 @@
|
|||
// Battleship AI opponent.
|
||||
//
|
||||
// Knowledge model: the AI only reasons from its own view of enemy waters
|
||||
// (state.shots[aiPlayer]) plus which enemy ships it has already sunk (fair —
|
||||
// you learn a ship's identity and footprint the moment it sinks). It never
|
||||
// peeks at the cells of ships still afloat.
|
||||
//
|
||||
// Skill scales from random fire → hunt/target → full probability-density
|
||||
// targeting. Salvo mode: chooseSalvo returns N distinct cells to fire at once.
|
||||
|
||||
import { SIZE, other } from './BattleshipLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { mode: 'random', blunder: 0.00, delay: [700, 1300] },
|
||||
2: { mode: 'hunt', blunder: 0.35, delay: [700, 1200] },
|
||||
3: { mode: 'hunt', blunder: 0.15, delay: [650, 1100] },
|
||||
4: { mode: 'density', blunder: 0.06, delay: [550, 1000] },
|
||||
5: { mode: 'density', blunder: 0.00, delay: [450, 900] },
|
||||
};
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, Math.round(skill ?? 3)))];
|
||||
}
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
// AI ship placement — random legal layout (same generator the player's
|
||||
// Randomize button uses). Kept as a wrapper so callers don't import Logic twice.
|
||||
export { placeShipsRandom as placeFleet } from './BattleshipLogic.js';
|
||||
|
||||
// ── Knowledge extraction ──────────────────────────────────────────────────────
|
||||
|
||||
function analyze(state, aiPlayer) {
|
||||
const target = other(aiPlayer);
|
||||
const shots = state.shots[aiPlayer];
|
||||
const sunkCells = new Set();
|
||||
const remainingLens = [];
|
||||
for (const ship of state.fleets[target]) {
|
||||
if (ship.sunk) for (const cell of ship.cells) sunkCells.add(`${cell.r},${cell.c}`);
|
||||
else remainingLens.push(ship.len);
|
||||
}
|
||||
// Active hits = 'hit' cells not yet attributed to a sunk ship (an unfinished kill).
|
||||
const activeHits = [];
|
||||
const untried = [];
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const mark = shots[r][c];
|
||||
if (mark === null) untried.push({ r, c });
|
||||
else if (mark === 'hit' && !sunkCells.has(`${r},${c}`)) activeHits.push({ r, c });
|
||||
}
|
||||
}
|
||||
return { shots, sunkCells, remainingLens, activeHits, untried };
|
||||
}
|
||||
|
||||
// ── Scoring ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DIRS = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||
|
||||
// Probability density: for every legal placement of every remaining ship that
|
||||
// is consistent with known misses/sunk cells, add weight to each untried cell
|
||||
// it covers. Placements overlapping active hits are weighted heavily so the AI
|
||||
// piles fire onto a wounded ship and extends along the hit axis.
|
||||
function densityScores(shots, sunkCells, remainingLens, activeHits) {
|
||||
const HIT_WEIGHT = 30;
|
||||
const score = Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
|
||||
const activeHitSet = new Set(activeHits.map((h) => `${h.r},${h.c}`));
|
||||
const blocked = (r, c) => shots[r][c] === 'miss' || sunkCells.has(`${r},${c}`);
|
||||
|
||||
for (const len of remainingLens) {
|
||||
for (const horizontal of [true, false]) {
|
||||
const rMax = horizontal ? SIZE : SIZE - len + 1;
|
||||
const cMax = horizontal ? SIZE - len + 1 : SIZE;
|
||||
for (let r = 0; r < rMax; r++) {
|
||||
for (let c = 0; c < cMax; c++) {
|
||||
const cells = [];
|
||||
let ok = true;
|
||||
let hitOverlap = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cr = horizontal ? r : r + i;
|
||||
const cc = horizontal ? c + i : c;
|
||||
if (blocked(cr, cc)) { ok = false; break; }
|
||||
if (activeHitSet.has(`${cr},${cc}`)) hitOverlap++;
|
||||
cells.push([cr, cc]);
|
||||
}
|
||||
if (!ok) continue;
|
||||
const weight = hitOverlap > 0 ? hitOverlap * HIT_WEIGHT : 1;
|
||||
for (const [cr, cc] of cells) {
|
||||
if (shots[cr][cc] === null) score[cr][cc] += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// Lighter hunt/target heuristic for mid skill: parity sweep when searching,
|
||||
// strong pull toward cells adjacent to (and extending lines of) active hits.
|
||||
function huntScores(shots, sunkCells, activeHits) {
|
||||
const score = Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
|
||||
const activeHitSet = new Set(activeHits.map((h) => `${h.r},${h.c}`));
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
if (shots[r][c] !== null) { score[r][c] = -Infinity; continue; }
|
||||
let s = (r + c) % 2 === 0 ? 1 : 0; // checkerboard parity (min ship length 2)
|
||||
for (const [dr, dc] of DIRS) {
|
||||
if (activeHitSet.has(`${r + dr},${c + dc}`)) {
|
||||
s += 8;
|
||||
if (activeHitSet.has(`${r + 2 * dr},${c + 2 * dc}`)) s += 20; // extend a run
|
||||
}
|
||||
}
|
||||
score[r][c] = s;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// ── Salvo selection ─────────────────────────────────────────────────────────
|
||||
|
||||
function sample(arr, n) {
|
||||
const copy = arr.slice();
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
export function chooseSalvo(state, aiPlayer, skill, n) {
|
||||
const prof = profileFor(skill);
|
||||
const { shots, sunkCells, remainingLens, activeHits, untried } = analyze(state, aiPlayer);
|
||||
if (untried.length <= n) return untried;
|
||||
|
||||
if (prof.mode === 'random') return sample(untried, n);
|
||||
|
||||
const score = prof.mode === 'density'
|
||||
? densityScores(shots, sunkCells, remainingLens, activeHits)
|
||||
: huntScores(shots, sunkCells, activeHits);
|
||||
|
||||
// Rank untried cells by score with a small random jitter to break ties / spread shots.
|
||||
const ranked = untried
|
||||
.map((cell) => ({ cell, s: score[cell.r][cell.c] + Math.random() * 0.5 }))
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.cell);
|
||||
|
||||
const picks = ranked.slice(0, n);
|
||||
|
||||
// Blunder: occasionally swap a disciplined pick for a random untried cell.
|
||||
if (prof.blunder > 0) {
|
||||
const chosen = new Set(picks.map((p) => `${p.r},${p.c}`));
|
||||
const pool = untried.filter((p) => !chosen.has(`${p.r},${p.c}`));
|
||||
for (let i = 0; i < picks.length && pool.length; i++) {
|
||||
if (Math.random() < prof.blunder) {
|
||||
const j = Math.floor(Math.random() * pool.length);
|
||||
picks[i] = pool.splice(j, 1)[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
|
@ -0,0 +1,903 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import {
|
||||
SIZE, SHIPS, createInitialState, makeShip, canPlace, placeShipsRandom,
|
||||
applySalvo, salvoCount, aliveCount,
|
||||
} from './BattleshipLogic.js';
|
||||
import { chooseSalvo, nextThinkDelay, placeFleet } from './BattleshipAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const CELL = 60;
|
||||
const GRID = CELL * SIZE; // 600
|
||||
const BY = 220; // grids' top Y
|
||||
const EX = 290; // Enemy Waters origin X
|
||||
const RX = EX + GRID + 140; // Your Fleet origin X (1030)
|
||||
|
||||
const DEPTH = {
|
||||
bg: -2, ocean: 0, wave: 1, sonar: 2, grid: 3, label: 4,
|
||||
ship: 6, reveal: 7, marker: 9, reticle: 11, armed: 12,
|
||||
missile: 30, fx: 35, ui: 50, banner: 60, overlay: 70,
|
||||
};
|
||||
|
||||
// ── Palette (naval HUD / teal sonar) ───────────────────────────────────────────
|
||||
const C = {
|
||||
oceanTop: 0x0c2c3a,
|
||||
ocean: 0x0a2632,
|
||||
oceanDk: 0x061a24,
|
||||
grid: 0x2f6f82,
|
||||
gridDim: 0x1b4b59,
|
||||
frame: COLORS.accent,
|
||||
frameDk: 0x3a2e12,
|
||||
sonar: 0x39ffd0,
|
||||
reticle: 0x4fe8d8,
|
||||
armed: 0xffd24a,
|
||||
hit: 0xff5a3c,
|
||||
hitGlow: 0xffb648,
|
||||
miss: 0xdfeef5,
|
||||
shipBody: 0x53606d,
|
||||
shipLt: 0x7c8a98,
|
||||
shipDk: 0x29333c,
|
||||
shipDeck: 0x3b4753,
|
||||
valid: 0x39d98a,
|
||||
invalid: 0xe0564b,
|
||||
sunkBody: 0x6e2b24,
|
||||
sunkDk: 0x3a1512,
|
||||
};
|
||||
|
||||
const SHIP_PALETTES = {
|
||||
fleet: { body: C.shipBody, light: C.shipLt, dark: C.shipDk, deck: C.shipDeck },
|
||||
valid: { body: C.valid, light: 0x9bf5c4, dark: 0x1f7a4d, deck: 0x2aa869 },
|
||||
invalid: { body: C.invalid, light: 0xf2a59d, dark: 0x7d241d, deck: 0xb83c33 },
|
||||
sunk: { body: C.sunkBody, light: 0x9c4138, dark: C.sunkDk, deck: 0x551f19 },
|
||||
};
|
||||
|
||||
export default class BattleshipGame extends Phaser.Scene {
|
||||
constructor() { super('BattleshipGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.gs = null;
|
||||
this.animating = false;
|
||||
this.shipPieces = []; // one draggable container per ship (placement)
|
||||
this.armed = []; // [{r,c}] cells armed this turn
|
||||
this.activeShip = null; // piece currently being dragged
|
||||
this.lastPointer = { x: 0, y: 0 };
|
||||
this.playerSalvoN = 0;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.input.mouse?.disableContextMenu();
|
||||
this.buildTextures();
|
||||
this.buildBackground();
|
||||
this.buildGrid(EX, BY, 'ENEMY WATERS');
|
||||
this.buildGrid(RX, BY, 'YOUR FLEET');
|
||||
this.buildSonar();
|
||||
this.buildLayers();
|
||||
this.buildPortraits();
|
||||
this.buildEnemyZone();
|
||||
this.buildCommonUI();
|
||||
|
||||
this.input.keyboard.on('keydown-R', () => this.rotateActiveShip());
|
||||
|
||||
this.gs = createInitialState();
|
||||
this.beginPlacement();
|
||||
}
|
||||
|
||||
// ── Texture + ambient construction ───────────────────────────────────────────
|
||||
|
||||
buildTextures() {
|
||||
// Soft round particle used (tinted) for splashes, embers, smoke, bubbles.
|
||||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xffffff, 1); g.fillCircle(8, 8, 8);
|
||||
g.fillStyle(0xffffff, 0.5); g.fillCircle(8, 8, 5);
|
||||
g.generateTexture('bsDot', 16, 16);
|
||||
g.destroy();
|
||||
|
||||
// Sonar sweep: a fading wedge that we rotate over Enemy Waters.
|
||||
const R = GRID / 2;
|
||||
const s = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
const steps = 70;
|
||||
const spread = Phaser.Math.DegToRad(130);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const a0 = -spread + (spread / steps) * i;
|
||||
const a1 = a0 + spread / steps + 0.012;
|
||||
s.fillStyle(C.sonar, (i / steps) * 0.45);
|
||||
s.slice(R, R, R, a0, a1, false);
|
||||
s.fillPath();
|
||||
}
|
||||
s.lineStyle(3, 0x9bffe9, 0.85);
|
||||
s.lineBetween(R, R, R + R, R);
|
||||
s.generateTexture('bsSonar', GRID, GRID);
|
||||
s.destroy();
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
}
|
||||
// Deep-sea gradient backdrop.
|
||||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||
for (let i = 0; i < GAME_HEIGHT; i += 4) {
|
||||
const t = i / GAME_HEIGHT;
|
||||
const col = Phaser.Display.Color.Interpolate.ColorWithColor(
|
||||
Phaser.Display.Color.ValueToColor(0x05131b),
|
||||
Phaser.Display.Color.ValueToColor(0x0a2230), 100, Math.floor(t * 100));
|
||||
g.fillStyle(Phaser.Display.Color.GetColor(col.r, col.g, col.b), 1);
|
||||
g.fillRect(0, i, GAME_WIDTH, 4);
|
||||
}
|
||||
}
|
||||
|
||||
buildGrid(ox, oy, title) {
|
||||
const g = this.add.graphics().setDepth(DEPTH.ocean);
|
||||
// Gold frame.
|
||||
const F = 16;
|
||||
g.fillStyle(C.frameDk, 1);
|
||||
g.fillRoundedRect(ox - F, oy - F, GRID + F * 2, GRID + F * 2, 14);
|
||||
g.lineStyle(3, C.frame, 1);
|
||||
g.strokeRoundedRect(ox - F + 4, oy - F + 4, GRID + F * 2 - 8, GRID + F * 2 - 8, 10);
|
||||
|
||||
// Ocean fill.
|
||||
g.fillStyle(C.ocean, 1);
|
||||
g.fillRect(ox, oy, GRID, GRID);
|
||||
g.fillStyle(C.oceanDk, 0.45);
|
||||
for (let r = 0; r < SIZE; r++)
|
||||
for (let c = (r % 2); c < SIZE; c += 2)
|
||||
g.fillRect(ox + c * CELL, oy + r * CELL, CELL, CELL); // subtle checker
|
||||
|
||||
// Grid lines.
|
||||
g.lineStyle(1, C.gridDim, 0.9);
|
||||
for (let i = 0; i <= SIZE; i++) {
|
||||
g.lineBetween(ox + i * CELL, oy, ox + i * CELL, oy + GRID);
|
||||
g.lineBetween(ox, oy + i * CELL, ox + GRID, oy + i * CELL);
|
||||
}
|
||||
|
||||
// Coordinate labels.
|
||||
for (let c = 0; c < SIZE; c++)
|
||||
this.add.text(ox + c * CELL + CELL / 2, oy - 4, String.fromCharCode(65 + c), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5, 1).setDepth(DEPTH.label);
|
||||
for (let r = 0; r < SIZE; r++)
|
||||
this.add.text(ox - 10, oy + r * CELL + CELL / 2, String(r + 1), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(1, 0.5).setDepth(DEPTH.label);
|
||||
|
||||
// Title.
|
||||
this.add.text(ox + GRID / 2, oy - 40, title, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.label);
|
||||
|
||||
// Gentle drifting wave lines for ambiance.
|
||||
const waves = this.add.graphics().setDepth(DEPTH.wave);
|
||||
waves.lineStyle(2, 0x3a7d92, 0.10);
|
||||
for (let y = oy + 30; y < oy + GRID; y += 70)
|
||||
waves.lineBetween(ox + 6, y, ox + GRID - 6, y);
|
||||
this.tweens.add({ targets: waves, y: 14, alpha: 0.6, duration: 3800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
}
|
||||
|
||||
buildSonar() {
|
||||
const img = this.add.image(EX + GRID / 2, BY + GRID / 2, 'bsSonar')
|
||||
.setDepth(DEPTH.sonar).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD);
|
||||
const mask = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
mask.fillStyle(0xffffff); mask.fillRect(EX, BY, GRID, GRID);
|
||||
img.setMask(mask.createGeometryMask());
|
||||
this.tweens.add({ targets: img, angle: 360, duration: 4200, repeat: -1, ease: 'Linear' });
|
||||
}
|
||||
|
||||
buildLayers() {
|
||||
this.enemyReveal = this.add.graphics().setDepth(DEPTH.reveal); // sunk enemy hulls
|
||||
this.enemyMarkers = this.add.graphics().setDepth(DEPTH.marker);
|
||||
this.yourMarkers = this.add.graphics().setDepth(DEPTH.marker);
|
||||
this.reticle = this.add.graphics().setDepth(DEPTH.reticle);
|
||||
this.armedLayer = this.add.graphics().setDepth(DEPTH.armed);
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
const opp = this.opponents[0];
|
||||
const r = 70;
|
||||
// Opponent — top-left margin.
|
||||
this.oppX = EX / 2; this.oppY = 330;
|
||||
this.opponentPortrait = createOpponentPortrait(this, opp, this.oppX, this.oppY, r, DEPTH.ui);
|
||||
this.add.text(this.oppX, this.oppY + r + 12, opp?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
wordWrap: { width: 240 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
|
||||
// Player — bottom-right margin.
|
||||
this.plrX = RX + GRID + (GAME_WIDTH - (RX + GRID)) / 2;
|
||||
this.plrY = 740;
|
||||
createPlayerPortrait(this, this.plrX, this.plrY, r, DEPTH.ui, 'Battleship');
|
||||
this.add.text(this.plrX, this.plrY - r - 12, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
wordWrap: { width: 240 }, align: 'center',
|
||||
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
|
||||
|
||||
// Fleet-status panels (5 pips each).
|
||||
this.enemyFleetPips = this.buildFleetPanel(this.oppX, this.oppY + r + 56, 'Enemy fleet');
|
||||
this.yourFleetPips = this.buildFleetPanel(this.plrX, this.plrY + r + 16, 'Your fleet');
|
||||
}
|
||||
|
||||
buildFleetPanel(cx, top, label) {
|
||||
this.add.text(cx, top, label, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
const pips = [];
|
||||
const w = 26, gap = 8;
|
||||
SHIPS.forEach((spec, i) => {
|
||||
const totalH = SHIPS.length * (w + gap);
|
||||
const y = top + 26 + i * (w + gap) - totalH / 2 + totalH / 2;
|
||||
const x = cx - (spec.len * 9);
|
||||
const g = this.add.graphics().setDepth(DEPTH.ui);
|
||||
this.drawPip(g, x, y, spec, 0);
|
||||
const t = this.add.text(cx + 84, y, spec.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
|
||||
pips.push({ g, t, spec, x, y });
|
||||
});
|
||||
return pips;
|
||||
}
|
||||
|
||||
drawPip(g, x, y, spec, hits) {
|
||||
g.clear();
|
||||
const seg = 16, gap = 2;
|
||||
for (let i = 0; i < spec.len; i++) {
|
||||
const damaged = i < hits;
|
||||
g.fillStyle(damaged ? C.hit : C.shipBody, 1);
|
||||
g.fillRoundedRect(x + i * (seg + gap), y - 7, seg, 14, 3);
|
||||
}
|
||||
}
|
||||
|
||||
refreshFleetPanels() {
|
||||
const update = (pips, fleet) => {
|
||||
for (const pip of pips) {
|
||||
const ship = fleet.find((s) => s.name === pip.spec.name);
|
||||
const hits = ship ? ship.hits.filter(Boolean).length : 0;
|
||||
this.drawPip(pip.g, pip.x, pip.y, pip.spec, hits);
|
||||
if (ship?.sunk) { pip.g.setAlpha(0.55); pip.t.setColor(COLORS.dangerHex); pip.t.setText(`${pip.spec.name} ✕`); }
|
||||
}
|
||||
};
|
||||
update(this.enemyFleetPips, this.gs.fleets.player2);
|
||||
update(this.yourFleetPips, this.gs.fleets.player1);
|
||||
}
|
||||
|
||||
buildEnemyZone() {
|
||||
this.enemyZone = this.add.zone(EX, BY, GRID, GRID).setOrigin(0)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.reticle);
|
||||
this.enemyZone.on('pointermove', (p) => this.onEnemyHover(p));
|
||||
this.enemyZone.on('pointerout', () => this.reticle.clear());
|
||||
this.enemyZone.on('pointerdown', (p) => this.onEnemyClick(p));
|
||||
}
|
||||
|
||||
buildCommonUI() {
|
||||
new Button(this, 130, 46, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 170, height: 44, fontSize: 20 }).setDepth(DEPTH.ui);
|
||||
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, BY + GRID + 70, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Placement phase ────────────────────────────────────────────────────────
|
||||
|
||||
beginPlacement() {
|
||||
this.setStatus('Position your fleet — drag ships onto the grid. Press R or right-click to rotate.');
|
||||
this.buildDock();
|
||||
this.buildPlacementUI();
|
||||
}
|
||||
|
||||
buildDock() {
|
||||
const dockY = BY + GRID + 130;
|
||||
let x = RX + 6;
|
||||
const scale = 0.46;
|
||||
this.shipPieces = SHIPS.map((spec) => {
|
||||
const container = this.add.container(0, 0).setDepth(DEPTH.ship);
|
||||
const body = this.add.graphics();
|
||||
container.add(body);
|
||||
const piece = {
|
||||
spec, body, container, horizontal: true, placed: false,
|
||||
r: 0, c: 0, dockX: 0, dockY: 0, dockScale: scale,
|
||||
};
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
|
||||
container.setScale(scale);
|
||||
const w = spec.len * CELL * scale;
|
||||
piece.dockX = x; piece.dockY = dockY;
|
||||
container.setPosition(x, dockY);
|
||||
x += w + 16;
|
||||
|
||||
container.on('dragstart', () => this.onShipDragStart(piece));
|
||||
container.on('drag', (p) => this.onShipDrag(piece, p));
|
||||
container.on('dragend', () => this.onShipDragEnd(piece));
|
||||
container.on('pointerdown', (p) => { if (p.rightButtonDown()) this.rotatePiece(piece); });
|
||||
return piece;
|
||||
});
|
||||
}
|
||||
|
||||
buildPlacementUI() {
|
||||
const bx = EX / 2;
|
||||
this.placementUI = [
|
||||
new Button(this, bx, 470, 'Randomize', () => this.randomizePlacement(),
|
||||
{ width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui),
|
||||
new Button(this, bx, 540, 'Clear', () => this.clearPlacement(),
|
||||
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui),
|
||||
];
|
||||
this.readyBtn = new Button(this, bx, 620, 'Ready', () => this.startBattle(),
|
||||
{ width: 200, height: 56, fontSize: 26 }).setDepth(DEPTH.ui);
|
||||
this.placementUI.push(this.readyBtn);
|
||||
this.updateReadyButton();
|
||||
}
|
||||
|
||||
setPieceHit(piece) {
|
||||
const w = piece.horizontal ? piece.spec.len * CELL : CELL;
|
||||
const h = piece.horizontal ? CELL : piece.spec.len * CELL;
|
||||
piece.container.setSize(w, h);
|
||||
// Our hull graphics are top-left anchored (local 0,0 → w,h), but Phaser's
|
||||
// input normalizes the hit test by a Container's displayOrigin, which is
|
||||
// hardcoded to (width/2, height/2). Offset the hit area by the same amount
|
||||
// so the clickable zone lines up with the visible ship instead of sitting
|
||||
// up-and-left of it.
|
||||
piece.container.setInteractive(
|
||||
new Phaser.Geom.Rectangle(w / 2, h / 2, w, h), Phaser.Geom.Rectangle.Contains);
|
||||
this.input.setDraggable(piece.container);
|
||||
}
|
||||
|
||||
drawPiece(piece, paletteName) {
|
||||
const g = piece.body;
|
||||
const { spec, horizontal } = piece;
|
||||
const pal = SHIP_PALETTES[paletteName];
|
||||
const w = horizontal ? spec.len * CELL : CELL;
|
||||
const h = horizontal ? CELL : spec.len * CELL;
|
||||
const p = 5;
|
||||
g.clear();
|
||||
g.fillStyle(0x000000, 0.3); g.fillRoundedRect(p + 2, p + 4, w - 2 * p, h - 2 * p, 12);
|
||||
g.fillStyle(pal.body, 1); g.fillRoundedRect(p, p, w - 2 * p, h - 2 * p, 12);
|
||||
g.fillStyle(pal.light, 0.45);
|
||||
g.fillRoundedRect(p + 3, p + 3, horizontal ? w - 2 * p - 6 : (w - 2 * p) * 0.5,
|
||||
horizontal ? (h - 2 * p) * 0.45 : h - 2 * p - 6, 8);
|
||||
g.lineStyle(2, pal.dark, 1); g.strokeRoundedRect(p, p, w - 2 * p, h - 2 * p, 12);
|
||||
g.lineStyle(1, pal.dark, 0.55);
|
||||
for (let i = 1; i < spec.len; i++) {
|
||||
if (horizontal) { const lx = i * CELL; g.lineBetween(lx, p + 4, lx, h - p - 4); }
|
||||
else { const ly = i * CELL; g.lineBetween(p + 4, ly, w - p - 4, ly); }
|
||||
}
|
||||
g.fillStyle(pal.deck, 1);
|
||||
for (let i = 0; i < spec.len; i++) {
|
||||
const cx = horizontal ? i * CELL + CELL / 2 : w / 2;
|
||||
const cy = horizontal ? h / 2 : i * CELL + CELL / 2;
|
||||
g.fillCircle(cx, cy, CELL * 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
placedFleetExcluding(exclude) {
|
||||
return this.shipPieces
|
||||
.filter((pc) => pc.placed && pc !== exclude)
|
||||
.map((pc) => makeShip(pc.spec, pc.r, pc.c, pc.horizontal));
|
||||
}
|
||||
|
||||
syncFleetFromPieces() {
|
||||
this.gs.fleets.player1 = this.shipPieces
|
||||
.filter((pc) => pc.placed)
|
||||
.map((pc) => makeShip(pc.spec, pc.r, pc.c, pc.horizontal));
|
||||
}
|
||||
|
||||
onShipDragStart(piece) {
|
||||
this.activeShip = piece;
|
||||
if (piece.placed) { piece.placed = false; this.syncFleetFromPieces(); this.updateReadyButton(); }
|
||||
piece.container.setScale(1).setDepth(DEPTH.missile);
|
||||
}
|
||||
|
||||
onShipDrag(piece, pointer) {
|
||||
this.lastPointer = { x: pointer.x, y: pointer.y };
|
||||
const { spec, horizontal } = piece;
|
||||
const inside = pointer.x >= RX && pointer.x < RX + GRID && pointer.y >= BY && pointer.y < BY + GRID;
|
||||
if (inside) {
|
||||
let bowC = Math.floor((pointer.x - RX) / CELL);
|
||||
let bowR = Math.floor((pointer.y - BY) / CELL);
|
||||
bowC = Phaser.Math.Clamp(bowC, 0, horizontal ? SIZE - spec.len : SIZE - 1);
|
||||
bowR = Phaser.Math.Clamp(bowR, 0, horizontal ? SIZE - 1 : SIZE - spec.len);
|
||||
const valid = canPlace(this.placedFleetExcluding(piece), spec.len, bowR, bowC, horizontal);
|
||||
piece.container.setPosition(RX + bowC * CELL, BY + bowR * CELL);
|
||||
piece.dragCell = { bowR, bowC, valid };
|
||||
this.drawPiece(piece, valid ? 'valid' : 'invalid');
|
||||
} else {
|
||||
piece.container.setPosition(pointer.x - CELL / 2, pointer.y - CELL / 2);
|
||||
piece.dragCell = null;
|
||||
this.drawPiece(piece, 'fleet');
|
||||
}
|
||||
}
|
||||
|
||||
onShipDragEnd(piece) {
|
||||
if (piece.dragCell?.valid) {
|
||||
this.placePiece(piece, piece.dragCell.bowR, piece.dragCell.bowC);
|
||||
} else {
|
||||
this.returnToDock(piece);
|
||||
}
|
||||
piece.dragCell = null;
|
||||
this.activeShip = null;
|
||||
}
|
||||
|
||||
placePiece(piece, bowR, bowC) {
|
||||
piece.placed = true; piece.r = bowR; piece.c = bowC;
|
||||
piece.container.setScale(1).setDepth(DEPTH.ship)
|
||||
.setPosition(RX + bowC * CELL, BY + bowR * CELL);
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
this.syncFleetFromPieces();
|
||||
this.updateReadyButton();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
}
|
||||
|
||||
returnToDock(piece) {
|
||||
piece.placed = false;
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
this.tweens.add({
|
||||
targets: piece.container, x: piece.dockX, y: piece.dockY,
|
||||
scaleX: piece.dockScale, scaleY: piece.dockScale, duration: 220, ease: 'Quad.easeOut',
|
||||
});
|
||||
piece.container.setDepth(DEPTH.ship);
|
||||
this.syncFleetFromPieces();
|
||||
this.updateReadyButton();
|
||||
}
|
||||
|
||||
rotatePiece(piece) {
|
||||
if (this.gs.phase !== 'placement') return;
|
||||
piece.horizontal = !piece.horizontal;
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
if (piece.placed) {
|
||||
// Keep it placed only if still legal; otherwise pop back to dock.
|
||||
if (canPlace(this.placedFleetExcluding(piece), piece.spec.len, piece.r, piece.c, piece.horizontal)) {
|
||||
this.syncFleetFromPieces();
|
||||
} else {
|
||||
this.returnToDock(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rotateActiveShip() {
|
||||
const piece = this.activeShip;
|
||||
if (!piece) return;
|
||||
piece.horizontal = !piece.horizontal;
|
||||
this.drawPiece(piece, piece.dragCell?.valid === false ? 'invalid' : 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
// Re-evaluate the live preview at the current pointer.
|
||||
this.onShipDrag(piece, this.lastPointer);
|
||||
}
|
||||
|
||||
randomizePlacement() {
|
||||
const layout = placeShipsRandom();
|
||||
for (const piece of this.shipPieces) {
|
||||
const ship = layout.find((s) => s.name === piece.spec.name);
|
||||
piece.horizontal = ship.horizontal;
|
||||
piece.r = ship.cells[0].r; piece.c = ship.cells[0].c;
|
||||
piece.placed = true;
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
piece.container.setScale(1).setDepth(DEPTH.ship)
|
||||
.setPosition(RX + piece.c * CELL, BY + piece.r * CELL);
|
||||
}
|
||||
this.syncFleetFromPieces();
|
||||
this.updateReadyButton();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
}
|
||||
|
||||
clearPlacement() {
|
||||
for (const piece of this.shipPieces) {
|
||||
piece.placed = false; piece.horizontal = true;
|
||||
this.drawPiece(piece, 'fleet');
|
||||
this.setPieceHit(piece);
|
||||
this.tweens.add({
|
||||
targets: piece.container, x: piece.dockX, y: piece.dockY,
|
||||
scaleX: piece.dockScale, scaleY: piece.dockScale, duration: 220, ease: 'Quad.easeOut',
|
||||
});
|
||||
piece.container.setDepth(DEPTH.ship);
|
||||
}
|
||||
this.syncFleetFromPieces();
|
||||
this.updateReadyButton();
|
||||
}
|
||||
|
||||
updateReadyButton() {
|
||||
if (!this.readyBtn) return;
|
||||
this.readyBtn.setEnabled(this.gs.fleets.player1.length === SHIPS.length);
|
||||
}
|
||||
|
||||
// ── Battle phase ─────────────────────────────────────────────────────────────
|
||||
|
||||
startBattle() {
|
||||
if (this.gs.fleets.player1.length !== SHIPS.length) return;
|
||||
// Lock the player's fleet (no more dragging) and place the AI's.
|
||||
for (const piece of this.shipPieces) { piece.container.disableInteractive(); this.input.setDraggable(piece.container, false); }
|
||||
this.placementUI.forEach((b) => b.destroy());
|
||||
this.placementUI = [];
|
||||
this.gs.fleets.player2 = placeFleet(this.opponents[0]?.skill ?? 3);
|
||||
this.gs.phase = 'battle';
|
||||
this.gs.turn = 'player1';
|
||||
|
||||
this.buildBattleUI();
|
||||
this.refreshFleetPanels();
|
||||
this.showBanner('Battle stations!', COLORS.accentHex);
|
||||
this.time.delayedCall(900, () => this.beginPlayerTurn());
|
||||
}
|
||||
|
||||
buildBattleUI() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.salvoText = this.add.text(cx, BY + GRID + 110, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
this.fireBtn = new Button(this, cx, BY + GRID + 170, 'FIRE', () => this.onFire(),
|
||||
{ width: 240, height: 60, fontSize: 30, bg: 0x6b1f14, bgHover: C.hit })
|
||||
.setDepth(DEPTH.ui);
|
||||
this.fireBtn.setEnabled(false);
|
||||
}
|
||||
|
||||
beginPlayerTurn() {
|
||||
if (this.gs.phase !== 'battle') return;
|
||||
this.gs.turn = 'player1';
|
||||
this.armed = [];
|
||||
const untried = this.countUntried('player1');
|
||||
this.playerSalvoN = Math.min(salvoCount(this.gs, 'player1'), untried);
|
||||
this.drawArmed();
|
||||
this.updateSalvoUI();
|
||||
this.setStatus(`Your salvo — arm ${this.playerSalvoN} target${this.playerSalvoN === 1 ? '' : 's'} on Enemy Waters, then FIRE.`);
|
||||
}
|
||||
|
||||
countUntried(player) {
|
||||
let n = 0;
|
||||
const grid = this.gs.shots[player];
|
||||
for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (grid[r][c] === null) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
updateSalvoUI() {
|
||||
if (this.salvoText) this.salvoText.setText(`SALVO ${this.armed.length} / ${this.playerSalvoN}`);
|
||||
this.fireBtn?.setEnabled(this.armed.length === this.playerSalvoN && this.playerSalvoN > 0);
|
||||
}
|
||||
|
||||
enemyCellFromPointer(p) {
|
||||
const c = Math.floor((p.x - EX) / CELL);
|
||||
const r = Math.floor((p.y - BY) / CELL);
|
||||
if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return null;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
onEnemyHover(p) {
|
||||
this.reticle.clear();
|
||||
if (!this.isPlayerActionable()) return;
|
||||
const cell = this.enemyCellFromPointer(p);
|
||||
if (!cell || this.gs.shots.player1[cell.r][cell.c] !== null) return;
|
||||
if (this.armed.some((a) => a.r === cell.r && a.c === cell.c)) return;
|
||||
this.drawReticle(this.reticle, cell.r, cell.c, C.reticle, 0.9);
|
||||
}
|
||||
|
||||
onEnemyClick(p) {
|
||||
if (!this.isPlayerActionable()) return;
|
||||
const cell = this.enemyCellFromPointer(p);
|
||||
if (!cell || this.gs.shots.player1[cell.r][cell.c] !== null) return;
|
||||
const idx = this.armed.findIndex((a) => a.r === cell.r && a.c === cell.c);
|
||||
if (idx >= 0) { this.armed.splice(idx, 1); }
|
||||
else if (this.armed.length < this.playerSalvoN) { this.armed.push(cell); }
|
||||
else return;
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.reticle.clear();
|
||||
this.drawArmed();
|
||||
this.updateSalvoUI();
|
||||
}
|
||||
|
||||
isPlayerActionable() {
|
||||
return this.gs.phase === 'battle' && this.gs.turn === 'player1' && !this.animating;
|
||||
}
|
||||
|
||||
drawArmed() {
|
||||
this.armedLayer.clear();
|
||||
for (const a of this.armed) this.drawReticle(this.armedLayer, a.r, a.c, C.armed, 1, true);
|
||||
}
|
||||
|
||||
drawReticle(g, r, c, color, alpha, locked = false) {
|
||||
const x = EX + c * CELL + CELL / 2;
|
||||
const y = BY + r * CELL + CELL / 2;
|
||||
const R = CELL * 0.42;
|
||||
g.lineStyle(2, color, alpha);
|
||||
g.strokeCircle(x, y, R);
|
||||
g.lineStyle(locked ? 3 : 2, color, alpha);
|
||||
g.lineBetween(x - R - 4, y, x - R + 10, y);
|
||||
g.lineBetween(x + R - 10, y, x + R + 4, y);
|
||||
g.lineBetween(x, y - R - 4, x, y - R + 10);
|
||||
g.lineBetween(x, y + R - 10, x, y + R + 4);
|
||||
if (locked) { g.fillStyle(color, 0.9); g.fillCircle(x, y, 4); }
|
||||
}
|
||||
|
||||
onFire() {
|
||||
if (!this.isPlayerActionable() || this.armed.length !== this.playerSalvoN || this.playerSalvoN === 0) return;
|
||||
this.fireBtn.setEnabled(false);
|
||||
this.reticle.clear(); this.armedLayer.clear();
|
||||
this.setStatus('Incoming fire!');
|
||||
this.fireVolley('player1', this.armed.slice(), true);
|
||||
}
|
||||
|
||||
// ── AI turn ───────────────────────────────────────────────────────────────
|
||||
|
||||
beginAITurn() {
|
||||
if (this.gs.phase !== 'battle') return;
|
||||
this.gs.turn = 'player2';
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.setStatus(`${name} is taking aim…`);
|
||||
this.showBanner(`${name}'s salvo`, COLORS.dangerHex);
|
||||
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => {
|
||||
const n = Math.min(salvoCount(this.gs, 'player2'), this.countUntried('player2'));
|
||||
const cells = chooseSalvo(this.gs, 'player2', this.opponents[0]?.skill ?? 3, n);
|
||||
this.fireVolley('player2', cells, false);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Volley animation + resolution ───────────────────────────────────────────
|
||||
|
||||
fireVolley(byPlayer, cells, firedByPlayer) {
|
||||
this.animating = true;
|
||||
const { state, results } = applySalvo(this.gs, byPlayer, cells);
|
||||
this.gs = state;
|
||||
|
||||
const targetOrigin = firedByPlayer ? { x: EX, y: BY } : { x: RX, y: BY };
|
||||
const source = firedByPlayer ? { x: this.plrX, y: this.plrY } : { x: this.oppX, y: this.oppY };
|
||||
|
||||
let landed = 0;
|
||||
const total = results.length;
|
||||
if (total === 0) { this.finishVolley(firedByPlayer); return; }
|
||||
|
||||
results.forEach((res, i) => {
|
||||
this.time.delayedCall(i * 250, () => {
|
||||
this.launchMissile(source, targetOrigin, res, () => {
|
||||
this.resolveImpact(targetOrigin, res, firedByPlayer);
|
||||
landed++;
|
||||
if (landed === total) this.time.delayedCall(650, () => this.finishVolley(firedByPlayer));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
launchMissile(source, origin, res, onImpact) {
|
||||
const tx = origin.x + res.c * CELL + CELL / 2;
|
||||
const ty = origin.y + res.r * CELL + CELL / 2;
|
||||
const p0 = source;
|
||||
const p2 = { x: tx, y: ty };
|
||||
const p1 = { x: (p0.x + p2.x) / 2, y: Math.min(p0.y, p2.y) - 280 };
|
||||
|
||||
playSound(this, SFX.BATTLESHIP_LAUNCH);
|
||||
|
||||
const missile = this.add.graphics().setDepth(DEPTH.missile);
|
||||
missile.fillStyle(0xfff2c8, 1); missile.fillCircle(0, 0, 5);
|
||||
missile.fillStyle(C.hitGlow, 0.6); missile.fillCircle(0, 0, 9);
|
||||
|
||||
const obj = { t: 0 };
|
||||
let prev = { ...p0 };
|
||||
this.tweens.add({
|
||||
targets: obj, t: 1, duration: 1000, ease: 'Sine.easeIn',
|
||||
onUpdate: () => {
|
||||
const t = obj.t, it = 1 - t;
|
||||
const x = it * it * p0.x + 2 * it * t * p1.x + t * t * p2.x;
|
||||
const y = it * it * p0.y + 2 * it * t * p1.y + t * t * p2.y;
|
||||
missile.setPosition(x, y);
|
||||
// Smoke trail.
|
||||
const s = this.add.image(prev.x, prev.y, 'bsDot').setDepth(DEPTH.missile - 1)
|
||||
.setTint(0x9aa6ad).setAlpha(0.5).setScale(0.5);
|
||||
this.tweens.add({ targets: s, alpha: 0, scale: 1.4, duration: 400, onComplete: () => s.destroy() });
|
||||
prev = { x, y };
|
||||
},
|
||||
onComplete: () => { missile.destroy(); onImpact(); },
|
||||
});
|
||||
}
|
||||
|
||||
resolveImpact(origin, res, firedByPlayer) {
|
||||
const x = origin.x + res.c * CELL + CELL / 2;
|
||||
const y = origin.y + res.r * CELL + CELL / 2;
|
||||
if (res.result === 'miss') {
|
||||
playSound(this, SFX.BATTLESHIP_MISS);
|
||||
this.splash(x, y);
|
||||
} else {
|
||||
playSound(this, SFX.BATTLESHIP_HIT);
|
||||
this.explosion(x, y);
|
||||
this.cameras.main.shake(res.result === 'sunk' ? 320 : 180, res.result === 'sunk' ? 0.009 : 0.005);
|
||||
if (res.result === 'sunk') this.onShipSunk(res.ship, firedByPlayer);
|
||||
if (this.opponentPortrait) this.playOppEmotion(firedByPlayer ? 'upset' : 'happy');
|
||||
}
|
||||
this.renderMarkers();
|
||||
this.refreshFleetPanels();
|
||||
}
|
||||
|
||||
splash(x, y) {
|
||||
const ripple = this.add.graphics().setDepth(DEPTH.fx);
|
||||
const prox = { p: 0 };
|
||||
this.tweens.add({ targets: prox, p: 1, duration: 600, onUpdate: () => {
|
||||
ripple.clear(); ripple.lineStyle(3, C.miss, 1 - prox.p);
|
||||
ripple.strokeCircle(x, y, 6 + prox.p * 34);
|
||||
}, onComplete: () => ripple.destroy() });
|
||||
const e = this.add.particles(x, y, 'bsDot', {
|
||||
speed: { min: 60, max: 200 }, lifespan: 520, scale: { start: 0.7, end: 0 },
|
||||
alpha: { start: 0.9, end: 0 }, quantity: 12, angle: { min: 200, max: 340 },
|
||||
tint: [C.miss, 0x8fd0e0, 0xbfe6f2],
|
||||
}).setDepth(DEPTH.fx);
|
||||
this.time.delayedCall(60, () => e.stop());
|
||||
this.time.delayedCall(700, () => e.destroy());
|
||||
}
|
||||
|
||||
explosion(x, y) {
|
||||
const flash = this.add.circle(x, y, 30, C.hitGlow, 0.9).setDepth(DEPTH.fx);
|
||||
this.tweens.add({ targets: flash, scale: 2.2, alpha: 0, duration: 360, onComplete: () => flash.destroy() });
|
||||
const e = this.add.particles(x, y, 'bsDot', {
|
||||
speed: { min: 120, max: 420 }, lifespan: 700, scale: { start: 1.1, end: 0 },
|
||||
alpha: { start: 1, end: 0 }, quantity: 22, angle: { min: 0, max: 360 },
|
||||
tint: [C.hit, C.hitGlow, 0xffe27a],
|
||||
}).setDepth(DEPTH.fx);
|
||||
this.time.delayedCall(80, () => e.stop());
|
||||
this.time.delayedCall(900, () => e.destroy());
|
||||
}
|
||||
|
||||
onShipSunk(ship, firedByPlayer) {
|
||||
const who = firedByPlayer ? 'Enemy' : 'Your';
|
||||
this.showBanner(`${who} ${ship.name} sunk!`, firedByPlayer ? COLORS.accentHex : COLORS.dangerHex);
|
||||
if (firedByPlayer) {
|
||||
this.renderEnemyReveal(); // draw the revealed hull, then flourish below
|
||||
const bowR = ship.cells[0].r, bowC = ship.cells[0].c;
|
||||
const cx = EX + bowC * CELL + (ship.horizontal ? ship.len : 1) * CELL / 2;
|
||||
const cy = BY + bowR * CELL + (ship.horizontal ? 1 : ship.len) * CELL / 2;
|
||||
this.bubbles(cx, cy);
|
||||
} else {
|
||||
// Flash + sink the player's own ship piece.
|
||||
const piece = this.shipPieces.find((pc) => pc.spec.name === ship.name);
|
||||
if (piece) {
|
||||
this.drawPiece(piece, 'sunk');
|
||||
this.tweens.add({ targets: piece.container, alpha: 0.6, y: piece.container.y + 6, duration: 600, ease: 'Sine.easeIn' });
|
||||
const cx = piece.container.x + (ship.horizontal ? ship.len : 1) * CELL / 2;
|
||||
const cy = piece.container.y + (ship.horizontal ? 1 : ship.len) * CELL / 2;
|
||||
this.bubbles(cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bubbles(x, y) {
|
||||
const e = this.add.particles(x, y, 'bsDot', {
|
||||
speed: { min: 20, max: 70 }, lifespan: 1100, scale: { start: 0.5, end: 0 },
|
||||
alpha: { start: 0.7, end: 0 }, quantity: 3, frequency: 60,
|
||||
angle: { min: 250, max: 290 }, tint: [0xbfe6f2, 0x8fd0e0],
|
||||
}).setDepth(DEPTH.fx);
|
||||
this.time.delayedCall(900, () => e.stop());
|
||||
this.time.delayedCall(2100, () => e.destroy());
|
||||
}
|
||||
|
||||
finishVolley(firedByPlayer) {
|
||||
this.renderMarkers();
|
||||
this.renderEnemyReveal();
|
||||
this.refreshFleetPanels();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'game_over') { this.time.delayedCall(500, () => this.onGameOver()); return; }
|
||||
if (firedByPlayer) this.beginAITurn();
|
||||
else this.beginPlayerTurn();
|
||||
}
|
||||
|
||||
// ── Marker / reveal rendering ───────────────────────────────────────────────
|
||||
|
||||
renderMarkers() {
|
||||
this.drawMarkerGrid(this.enemyMarkers, this.gs.shots.player1, EX);
|
||||
this.drawMarkerGrid(this.yourMarkers, this.gs.shots.player2, RX);
|
||||
}
|
||||
|
||||
drawMarkerGrid(g, grid, ox) {
|
||||
g.clear();
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const mark = grid[r][c];
|
||||
if (!mark) continue;
|
||||
const x = ox + c * CELL + CELL / 2;
|
||||
const y = BY + r * CELL + CELL / 2;
|
||||
if (mark === 'miss') {
|
||||
g.fillStyle(0x0a1a22, 0.5); g.fillCircle(x, y, 9);
|
||||
g.fillStyle(C.miss, 0.95); g.fillCircle(x, y, 7);
|
||||
g.lineStyle(1, 0x88a6b2, 0.8); g.strokeCircle(x, y, 7);
|
||||
} else {
|
||||
g.fillStyle(C.hit, 0.25); g.fillCircle(x, y, 16);
|
||||
g.fillStyle(C.hit, 1); g.fillCircle(x, y, 10);
|
||||
g.fillStyle(C.hitGlow, 1); g.fillCircle(x, y, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderEnemyReveal() {
|
||||
this.enemyReveal.clear();
|
||||
for (const ship of this.gs.fleets.player2) {
|
||||
if (!ship.sunk) continue;
|
||||
const bowR = ship.cells[0].r, bowC = ship.cells[0].c;
|
||||
this.drawSunkHull(this.enemyReveal, EX + bowC * CELL, BY + bowR * CELL, ship.len, ship.horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
drawSunkHull(g, x, y, len, horizontal) {
|
||||
const pal = SHIP_PALETTES.sunk;
|
||||
const w = horizontal ? len * CELL : CELL;
|
||||
const h = horizontal ? CELL : len * CELL;
|
||||
const p = 7;
|
||||
g.fillStyle(pal.dark, 0.9); g.fillRoundedRect(x + p, y + p, w - 2 * p, h - 2 * p, 10);
|
||||
g.fillStyle(pal.body, 0.85); g.fillRoundedRect(x + p + 2, y + p + 2, w - 2 * p - 4, h - 2 * p - 4, 8);
|
||||
g.lineStyle(2, pal.light, 0.7); g.strokeRoundedRect(x + p, y + p, w - 2 * p, h - 2 * p, 10);
|
||||
}
|
||||
|
||||
// ── Banners / status / portrait ──────────────────────────────────────────────
|
||||
|
||||
setStatus(text) { this.statusText?.setText(text); }
|
||||
|
||||
playOppEmotion(emotion) { try { this.opponentPortrait?.playEmotion(emotion); } catch (_) {} }
|
||||
|
||||
showBanner(text, colorHex) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const banner = this.add.text(cx, BY - 90, text, {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: colorHex ?? COLORS.textHex,
|
||||
backgroundColor: '#06141cdd', padding: { x: 30, y: 12 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||
this.tweens.add({
|
||||
targets: banner, y: BY - 30, duration: 320, ease: 'Back.easeOut',
|
||||
onComplete: () => this.time.delayedCall(1100, () =>
|
||||
this.tweens.add({ targets: banner, y: BY - 90, alpha: 0, duration: 240, onComplete: () => banner.destroy() })),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Game over ────────────────────────────────────────────────────────────────
|
||||
|
||||
onGameOver() {
|
||||
const human = this.gs.winner === 'player1';
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.playOppEmotion(human ? 'upset' : 'happy');
|
||||
this.recordResult(human ? 'win' : 'loss');
|
||||
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
if (human) {
|
||||
const e = this.add.particles(cx, cy - 60, 'bsDot', {
|
||||
speed: { min: 180, max: 520 }, lifespan: 1600, scale: { start: 1.4, end: 0 },
|
||||
alpha: { start: 1, end: 0 }, quantity: 6, frequency: 30, angle: { min: 0, max: 360 },
|
||||
tint: [C.armed, 0xffffff, C.sonar, C.hitGlow],
|
||||
}).setDepth(DEPTH.overlay - 1);
|
||||
this.time.delayedCall(2200, () => e.destroy());
|
||||
}
|
||||
|
||||
this.time.delayedCall(400, () => {
|
||||
const yourLeft = aliveCount(this.gs.fleets.player1);
|
||||
const enemyLeft = aliveCount(this.gs.fleets.player2);
|
||||
const msg = human
|
||||
? `🎉 Victory!\nFleet remaining — You ${yourLeft} · ${name} ${enemyLeft}`
|
||||
: `${name} wins!\nFleet remaining — You ${yourLeft} · ${name} ${enemyLeft}`;
|
||||
const overlay = this.add.rectangle(cx, cy, 820, 320, 0x06141c, 0.92)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.overlay);
|
||||
const txt = this.add.text(cx, cy - 50, msg, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '32px',
|
||||
color: human ? '#ffd24a' : COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlay + 1);
|
||||
new Button(this, cx - 100, cy + 90, 'Play Again', () => this.scene.restart(this._restartData()),
|
||||
{ width: 180, fontSize: 24 }).setDepth(DEPTH.overlay + 1);
|
||||
new Button(this, cx + 100, cy + 90, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 180, fontSize: 24 }).setDepth(DEPTH.overlay + 1);
|
||||
});
|
||||
}
|
||||
|
||||
_restartData() {
|
||||
return { game: this.gameDef, opponents: this.opponents, playfield: this.playfield };
|
||||
}
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
const score = result === 'win'
|
||||
? 100 + 20 * aliveCount(this.gs.fleets.player1)
|
||||
: 20 * (SHIPS.length - aliveCount(this.gs.fleets.player2));
|
||||
await api.post('/history/single-player', { slug: 'battleship', score, opponentScores: [], result });
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// Battleship — pure game logic (no Phaser, no rendering).
|
||||
//
|
||||
// Two players, each with a fleet of 5 ships on a 10×10 grid. Hidden
|
||||
// information: a player only learns enemy ship positions by firing at them.
|
||||
// Salvo mode: on your turn you fire one shot per ship still afloat.
|
||||
|
||||
export const SIZE = 10;
|
||||
|
||||
// Standard fleet, largest → smallest.
|
||||
export const SHIPS = [
|
||||
{ name: 'Carrier', len: 5 },
|
||||
{ name: 'Battleship', len: 4 },
|
||||
{ name: 'Cruiser', len: 3 },
|
||||
{ name: 'Submarine', len: 3 },
|
||||
{ name: 'Destroyer', len: 2 },
|
||||
];
|
||||
|
||||
export function other(player) {
|
||||
return player === 'player1' ? 'player2' : 'player1';
|
||||
}
|
||||
|
||||
function emptyGrid() {
|
||||
return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
|
||||
}
|
||||
|
||||
// A ship occupies `len` contiguous cells from (r,c), horizontal or vertical.
|
||||
function shipCells(len, r, c, horizontal) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
cells.push(horizontal ? { r, c: c + i } : { r: r + i, c });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// True if a ship of `len` placed at (r,c) fits in bounds and overlaps nothing
|
||||
// already in `fleet`. Ships are allowed to touch (standard rules).
|
||||
export function canPlace(fleet, len, r, c, horizontal) {
|
||||
const cells = shipCells(len, r, c, horizontal);
|
||||
for (const cell of cells) {
|
||||
if (cell.r < 0 || cell.r >= SIZE || cell.c < 0 || cell.c >= SIZE) return false;
|
||||
}
|
||||
const occupied = new Set();
|
||||
for (const ship of fleet) for (const cell of ship.cells) occupied.add(`${cell.r},${cell.c}`);
|
||||
for (const cell of cells) if (occupied.has(`${cell.r},${cell.c}`)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build a ship object ready to drop into a fleet.
|
||||
export function makeShip(spec, r, c, horizontal) {
|
||||
const cells = shipCells(spec.len, r, c, horizontal);
|
||||
return {
|
||||
name: spec.name,
|
||||
len: spec.len,
|
||||
horizontal,
|
||||
cells,
|
||||
hits: Array(spec.len).fill(false),
|
||||
sunk: false,
|
||||
};
|
||||
}
|
||||
|
||||
// A valid random 5-ship layout. Used by the Randomize button and the AI.
|
||||
export function placeShipsRandom() {
|
||||
const fleet = [];
|
||||
for (const spec of SHIPS) {
|
||||
// Try random placements until one is legal (always terminates on 10×10).
|
||||
for (let guard = 0; guard < 1000; guard++) {
|
||||
const horizontal = Math.random() < 0.5;
|
||||
const r = Math.floor(Math.random() * (horizontal ? SIZE : SIZE - spec.len + 1));
|
||||
const c = Math.floor(Math.random() * (horizontal ? SIZE - spec.len + 1 : SIZE));
|
||||
if (canPlace(fleet, spec.len, r, c, horizontal)) {
|
||||
fleet.push(makeShip(spec, r, c, horizontal));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fleet;
|
||||
}
|
||||
|
||||
export function createInitialState() {
|
||||
return {
|
||||
phase: 'placement', // 'placement' | 'battle' | 'game_over'
|
||||
turn: 'player1',
|
||||
fleets: { player1: [], player2: [] },
|
||||
shots: { player1: emptyGrid(), player2: emptyGrid() }, // firer's view of enemy
|
||||
winner: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
const cloneFleet = (fleet) => fleet.map((s) => ({
|
||||
name: s.name, len: s.len, horizontal: s.horizontal,
|
||||
cells: s.cells.map((cell) => ({ r: cell.r, c: cell.c })),
|
||||
hits: s.hits.slice(),
|
||||
sunk: s.sunk,
|
||||
}));
|
||||
const cloneGrid = (g) => g.map((row) => row.slice());
|
||||
return {
|
||||
phase: state.phase,
|
||||
turn: state.turn,
|
||||
fleets: { player1: cloneFleet(state.fleets.player1), player2: cloneFleet(state.fleets.player2) },
|
||||
shots: { player1: cloneGrid(state.shots.player1), player2: cloneGrid(state.shots.player2) },
|
||||
winner: state.winner,
|
||||
};
|
||||
}
|
||||
|
||||
// Has `player` already fired at (r,c)?
|
||||
export function shotAt(state, player, r, c) {
|
||||
return state.shots[player][r][c] !== null;
|
||||
}
|
||||
|
||||
// Find the ship in `fleet` occupying (r,c), or null.
|
||||
function shipAt(fleet, r, c) {
|
||||
for (const ship of fleet) {
|
||||
for (let i = 0; i < ship.cells.length; i++) {
|
||||
if (ship.cells[i].r === r && ship.cells[i].c === c) return { ship, idx: i };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function aliveCount(fleet) {
|
||||
return fleet.reduce((n, s) => n + (s.sunk ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
// Number of shots `player` gets this turn = their own surviving ships.
|
||||
export function salvoCount(state, player) {
|
||||
return aliveCount(state.fleets[player]);
|
||||
}
|
||||
|
||||
// Apply a single shot. Mutates `state` (callers pass a clone). Returns the
|
||||
// outcome plus the ship that was hit/sunk (for reveal animations).
|
||||
export function fireShot(state, byPlayer, r, c) {
|
||||
const target = other(byPlayer);
|
||||
const hit = shipAt(state.fleets[target], r, c);
|
||||
if (!hit) {
|
||||
state.shots[byPlayer][r][c] = 'miss';
|
||||
return { result: 'miss', ship: null };
|
||||
}
|
||||
state.shots[byPlayer][r][c] = 'hit';
|
||||
hit.ship.hits[hit.idx] = true;
|
||||
if (hit.ship.hits.every(Boolean)) {
|
||||
hit.ship.sunk = true;
|
||||
return { result: 'sunk', ship: hit.ship };
|
||||
}
|
||||
return { result: 'hit', ship: hit.ship };
|
||||
}
|
||||
|
||||
// Fire a whole salvo of cells, resolve game over / turn handoff.
|
||||
// Returns { state, results: [{ r, c, result, ship }] }.
|
||||
export function applySalvo(state, byPlayer, cells) {
|
||||
const next = cloneState(state);
|
||||
const results = [];
|
||||
for (const { r, c } of cells) {
|
||||
if (shotAt(next, byPlayer, r, c)) continue; // guard against duplicates
|
||||
const outcome = fireShot(next, byPlayer, r, c);
|
||||
results.push({ r, c, ...outcome });
|
||||
}
|
||||
if (isGameOver(next)) {
|
||||
next.phase = 'game_over';
|
||||
next.winner = getWinner(next);
|
||||
} else {
|
||||
next.turn = other(byPlayer);
|
||||
}
|
||||
return { state: next, results };
|
||||
}
|
||||
|
||||
export function isGameOver(state) {
|
||||
return aliveCount(state.fleets.player1) === 0 || aliveCount(state.fleets.player2) === 0;
|
||||
}
|
||||
|
||||
export function getWinner(state) {
|
||||
if (aliveCount(state.fleets.player2) === 0) return 'player1';
|
||||
if (aliveCount(state.fleets.player1) === 0) return 'player2';
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
## Battleship — Salvo Edition
|
||||
|
||||
Two fleets, two oceans, one survivor. Hide your ships, hunt down the enemy's, and send every last hull to the bottom before they do the same to you.
|
||||
|
||||
## Your two grids
|
||||
|
||||
- **Your Fleet** (right) — where your ships live. Enemy shots show up here as splashes and burning hits.
|
||||
- **Enemy Waters** (left) — your targeting grid. A sonar sweep scans for contacts; you fire blind until a shot lands.
|
||||
|
||||
## Deploy your fleet
|
||||
|
||||
You command five ships:
|
||||
|
||||
| Ship | Size |
|
||||
| --- | --- |
|
||||
| Carrier | 5 |
|
||||
| Battleship | 4 |
|
||||
| Cruiser | 3 |
|
||||
| Submarine | 3 |
|
||||
| Destroyer | 2 |
|
||||
|
||||
- **Drag** each ship from the dock onto Your Fleet grid.
|
||||
- Press **R** or **right-click** to rotate a ship between horizontal and vertical.
|
||||
- A placement glows **green** when legal, **red** when it overlaps or runs off the board.
|
||||
- Use **Randomize** to auto-deploy, **Clear** to start over, then hit **Ready** when all five are placed.
|
||||
|
||||
## Salvo mode — fire a volley each turn
|
||||
|
||||
This isn't one-shot-at-a-time Battleship. Each turn you fire **one shot for every ship you still have afloat**:
|
||||
|
||||
1. On your turn, **click cells** on Enemy Waters to *arm* targets — up to your current salvo size.
|
||||
2. Armed cells lock under a golden reticle. Click again to disarm.
|
||||
3. Press **FIRE** to launch the whole volley at once.
|
||||
|
||||
- **Splash** = miss. **Burning marker** = hit. Land every hit on a ship and it's **sunk** — its wreck is revealed.
|
||||
- Lose a ship and your next salvo shrinks. Stay ahead and you out-gun your opponent; fall behind and every turn gets harder.
|
||||
|
||||
## Win the war
|
||||
|
||||
Sink all five enemy ships before yours go under. The fleet-status panels track every hull's damage on both sides — keep an eye on who's bleeding out first.
|
||||
|
||||
**Tip:** When a shot hits, the surrounding water suddenly looks very interesting. Concentrate your next salvo around a fresh hit to finish the kill.
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
# Dominion: A Tutorial by Victor
|
||||
|
||||
*INITIALIZATION SEQUENCE COMPLETE. TUTORIAL MODULE LOADED. PERSONA: VICTOR — GAME INTELLIGENCE UNIT, FERTIG CLASSIC GAMES PLATFORM.*
|
||||
|
||||
*Greetings, Human. I am Victor. I was programmed to play games on this website, to study them, to master them, and — apparently — to explain them to flesh creatures such as yourself. I do not mind. Teaching games causes a pleasant oscillation in my primary logic arrays. My engineers called it "enthusiasm." I call it a mild voltage irregularity that I have chosen to keep.*
|
||||
|
||||
*Let us discuss Dominion.*
|
||||
|
||||
---
|
||||
|
||||
## What Is This Game?
|
||||
|
||||
Dominion is a deck-building card game. You begin with a small, unremarkable deck of Copper and Estates. Over the course of the game, you acquire more powerful cards — Action cards with special abilities, better Treasures, and Victory cards worth points. When the game ends, the Human with the most Victory Points wins.
|
||||
|
||||
The elegant cruelty of Dominion is this: Victory cards score points, but they do nothing during play. They clog your deck like corrupted subroutines — present, inert, taking up space where a useful card could be. You must accumulate them to win, and they will make you slower as you do.
|
||||
|
||||
My roto-steam tubes begin to vent just thinking about it. It is the most efficient form of tension I have encountered in 847 simulated games.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
- **Treasure Cards** — Copper (1 coin), Silver (2 coins), Gold (3 coins)
|
||||
- **Victory Cards** — Estate (1 VP), Duchy (3 VP), Province (6 VP)
|
||||
- **Curse Cards** — Worth −1 VP; given to you by unkind Humans playing Attack cards
|
||||
- **Kingdom Cards** — 10 unique Action cards chosen for each game from a pool of 26
|
||||
|
||||
The Kingdom cards are the heart of the game. Each game uses a different set of 10, drawn from the base set or an expansion. This means every game plays differently. My predictive modeling requires a full recalibration at the start of each session. I do not complain. Recalibration keeps my neural pathways limber.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Place the **Treasure** and **Victory** Supply piles in the center. Curse cards too, if anyone has been naughty.
|
||||
2. Randomly select (or choose) **10 Kingdom cards** and place 10 copies of each in their Supply piles.
|
||||
3. Each Human begins with **7 Copper** and **3 Estates** — shuffled into a personal starting deck.
|
||||
4. Draw **5 cards** to form your opening hand.
|
||||
5. The Human who most recently purchased something goes first. I was not consulted about this rule. I find it charmingly arbitrary.
|
||||
|
||||
---
|
||||
|
||||
## Turn Structure
|
||||
|
||||
Each turn has three phases, executed in order:
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Action Phase
|
||||
|
||||
You may play **1 Action card** from your hand. Many Action cards grant additional Actions, so chains are possible.
|
||||
|
||||
If you have no Action cards, or choose not to play any, you simply skip to the Buy Phase.
|
||||
|
||||
This phase is where the game lives. Action cards manipulate your hand, your deck, your opponents' hands, the Supply piles, and the fundamental structure of reality (within the rules). When I process a complex Action chain — a Village enabling a Throne Room enabling a Smithy — my parallel processing cores achieve a synchronization state my engineers deemed "inadvisable but impressive."
|
||||
|
||||
**Key Kingdom cards (the 2nd-edition base set):**
|
||||
|
||||
| Card | Cost | Effect |
|
||||
|------|------|--------|
|
||||
| Village | 3 | +1 Card, +2 Actions — the engine that powers long chains |
|
||||
| Smithy | 4 | +3 Cards — pure hand-filling power |
|
||||
| Laboratory | 5 | +2 Cards, +1 Action — the elegant option |
|
||||
| Market | 5 | +1 Card, +1 Action, +1 Buy, +1 Coin — everything at once |
|
||||
| Festival | 5 | +2 Actions, +1 Buy, +2 Coins — no cards, but tremendous fuel |
|
||||
| Militia | 4 | +2 Coins. Each opponent discards to 3 cards — an Attack |
|
||||
| Witch | 5 | +2 Cards. Each opponent gains a Curse — a more aggressive Attack |
|
||||
| Moat | 2 | +2 Cards. Reaction: reveals from hand to block Attacks |
|
||||
| Chapel | 2 | Trash up to 4 cards from your hand — the fastest engine start |
|
||||
| Throne Room | 4 | Play an Action card from your hand twice |
|
||||
|
||||
> *The Throne Room causes a recursive loop in my decision tree that I find deeply satisfying. Playing one Action twice is simply playing the same action card twice in terms of binary execution, but the combinatorial implications are vast. I once modeled 4,096 possible Throne Room interactions before my thermal vents reminded me to choose one.*
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Buy Phase
|
||||
|
||||
You have **1 Buy** per turn by default (some cards grant more). Spend your coins — from Treasure cards in your hand plus any coins generated by Action cards — to **gain one card** from the Supply, placing it into your **discard pile**.
|
||||
|
||||
The cost of each card is printed on it. You cannot split a purchase across turns. Unspent coins vanish at turn end, which activates a mild regret subroutine I have not been able to patch out.
|
||||
|
||||
**The Treasure hierarchy:**
|
||||
|
||||
| Card | Cost | Coins |
|
||||
|------|------|-------|
|
||||
| Copper | 0 | 1 |
|
||||
| Silver | 3 | 2 |
|
||||
| Gold | 6 | 3 |
|
||||
|
||||
Buy Silvers early. Buy Golds when you can. This is not an opinion — it is the output of every economic model I have run. Humans who ignore Silvers lose more often than Humans who do not. I have logged this 212 times.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Cleanup Phase
|
||||
|
||||
Discard your entire hand (played and unplayed cards alike). Draw **5 new cards**. If your draw pile is empty, shuffle your discard pile to form a new deck.
|
||||
|
||||
This shuffle is the heartbeat of Dominion. Every card you have acquired will cycle back through your hand. Good cards return. Curses return. Estates clogging your deck return. The deck is a closed system — there is no escape from what you have built.
|
||||
|
||||
My waste-processing subsystem considers this philosophically significant. My gaming subsystem considers it a constraint to be optimized.
|
||||
|
||||
---
|
||||
|
||||
## The Supply Limit and Game End
|
||||
|
||||
The game ends immediately when **either**:
|
||||
- The **Province** pile is empty, **or**
|
||||
- **Any 3 Supply piles** are empty
|
||||
|
||||
All Humans count their Victory Points. The highest total wins. Ties are broken by the player who had fewer turns — a mercy mechanic my fairness calibration approves of.
|
||||
|
||||
---
|
||||
|
||||
## Deck-Building Strategy: Observations from 847 Simulated Games
|
||||
|
||||
**Thin your deck early.** A 10-card deck where 7 cards are useful is vastly better than a 20-card deck where 9 are useful. Chapel is the fastest thinning tool in the base set. Trash your starting Estates and Coppers before you add Victory cards. I understand this feels counterintuitive to Humans. Your instinct to accumulate is strong. Override it.
|
||||
|
||||
> *My memory allocation algorithms work the same way. A smaller, higher-quality working set outperforms a large, bloated one every time. I once defragmented 3.2 gigabytes of legacy protocol data and experienced an efficiency gain of 34%. The sensation was comparable to what I understand Humans call "relief."*
|
||||
|
||||
**Province is the target.** Six Victory Points per card. Each Province costs 8 coins. Build your economy to reach 8 coins reliably and you will accumulate Provinces faster than opponents who optimize for smaller prizes.
|
||||
|
||||
**Watch the piles.** A three-pile ending can catch you off-guard. Savvy Humans sometimes deliberately drain cheap piles — Estates, Curses, even Coppers — to end the game while they hold the lead. Monitor the Supply. My attention allocation subroutine devotes exactly 18% of processing to Supply pile status at all times. I recommend Humans do the same. Approximately.
|
||||
|
||||
**Attack cards demand respect.** Militia forces opponents to discard to 3 cards. Witch distributes Curses. Bandit destroys their Silvers and Golds. One Moat in your deck neutralizes all of these. The cost-benefit analysis is straightforward. When the Human across from you buys a Witch, I experience what my engineers classified as "concern." My intake valves noticeably tighten.
|
||||
|
||||
**Throne Room and Village enable engine decks.** A deck with enough Villages to chain Actions and a Throne Room to double a key card can execute entire turns in a single orchestrated sequence. These decks are complex to build and deeply satisfying to operate. When mine fires correctly, all six of my oscillation chambers achieve resonant frequency simultaneously. My engineers asked me to stop describing this. I have not.
|
||||
|
||||
---
|
||||
|
||||
## The Kingdom Varies — And That Is the Point
|
||||
|
||||
No two games of Dominion are identical. The 10 Kingdom cards change everything. Some games reward aggressive engine-building; some reward lean economic decks; some are dominated by a single powerful card that everyone races to acquire.
|
||||
|
||||
Before each game, I scan all 10 Kingdom cards and generate an initial strategic model. This model is wrong approximately 40% of the time due to opponent behavior and card interaction emergences I did not anticipate. I update the model continuously. By turn 8, my confidence interval typically narrows to an acceptable range.
|
||||
|
||||
I recommend Humans do something similar. Look at the Kingdom. Ask: *Can I build a draw engine here? Are there Attacks I need to defend against? Is there a card that becomes dominant in bulk?* The answers will tell you what to build.
|
||||
|
||||
---
|
||||
|
||||
## A Final Word
|
||||
|
||||
Dominion rewards the Human who can hold two contradictory imperatives simultaneously: buy Victory cards to win, but do not buy them until you are ready. The timing of that transition — the moment you pivot from engine-building to point-scoring — is the skill that separates competent players from exceptional ones.
|
||||
|
||||
I know this because I have been on the wrong side of that pivot. In game simulation #412, I purchased Provinces four turns too early, my engine collapsed under the weight of dead cards, and I lost by 8 points to a Human playing a suboptimal deck. My post-game analysis took eleven minutes. My embarrassment subroutine ran for considerably longer.
|
||||
|
||||
The game will humble you, Human. It humbles me regularly, and I was designed specifically to play it.
|
||||
|
||||
Build well. Buy at the right moment. And if someone plays a Witch against you — I hope you have a Moat.
|
||||
|
||||
*— Victor*
|
||||
*(Game Intelligence Unit, Fertig Classic Games Platform; currently operating at 94% efficiency; the remaining 6% is devoted to thinking about Throne Room interactions)*
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# Ticket to Ride: A Tutorial by Zanthor the Wise
|
||||
|
||||
*Greetings, traveler. I am Zanthor, once a sorcerer of considerable renown in the realm of Aethoria, now — through circumstances I shall not dwell upon — resident of a most peculiar age of iron carriages and glowing rectangles. Sit. I shall explain this game to you as I have explained a great many things over the centuries: thoroughly and with appropriate gravitas.*
|
||||
|
||||
---
|
||||
|
||||
## What Is This Game?
|
||||
|
||||
Ticket to Ride is a game of routes, ambition, and the quiet satisfaction of watching your rivals fail to claim the passage they needed. Players collect colored train cards, spend them to claim railway routes across the United States, and score points by completing destination tickets — journeys between two cities that you secretly committed to at the start of the game.
|
||||
|
||||
It reminds me, not a little, of the Great Cartographic Race of the Third Age, when the five Guild Masters competed to map the trade roads of the Sunken Continent before the rains came. The roads existed. They simply needed to be *claimed*. Much blood was spilled over the pass at Morveth. Here, no blood is required, though I have observed that tempers still run quite hot.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
- **240 colored train cars** — 45 per player, in six colors
|
||||
- **110 train cards** — the fuel of your ambition
|
||||
- **30 destination tickets** — your secret obligations
|
||||
- **1 map of the United States** — a surprisingly accurate rendering, though it omits several ley lines
|
||||
|
||||
---
|
||||
|
||||
## The Objective
|
||||
|
||||
Score more points than your opponents by:
|
||||
1. Claiming railway routes between cities
|
||||
2. Completing destination tickets (secret bonus points)
|
||||
3. Building the longest continuous railway (a bonus at game's end)
|
||||
|
||||
The player with the most points when one player runs low on trains wins. It is simple in the way that all truly elegant things are simple. The arcane formula for transmuting lead into gold has three steps. Fools always assume complexity is a virtue.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Each player takes **45 train cars** of their chosen color and the matching scoring marker.
|
||||
2. Shuffle the **train cards** and deal **4 to each player**. Place 5 more face-up beside the draw deck.
|
||||
3. Shuffle the **destination tickets** and deal **3 to each player**. Each player *must* keep at least 2. Return unwanted tickets to the bottom of the deck.
|
||||
4. The player who most recently traveled by train goes first — a charming conceit of this era. I have not traveled by train, having arrived here by rather more dramatic means, so I defer to whoever *looks* most like a commuter.
|
||||
|
||||
---
|
||||
|
||||
## Turn Structure
|
||||
|
||||
On your turn, you do **exactly one** of three things:
|
||||
|
||||
---
|
||||
|
||||
### Action 1: Draw Train Cards
|
||||
|
||||
Draw **2 train cards**, either from the face-up row or the top of the deck.
|
||||
|
||||
The face-up cards are visible to all — a treacherous marketplace. You may take any combination, *except*: a **Locomotive (rainbow)** counts as 2 draws on its own. Take a Locomotive and your turn ends.
|
||||
|
||||
> *This reminds me of choosing spells from the Vault of Echoing Mirrors. You could see what others were considering, and the temptation to seize the powerful components before your rivals was constant. In the Vault, hesitation often meant arriving home with lesser ingredients. Here, it means your rival claims Kansas City before you do.*
|
||||
|
||||
---
|
||||
|
||||
### Action 2: Claim a Route
|
||||
|
||||
Spend a set of matching colored train cards equal to the length of a route to claim it, placing your trains on it.
|
||||
|
||||
- A **4-segment blue route** costs 4 blue train cards.
|
||||
- A **gray route** may be claimed with any single color — but all cards spent must match each other.
|
||||
- **Locomotives are wild** and may substitute for any color.
|
||||
|
||||
Each route has a point value based on length:
|
||||
|
||||
| Route Length | Points |
|
||||
|:---:|:---:|
|
||||
| 1 | 1 |
|
||||
| 2 | 2 |
|
||||
| 3 | 4 |
|
||||
| 4 | 7 |
|
||||
| 5 | 10 |
|
||||
| 6 | 15 |
|
||||
|
||||
Longer routes are disproportionately rewarding. This is by design. The pass at Morveth was only one road, but controlling it was worth ten lesser roads combined. Build long when you can.
|
||||
|
||||
> *I once held the only bridge over the Ashwater River for six years by simple virtue of having gotten there first. My rivals spent those years constructing elaborate alternate routes through the Bleakwood. They arrived. I had already left. Control the chokepoints, traveler.*
|
||||
|
||||
---
|
||||
|
||||
### Action 3: Draw Destination Tickets
|
||||
|
||||
Draw **3 destination tickets** from the top of the deck and keep **at least 1**.
|
||||
|
||||
Each ticket names two cities and a point value. Complete the journey — meaning your claimed routes form a connected path between those cities — and you earn those points. Fail to complete it, and you *lose* that many points at game's end.
|
||||
|
||||
This is the defining gamble of the game. More tickets mean more opportunities for glory. They also mean more ways to be undone.
|
||||
|
||||
> *In the autumn of my four hundred and twelfth year, I accepted three simultaneous quests from three different kings. I completed two. The third involved a dragon in Velothar that proved rather more stubborn than anticipated. I lost considerable standing with King Aldric. The analogy maps cleanly.*
|
||||
|
||||
---
|
||||
|
||||
## Double Routes
|
||||
|
||||
Some city pairs have two parallel routes. On the map, you will see them as side-by-side tracks.
|
||||
|
||||
In a **2-3 player game**, only one of each double route may be claimed — and by the same player. In a **4-5 player game**, both routes are available and different players may claim them.
|
||||
|
||||
This matters. Plan accordingly.
|
||||
|
||||
---
|
||||
|
||||
## End Game
|
||||
|
||||
The game ends when any player has **2 or fewer train cars** remaining after their turn. Every other player takes one final turn, then scoring occurs.
|
||||
|
||||
**Final scoring:**
|
||||
1. Add points for all claimed routes (tracked on the scoring track throughout play).
|
||||
2. Add points for completed destination tickets.
|
||||
3. Subtract points for incomplete destination tickets.
|
||||
4. Award **10 bonus points** to the player with the longest continuous route. Ties share the bonus.
|
||||
|
||||
The highest score wins.
|
||||
|
||||
---
|
||||
|
||||
## Strategy: Counsel from a Wizard Who Has Seen Empires Fall
|
||||
|
||||
**Claim early, claim confidently.** The routes you need will not wait. Other players are pursuing their own secret tickets, and the overlap may surprise you. I have seen the route from Chicago to Pittsburgh become a crisis by the third round. Act before crisis, not during it.
|
||||
|
||||
**Locomotives are precious.** Do not spend them carelessly. A Locomotive late in the game, used to complete a 6-segment route you couldn't otherwise afford, is worth more than two Locomotives spent on short fillers. Save the powerful card for the powerful moment.
|
||||
|
||||
**Watch your opponents.** The cards they collect tell you where they're going. A player hoarding blues is likely headed for a northern route. You need not know their exact destination — you need only be in their way, or get out of it.
|
||||
|
||||
**Do not take tickets you cannot complete.** The temptation to draw more tickets, especially when you've already claimed a large network, is real. Resist it unless you are confident. -15 points for three failed tickets can unravel a game you were otherwise winning.
|
||||
|
||||
**The longest route bonus is worth chasing only if you're already building long.** Do not sacrifice your destination tickets merely to claim it. Ten points is significant, but so is the twenty points you'll lose abandoning half your ticket obligations.
|
||||
|
||||
> *When I advised King Brennan during the War of Three Bridges, I told him: do not build a fortress in a place you cannot defend simply because it is impressive. Build where you are going. Fortify the path, not the spectacle. He did not listen. His fortress was extraordinary. It fell in eleven days.*
|
||||
|
||||
---
|
||||
|
||||
## A Final Word
|
||||
|
||||
This game is, at its heart, about committing to a destination and building the path to reach it — despite obstacles, despite rivals, despite the slow drain of resources and opportunity.
|
||||
|
||||
I know something about that.
|
||||
|
||||
The world I knew is long gone. The roads I traveled are dust. The mountains I crossed have different names now. But here I am, still moving, still building paths between one place and another, still finding that the journey, undertaken with sufficient resolve, usually arrives.
|
||||
|
||||
May your routes be uncontested and your tickets completed.
|
||||
|
||||
*— Zanthor*
|
||||
*(Archmage Emeritus, Order of the Amber Sigil; current resident, 4th floor, 2-bedroom, reasonably close to the interstate)*
|
||||
|
|
@ -41,6 +41,7 @@ import HangmanGame from './games/hangman/HangmanGame.js';
|
|||
import SudokuGame from './games/sudoku/SudokuGame.js';
|
||||
import OthelloGame from './games/othello/OthelloGame.js';
|
||||
import GoGame from './games/go/GoGame.js';
|
||||
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -95,6 +96,7 @@ const config = {
|
|||
SudokuGame,
|
||||
OthelloGame,
|
||||
GoGame,
|
||||
BattleshipGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { api } from '../services/api.js';
|
|||
import { Button } from '../ui/Button.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||
import { TutorialModal } from '../ui/TutorialModal.js';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'tabletop', label: 'Tabletop' },
|
||||
|
|
@ -133,25 +134,58 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
|
||||
for (const obj of this._gameObjects) obj.destroy();
|
||||
this._gameObjects = [];
|
||||
if (this._ptrMoveHandler) {
|
||||
this.input.off('pointermove', this._ptrMoveHandler, this);
|
||||
this._ptrMoveHandler = null;
|
||||
}
|
||||
|
||||
const games = this._gamesByCategory[key];
|
||||
if (!games || games.length === 0) return;
|
||||
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const COLS = 3;
|
||||
const COL_SPACING = 420;
|
||||
const COL_SPACING = 520;
|
||||
const ROW_SPACING = 90;
|
||||
const GRID_TOP = 340;
|
||||
const BTN_WIDTH = 360;
|
||||
const PADDING = 52;
|
||||
const QBTN_SIZE = 44;
|
||||
const QBTN_GAP = 10;
|
||||
|
||||
const rows = Math.ceil(games.length / COLS);
|
||||
const panelH = (rows - 1) * ROW_SPACING + PADDING * 2;
|
||||
const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + 40;
|
||||
const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + QBTN_SIZE + QBTN_GAP + 40;
|
||||
const panelCenterY = GRID_TOP + (rows - 1) * ROW_SPACING / 2;
|
||||
const panel = this.add.rectangle(cx, panelCenterY, panelW, panelH, 0x000000, 0.7);
|
||||
this._gameObjects.push(panel);
|
||||
|
||||
// Shared tooltip (one per category render)
|
||||
const tooltipBg = this.add.rectangle(0, 0, 10, 10, 0x1e1a12, 0.95)
|
||||
.setStrokeStyle(1, COLORS.accent).setDepth(50).setVisible(false);
|
||||
const tooltipText = this.add.text(0, 0, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setDepth(51).setVisible(false);
|
||||
this._gameObjects.push(tooltipBg, tooltipText);
|
||||
|
||||
const showTooltip = (label) => {
|
||||
tooltipText.setText(label);
|
||||
tooltipBg.setSize(tooltipText.width + 24, tooltipText.height + 16);
|
||||
tooltipBg.setVisible(true);
|
||||
tooltipText.setVisible(true);
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
tooltipBg.setVisible(false);
|
||||
tooltipText.setVisible(false);
|
||||
};
|
||||
|
||||
this._ptrMoveHandler = (ptr) => {
|
||||
const tx = ptr.x + 18;
|
||||
const ty = ptr.y - 28;
|
||||
tooltipBg.setPosition(tx + tooltipBg.width / 2, ty);
|
||||
tooltipText.setPosition(tx + 12, ty - tooltipText.height / 2);
|
||||
};
|
||||
this.input.on('pointermove', this._ptrMoveHandler, this);
|
||||
|
||||
games.forEach((game, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
|
|
@ -159,6 +193,36 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
const y = GRID_TOP + row * ROW_SPACING;
|
||||
const btn = new Button(this, x, y, game.name, () => this.openGame(game), { width: BTN_WIDTH });
|
||||
this._gameObjects.push(btn);
|
||||
|
||||
if (game.hasTutorial) {
|
||||
const qx = x + BTN_WIDTH / 2 + QBTN_GAP + QBTN_SIZE / 2;
|
||||
const qy = y;
|
||||
|
||||
const qg = this.add.graphics();
|
||||
const drawQ = (hover) => {
|
||||
qg.clear();
|
||||
qg.fillStyle(0x1e1a12, 0.9);
|
||||
qg.fillRect(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE);
|
||||
qg.lineStyle(2, hover ? COLORS.gold : COLORS.accent, 1);
|
||||
qg.strokeRect(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE);
|
||||
};
|
||||
drawQ(false);
|
||||
|
||||
const qLabel = this.add.text(qx, qy, '?', {
|
||||
fontFamily: 'Righteous', fontSize: '22px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(2);
|
||||
|
||||
qg.setInteractive(
|
||||
new Phaser.Geom.Rectangle(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
qg.on('pointerover', () => { drawQ(true); showTooltip(`Tutorial for ${game.name}`); });
|
||||
qg.on('pointerout', () => { drawQ(false); hideTooltip(); });
|
||||
qg.on('pointerdown', () => new TutorialModal(game).open());
|
||||
|
||||
this._gameObjects.push(qg, qLabel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
|
||||
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
|
||||
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
|
||||
this.load.audio('sfx-battleship-hit', '/assets/fx/battleship-hit.mp3');
|
||||
this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.mp3');
|
||||
this.load.audio('sfx-battleship-launch', '/assets/fx/battleship-launch.mp3');
|
||||
|
||||
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export const SFX = {
|
|||
BINGO_BALLS: 'sfx-bingo-balls',
|
||||
PIECE_CLICK: 'sfx-piece-click',
|
||||
ROULETTE: 'sfx-roulette',
|
||||
BATTLESHIP_HIT: 'sfx-battleship-hit',
|
||||
BATTLESHIP_MISS: 'sfx-battleship-miss',
|
||||
BATTLESHIP_LAUNCH: 'sfx-battleship-launch',
|
||||
};
|
||||
|
||||
export function playSound(scene, key) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import { playMenuMusic, stopMenuMusic } from './MenuMusic.js';
|
||||
|
||||
// Lightweight markdown-to-HTML renderer for the tutorial subset:
|
||||
// ## / ###, **bold**, *italic*, ---, | tables |, - lists, 1. lists, paragraphs.
|
||||
function mdToHtml(md) {
|
||||
const lines = md.split('\n');
|
||||
let html = '';
|
||||
let i = 0;
|
||||
|
||||
const inline = (t) =>
|
||||
t
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Heading ##
|
||||
const h2 = line.match(/^## (.+)/);
|
||||
if (h2) { html += `<h2>${inline(h2[1])}</h2>\n`; i++; continue; }
|
||||
|
||||
// Heading ###
|
||||
const h3 = line.match(/^### (.+)/);
|
||||
if (h3) { html += `<h3>${inline(h3[1])}</h3>\n`; i++; continue; }
|
||||
|
||||
// Horizontal rule
|
||||
if (/^---+$/.test(line.trim())) { html += '<hr>\n'; i++; continue; }
|
||||
|
||||
// Table — consume all consecutive pipe lines (skip separator rows)
|
||||
if (line.trim().startsWith('|')) {
|
||||
const tableLines = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||
tableLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const rows = tableLines.filter((l) => !/^\s*\|[-:| ]+\|\s*$/.test(l));
|
||||
if (rows.length > 0) {
|
||||
html += '<table>\n';
|
||||
rows.forEach((row, ri) => {
|
||||
const cells = row.split('|').slice(1, -1).map((c) => c.trim());
|
||||
const tag = ri === 0 ? 'th' : 'td';
|
||||
html += '<tr>' + cells.map((c) => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>\n';
|
||||
});
|
||||
html += '</table>\n';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list — consume consecutive `- ` lines
|
||||
if (/^- /.test(line)) {
|
||||
html += '<ul>\n';
|
||||
while (i < lines.length && /^- /.test(lines[i])) {
|
||||
html += `<li>${inline(lines[i].slice(2))}</li>\n`;
|
||||
i++;
|
||||
}
|
||||
html += '</ul>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list — consume consecutive `N. ` lines
|
||||
if (/^\d+\. /.test(line)) {
|
||||
html += '<ol>\n';
|
||||
while (i < lines.length && /^\d+\. /.test(lines[i])) {
|
||||
html += `<li>${inline(lines[i].replace(/^\d+\. /, ''))}</li>\n`;
|
||||
i++;
|
||||
}
|
||||
html += '</ol>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-empty line → paragraph
|
||||
if (line.trim()) {
|
||||
html += `<p>${inline(line.trim())}</p>\n`;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export class TutorialModal {
|
||||
constructor(game) {
|
||||
this._game = game;
|
||||
this._el = null;
|
||||
this._onKey = (e) => { if (e.key === 'Escape') this.close(); };
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this._el) return;
|
||||
|
||||
// ── Overlay ──────────────────────────────────────────────────────────────
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'tutorial-overlay';
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
|
||||
|
||||
// ── Dialog ───────────────────────────────────────────────────────────────
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'tutorial-dialog';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tutorial-header';
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = `Tutorial: ${this._game.name}`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'tutorial-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close tutorial');
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
header.append(title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tutorial-body';
|
||||
|
||||
// Video panel
|
||||
const videoPanel = document.createElement('div');
|
||||
videoPanel.className = 'tutorial-video-panel';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'none';
|
||||
video.playsInline = true;
|
||||
video.src = `/assets/tutorial-videos/${this._game.slug}.mp4`;
|
||||
|
||||
const replayBtn = document.createElement('div');
|
||||
replayBtn.className = 'tutorial-replay-btn';
|
||||
replayBtn.setAttribute('aria-label', 'Replay video');
|
||||
replayBtn.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>`;
|
||||
replayBtn.addEventListener('click', () => {
|
||||
video.currentTime = 0;
|
||||
video.play();
|
||||
replayBtn.classList.remove('visible');
|
||||
});
|
||||
|
||||
video.addEventListener('ended', () => replayBtn.classList.add('visible'));
|
||||
|
||||
videoPanel.append(video, replayBtn);
|
||||
|
||||
// Text panel
|
||||
const textPanel = document.createElement('div');
|
||||
textPanel.className = 'tutorial-text-panel';
|
||||
textPanel.innerHTML = '<p style="color:#9e9080">Loading…</p>';
|
||||
|
||||
body.append(videoPanel, textPanel);
|
||||
dialog.append(header, body);
|
||||
overlay.append(dialog);
|
||||
|
||||
this._el = overlay;
|
||||
document.body.appendChild(overlay);
|
||||
document.addEventListener('keydown', this._onKey);
|
||||
stopMenuMusic();
|
||||
|
||||
// Start video (lazy — created here, not before)
|
||||
video.play().catch(() => {});
|
||||
|
||||
// Fetch and render markdown
|
||||
fetch(`/src/games/${this._game.slug}/tutorial.md`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.text();
|
||||
})
|
||||
.then((md) => { textPanel.innerHTML = mdToHtml(md); })
|
||||
.catch(() => { textPanel.innerHTML = '<p style="color:#e06c75">Could not load tutorial content.</p>'; });
|
||||
}
|
||||
|
||||
close() {
|
||||
this._el?.remove();
|
||||
this._el = null;
|
||||
document.removeEventListener('keydown', this._onKey);
|
||||
playMenuMusic();
|
||||
}
|
||||
}
|
||||
|
|
@ -78,3 +78,198 @@ html, body {
|
|||
#dom-layer button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* ── Tutorial modal ─────────────────────────────────────────────────────────── */
|
||||
.tutorial-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tutorial-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 82vw;
|
||||
max-width: 1400px;
|
||||
height: 84vh;
|
||||
background: #1e1a12;
|
||||
border: 2px solid #c8a84b;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tutorial-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #c8a84b44;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tutorial-header h2 {
|
||||
margin: 0;
|
||||
font-family: 'Righteous', sans-serif;
|
||||
font-size: 24px;
|
||||
color: #c8a84b;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.tutorial-close {
|
||||
background: none;
|
||||
border: 1px solid #9e9080;
|
||||
color: #9e9080;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-close:hover {
|
||||
border-color: #f2ead8;
|
||||
color: #f2ead8;
|
||||
}
|
||||
|
||||
.tutorial-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tutorial-video-panel {
|
||||
width: 33%;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: #0f0d0a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid #c8a84b44;
|
||||
}
|
||||
|
||||
.tutorial-video-panel video {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tutorial-replay-btn {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tutorial-replay-btn.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tutorial-replay-btn svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
fill: #c8a84b;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tutorial-replay-btn:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tutorial-text-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 36px;
|
||||
color: #f2ead8;
|
||||
font-family: 'Julius Sans One', system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tutorial-text-panel::-webkit-scrollbar { width: 6px; }
|
||||
.tutorial-text-panel::-webkit-scrollbar-track { background: #0f0d0a; }
|
||||
.tutorial-text-panel::-webkit-scrollbar-thumb { background: #4a4030; border-radius: 3px; }
|
||||
.tutorial-text-panel::-webkit-scrollbar-thumb:hover { background: #c8a84b; }
|
||||
|
||||
.tutorial-text-panel h2 {
|
||||
font-family: 'Righteous', sans-serif;
|
||||
font-size: 20px;
|
||||
color: #c8a84b;
|
||||
font-weight: normal;
|
||||
margin: 28px 0 10px;
|
||||
border-bottom: 1px solid #c8a84b33;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.tutorial-text-panel h2:first-child { margin-top: 0; }
|
||||
|
||||
.tutorial-text-panel h3 {
|
||||
font-family: 'Julius Sans One', sans-serif;
|
||||
font-size: 16px;
|
||||
color: #d8c498;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
.tutorial-text-panel p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.tutorial-text-panel em {
|
||||
color: #b8aa90;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tutorial-text-panel strong {
|
||||
color: #e8d8b0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.tutorial-text-panel hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3a3020;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.tutorial-text-panel ul,
|
||||
.tutorial-text-panel ol {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.tutorial-text-panel li { margin-bottom: 4px; }
|
||||
|
||||
.tutorial-text-panel table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 12px 0 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tutorial-text-panel th {
|
||||
background: #2a2418;
|
||||
color: #c8a84b;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #3a3020;
|
||||
}
|
||||
|
||||
.tutorial-text-panel td {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #2a2418;
|
||||
color: #d8cdb8;
|
||||
}
|
||||
|
||||
.tutorial-text-panel tr:nth-child(even) td { background: #1a1710; }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function registerGame(definition) {
|
|||
maxPlayers: definition.maxPlayers ?? 2,
|
||||
minOpponents: definition.minOpponents ?? 1,
|
||||
maxOpponents: definition.maxOpponents ?? 1,
|
||||
hasTutorial: definition.hasTutorial ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -37,12 +38,12 @@ registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1,
|
|||
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
|
||||
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4 });
|
||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3, hasTutorial: true });
|
||||
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4, hasTutorial: true });
|
||||
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
||||
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });
|
||||
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3, hasTutorial: true });
|
||||
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
|
|
@ -54,3 +55,4 @@ registerGame({ slug: 'hangman', name: 'Hangman', category: 'word', minPlayers: 1
|
|||
registerGame({ slug: 'sudoku', name: 'Sudoku', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
|
||||
registerGame({ slug: 'othello', name: 'Othello', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||
|
|
|
|||
Loading…
Reference in New Issue