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,
|
DISCARD_PILE_COUNT,
|
||||||
HAND_SIZE,
|
HAND_SIZE,
|
||||||
STOCK_SIZE,
|
STOCK_SIZE,
|
||||||
|
applyAutoPass,
|
||||||
applyDiscard,
|
applyDiscard,
|
||||||
applyPlay,
|
applyPlay,
|
||||||
buildPileTopValue,
|
buildPileTopValue,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
discardTop,
|
discardTop,
|
||||||
getValidPlays,
|
getValidPlays,
|
||||||
|
isPlayerStuck,
|
||||||
nextRequired,
|
nextRequired,
|
||||||
stockTop,
|
stockTop,
|
||||||
} from './SkipBoLogic.js';
|
} from './SkipBoLogic.js';
|
||||||
|
|
@ -666,10 +668,8 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
this.gs = next;
|
this.gs = next;
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
if (this.gs.phase === 'gameover') {
|
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||||
this.endGame();
|
if (this.checkForStuck()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
// After a successful play it's still the same player's turn until they
|
// After a successful play it's still the same player's turn until they
|
||||||
// discard. If AI's turn, continue.
|
// discard. If AI's turn, continue.
|
||||||
if (this.gs.currentPlayer !== 0) {
|
if (this.gs.currentPlayer !== 0) {
|
||||||
|
|
@ -688,6 +688,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||||
|
if (this.checkForStuck()) return;
|
||||||
if (this.gs.currentPlayer !== 0) {
|
if (this.gs.currentPlayer !== 0) {
|
||||||
this.runAITurn();
|
this.runAITurn();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -775,6 +776,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||||
|
if (this.checkForStuck()) return;
|
||||||
this.time.delayedCall(420, () => this.stepAI());
|
this.time.delayedCall(420, () => this.stepAI());
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -785,6 +787,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.animating = false;
|
this.animating = false;
|
||||||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||||||
|
if (this.checkForStuck()) return;
|
||||||
if (this.gs.currentPlayer === 0) {
|
if (this.gs.currentPlayer === 0) {
|
||||||
this.setStatus('Your turn');
|
this.setStatus('Your turn');
|
||||||
} else {
|
} 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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
endGame() {
|
endGame() {
|
||||||
this.gameOver = true;
|
this.gameOver = true;
|
||||||
const winnerSeat = this.gs.winner;
|
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 youWon = winnerSeat === 0;
|
||||||
const name = winnerSeat === 0
|
const name = winnerSeat === 0
|
||||||
? (auth.user?.username ?? 'You')
|
? (auth.user?.username ?? 'You')
|
||||||
|
|
@ -815,10 +855,12 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recordHistory(youWon);
|
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)
|
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
||||||
.setInteractive().setDepth(D.modal);
|
.setInteractive().setDepth(D.modal);
|
||||||
const panelW = 720;
|
const panelW = 720;
|
||||||
|
|
@ -829,9 +871,7 @@ export default class SkipBoGame extends Phaser.Scene {
|
||||||
fontFamily: 'Righteous', fontSize: '42px',
|
fontFamily: 'Righteous', fontSize: '42px',
|
||||||
color: youWon ? COLORS.goldHex : COLORS.textHex,
|
color: youWon ? COLORS.goldHex : COLORS.textHex,
|
||||||
}).setOrigin(0.5).setDepth(D.modal);
|
}).setOrigin(0.5).setDepth(D.modal);
|
||||||
this.add.text(CX, CY - 10, youWon
|
this.add.text(CX, CY - 10, subtitle, {
|
||||||
? 'You emptied your stock first.'
|
|
||||||
: `${name} emptied their stock first.`, {
|
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||||
}).setOrigin(0.5).setDepth(D.modal);
|
}).setOrigin(0.5).setDepth(D.modal);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,10 @@ export function createInitialState({ playerCount, seed, startingPlayer = 0 } = {
|
||||||
completedPile: [],
|
completedPile: [],
|
||||||
currentPlayer: startingPlayer,
|
currentPlayer: startingPlayer,
|
||||||
phase: 'play', // 'play' | 'gameover'
|
phase: 'play', // 'play' | 'gameover'
|
||||||
winner: null,
|
winner: null, // seat index, or -1 for draw
|
||||||
seed: seed ?? null,
|
seed: seed ?? null,
|
||||||
turnCount: 0,
|
turnCount: 0,
|
||||||
|
consecutiveStuckTurns: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Top of every stock starts face-up. We model "face-up" implicitly: stock
|
// Top of every stock starts face-up. We model "face-up" implicitly: stock
|
||||||
|
|
@ -140,6 +141,7 @@ export function cloneState(state) {
|
||||||
winner: state.winner,
|
winner: state.winner,
|
||||||
seed: state.seed,
|
seed: state.seed,
|
||||||
turnCount: state.turnCount,
|
turnCount: state.turnCount,
|
||||||
|
consecutiveStuckTurns: state.consecutiveStuckTurns,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,11 +306,48 @@ export function applyDiscard(state, handIdx, discardIdx) {
|
||||||
// Advance turn
|
// Advance turn
|
||||||
next.currentPlayer = (seat + 1) % next.players.length;
|
next.currentPlayer = (seat + 1) % next.players.length;
|
||||||
next.turnCount += 1;
|
next.turnCount += 1;
|
||||||
|
next.consecutiveStuckTurns = 0; // normal discard resets the stuck counter
|
||||||
refillHand(next, next.currentPlayer);
|
refillHand(next, next.currentPlayer);
|
||||||
|
|
||||||
return next;
|
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
|
// Convenience for UI — count cards in each pile per seat for the always-on
|
||||||
// HUD. The top stock card is always visible separately.
|
// HUD. The top stock card is always visible separately.
|
||||||
export function snapshotCounts(state) {
|
export function snapshotCounts(state) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue