51 Commits

Author SHA1 Message Date
adlee-was-taken
21985b7e9b Route all lobby transitions through showLobby() for animation cleanup
game_ended, queue_left, and cancelMatchmaking were calling
showScreen('lobby') directly, bypassing the cancelAll() cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:39 -05:00
adlee-was-taken
56305424ff Thorough animation cleanup when leaving game
- Tag deal container with class so cleanup() can find and remove it
- Remove .traveling-card and .deal-anim-container overlays in cleanup()
- Restore opacity/visibility on cards hidden mid-animation
- Reset all animation flags (dealAnimationInProgress, etc.) in showLobby()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:09:37 -05:00
adlee-was-taken
0bfe9d5f9f Cancel animations on game leave to prevent overlay flash on lobby
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:06:11 -05:00
adlee-was-taken
a0bb28d5eb Fix opponent swap animation instant shrink on mobile portrait
Let overlay card start at deck size and smoothly scale down to opponent
card size during the arc, instead of instantly shrinking before animating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:00:20 -05:00
adlee-was-taken
55006d6ff4 Fix bottom bar width: add align-self: stretch to override parent center
Parent #game-screen has align-items: center which shrink-wraps flex
children. Adding align-self: stretch makes the bottom bar span the
full screen width so space-between can distribute items properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:43:40 -05:00
adlee-was-taken
adcc59b6fc Spread bottom bar items with space-between
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:40:57 -05:00
adlee-was-taken
7e0c006f5e Revert bottom bar to original working state
Remove all flush-edge styling (negative margins, half-pills, border
removal). Restore original padding, justify-content, and pill shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:38:18 -05:00
adlee-was-taken
02f9b3c44d Fix layout: restore 12px padding, use negative margins for flush edges
Zero padding was breaking game layout. Keep 12px padding for layout
stability and use margin-left: -12px / margin-right: -12px on the
edge items to push them flush against screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:36:18 -05:00
adlee-was-taken
9f75cdb0dc Pin Hole and End Game flush to screen edges with half-pill shape
Zero horizontal padding on bottom bar, remove border on flush side,
use half-rounded pill shape so they sit against the screen edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:34:27 -05:00
adlee-was-taken
519d08a2a6 Fix layout: move rules drawer out of game-layout, restore bottom bar padding
Reverts flush-edge pill styling and restores horizontal padding to prevent
clipping. Rules drawer is now a sibling of bottom-bar, not inside game-layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:32:32 -05:00
adlee-was-taken
9419cb562e Move rules drawer inside game-layout to fix layout breakage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:28:36 -05:00
adlee-was-taken
17c8e574ab Pin Hole and End Game buttons flush to screen edges on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:26:18 -05:00
adlee-was-taken
94edb685a7 Move dealer chip to bottom-left of player panel on mobile, pin bottom bar edges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:22:54 -05:00
adlee-was-taken
6b7d6c459e Remove redundant Scores button, rename Standings to Scorecard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:19:47 -05:00
adlee-was-taken
1de282afc2 Change mobile rules pill default text from "S" to "RULES"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:17:41 -05:00
adlee-was-taken
9b0a8295eb Add mobile rules indicator pill and drawer
Shows "S" (standard) or "!" (house rules) in the mobile bottom bar.
Tapping opens a drawer with the full active rules list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:14:09 -05:00
adlee-was-taken
28a0f90374 Restore dealer chip to 38px and shift further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:05:54 -05:00
adlee-was-taken
0df451aa99 Enlarge local dealer chip to 34px and nudge further out
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:03:37 -05:00
adlee-was-taken
8d7b024525 Adjust local player dealer chip size and position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:00:46 -05:00
adlee-was-taken
9c08b4735a Shrink local player dealer chip in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:58:53 -05:00
adlee-was-taken
49916e6a6c Remove top padding above game header in desktop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:53:41 -05:00
adlee-was-taken
e0641de449 Move knocker OUT badge to bottom-right on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:49:43 -05:00
adlee-was-taken
e2a90c0f34 Fix knocker highlight not showing on opponents
markKnocker() was called before opponent areas were rebuilt by
innerHTML='', so the is-knocker class and OUT badge were immediately
destroyed. Move markKnocker to after opponent areas are created.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:37:33 -05:00
adlee-was-taken
86f5222746 Enhance knocker highlight with glowing box-shadow animation
Makes the red border on the knocker's area more visible, especially
for opponents on mobile where the area is small.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:26:09 -05:00
adlee-was-taken
60997e8ad4 Compact final results for mobile, delay turn shake hint
Final results modal: keep BY POINTS and BY HOLES side-by-side on
mobile, compact spacing, buttons side-by-side, bottom padding for
mobile bar overlay.

