feat: improve Go Fish AI with card sprites and turn exclusions

- Add custom card texture spritesheet and render cards using sprite frames
- Prevent AI from repeatedly asking for the same rank after a failed catch
- Track and exclude previously attempted (target, rank) pairs per turn to reduce redundant moves
- Load gofish-cards assets in preload scene
This commit is contained in:
Brian Fertig 2026-05-30 19:28:48 -06:00
parent 618d3d31c4
commit b6d85e30be
5 changed files with 28 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

View File

@ -71,7 +71,7 @@ export function observeLog(memory, state, selfSeat) {
* Pick (targetSeat, rank) for the AI. Falls back gracefully if memory is
* empty (e.g. very first turn).
*/
export function chooseAction(state, seat, memory) {
export function chooseAction(state, seat, memory, exclude = new Set()) {
const me = state.players[seat];
if (!me || me.hand.length === 0) return null;
@ -94,6 +94,7 @@ export function chooseAction(state, seat, memory) {
if (target === seat) continue;
const opp = state.players[target];
if (opp.sittingOut || opp.hand.length === 0) continue;
if (exclude.has(`${target}:${rank}`)) continue;
let score = 0;
const belief = memory.beliefs[target][rank] || 0;
@ -129,6 +130,7 @@ export function chooseAction(state, seat, memory) {
for (const rank of myRanks) {
for (let t = 0; t < state.players.length; t++) {
if (t === seat) continue;
if (exclude.has(`${t}:${rank}`)) continue;
const opp = state.players[t];
if (!opp.sittingOut && opp.hand.length > 0) {
return { targetSeat: t, rank };

View File

@ -116,6 +116,8 @@ const SUIT_COLORS = {
d: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' },
};
const GOFISH_CARD_FRAME = { A: 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, T: 9, J: 10, Q: 11, K: 12 };
// ── Scene ───────────────────────────────────────────────────────────────────
export default class GoFishGame extends Phaser.Scene {
constructor() { super('GoFishGame'); }
@ -357,6 +359,7 @@ export default class GoFishGame extends Phaser.Scene {
const playerCount = this.slotForSeat.length;
const finalState = createInitialState({ playerCount, matchSize: this.matchVariant });
this.aiMemory = [];
this.aiTurnExclusions = new Map();
for (let s = 0; s < playerCount; s++) {
this.aiMemory[s] = createMemory(playerCount);
observeLog(this.aiMemory[s], finalState, s);
@ -496,6 +499,17 @@ export default class GoFishGame extends Phaser.Scene {
return;
}
const frame = GOFISH_CARD_FRAME[card.rank];
if (frame !== undefined && this.textures.exists('gofish-cards')) {
g.destroy();
container.add(
this.add.image(0, 0, 'gofish-cards', frame)
.setDisplaySize(CARD_W, CARD_H)
.setOrigin(0.5)
);
return;
}
const suit = SUIT_COLORS[card.suit] ?? SUIT_COLORS.s;
g.fillStyle(suit.fill, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
@ -763,6 +777,10 @@ export default class GoFishGame extends Phaser.Scene {
}
// Catch, or fish with empty pool — normal completion.
if (last.result === 'catch' && askerSeat !== 0) {
if (!this.aiTurnExclusions.has(askerSeat)) this.aiTurnExclusions.set(askerSeat, new Set());
this.aiTurnExclusions.get(askerSeat).add(`${targetSeat}:${rank}`);
}
if (last.result === 'catch' && targetSeat !== 0) {
this.opponentPortraits[targetSeat]?.playEmotion('upset');
}
@ -1335,7 +1353,12 @@ export default class GoFishGame extends Phaser.Scene {
if (this.gameOver || this.animating) return;
const seat = this.gs.currentPlayer;
if (seat === 0) return;
const action = chooseAction(this.gs, seat, this.aiMemory[seat]);
// Clear exclusions when starting a fresh turn (not continuing after a catch).
if (this.gs.lastAsk?.askerSeat !== seat || this.gs.lastAsk?.result !== 'catch') {
this.aiTurnExclusions.set(seat, new Set());
}
const exclude = this.aiTurnExclusions.get(seat) ?? new Set();
const action = chooseAction(this.gs, seat, this.aiMemory[seat], exclude);
if (!action) {
// Shouldn't happen — fail-safe: end the game if no action available.
this.endGame();

View File

@ -76,6 +76,7 @@ export default class PreloadScene extends Phaser.Scene {
// Prosperity token sprites (1 VP, 5 VP, Gold) — 150×150 each.
this.load.spritesheet('dominion-tokens', '/assets/images/dominion-tokens.png', { frameWidth: 150, frameHeight: 150 });
this.load.spritesheet('ttr-cards', '/assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('gofish-cards', '/assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('tab-icons', '/assets/images/tab-icons.png', { frameWidth: 128, frameHeight: 128 });
}