feat(catan): add standard tile placement option and update robber voice lines

- Add `STANDARD_RESOURCES` array to `CatanBoard.js` for the classic beginner layout
- Allow users to choose between 'random' and 'standard' tile placement in `OpponentSelectScene`
- Pass `tilePlacement` config through `GameRoomScene` to `CatanGame` and `CatanLogic`
- Update robber voice clip files to new recordings
This commit is contained in:
Brian Fertig 2026-05-23 15:43:45 -06:00
parent aa5609f2f9
commit 10df4781ef
9 changed files with 80 additions and 15 deletions

View File

@ -36,6 +36,16 @@ export const RESOURCE_BAG = [
'desert',
];
// Fixed hex resources for the standard Catan beginner layout.
// Indexed by hex ID (rows [3,4,5,4,3], left-to-right top-to-bottom).
export const STANDARD_RESOURCES = [
'ore', 'wool', 'lumber', // row 0: hex 0-2
'grain', 'brick', 'wool', 'brick', // row 1: hex 3-6
'lumber', 'grain', 'desert','grain', 'ore', // row 2: hex 7-11
'lumber', 'ore', 'grain', 'wool', // row 3: hex 12-15
'brick', 'wool', 'lumber', // row 4: hex 16-18
];
// 18 number chits for the 18 non-desert hexes.
export const CHIT_BAG = [2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12];

View File

@ -24,6 +24,7 @@ export default class CatanGame extends Phaser.Scene {
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'random';
this.gs = null;
this.busy = false;
this.highlights = [];
@ -1019,7 +1020,7 @@ export default class CatanGame extends Phaser.Scene {
this.busy = false;
this.placeMode = null;
const playerCount = Math.min(4, 1 + this.opponents.length);
this.gs = L.createInitialState(playerCount);
this.gs = L.createInitialState(playerCount, { tilePlacement: this.tilePlacement });
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names);
this.drawHexes();
@ -1128,8 +1129,9 @@ export default class CatanGame extends Phaser.Scene {
const m = AI.chooseRobberMove(this.gs, seat);
const preRobberHex = this.gs.robberHex;
this.gs = L.moveRobber(this.gs, m.hexId, m.targetSeat);
const animPromise = this.animateRobber(preRobberHex, m.hexId);
if (m.targetSeat != null) this.opponentPortraits[seat]?.playEmotion?.('happy');
await this.animateRobber(preRobberHex, m.hexId);
await animPromise;
this.renderAll();
await this.delay(450);
this.busy = false;

View File

@ -3,7 +3,7 @@
import {
NODES, EDGES, HEXES, PORT_SLOTS, edgeBetween,
RESOURCE_BAG, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
RESOURCE_BAG, STANDARD_RESOURCES, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
CHIT_SPIRAL, CHIT_SEQUENCE,
} from './CatanBoard.js';
@ -42,11 +42,11 @@ export function edgeOwner(state, edgeId) {
}
// ── initial state ─────────────────────────────────────────────────────────────
export function createInitialState(playerCount = 3) {
export function createInitialState(playerCount = 3, { tilePlacement = 'random' } = {}) {
const n = Math.max(3, Math.min(4, playerCount));
// Resources onto hexes.
const resources = shuffle(RESOURCE_BAG);
const resources = tilePlacement === 'standard' ? [...STANDARD_RESOURCES] : shuffle(RESOURCE_BAG);
const hexes = HEXES.map((h, i) => ({
id: h.id,
resource: resources[i],

View File

@ -8,20 +8,22 @@ export default class GameRoomScene extends Phaser.Scene {
constructor() { super('GameRoom'); }
init(data) {
this.game = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.game = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? null;
}
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,
opponents: this.opponents,
playfield: this.playfield,
cardBack: this.cardBack,
game: this.game,
opponents: this.opponents,
playfield: this.playfield,
cardBack: this.cardBack,
tilePlacement: this.tilePlacement,
});
return;
}

View File

@ -31,6 +31,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.playfieldTiles = [];
this.selectedCardBack = null;
this.cardBackTiles = [];
this.selectedTilePlacement = 'random';
this._initializing = false;
}
@ -38,6 +39,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
playMenuMusic();
setMenuMusicVolume(0.25);
const cx = GAME_WIDTH / 2;
const isCatan = this.gameDef.slug === 'catan';
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
@ -103,6 +105,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (!this._startingGame) setMenuMusicVolume(0.6);
});
if (isCatan) this.buildTilePlacementSection(340, 1013);
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -400,6 +404,52 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.startBtn.setEnabled(this.selected.size >= min);
}
// ── Catan: tile placement toggle ───────────────────────────────────────────
buildTilePlacementSection(centerX, centerY) {
const options = [
{ id: 'random', label: 'Random' },
{ id: 'standard', label: 'Standard' },
];
const pillW = 150, pillH = 40, pillGap = 12;
const totalW = options.length * pillW + (options.length - 1) * pillGap;
const labelY = centerY - 28;
const pillY = centerY + 10;
const labelText = this.add.text(centerX, labelY, 'Tile Placement', {
fontFamily: '"Julius Sans One"',
fontSize: '20px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
this.children.moveBelow(labelBg, labelText);
this._tilePlacementBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedTilePlacement === opt.id;
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._tilePlacementBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedTilePlacement ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedTilePlacement = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedTilePlacement !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedTilePlacement !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._tilePlacementBtns.push({ bg, id: opt.id });
});
}
// ── Generic option section builder ─────────────────────────────────────────
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
@ -517,8 +567,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.scene.start('GameRoom', {
game: this.gameDef,
opponents,
playfield: this.selectedPlayfield,
cardBack: this.selectedCardBack,
playfield: this.selectedPlayfield,
cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement,
});
}
}