Bunch o Changes

This commit is contained in:
Brian Fertig 2026-05-23 17:26:21 -06:00
parent 10df4781ef
commit dff196e7ed
7 changed files with 345 additions and 46 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -24,7 +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.tilePlacement = data.tilePlacement ?? 'standard';
this.gs = null;
this.busy = false;
this.highlights = [];
@ -142,7 +142,7 @@ export default class CatanGame extends Phaser.Scene {
// Layer 1: resource swatch fill (outer colored border ring)
const swatch = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].swatch;
g.fillStyle(swatch, 1);
g.fillStyle(swatch, 0.55);
g.fillPoints(pts, true);
// Layer 2: dark fill inset (inner black border ring)
@ -169,7 +169,7 @@ export default class CatanGame extends Phaser.Scene {
}
// Thin outer crisp stroke
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.8);
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
this.hexBorderGfx.strokePoints(pts, true);
// Resource label
@ -392,6 +392,100 @@ export default class CatanGame extends Phaser.Scene {
mk('endTurn', 'End Turn', () => this.onEndTurn());
new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud);
this.buildDevCardTooltip();
this._buildTurnTriangle();
}
_buildTurnTriangle() {
const g = this.add.graphics().setDepth(D.hud + 8);
g.fillStyle(0xffdd00, 1);
g.fillTriangle(-10, -13, -10, 13, 10, 0);
g.setPosition(-9999, -9999);
this.turnIndicator = g;
this._turnSeat = null;
this.tweens.add({
targets: g,
scaleX: 1.4, scaleY: 1.4,
duration: 700,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut',
});
}
_seatPortraitPos(seat) {
if (seat === 0) return { x: 90, y: 980, r: 64 };
const panel = this.oppPanels?.find(p => p.seat === seat);
return panel ? { x: panel.x, y: panel.y, r: 56 } : null;
}
_updateTurnIndicator() {
const seat = this.gs?.currentPlayer;
if (seat == null) return;
const pos = this._seatPortraitPos(seat);
if (!pos) return;
const tx = pos.x - pos.r - 20;
const ty = pos.y;
if (this._turnSeat === seat) return;
this._turnSeat = seat;
if (this.turnIndicator.x < 0) {
this.turnIndicator.setPosition(tx, ty);
} else {
this.tweens.add({
targets: this.turnIndicator,
x: tx, y: ty,
duration: 600,
ease: 'Cubic.Out',
});
}
}
buildDevCardTooltip() {
const popW = 320, popH = 160, popR = 10;
const g = this.add.graphics();
const titleTxt = this.add.text(0, -48, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8',
}).setOrigin(0.5);
const descTxt = this.add.text(0, 12, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
align: 'center', wordWrap: { width: popW - 32 },
}).setOrigin(0.5);
this._devTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
.setDepth(D.panel + 5);
this._devTooltip.gfx = g;
this._devTooltip.titleTxt = titleTxt;
this._devTooltip.descTxt = descTxt;
this._devTooltip.popW = popW;
this._devTooltip.popH = popH;
this._devTooltip.popR = popR;
}
showDevCardTooltip(cardX, cardTopY, type, isNew, borderColor) {
const DEV_DESC = {
knight: 'Move the Robber to any tile and steal a resource from a player there.',
roadBuilding: 'Place 2 roads anywhere you could legally build them, for free.',
vp: 'Worth 1 Victory Point. Kept hidden until you reach 10 VP and win.',
monopoly: 'Name a resource. Every other player gives you all of that resource.',
yearOfPlenty: 'Take any 2 resources of your choice directly from the bank.',
};
const tt = this._devTooltip;
const { gfx: g, titleTxt, descTxt, popW, popH, popR } = tt;
g.clear();
g.fillStyle(0x0d1117, 0.96);
g.fillRoundedRect(-popW / 2, -popH / 2, popW, popH, popR);
g.lineStyle(2, borderColor, 0.9);
g.strokeRoundedRect(-popW / 2, -popH / 2, popW, popH, popR);
titleTxt.setText(DEV_INFO[type]?.label ?? type);
let d = DEV_DESC[type] ?? '';
if (isNew) d += '\n(Not playable until next turn)';
descTxt.setText(d);
const ttX = Phaser.Math.Clamp(cardX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10);
tt.setPosition(ttX, cardTopY - popH / 2 - 10);
}
hideDevCardTooltip() {
this._devTooltip?.setPosition(-9999, -9999);
}
buildCostLegend() {
@ -667,40 +761,51 @@ export default class CatanGame extends Phaser.Scene {
badgeContainer.add([bg, vpText]);
panel.vpBadge = badgeContainer;
// Rebuild face-down card fan to the right of the portrait
// Rebuild face-down card rows to the right of the portrait
if (!panel.cardFan) panel.cardFan = [];
panel.cardFan.forEach(o => o.destroy());
panel.cardFan = [];
const cardW = 30, cardH = 43, cardStep = cardW + 3;
const rowStartX = panel.x + 88;
const maxShow = 8;
// ── Resource cards (side by side) ──────────────────────────────────────
if (cards > 0) {
const frameIdx = this.cardBack?.spriteIndex ?? 0;
const cardW = 32, cardH = 45;
const maxShow = 7;
const show = Math.min(cards, maxShow);
const step = 16;
const fanStartX = panel.x + 74;
for (let i = 0; i < show; i++) {
const img = this.add.image(fanStartX + i * step, panel.y, 'cardbacks', frameIdx)
const img = this.add.image(rowStartX + i * cardStep, panel.y, 'cardbacks', frameIdx)
.setDisplaySize(cardW, cardH)
.setDepth(D.hud + i + 1);
.setDepth(D.hud + 1);
panel.cardFan.push(img);
}
if (cards > maxShow) {
const countX = fanStartX + (maxShow - 1) * step + cardW / 2 + 6;
const badge = this.add.text(countX, panel.y, `×${cards}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#ffd700',
const bx = rowStartX + maxShow * cardStep;
panel.cardFan.push(this.add.text(bx, panel.y, `+${cards - maxShow}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.hud + 20);
panel.cardFan.push(badge);
} else {
const countX = fanStartX + (show - 1) * step + cardW / 2 + 6;
const badge = this.add.text(countX, panel.y, `${cards}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
stroke: '#000000', strokeThickness: 2,
}).setOrigin(0, 0.5).setDepth(D.hud + 20);
panel.cardFan.push(badge);
}).setOrigin(0, 0.5).setDepth(D.hud + 20));
}
}
// ── Dev cards (face-down, using dev card back) ─────────────────────────
const devTotal = p.devCards.length + p.newDevCards.length + (p.vpCards ?? 0);
if (devTotal > 0) {
const devY = panel.y + cardH + 5;
const devShow = Math.min(devTotal, maxShow);
for (let i = 0; i < devShow; i++) {
const img = this.add.image(rowStartX + i * cardStep, devY, 'catan-cards', 8)
.setDisplaySize(cardW, cardH)
.setDepth(D.hud + 1);
panel.cardFan.push(img);
}
if (devTotal > maxShow) {
const bx = rowStartX + maxShow * cardStep;
panel.cardFan.push(this.add.text(bx, devY, `+${devTotal - maxShow}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.hud + 20));
}
}
}
@ -816,6 +921,155 @@ export default class CatanGame extends Phaser.Scene {
});
}
async animateCostPayment(seat, type) {
const cost = COSTS[type];
if (!cost) return;
const resources = [];
for (const [r, n] of Object.entries(cost)) for (let i = 0; i < n; i++) resources.push(r);
const isHuman = seat === 0;
const usedHandIdxs = new Set();
let oppCardOffset = 0;
for (const resource of resources) {
const frameIdx = RESOURCE_TYPES.indexOf(resource);
const bankPos = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
let srcX, srcY, startFaceUp;
if (isHuman) {
startFaceUp = true;
let ci = -1;
for (let i = 0; i < this.handDisplay.length; i++) {
if (!usedHandIdxs.has(i) && this.handDisplay[i] === resource) { ci = i; usedHandIdxs.add(i); break; }
}
srcX = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].x : 90;
srcY = (ci >= 0 && this.handCardObjs[ci]) ? this.handCardObjs[ci].y : 950;
} else {
startFaceUp = false;
const panel = this.oppPanels.find(p => p.seat === seat);
if (panel?.cardFan[oppCardOffset]) {
srcX = panel.cardFan[oppCardOffset].x;
srcY = panel.cardFan[oppCardOffset].y;
} else {
const pos = this._seatPortraitPos(seat);
srcX = (pos?.x ?? 130) + 88 + oppCardOffset * 33;
srcY = pos?.y ?? 300;
}
oppCardOffset++;
}
await this._flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp);
}
}
_flyCardToBank(srcX, srcY, frameIdx, bankPos, startFaceUp) {
return new Promise(resolve => {
const cardW = 60, cardH = 84;
const bigW = 90, bigH = 126;
if (startFaceUp) {
// Human card: already face-up, just fly to bank.
const img = this.add.image(srcX, srcY, 'catan-cards', frameIdx)
.setDisplaySize(cardW, cardH).setDepth(D.banner);
this.tweens.add({
targets: img, x: bankPos.x, y: bankPos.y, duration: 600, ease: 'Quad.InOut',
onComplete: () => this.tweens.add({
targets: img, alpha: 0, duration: 200,
onComplete: () => { img.destroy(); resolve(); },
}),
});
return;
}
// Opponent card: show face-down → flip → resize → fly.
const img = this.add.image(srcX, srcY, 'cardbacks', this.cardBack?.spriteIndex ?? 0)
.setDisplaySize(cardW, cardH).setDepth(D.banner);
// Brief beat so the player can register the face-down card.
this.time.delayedCall(120, () => {
// Flip first half: collapse width to 0.
this.tweens.add({
targets: img, scaleX: 0, duration: 110, ease: 'Linear',
onComplete: () => {
// Swap to face-up artwork (still at small size for now).
img.setTexture('catan-cards', frameIdx).setDisplaySize(cardW, cardH);
const sx = img.scaleX, sy = img.scaleY;
img.scaleX = 0;
// Flip second half: restore width.
this.tweens.add({
targets: img, scaleX: sx, duration: 110, ease: 'Linear',
onComplete: () => {
// Scale up to bank card size with a little bounce.
this.tweens.add({
targets: img, scaleX: sx * (bigW / cardW), scaleY: sy * (bigH / cardH),
duration: 230, ease: 'Back.Out',
onComplete: () => {
// Now fly to the bank.
this.tweens.add({
targets: img, x: bankPos.x, y: bankPos.y, duration: 720, ease: 'Quad.InOut',
onComplete: () => this.tweens.add({
targets: img, alpha: 0, duration: 180,
onComplete: () => { img.destroy(); resolve(); },
}),
});
},
});
},
});
},
});
});
});
}
animatePiecePlacement(seat, type, destX, destY) {
return new Promise(resolve => {
const srcPos = this._seatPortraitPos(seat);
if (!srcPos) { resolve(); return; }
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
const g = this.add.graphics();
const container = this.add.container(srcPos.x, srcPos.y, [g]).setDepth(D.banner - 1);
if (type === 'road') {
g.lineStyle(10, 0xffffff, 0.9); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(7, col.hexDark, 1); g.lineBetween(-13, 0, 13, 0);
g.lineStyle(4, col.hex, 1); g.lineBetween(-13, 0, 13, 0);
} else if (type === 'settlement') {
g.fillStyle(0xffffff, 1);
g.fillRect(-11, -3, 22, 15); g.fillTriangle(-14, -3, 14, -3, 0, -17);
g.fillStyle(col.hex, 1);
g.fillRect(-9, -3, 18, 12); g.fillTriangle(-11, -3, 11, -3, 0, -14);
g.lineStyle(2, col.hexDark, 1); g.strokeRect(-9, -3, 18, 12);
} else if (type === 'city') {
g.fillStyle(0xffffff, 1);
g.fillRect(-17, 0, 18, 15); g.fillRect(-6, -9, 21, 24); g.fillTriangle(-9, -9, 17, -9, 6, -22);
g.fillStyle(col.hex, 1);
g.fillRect(-15, 0, 15, 12); g.fillRect(-4, -8, 17, 20); g.fillTriangle(-7, -8, 15, -8, 6, -19);
g.lineStyle(2, col.hexDark, 1); g.strokeRect(-15, 0, 15, 12); g.strokeRect(-4, -8, 17, 20);
}
const emitter = this.add.particles(0, 0, 'catanParticle', {
follow: container,
speed: { min: 25, max: 70 },
lifespan: 450,
scale: { start: 0.9, end: 0 },
alpha: { start: 0.85, end: 0 },
quantity: 2,
frequency: 28,
tint: [col.hex, 0xffffff, col.hexDark],
angle: { min: 0, max: 360 },
}).setDepth(D.banner - 2);
const duration = 2000;
const arcHeight = Math.max(180, Math.abs(destY - srcPos.y) * 0.7 + 120);
const peakY = Math.min(srcPos.y, destY) - arcHeight;
const half = duration / 2;
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
this.tweens.chain({ targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{ y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
emitter.stop();
container.destroy();
this.time.delayedCall(500, () => emitter.destroy());
resolve();
}},
]});
});
}
updateHand() {
const p = this.gs.players[0];
@ -943,23 +1197,52 @@ export default class CatanGame extends Phaser.Scene {
const p = this.gs.players[0];
const cards = [...p.devCards, ...p.newDevCards.map((c) => c + '*')];
if (p.vpCards) for (let i = 0; i < p.vpCards; i++) cards.push('vp');
if (!cards.length) {
this.devHandContainer.add(this.add.text(740, 980, '—', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5));
return;
}
const DEV_VISUAL = {
knight: { frame: 5, border: 0xb03030 },
roadBuilding: { frame: 6, border: 0x8b5a2b },
vp: { frame: 7, border: 0xdaa520 },
monopoly: { frame: 9, border: 0x7b2d8b },
yearOfPlenty: { frame: 10, border: 0x2d8b57 },
};
const cardW = 60, cardH = 84, cardR = 6, borderW = 3;
const step = 66;
const y = 980;
let x = 740;
const y = 970;
cards.forEach((card) => {
const isNew = card.endsWith('*');
const type = isNew ? card.slice(0, -1) : card;
const g = this.add.graphics();
g.fillStyle(isNew ? 0x6a5a2a : 0x3a2f6b, 1); g.fillRoundedRect(x - 28, y - 36, 56, 72, 6);
g.lineStyle(2, COLORS.accent, 0.8); g.strokeRoundedRect(x - 28, y - 36, 56, 72, 6);
this.devHandContainer.add(g);
this.devHandContainer.add(this.add.text(x, y, DEV_INFO[type]?.short ?? type, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#f2ead8', align: 'center', wordWrap: { width: 52 },
}).setOrigin(0.5));
x += 64;
const visual = DEV_VISUAL[type] ?? { frame: 7, border: COLORS.accent };
const bg = this.add.graphics();
bg.fillStyle(0x111111, 0.85);
bg.fillRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
const img = this.add.image(x, y, 'catan-cards', visual.frame)
.setDisplaySize(54, 78)
.setAlpha(isNew ? 0.5 : 1);
const border = this.add.graphics();
border.lineStyle(borderW, visual.border, isNew ? 0.45 : 1);
border.strokeRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
const hit = this.add.rectangle(x, y, cardW, cardH, 0x000000, 0).setInteractive();
const cx = x, topY = y - cardH / 2;
hit.on('pointerover', () => this.showDevCardTooltip(cx, topY, type, isNew, visual.border));
hit.on('pointerout', () => this.hideDevCardTooltip());
this.devHandContainer.add([bg, img, border, hit]);
x += step;
});
if (!cards.length) {
this.devHandContainer.add(this.add.text(740, 970, '—', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0, 0.5));
}
}
updateStatus() {
@ -979,6 +1262,7 @@ export default class CatanGame extends Phaser.Scene {
}
this.statusText.setText(msg);
this.logText.setText(s.log[s.log.length - 1] ?? '');
this._updateTurnIndicator();
}
updateButtons() {
@ -1146,6 +1430,13 @@ export default class CatanGame extends Phaser.Scene {
const a = AI.chooseAction(this.gs, seat);
if (a.type === 'endTurn') { this.gs = L.endTurn(this.gs); break; }
const before = JSON.stringify(this.gs.players[seat]) + this.gs.phase;
if (a.type === 'buildRoad' || a.type === 'buildSettlement' || a.type === 'buildCity') {
const pieceType = { buildCity: 'city', buildSettlement: 'settlement', buildRoad: 'road' }[a.type];
const dest = a.type === 'buildRoad' ? this.edgePos(a.edgeId) : this.nodePos(a.nodeId);
enqueueSpeech(`catan-purchase-${pieceType}`);
await this.animateCostPayment(seat, pieceType);
await this.animatePiecePlacement(seat, pieceType, dest.x, dest.y);
}
this.gs = this.applyAction(seat, a);
if (this.gs.phase === 'moveRobber') {
const m = AI.chooseRobberMove(this.gs, seat);
@ -1166,10 +1457,10 @@ export default class CatanGame extends Phaser.Scene {
applyAction(seat, a) {
switch (a.type) {
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
case 'buildCity': return L.buildCity(this.gs, seat, a.nodeId);
case 'buildSettlement': return L.buildSettlement(this.gs, seat, a.nodeId);
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
case 'buyDev': return L.buyDevCard(this.gs, seat);
case 'buildRoad': return L.buildRoad(this.gs, seat, a.edgeId);
case 'buyDev': enqueueSpeech('catan-purchase-development-card'); return L.buyDevCard(this.gs, seat);
case 'bankTrade': return L.tradeWithBank(this.gs, seat, a.give, a.get);
case 'playDev':
if (a.card === 'knight') return L.playKnight(this.gs, seat);
@ -1273,13 +1564,19 @@ export default class CatanGame extends Phaser.Scene {
this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
}
doBuild(type, id) {
async doBuild(type, id) {
this.busy = true;
this.clearHighlights();
this.placeMode = null;
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
const dest = type === 'road' ? this.edgePos(id) : this.nodePos(id);
enqueueSpeech(`catan-purchase-${type}`);
await this.animateCostPayment(0, type);
await this.animatePiecePlacement(0, type, dest.x, dest.y);
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
if (type === 'settlement') this.gs = L.buildSettlement(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
playSound(this, SFX.PIECE_CLICK);
this.busy = false;
this.advance();
}
@ -1287,6 +1584,7 @@ export default class CatanGame extends Phaser.Scene {
if (this.busy || this.gs.phase !== 'action') return;
this.clearHighlights(); this.placeMode = null;
this.gs = L.buyDevCard(this.gs, 0);
enqueueSpeech('catan-purchase-development-card');
playSound(this, SFX.CARD_DEAL);
this.advance();
}
@ -1366,10 +1664,11 @@ export default class CatanGame extends Phaser.Scene {
const stepper = (col, r, i, side) => {
const x = col, y = 350 + i * 50;
const hitBox = new Phaser.Geom.Rectangle(-22, -22, 44, 44);
const lbl = this.add.text(x - 150, y, RESOURCE_INFO[r].label, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(D.panel + 1);
const minus = this.add.text(x - 10, y, '', { fontFamily: 'Righteous', fontSize: '28px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
const minus = this.add.text(x - 16, y, '', { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.dangerHex }).setOrigin(0.5).setInteractive(hitBox, Phaser.Geom.Rectangle.Contains, true).setDepth(D.panel + 1);
const val = this.add.text(x + 30, y, '0', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.panel + 1);
const plus = this.add.text(x + 70, y, '+', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(D.panel + 1);
const plus = this.add.text(x + 76, y, '+', { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.goldHex }).setOrigin(0.5).setInteractive(hitBox, Phaser.Geom.Rectangle.Contains, true).setDepth(D.panel + 1);
const bag = side === 'give' ? give : get;
valTexts[side + r] = val;
minus.on('pointerdown', () => { if (bag[r] > 0) { bag[r]--; val.setText(String(bag[r])); } });

View File

@ -12,7 +12,7 @@ export default class GameRoomScene extends Phaser.Scene {
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
}
create() {

View File

@ -31,7 +31,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.playfieldTiles = [];
this.selectedCardBack = null;
this.cardBackTiles = [];
this.selectedTilePlacement = 'random';
this.selectedTilePlacement = 'standard';
this._initializing = false;
}