Turn shake: delay 5s before first shake, 300ms every 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:21:45 -05:00
adlee-was-taken
3e133b17c0 Delay turn shake hint by 5s, reduce to 300ms every 2s
Less aggressive draw hint: waits 5 seconds before first shake,
then shakes for 300ms every 2 seconds with slightly less movement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:13:40 -05:00
adlee-was-taken
9866fb8e92 Move discard button below held card on mobile portrait
Position the button centered beneath the held card instead of to the
right side. Reset writing-mode to horizontal and add width:auto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:39:33 -05:00
adlee-was-taken
4a5cfb68f1 Set held card offset to 0.48 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:35:58 -05:00
adlee-was-taken
ebb00f613c Lower held card offset to 0.55 on mobile portrait
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:34:00 -05:00
adlee-was-taken
98aa0823ed Set held card mobile portrait offset back to 0.65
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:33:36 -05:00
adlee-was-taken
4a3d62e26e Nudge held card up slightly to clear DRAW/DISCARD labels
Increase mobile portrait overlap offset from 0.65 to 0.8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:32:46 -05:00
adlee-was-taken
d958258066 Lower held card position to just above the labels on mobile portrait
Reduce overlap offset from 1.15 to 0.65 so the held card sits at the
DRAW/DISCARD label level rather than up in the opponents area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:30:33 -05:00
adlee-was-taken
26bc151458 Move held card to gap above deck area on mobile portrait
Position the held card a full card height above the deck (1.15x offset)
so it sits in the space between opponents and the draw/discard piles.
All three position calculations (app.js x2, card-animations.js) are
synced so draw animations land at the correct held position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:27:45 -05:00
adlee-was-taken
0d5c0c613d Add DRAW and DISCARD labels above deck and discard piles
Wrap each pile in a .pile-wrapper with a small label above it.
Fix direct child selectors that broke with the new wrapper nesting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:23:38 -05:00
adlee-was-taken
e9692de6c6 Add top padding to table-center on mobile portrait for held card clearance
Increase top padding from 5px to 20px so the deck/discard sit lower,
giving the held card more breathing room from the opponents row above.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:20:21 -05:00
adlee-was-taken
3414bfad1a Sync held card position across all animation paths for mobile portrait
Update getHoldingRect() in card-animations.js and the second held card
positioning path in app.js to use the same reduced overlap offset on
mobile portrait. All three places that compute the held position now
use 0.15 on mobile-portrait vs 0.35 on desktop/landscape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:12:46 -05:00
adlee-was-taken
ecad259db2 Lower held card position and add opponent row padding on mobile
Reduce held card overlap offset from 0.35 to 0.15 on mobile portrait
so it doesn't cover the second row of opponents. Increase bottom
padding on opponents row from 6px to 12px for more breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:10:11 -05:00
adlee-was-taken
932e9ca4ef Enhance Your Turn status gradient to be more visible
Widen the green gradient range to match the visual pop of the
opponent turn purple gradient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:03:40 -05:00
adlee-was-taken
10825e8b82 Add opponent-turn class to status message in renderGame
The status was set without a type class in renderGame(), overriding
the styled version from updateStatusFromGameState() on every state
update. Now the purple background shows consistently for opponent turns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:00:38 -05:00
adlee-was-taken
53abde53ac Anchor back buttons to top-left corner of header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:48:16 -05:00
adlee-was-taken
d7ba3154a1 Scope container margin-top to mobile-portrait only
Remove margin-top from base rules/leaderboard/matchmaking styles so
desktop and landscape layouts are unaffected. The 50px top margin is
now only applied via the mobile-portrait override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:45:36 -05:00
adlee-was-taken
197595fc4d Fix mobile-portrait override resetting container margin-top to 0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:44:35 -05:00
adlee-was-taken
e38d8c1561 Add margin-top to matchmaking screen to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:39 -05:00
adlee-was-taken
afb4869b21 Add margin-top to rules and leaderboard containers to clear auth bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:42:10 -05:00
adlee-was-taken
c6769f9257 Fix back button width and add border to leaderboard header
Override width:100% from .btn base class with width:auto on both
back buttons. Add padding-bottom and border-bottom to leaderboard
header to match rules page styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:39:51 -05:00
adlee-was-taken
8657a0501f Move Back button into header on Rules and Leaderboard pages
Position the button absolutely on the left side of the header,
vertically centered with the title. Remove mobile fixed-position
override that placed it in the top bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:37:13 -05:00
adlee-was-taken
730ba9c462 Fix portrait back buttons: fixed top-left, push containers down
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:33:25 -05:00
adlee-was-taken
1ba80606a7 Add top padding to rules/leaderboard screens in portrait mode
Prevents auth bar from overlapping back buttons. Back buttons
align to start instead of stretching full width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:28:22 -05:00
adlee-was-taken
3261e6ee26 Make CPU turn chain fire-and-forget so end game is instant
The CPU turn chain was awaited inline inside game_lock, blocking the
WebSocket message loop. The end_game message couldn't be processed
until all CPU turns finished. Now check_and_run_cpu_turn launches a
background task and returns immediately, keeping the message loop
responsive. The end_game and leave handlers cancel the task on demand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:26:58 -05:00
adlee-was-taken
de3495635b Cancel CPU turns immediately when host ends game
Convert CPU turn chain to a cancellable asyncio.Task tracked on Room,
so ending the game or leaving no longer blocks waiting for CPU sleeps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:21:22 -05:00
adlee-was-taken
4c23f2b4a9 Increase mobile portrait opponent row gap to 9px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:16:32 -05:00
8 changed files with 360 additions and 91 deletions

