feat: implement auto-pass and draw handling for SkipBo
- Add `isPlayerStuck` and `applyAutoPass` logic to SkipBoLogic. - Handle stuck players by automatically passing their turn. - Detect draw conditions when all players are stuck consecutively. - Update UI to display appropriate messages for auto-passes and draws. - Pass subtitle text to `showGameOverPanel` for clearer win/loss/draw context.
This commit is contained in:
parent
70573a8ddd
commit
792fd35fd4
|
|
@ -9,6 +9,7 @@ import {
|
|||
DISCARD_PILE_COUNT,
|
||||
HAND_SIZE,
|
||||
STOCK_SIZE,
|
||||
applyAutoPass,
|
||||
applyDiscard,
|
||||
applyPlay,
|
||||
buildPileTopValue,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
createInitialState,
|
||||
discardTop,
|
||||
getValidPlays,
|
||||
isPlayerStuck,
|
||||
nextRequired,
|
||||
stockTop,
|
||||
} from './SkipBoLogic.js';
|
||||
|
|
@ -666,10 +668,8 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
this.gs = next;
|
||||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'gameover') {
|
||||
this.endGame();
|
||||
return;
|
||||
}
|
||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||
if (this.checkForStuck()) return;
|
||||
// After a successful play it's still the same player's turn until they
|
||||
// discard. If AI's turn, continue.
|
||||
if (this.gs.currentPlayer !== 0) {
|
||||
|
|
@ -688,6 +688,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||
if (this.checkForStuck()) return;
|
||||
if (this.gs.currentPlayer !== 0) {
|
||||
this.runAITurn();
|
||||
} else {
|
||||
|
|
@ -775,6 +776,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||
if (this.checkForStuck()) return;
|
||||
this.time.delayedCall(420, () => this.stepAI());
|
||||
});
|
||||
} else {
|
||||
|
|
@ -785,6 +787,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||
if (this.checkForStuck()) return;
|
||||
if (this.gs.currentPlayer === 0) {
|
||||
this.setStatus('Your turn');
|
||||
} else {
|
||||
|
|
@ -794,11 +797,48 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Stuck / auto-pass ───────────────────────────────────────────────────
|
||||
|
||||
checkForStuck() {
|
||||
if (!isPlayerStuck(this.gs)) return false;
|
||||
const seat = this.gs.currentPlayer;
|
||||
const name = seat === 0
|
||||
? (auth.user?.username ?? 'You')
|
||||
: (this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`);
|
||||
const subject = seat === 0 ? 'You have' : `${name} has`;
|
||||
this.setStatus(`${subject} no moves — passing turn.`);
|
||||
this.animating = true;
|
||||
this.time.delayedCall(1400, () => {
|
||||
this.gs = applyAutoPass(this.gs);
|
||||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||
if (this.checkForStuck()) return;
|
||||
if (this.gs.currentPlayer !== 0) {
|
||||
this.runAITurn();
|
||||
} else {
|
||||
this.setStatus('Your turn');
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Endgame ─────────────────────────────────────────────────────────────
|
||||
|
||||
endGame() {
|
||||
this.gameOver = true;
|
||||
const winnerSeat = this.gs.winner;
|
||||
|
||||
if (winnerSeat === -1) {
|
||||
this.setStatus("It's a draw!");
|
||||
for (let s = 1; s < this.gs.players.length; s++) {
|
||||
this.opponentPortraits[s]?.playEmotion?.('loss');
|
||||
}
|
||||
this.recordHistory(false);
|
||||
this.showGameOverPanel("It's a draw!", false, null, 'No player could make a move.');
|
||||
return;
|
||||
}
|
||||
|
||||
const youWon = winnerSeat === 0;
|
||||
const name = winnerSeat === 0
|
||||
? (auth.user?.username ?? 'You')
|
||||
|
|
@ -815,10 +855,12 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
this.recordHistory(youWon);
|
||||
this.showGameOverPanel(msg, youWon, name);
|
||||
this.showGameOverPanel(msg, youWon, name, youWon
|
||||
? 'You emptied your stock first.'
|
||||
: `${name} emptied their stock first.`);
|
||||
}
|
||||
|
||||
showGameOverPanel(msg, youWon, name) {
|
||||
showGameOverPanel(msg, youWon, name, subtitle) {
|
||||
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
||||
.setInteractive().setDepth(D.modal);
|
||||
const panelW = 720;
|
||||
|
|
@ -829,9 +871,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
|||
fontFamily: 'Righteous', fontSize: '42px',
|
||||
color: youWon ? COLORS.goldHex : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal);
|
||||
this.add.text(CX, CY - 10, youWon
|
||||
? 'You emptied your stock first.'
|
||||
: `${name} emptied their stock first.`, {
|
||||
this.add.text(CX, CY - 10, subtitle, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal);
|
||||
|
||||
|
|
|
|||
|
|
@ -85,9 +85,10 @@ export function createInitialState({ playerCount, seed, startingPlayer = 0 } = {
|
|||
completedPile: [],
|
||||
currentPlayer: startingPlayer,
|
||||
phase: 'play', // 'play' | 'gameover'
|
||||
winner: null,
|
||||
winner: null, // seat index, or -1 for draw
|
||||
seed: seed ?? null,
|
||||
turnCount: 0,
|
||||
consecutiveStuckTurns: 0,
|
||||
};
|
||||
|
||||
// Top of every stock starts face-up. We model "face-up" implicitly: stock
|
||||
|
|
@ -140,6 +141,7 @@ export function cloneState(state) {
|
|||
winner: state.winner,
|
||||
seed: state.seed,
|
||||
turnCount: state.turnCount,
|
||||
consecutiveStuckTurns: state.consecutiveStuckTurns,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -304,11 +306,48 @@ export function applyDiscard(state, handIdx, discardIdx) {
|
|||
// Advance turn
|
||||
next.currentPlayer = (seat + 1) % next.players.length;
|
||||
next.turnCount += 1;
|
||||
next.consecutiveStuckTurns = 0; // normal discard resets the stuck counter
|
||||
refillHand(next, next.currentPlayer);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current player is completely stuck:
|
||||
* their hand is empty, the draw pile is exhausted (including completed pile),
|
||||
* and they have no valid plays from stock or discard tops.
|
||||
*/
|
||||
export function isPlayerStuck(state) {
|
||||
if (state.phase !== 'play') return false;
|
||||
const seat = state.currentPlayer;
|
||||
const player = state.players[seat];
|
||||
const canDraw = state.drawPile.length > 0 || state.completedPile.length > 0;
|
||||
if (player.hand.length > 0 || canDraw) return false;
|
||||
return !hasAnyPlay(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-passes the current player's turn when they are stuck.
|
||||
* Increments consecutiveStuckTurns; if every player is stuck in a row
|
||||
* the game ends as a draw (winner === -1).
|
||||
*/
|
||||
export function applyAutoPass(state) {
|
||||
if (state.phase !== 'play') return state;
|
||||
const next = cloneState(state);
|
||||
next.consecutiveStuckTurns += 1;
|
||||
|
||||
if (next.consecutiveStuckTurns >= next.players.length) {
|
||||
next.phase = 'gameover';
|
||||
next.winner = -1; // draw
|
||||
return next;
|
||||
}
|
||||
|
||||
next.currentPlayer = (next.currentPlayer + 1) % next.players.length;
|
||||
next.turnCount += 1;
|
||||
refillHand(next, next.currentPlayer);
|
||||
return next;
|
||||
}
|
||||
|
||||
// Convenience for UI — count cards in each pile per seat for the always-on
|
||||
// HUD. The top stock card is always visible separately.
|
||||
export function snapshotCounts(state) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue