From 4fcdf13f66c07e9d7a4ce7908141af0cc8cee88d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 21 Feb 2026 22:52:44 -0500 Subject: [PATCH] Fix mobile portrait layout: lobby overlap, deal animation, card font sizes - Add renderGame() guard during deal animation to prevent DOM destruction mid-animation causing cards to pile up at wrong positions - Push lobby content below fixed auth-bar (padding 15px -> 50px top) - Scale player card font-size to 1.5rem/1.3rem for readable text on mobile - Add full mobile portrait layout: bottom drawers, compact header, responsive card grid sizing, safe-area insets, and mobile detection via matchMedia - Add cardFontSize() helper for consistent proportional font scaling - Add mobile bottom bar with drawer toggles for standings/scores Co-Authored-By: Claude Opus 4.6 --- client/app.js | 115 ++++++++-- client/card-animations.js | 15 +- client/card-manager.js | 7 + client/index.html | 11 +- client/style.css | 468 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 595 insertions(+), 21 deletions(-) diff --git a/client/app.js b/client/app.js index 3a6212a..67b97ff 100644 --- a/client/app.js +++ b/client/app.js @@ -66,10 +66,14 @@ class GolfGame { this.discardHistory = []; this.maxDiscardHistory = 5; + // Mobile detection + this.isMobile = false; + this.initElements(); this.initAudio(); this.initCardTooltips(); this.bindEvents(); + this.initMobileDetection(); this.checkUrlParams(); } @@ -85,6 +89,51 @@ class GolfGame { } } + initMobileDetection() { + const mql = window.matchMedia('(max-width: 500px) and (orientation: portrait)'); + const update = (e) => { + this.isMobile = e.matches; + document.body.classList.toggle('mobile-portrait', e.matches); + // Close any open drawers on layout change + if (!e.matches) { + this.closeDrawers(); + } + }; + mql.addEventListener('change', update); + update(mql); + + // Bottom bar drawer toggles + const bottomBar = document.getElementById('mobile-bottom-bar'); + const backdrop = document.getElementById('drawer-backdrop'); + if (bottomBar) { + bottomBar.addEventListener('click', (e) => { + const btn = e.target.closest('.mobile-bar-btn'); + if (!btn) return; + const drawerId = btn.dataset.drawer; + const panel = document.getElementById(drawerId); + if (!panel) return; + + const isOpen = panel.classList.contains('drawer-open'); + this.closeDrawers(); + if (!isOpen) { + panel.classList.add('drawer-open'); + btn.classList.add('active'); + if (backdrop) backdrop.classList.add('visible'); + } + }); + } + if (backdrop) { + backdrop.addEventListener('click', () => this.closeDrawers()); + } + } + + closeDrawers() { + document.querySelectorAll('.side-panel.drawer-open').forEach(p => p.classList.remove('drawer-open')); + document.querySelectorAll('.mobile-bar-btn.active').forEach(b => b.classList.remove('active')); + const backdrop = document.getElementById('drawer-backdrop'); + if (backdrop) backdrop.classList.remove('visible'); + } + initAudio() { // Initialize audio context on first user interaction const initCtx = () => { @@ -1876,20 +1925,33 @@ class GolfGame { this.dealAnimationInProgress = true; if (window.cardAnimations) { - window.cardAnimations.animateDealing( - this.gameState, - (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx), - () => { - // Deal complete - allow flip prompts - this.dealAnimationInProgress = false; - // Show real cards - this.playerCards.style.visibility = 'visible'; - this.opponentsRow.style.visibility = 'visible'; - this.renderGame(); - // Stagger opponent initial flips right after dealing - this.animateOpponentInitialFlips(); - } - ); + // Use double-rAF to ensure layout is fully computed after renderGame(). + // First rAF: browser computes styles. Second rAF: layout is painted. + // This is critical on mobile where CSS !important rules need to apply + // before getBoundingClientRect() returns correct card slot positions. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Verify rects are valid before starting animation + const testRect = this.getCardSlotRect(this.playerId, 0); + if (this.isMobile) { + console.log('[DEAL] Starting deal animation, test rect:', testRect); + } + window.cardAnimations.animateDealing( + this.gameState, + (playerId, cardIdx) => this.getCardSlotRect(playerId, cardIdx), + () => { + // Deal complete - allow flip prompts + this.dealAnimationInProgress = false; + // Show real cards + this.playerCards.style.visibility = 'visible'; + this.opponentsRow.style.visibility = 'visible'; + this.renderGame(); + // Stagger opponent initial flips right after dealing + this.animateOpponentInitialFlips(); + } + ); + }); + }); } else { // Fallback this.dealAnimationInProgress = false; @@ -1950,14 +2012,22 @@ class GolfGame { getCardSlotRect(playerId, cardIdx) { if (playerId === this.playerId) { const cards = this.playerCards.querySelectorAll('.card'); - return cards[cardIdx]?.getBoundingClientRect() || null; + const rect = cards[cardIdx]?.getBoundingClientRect() || null; + if (this.isMobile && rect) { + console.log(`[DEAL-DEBUG] Player card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height}); + } + return rect; } else { const area = this.opponentsRow.querySelector( `.opponent-area[data-player-id="${playerId}"]` ); if (area) { const cards = area.querySelectorAll('.card'); - return cards[cardIdx]?.getBoundingClientRect() || null; + const rect = cards[cardIdx]?.getBoundingClientRect() || null; + if (this.isMobile && rect) { + console.log(`[DEAL-DEBUG] Opponent ${playerId} card[${cardIdx}] rect:`, {l: rect.left, t: rect.top, w: rect.width, h: rect.height}); + } + return rect; } } return null; @@ -2932,6 +3002,11 @@ class GolfGame { } screen.classList.add('active'); + // Close mobile drawers on screen change + if (this.isMobile) { + this.closeDrawers(); + } + // Handle auth bar visibility - hide global bar during game, show in-game controls instead const isGameScreen = screen === this.gameScreen; const user = this.auth?.user; @@ -3444,6 +3519,10 @@ class GolfGame { this.heldCardFloating.style.top = `${cardTop}px`; this.heldCardFloating.style.width = `${cardWidth}px`; this.heldCardFloating.style.height = `${cardHeight}px`; + // Scale font to card width (matches cardAnimations.cardFontSize ratio) + if (this.isMobile) { + 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) @@ -3503,6 +3582,9 @@ class GolfGame { this.heldCardFloating.style.top = `${cardTop}px`; this.heldCardFloating.style.width = `${cardWidth}px`; this.heldCardFloating.style.height = `${cardHeight}px`; + if (this.isMobile) { + this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`; + } this.heldCardFloatingContent.innerHTML = ''; this.heldCardFloating.classList.remove('hidden'); @@ -3648,6 +3730,7 @@ class GolfGame { renderGame() { if (!this.gameState) return; + if (this.dealAnimationInProgress) return; // Update CPU considering visual state this.updateCpuConsideringState(); diff --git a/client/card-animations.js b/client/card-animations.js index fd8b97a..ad00d0d 100644 --- a/client/card-animations.js +++ b/client/card-animations.js @@ -75,6 +75,13 @@ class CardAnimations { return easings[type] || 'easeOutQuad'; } + // Font size proportional to card width — consistent across all card types. + // Mobile uses a tighter ratio since cards are smaller and closer together. + cardFontSize(width) { + const ratio = document.body.classList.contains('mobile-portrait') ? 0.35 : 0.5; + return (width * ratio) + 'px'; + } + // Create animated card element with 3D flip structure createAnimCard(rect, showBack = false, deckColor = null) { const card = document.createElement('div'); @@ -94,7 +101,7 @@ class CardAnimations { card.style.height = rect.height + 'px'; // Scale font-size proportionally to card width const front = card.querySelector('.draw-anim-front'); - if (front) front.style.fontSize = (rect.width * 0.5) + 'px'; + if (front) front.style.fontSize = this.cardFontSize(rect.width); } // Apply deck color to back @@ -1180,7 +1187,7 @@ class CardAnimations { if (handFront) { timeline.add({ targets: handFront, - fontSize: (discardRect.width * 0.5) + 'px', + fontSize: this.cardFontSize(discardRect.width), duration: T.arc, easing: this.getEasing('arc'), }, `-=${T.arc}`); @@ -1205,7 +1212,7 @@ class CardAnimations { if (heldFront) { timeline.add({ targets: heldFront, - fontSize: (handRect.width * 0.5) + 'px', + fontSize: this.cardFontSize(handRect.width), duration: T.arc, easing: this.getEasing('arc'), }, `-=${T.arc}`); @@ -1424,7 +1431,7 @@ class CardAnimations { card.style.height = rect.height + 'px'; // Scale font-size proportionally to card width const front = card.querySelector('.draw-anim-front'); - if (front) front.style.fontSize = (rect.width * 0.5) + 'px'; + if (front) front.style.fontSize = this.cardFontSize(rect.width); if (rotation) { card.style.transform = `rotate(${rotation}deg)`; diff --git a/client/card-manager.js b/client/card-manager.js index acfbb81..785f201 100644 --- a/client/card-manager.js +++ b/client/card-manager.js @@ -126,6 +126,13 @@ class CardManager { cardEl.style.width = `${rect.width}px`; cardEl.style.height = `${rect.height}px`; + // On mobile, scale font proportional to card width so rank/suit fit + if (document.body.classList.contains('mobile-portrait')) { + cardEl.style.fontSize = `${rect.width * 0.35}px`; + } else { + cardEl.style.fontSize = ''; + } + if (animate) { const moveDuration = window.TIMING?.card?.moving || 350; setTimeout(() => cardEl.classList.remove('moving'), moveDuration); diff --git a/client/index.html b/client/index.html index 68cfce4..402df87 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + Golf Card Game @@ -398,6 +398,15 @@ + + +
+ + +
+ + +
diff --git a/client/style.css b/client/style.css index 638a2e5..03d7720 100644 --- a/client/style.css +++ b/client/style.css @@ -4877,3 +4877,471 @@ body.screen-shake { border-radius: 6px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); } + +/* ============================================ + MOBILE PORTRAIT LAYOUT + ============================================ + All rules scoped under body.mobile-portrait. + Triggered by JS matchMedia on narrow portrait screens. + Desktop layout is completely untouched. + ============================================ */ + +/* Mobile bottom bar - hidden on desktop */ +#mobile-bottom-bar { + display: none; +} + +body.mobile-portrait { + height: 100dvh; + overflow: hidden; + overscroll-behavior: contain; + touch-action: manipulation; +} + +body.mobile-portrait #app { + padding: 0; + height: 100dvh; + overflow: hidden; +} + +/* --- Mobile: Game screen fills viewport --- */ +/* IMPORTANT: Must include .active to avoid overriding .screen { display: none } */ +body.mobile-portrait #game-screen.active { + height: 100dvh; + overflow: hidden; + margin-left: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +body.mobile-portrait .game-layout { + flex: 1; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +body.mobile-portrait .game-main { + flex: 1; + gap: 0; + justify-content: flex-start; + overflow: hidden; + min-height: 0; +} + +/* --- Mobile: Compact header (single row) --- */ +body.mobile-portrait .game-header { + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 10px; + padding-top: calc(4px + env(safe-area-inset-top, 0px)); + font-size: 0.8rem; + min-height: 36px; + width: 100%; + margin-left: 0; + gap: 6px; +} + +body.mobile-portrait .header-col-left { + flex: 0 0 auto; + gap: 6px; +} + +body.mobile-portrait .header-col-center { + flex: 1; + min-width: 0; +} + +body.mobile-portrait .header-col-right { + flex: 0 0 auto; + gap: 4px; +} + +/* Hide non-essential header items on mobile */ +body.mobile-portrait .active-rules-bar, +body.mobile-portrait .game-username, +body.mobile-portrait #game-logout-btn { + display: none !important; +} + +body.mobile-portrait .status-message { + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +body.mobile-portrait .round-info { + font-size: 0.8rem; +} + +body.mobile-portrait #leave-game-btn { + padding: 2px 6px; + font-size: 0.65rem; +} + +body.mobile-portrait .mute-btn { + font-size: 0.9rem; + padding: 2px; +} + +body.mobile-portrait .final-turn-badge { + font-size: 0.7rem; + padding: 2px 8px; +} + +/* --- Mobile: Game table — opponents pinned top, rest centered in remaining space --- */ +body.mobile-portrait .game-table { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 0 !important; + flex: 1; + overflow: hidden; + padding: 0 4px; + min-height: 0; +} + +/* --- Mobile: Opponents as flat horizontal strip, pinned to top --- */ +body.mobile-portrait .opponents-row { + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: flex-start; + gap: 6px; + min-height: 0 !important; + padding: 2px 8px; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + flex-shrink: 0; +} + +/* --- Mobile: Player row gets remaining space, centered vertically --- */ +body.mobile-portrait .player-row { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + flex: 1; + min-height: 0; +} + +/* Remove all arch rotation and margin on mobile */ +body.mobile-portrait .opponents-row .opponent-area { + margin-bottom: 0 !important; + transform: none !important; + flex-shrink: 0; +} + +body.mobile-portrait .opponent-area { + padding: 3px 5px 4px; + border-radius: 6px; + min-width: 0; +} + +body.mobile-portrait .opponent-area h4 { + font-size: 0.6rem; + margin: 0 0 2px 0; + padding: 2px 4px; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; +} + +body.mobile-portrait .opponent-area .card-grid { + grid-template-columns: repeat(3, 32px) !important; + gap: 2px !important; +} + +body.mobile-portrait .opponent-area .card { + width: 32px !important; + height: 45px !important; + font-size: 0.6rem !important; + border-radius: 3px; +} + +body.mobile-portrait .opponent-showing { + font-size: 0.55rem; + padding: 0px 3px; + margin-left: 3px; +} + +/* --- Mobile: Deck/Discard area centered --- */ +body.mobile-portrait .table-center { + padding: 5px 10px; + border-radius: 8px; +} + +body.mobile-portrait .deck-area { + gap: 10px; + align-items: flex-start; +} + +body.mobile-portrait .deck-area > .card, +body.mobile-portrait #deck, +body.mobile-portrait #discard { + width: 60px !important; + height: 84px !important; + font-size: 1.3rem !important; +} + +/* Held card floating should NOT be constrained to deck/discard size */ +body.mobile-portrait .held-card-floating { + width: 72px !important; + height: 101px !important; +} + +body.mobile-portrait .discard-stack { + gap: 6px; +} + +/* Discard button - horizontal on mobile instead of vertical tab */ +body.mobile-portrait #discard-btn { + position: fixed; + writing-mode: horizontal-tb; + text-orientation: initial; + padding: 8px 16px; + font-size: 0.8rem; + border-radius: 8px; +} + +/* --- Mobile: Player cards — explicit sizes for reliable layout --- */ +body.mobile-portrait .player-section { + width: auto; + padding: 0; +} + +body.mobile-portrait .player-area { + padding: 5px 8px; + border-radius: 8px; + width: auto; + display: inline-block; +} + +body.mobile-portrait .player-area h4 { + font-size: 0.8rem; + padding: 3px 8px; + margin-bottom: 4px; +} + +body.mobile-portrait .player-showing { + font-size: 0.75rem; +} + +/* Player hand: fixed-size cards */ +body.mobile-portrait .player-section .card-grid { + grid-template-columns: repeat(3, 72px) !important; + gap: 5px !important; + justify-content: center; +} + +body.mobile-portrait .player-section .card { + width: 72px !important; + height: 101px !important; + font-size: 1.5rem !important; +} + +/* Real cards: font-size is now set inline by card-manager.js (proportional to card width). + Override the desktop clamp values to inherit from the element. */ +body.mobile-portrait .real-card .card-face-front, +body.mobile-portrait .real-card .card-face-back { + font-size: inherit; + line-height: 1; +} + +/* --- Mobile: Side panels become bottom drawers --- */ +body.mobile-portrait .side-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + max-height: 55vh; + border-radius: 16px 16px 0 0; + padding: 12px 16px; + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + z-index: 600; + transform: translateY(100%); + transition: transform 0.3s ease-out; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +body.mobile-portrait .side-panel.left-panel, +body.mobile-portrait .side-panel.right-panel { + left: 0; + right: 0; +} + +body.mobile-portrait .side-panel.drawer-open { + transform: translateY(0); +} + +/* Drawer handle */ +body.mobile-portrait .side-panel::before { + content: ''; + display: block; + width: 40px; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + margin: 0 auto 10px; +} + +/* Drawer backdrop */ +body.mobile-portrait .drawer-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 599; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-out; +} + +body.mobile-portrait .drawer-backdrop.visible { + opacity: 1; + pointer-events: auto; +} + +/* Score table in drawer: full width */ +body.mobile-portrait .side-panel table { + width: 100%; + font-size: 0.85rem; +} + +body.mobile-portrait .side-panel th, +body.mobile-portrait .side-panel td { + padding: 4px 8px; +} + +/* Standings list in drawer */ +body.mobile-portrait .standings-list .rank-row { + font-size: 0.85rem; + padding: 3px 0; +} + +/* Game buttons in drawer */ +body.mobile-portrait .game-buttons { + display: flex; + gap: 8px; + justify-content: center; + padding: 8px 0; +} + +/* --- Mobile: Bottom bar --- */ +body.mobile-portrait #mobile-bottom-bar { + display: flex; + justify-content: space-around; + align-items: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + padding: 6px 16px; + padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); + width: 100%; + z-index: 500; + flex-shrink: 0; + border-top: 1px solid rgba(244, 164, 96, 0.2); +} + +body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.8); + font-size: 0.75rem; + font-weight: 600; + padding: 6px 16px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 6px; + transition: background 0.15s, color 0.15s; +} + +body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn:active { + background: rgba(244, 164, 96, 0.3); + color: #f4a460; +} + +body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active { + color: #f4a460; + background: rgba(244, 164, 96, 0.15); +} + +/* --- Mobile: Non-game screens --- */ +body.mobile-portrait #lobby-screen { + padding: 50px 12px 15px; + overflow-y: auto; + max-height: 100dvh; +} + +body.mobile-portrait #waiting-screen { + padding: 10px 12px; + overflow-y: auto; + max-height: 100dvh; +} + +/* --- Mobile: Very short screens (e.g. iPhone SE) --- */ +@media (max-height: 600px) { + body.mobile-portrait .opponents-row { + padding: 2px 8px 0; + } + + body.mobile-portrait .opponent-area .card-grid { + grid-template-columns: repeat(3, 26px) !important; + gap: 1px !important; + } + + body.mobile-portrait .opponent-area .card { + width: 26px !important; + height: 36px !important; + font-size: 0.45rem !important; + } + + body.mobile-portrait .table-center { + padding: 3px 8px; + } + + body.mobile-portrait .deck-area > .card, + body.mobile-portrait #deck, + body.mobile-portrait #discard { + width: 50px !important; + height: 70px !important; + font-size: 1.1rem !important; + } + + body.mobile-portrait .held-card-floating { + width: 60px !important; + height: 84px !important; + } + + body.mobile-portrait .player-area { + padding: 3px 5px; + } + + body.mobile-portrait .player-section .card-grid { + grid-template-columns: repeat(3, 60px) !important; + gap: 4px !important; + } + + body.mobile-portrait .player-section .card { + width: 60px !important; + height: 84px !important; + font-size: 1.3rem !important; + } + + body.mobile-portrait .player-area h4 { + font-size: 0.7rem; + padding: 2px 6px; + margin-bottom: 3px; + } +}