View File

@@ -950,7 +950,7 @@ class GolfGame {
// Host ended the game or player was kicked
this._intentionalClose = true;
if (this.ws) this.ws.close();
this.showScreen('lobby');
this.showLobby();
if (data.reason) {
this.showError(data.reason);
}
@@ -975,7 +975,7 @@ class GolfGame {
case 'queue_left':
this.stopMatchmakingTimer();
this.showScreen('lobby');
this.showLobby();
break;
case 'error':
@@ -995,7 +995,7 @@ class GolfGame {
cancelMatchmaking() {
this.send({ type: 'queue_leave' });
this.stopMatchmakingTimer();
this.showScreen('lobby');
this.showLobby();
}
startMatchmakingTimer() {
@@ -2807,16 +2807,7 @@ class GolfGame {
// Use unified swap animation
if (window.cardAnimations) {
// For opponent swaps, size the held card to match the opponent card
// rather than the deck size (default holding rect uses deck dimensions,
// which looks oversized next to small opponent cards on mobile)
const holdingRect = window.cardAnimations.getHoldingRect();
const heldRect = holdingRect ? {
left: holdingRect.left,
top: holdingRect.top,
width: sourceRect.width,
height: sourceRect.height
} : null;
const heldRect = window.cardAnimations.getHoldingRect();
window.cardAnimations.animateUnifiedSwap(
discardCard, // handCardData - card going to discard
@@ -3066,6 +3057,14 @@ class GolfGame {
}
showLobby() {
if (window.cardAnimations) {
window.cardAnimations.cancelAll();
}
this.dealAnimationInProgress = false;
this.isDrawAnimating = false;
this.localDiscardAnimating = false;
this.opponentDiscardAnimating = false;
this.opponentSwapAnimation = false;
this.showScreen(this.lobbyScreen);
this.lobbyError.textContent = '';
this.roomCode = null;
@@ -3138,6 +3137,24 @@ class GolfGame {
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
}
this.activeRulesBar.classList.remove('hidden');
// Update mobile rules indicator
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
const mobileRulesContent = document.getElementById('mobile-rules-content');
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
const isHouseRules = rules.length > 0;
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
if (!isHouseRules) {
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
} else {
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
rules.map(renderTag).join('');
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
}
}
}
// V3_14: Map display names to rule keys
@@ -3544,7 +3561,9 @@ class GolfGame {
const cardHeight = deckRect.height;
// Position card centered, overlapping both piles (lower than before)
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
// On mobile portrait, place held card fully above the deck/discard area
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
this.heldCardFloating.style.left = `${cardLeft}px`;
@@ -3556,11 +3575,21 @@ class GolfGame {
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
}
// Position discard button attached to right side of held card
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
// Position discard button
if (isMobilePortrait) {
// Below the held card, centered
const btnRect = this.discardBtn.getBoundingClientRect();
const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
const buttonTop = cardTop + cardHeight + 4;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
} else {
// Right side of held card (desktop)
const buttonLeft = cardLeft + cardWidth;
const buttonTop = cardTop + cardHeight * 0.3;
this.discardBtn.style.left = `${buttonLeft}px`;
this.discardBtn.style.top = `${buttonTop}px`;
}
if (card.rank === '★') {
this.heldCardFloating.classList.add('joker');
@@ -3606,7 +3635,8 @@ class GolfGame {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
const cardLeft = centerX - cardWidth / 2;
const cardTop = deckRect.top - overlapOffset;
@@ -3778,14 +3808,19 @@ class GolfGame {
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
// Show/hide final turn badge with enhanced urgency
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
const isFinalTurn = this.gameState.phase === 'final_turn';
if (isFinalTurn) {
this.updateFinalTurnDisplay();
this.gameScreen.classList.add('final-turn-active');
this.finalTurnBadge.classList.remove('hidden');
if (!this.finalTurnAnnounced) {
this.playSound('alert');
this.finalTurnAnnounced = true;
}
} else {
this.finalTurnBadge.classList.add('hidden');
this.gameScreen.classList.remove('final-turn-active');
this.finalTurnAnnounced = false;
this.clearKnockerMark();
}
// Toggle not-my-turn class to disable hover effects when it's not player's turn
@@ -3807,7 +3842,7 @@ class GolfGame {
: this.gameState.current_player_id;
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
if (displayedPlayer && displayedPlayerId !== this.playerId) {
this.setStatus(`${displayedPlayer.name}'s turn`);
this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
}
// Update player header (name + score like opponents)
@@ -4151,6 +4186,13 @@ class GolfGame {
// Update scoreboard panel
this.updateScorePanel();
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
if (this.gameState.phase === 'final_turn') {
this.markKnocker(this.gameState.finisher_id);
} else {
this.clearKnockerMark();
}
// Initialize anime.js hover listeners on newly created cards
if (window.cardAnimations) {
window.cardAnimations.initHoverListeners(this.playerCards);

View File

@@ -46,7 +46,8 @@ class CardAnimations {
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
return {
left: centerX - cardWidth / 2,
@@ -155,12 +156,20 @@ class CardAnimations {
}
this.activeAnimations.clear();
// Remove all animation card elements (including those marked as animating)
document.querySelectorAll('.draw-anim-card').forEach(el => {
// Remove all animation overlay elements
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
delete el.dataset.animating;
el.remove();
});
// Restore visibility on any cards hidden during animations
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
el.style.opacity = '';
});
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
el.style.visibility = '';
});
// Restore discard pile visibility if it was hidden during animation
const discardPile = document.getElementById('discard');
if (discardPile && discardPile.style.opacity === '0') {
@@ -756,22 +765,28 @@ class CardAnimations {
anime({
targets: element,
translateX: [0, -8, 8, -6, 4, 0],
duration: 400,
translateX: [0, -6, 6, -4, 3, 0],
duration: 300,
easing: 'easeInOutQuad'
});
};
// Do initial shake, then repeat every 3 seconds
doShake();
const interval = setInterval(doShake, 3000);
this.activeAnimations.set(id, { interval });
// Delay first shake by 5 seconds, then repeat every 2 seconds
const timeout = setTimeout(() => {
if (!this.activeAnimations.has(id)) return;
doShake();
const interval = setInterval(doShake, 2000);
const entry = this.activeAnimations.get(id);
if (entry) entry.interval = interval;
}, 5000);
this.activeAnimations.set(id, { timeout });
}
stopTurnPulse(element) {
const id = 'turnPulse';
const existing = this.activeAnimations.get(id);
if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause();
this.activeAnimations.delete(id);
@@ -1515,6 +1530,7 @@ class CardAnimations {
// Create container for animation cards
const container = document.createElement('div');
container.className = 'deal-anim-container';
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
document.body.appendChild(container);

View File

@@ -328,18 +328,24 @@
</div>
<span class="held-label">Holding</span>
</div>
<div id="deck" class="card card-back"></div>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
<div class="pile-wrapper">
<span class="pile-label">DRAW</span>
<div id="deck" class="card card-back"></div>
</div>
<div class="pile-wrapper">
<span class="pile-label">DISCARD</span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div>
</div>
</div>
@@ -398,16 +404,23 @@
<tbody></tbody>
</table>
</div>
</div>
<!-- Mobile bottom bar (hidden on desktop) -->
<div id="mobile-bottom-bar">
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
</div>
<!-- Mobile rules drawer -->
<div id="rules-drawer" class="side-panel rules-drawer-panel">
<h4>Active Rules</h4>
<div id="mobile-rules-content"></div>
</div>
<!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div>
</div>
@@ -415,9 +428,8 @@
<!-- Rules Screen -->
<div id="rules-screen" class="screen">
<div class="rules-container">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<div class="rules-header">
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
</div>
@@ -733,9 +745,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen">
<div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p>
</div>

View File

@@ -1124,6 +1124,20 @@ input::placeholder {
align-items: flex-start;
}
.pile-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.pile-label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 3px;
}
/* Gentle pulse when it's your turn to draw - handled by anime.js */
/* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
@@ -1654,7 +1668,7 @@ input::placeholder {
}
.status-message.your-turn {
background: linear-gradient(135deg, #b5d484 0%, #9ab973 100%);
background: linear-gradient(135deg, #c8e6a0 0%, #8fbf5a 100%);
color: #2d3436;
}
@@ -2798,26 +2812,63 @@ input::placeholder {
/* Mobile adjustments for final results modal */
@media (max-width: 500px) {
.final-results-content {
padding: 20px 25px;
padding: 15px 12px;
width: 95%;
}
.final-results-content h2 {
font-size: 1.5rem;
font-size: 1.3rem;
margin-bottom: 10px;
}
.double-victory-banner {
padding: 8px 12px;
font-size: 0.95rem;
margin-bottom: 12px;
}
.final-rankings {
flex-direction: column;
gap: 15px;
flex-direction: row;
gap: 8px;
margin-bottom: 15px;
}
.final-ranking-section {
padding: 8px;
}
.final-ranking-section h3 {
font-size: 0.7rem;
margin-bottom: 6px;
padding-bottom: 4px;
}
.final-rank-row {
font-size: 0.8rem;
padding: 4px 5px;
margin-bottom: 2px;
}
.final-rank-row .rank-pos {
width: 22px;
font-size: 0.9rem;
padding: 6px 8px;
}
.final-rank-row .rank-val {
font-size: 0.8rem;
}
.final-actions {
flex-direction: row;
gap: 8px;
padding-bottom: 60px;
}
.final-actions .btn {
min-width: 120px;
padding: 12px 20px;
min-width: 0;
flex: 1;
padding: 10px 12px;
font-size: 0.9rem;
}
}
@@ -2854,12 +2905,15 @@ input::placeholder {
/* Rules back button */
.rules-back-btn {
position: absolute;
left: 0;
top: 0;
width: auto;
padding: 4px 12px;
font-size: 0.8rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 15px;
}
.rules-back-btn:hover {
@@ -2875,6 +2929,7 @@ input::placeholder {
/* Rules header */
.rules-header {
position: relative;
text-align: center;
margin-bottom: 25px;
padding-bottom: 20px;
@@ -3289,7 +3344,11 @@ input::placeholder {
display: none;
}
/* Hide global auth-bar when game screen is active */
/* Hide global auth-bar and remove top padding when game screen is active */
#app:has(#game-screen.active) {
padding-top: 0;
}
#app:has(#game-screen.active) > .auth-bar {
display: none !important;
}
@@ -3511,11 +3570,15 @@ input::placeholder {
border-radius: 12px;
padding: 20px 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0;
}
.leaderboard-header {
position: relative;
text-align: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 2px solid rgba(244, 164, 96, 0.3);
}
.leaderboard-header h1 {
@@ -3532,12 +3595,15 @@ input::placeholder {
/* Leaderboard back button */
.leaderboard-back-btn {
position: absolute;
left: 0;
top: 0;
width: auto;
padding: 4px 12px;
font-size: 0.8rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 15px;
}
.leaderboard-back-btn:hover {
@@ -4306,6 +4372,15 @@ input::placeholder {
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.3);
}
.player-area .dealer-chip {
width: 38px;
height: 38px;
font-size: 18px;
border-width: 2px;
bottom: -22px;
left: -22px;
}
/* --- V3_03: Round End Reveal --- */
.reveal-prompt {
position: fixed;
@@ -4387,6 +4462,13 @@ input::placeholder {
.opponent-area.is-knocker {
border: 2px solid #ff6b35;
border-radius: 8px;
box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4);
animation: knocker-glow 2s ease-in-out infinite;
}
@keyframes knocker-glow {
0%, 100% { box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4); }
50% { box-shadow: 0 0 20px 6px rgba(255, 107, 53, 0.6); }
}
.knocker-badge {
@@ -5002,6 +5084,12 @@ body.mobile-portrait .game-header #leave-game-btn {
display: none !important;
}
body.mobile-portrait .rules-container,
body.mobile-portrait .leaderboard-container,
body.mobile-portrait #matchmaking-screen {
margin-top: 50px;
}
body.mobile-portrait .status-message {
font-size: 1.02rem;
white-space: nowrap;
@@ -5052,9 +5140,9 @@ body.mobile-portrait .opponents-row {
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 4px 10px;
gap: 9px 10px;
min-height: 0 !important;
padding: 2px 4px 6px;
padding: 2px 4px 12px;
overflow: visible;
flex-shrink: 0;
}
@@ -5120,6 +5208,11 @@ body.mobile-portrait .opponent-area .card {
border-radius: 3px;
}
body.mobile-portrait .opponent-area .knocker-badge {
top: auto;
bottom: -10px;
}
body.mobile-portrait .opponent-showing {
font-size: 0.85rem;
padding: 0px 3px;
@@ -5128,7 +5221,7 @@ body.mobile-portrait .opponent-showing {
/* --- Mobile: Deck/Discard area centered --- */
body.mobile-portrait .table-center {
padding: 5px 10px;
padding: 20px 10px 5px;
border-radius: 8px;
}
@@ -5137,7 +5230,7 @@ body.mobile-portrait .deck-area {
align-items: flex-start;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait .deck-area .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 64px !important;
@@ -5160,7 +5253,8 @@ body.mobile-portrait #discard-btn {
position: fixed;
writing-mode: horizontal-tb;
text-orientation: initial;
padding: 8px 16px;
width: auto;
padding: 6px 18px;
font-size: 0.8rem;
border-radius: 8px;
}
@@ -5184,8 +5278,8 @@ body.mobile-portrait .player-area .dealer-chip {
height: 22px;
font-size: 11px;
border-width: 2px;
bottom: auto;
top: -8px;
top: auto;
bottom: -8px;
left: -8px;
}
@@ -5307,8 +5401,9 @@ body.mobile-portrait .game-buttons {
/* --- Mobile: Bottom bar --- */
body.mobile-portrait #mobile-bottom-bar {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
align-self: stretch;
gap: 8px;
background: none;
flex-shrink: 0;
@@ -5317,7 +5412,7 @@ body.mobile-portrait #mobile-bottom-bar {
z-index: 900;
}
/* Hole indicator — pinned left with pill background */
/* Hole indicator — pinned left */
body.mobile-portrait #mobile-bottom-bar .mobile-round-info {
margin-right: auto;
color: rgba(255, 255, 255, 0.9);
@@ -5370,6 +5465,59 @@ body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active {
box-shadow: 0 2px 12px rgba(244, 164, 96, 0.4);
}
/* --- Mobile: Rules indicator button --- */
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn {
padding: 4px 9px;
font-size: 0.77rem;
font-weight: 700;
min-width: unset;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn.house-rules {
background: rgba(244, 164, 96, 0.25);
border-color: rgba(244, 164, 96, 0.4);
color: #f4a460;
}
/* --- Mobile: Rules drawer content --- */
#rules-drawer {
display: none;
}
body.mobile-portrait #rules-drawer {
display: block;
}
body.mobile-portrait .rules-drawer-panel .mobile-rules-content-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
body.mobile-portrait .rules-drawer-panel .rule-tag {
background: rgba(244, 164, 96, 0.3);
color: #f4a460;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
body.mobile-portrait .rules-drawer-panel .rule-tag.standard {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
body.mobile-portrait .rules-drawer-panel .rule-tag.unranked {
background: rgba(220, 80, 80, 0.3);
color: #f08080;
border: 1px solid rgba(220, 80, 80, 0.4);
}
/* --- Mobile: Non-game screens --- */
body.mobile-portrait #lobby-screen {
padding: 55px 12px 15px;
@@ -5465,7 +5613,7 @@ body.mobile-portrait .ss-next-btn {
padding: 3px 8px;
}
body.mobile-portrait .deck-area > .card,
body.mobile-portrait .deck-area .card,
body.mobile-portrait #deck,
body.mobile-portrait #discard {
width: 60px !important;

View File

@@ -1934,7 +1934,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
async def process_cpu_turn(
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
) -> None:
"""Process a complete turn for a CPU player."""
"""Process a complete turn for a CPU player.
May raise asyncio.CancelledError if the game is ended mid-turn.
The caller (check_and_run_cpu_turn) handles cancellation.
"""
import asyncio
from services.game_logger import get_logger

View File

@@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -240,7 +240,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
# ---------------------------------------------------------------------------
@@ -297,7 +297,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -329,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
})
else:
await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
else:
logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
@@ -364,7 +364,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -380,7 +380,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -400,7 +400,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -418,7 +418,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
@@ -443,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
check_and_run_cpu_turn(ctx.current_room)
else:
await broadcast_game_state(ctx.current_room)
@@ -473,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return
# Cancel any running CPU turn task so the game ends immediately
if ctx.current_room.cpu_turn_task:
ctx.current_room.cpu_turn_task.cancel()
try:
await ctx.current_room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
ctx.current_room.cpu_turn_task = None
await ctx.current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",

View File

@@ -705,8 +705,13 @@ async def broadcast_game_state(room: Room):
})
async def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and run their turn."""
def check_and_run_cpu_turn(room: Room):
"""Check if current player is CPU and start their turn as a background task.
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
room.cpu_turn_task. This allows the WebSocket message loop to remain
responsive so that end_game/leave messages can cancel the task immediately.
"""
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
@@ -718,21 +723,54 @@ async def check_and_run_cpu_turn(room: Room):
if not room_player or not room_player.is_cpu:
return
# Brief pause before CPU starts - animations are faster now
await asyncio.sleep(0.25)
task = asyncio.create_task(_run_cpu_chain(room))
room.cpu_turn_task = task
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
def _on_done(t: asyncio.Task):
# Clear the reference when the task finishes (success, cancel, or error)
if room.cpu_turn_task is t:
room.cpu_turn_task = None
if not t.cancelled() and t.exception():
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
task.add_done_callback(_on_done)
# Check if next player is also CPU (chain CPU turns)
await check_and_run_cpu_turn(room)
async def _run_cpu_chain(room: Room):
"""Run consecutive CPU turns until a human player's turn or game ends."""
while True:
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return
current = room.game.current_player()
if not current:
return
room_player = room.get_player(current.id)
if not room_player or not room_player.is_cpu:
return
# Brief pause before CPU starts - animations are faster now
await asyncio.sleep(0.25)
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room."""
# Cancel any running CPU turn task before cleanup
if room.cpu_turn_task:
room.cpu_turn_task.cancel()
try:
await room.cpu_turn_task
except (asyncio.CancelledError, Exception):
pass
room.cpu_turn_task = None
room_code = room.code
room_player = room.remove_player(player_id)

View File

@@ -69,6 +69,7 @@ class Room:
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
game_log_id: Optional[str] = None
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cpu_turn_task: Optional[asyncio.Task] = None
def add_player(
self,