diff --git a/README.md b/README.md index dfd665f..7343ce7 100644 --- a/README.md +++ b/README.md @@ -30,32 +30,17 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000 Open `http://localhost:8000` in your browser. -## Game Rules +## How to Play -See [server/RULES.md](server/RULES.md) for complete rules documentation. +**6-Card Golf** is a card game where you try to get the **lowest score** across multiple rounds (holes). -### Basic Scoring +- Each player has 6 cards in a 2×3 grid (most start face-down) +- On your turn: **draw** a card, then **swap** it with one of yours or **discard** it +- **Column pairs** (same rank top & bottom) score **0 points** — very powerful! +- When any player reveals all 6 cards, everyone else gets one final turn +- Lowest total score after all rounds wins -| Card | Points | -|------|--------| -| Ace | 1 | -| 2 | **-2** | -| 3-10 | Face value | -| Jack, Queen | 10 | -| King | **0** | -| Joker | -2 *(optional)* | - -**Column pairs** (same rank in a column) score **0 points**. - -### Turn Structure - -1. Draw from deck OR take from discard pile -2. **If from deck:** Swap with a card OR discard and flip a face-down card -3. **If from discard:** Must swap (cannot re-discard) - -### Ending - -When a player reveals all 6 cards, others get one final turn. Lowest score wins. +**For detailed rules, card values, and house rule explanations, see the in-game Rules page or [server/RULES.md](server/RULES.md).** ## AI Personalities @@ -72,26 +57,14 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins. ## House Rules -### Point Modifiers -- `super_kings` - Kings worth -2 (instead of 0) -- `ten_penny` - 10s worth 1 (instead of 10) -- `lucky_swing` - Single Joker worth -5 -- `eagle_eye` - Paired Jokers score -8 +The game supports 15+ optional house rules including: -### Bonuses & Penalties -- `knock_bonus` - First to go out gets -5 -- `underdog_bonus` - Lowest scorer gets -3 -- `knock_penalty` - +10 if you go out but aren't lowest -- `tied_shame` - +5 penalty for tied scores -- `blackjack` - Score of exactly 21 becomes 0 +- **Flip Modes** - Standard, Speed Golf (must flip after discard), Suspense (optional flip near endgame) +- **Point Modifiers** - Super Kings (-2), Ten Penny (10=1), Lucky Swing Joker (-5) +- **Bonuses & Penalties** - Knock bonus/penalty, Underdog bonus, Tied Shame, Blackjack (21→0) +- **Joker Variants** - Standard, Eagle Eye (paired Jokers = -8) -### Gameplay Options -- `flip_mode` - What happens when discarding from deck: - - `never` - Standard (no flip) - - `always` - Speed Golf (must flip after discard) - - `endgame` - Suspense (optional flip when any player has ≤1 face-down card) -- `use_jokers` - Add Jokers to deck -- `eagle_eye` - Paired Jokers score -8 instead of canceling +See the in-game Rules page or [server/RULES.md](server/RULES.md) for complete explanations. ## Development diff --git a/client/app.js b/client/app.js index 475921c..22bdb7e 100644 --- a/client/app.js +++ b/client/app.js @@ -189,6 +189,7 @@ class GolfGame { this.leaveGameBtn = document.getElementById('leave-game-btn'); this.activeRulesBar = document.getElementById('active-rules-bar'); this.activeRulesList = document.getElementById('active-rules-list'); + this.finalTurnBadge = document.getElementById('final-turn-badge'); } bindEvents() { @@ -1261,10 +1262,22 @@ class GolfGame { if (rules.length === 0) { // Show "Standard Rules" when no variants selected this.activeRulesList.innerHTML = 'Standard'; - } else { + } else if (rules.length <= 2) { + // Show all rules if 2 or fewer this.activeRulesList.innerHTML = rules .map(rule => `${rule}`) .join(''); + } else { + // Show first 2 rules + "+N more" with tooltip + const displayed = rules.slice(0, 2); + const hidden = rules.slice(2); + const moreCount = hidden.length; + const tooltip = hidden.join(', '); + + this.activeRulesList.innerHTML = displayed + .map(rule => `${rule}`) + .join('') + + `+${moreCount} more`; } this.activeRulesBar.classList.remove('hidden'); } @@ -1351,20 +1364,24 @@ class GolfGame { updateStatusFromGameState() { if (!this.gameState) { this.setStatus(''); + this.finalTurnBadge.classList.add('hidden'); return; } const isFinalTurn = this.gameState.phase === 'final_turn'; const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); + // Show/hide final turn badge separately + if (isFinalTurn) { + this.finalTurnBadge.classList.remove('hidden'); + } else { + this.finalTurnBadge.classList.add('hidden'); + } + if (currentPlayer && currentPlayer.id !== this.playerId) { - const prefix = isFinalTurn ? '⚡ Final turn: ' : ''; - this.setStatus(`${prefix}${currentPlayer.name}'s turn`); + this.setStatus(`${currentPlayer.name}'s turn`); } else if (this.isMyTurn()) { - const message = isFinalTurn - ? '⚡ Final turn! Draw a card' - : 'Your turn - draw a card'; - this.setStatus(message, 'your-turn'); + this.setStatus('Your turn - draw a card', 'your-turn'); } else { this.setStatus(''); } @@ -1467,6 +1484,14 @@ class GolfGame { this.currentRoundSpan.textContent = this.gameState.current_round; this.totalRoundsSpan.textContent = this.gameState.total_rounds; + // Show/hide final turn badge + const isFinalTurn = this.gameState.phase === 'final_turn'; + if (isFinalTurn) { + this.finalTurnBadge.classList.remove('hidden'); + } else { + this.finalTurnBadge.classList.add('hidden'); + } + // Update status message (handled by specific actions, but set default here) const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); if (currentPlayer && currentPlayer.id !== this.playerId) { @@ -1487,9 +1512,14 @@ class GolfGame { // Update player name in header (truncate if needed) const displayName = me.name.length > 12 ? me.name.substring(0, 11) + '…' : me.name; const checkmark = me.all_face_up ? ' ✓' : ''; - const crownEmoji = isRoundWinner ? ' 👑' : ''; - // Set text content before the score span - this.playerHeader.childNodes[0].textContent = displayName + checkmark + crownEmoji; + // Remove old crown if exists + const existingCrown = this.playerHeader.querySelector('.winner-crown'); + if (existingCrown) existingCrown.remove(); + // Set content - crown goes at the start + this.playerHeader.firstChild.textContent = displayName + checkmark; + if (isRoundWinner) { + this.playerHeader.insertAdjacentHTML('afterbegin', '👑'); + } } // Update discard pile (skip if holding a drawn card) @@ -1554,10 +1584,10 @@ class GolfGame { const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; const showingScore = this.calculateShowingScore(player.cards); - const crownEmoji = isRoundWinner ? ' 👑' : ''; + const crownHtml = isRoundWinner ? '👑' : ''; div.innerHTML = ` -

${displayName}${player.all_face_up ? ' ✓' : ''}${crownEmoji}${showingScore}

+

${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}

${player.cards.map(card => this.renderCard(card, false, false)).join('')}
@@ -1734,6 +1764,14 @@ class GolfGame { showScoreboard(scores, isFinal, rankings) { this.scoreTable.innerHTML = ''; + // Clear the final turn badge and status message + this.finalTurnBadge.classList.add('hidden'); + if (isFinal) { + this.setStatus('Game Over!'); + } else { + this.setStatus('Hole complete'); + } + // Find round winner(s) - lowest round score (not total) const roundScores = scores.map(s => s.score); const minRoundScore = Math.min(...roundScores); diff --git a/client/index.html b/client/index.html index 0dc4e33..2e08275 100644 --- a/client/index.html +++ b/client/index.html @@ -10,9 +10,8 @@
-

🏌️ Golf

-

6-Card Golf Card Game

- +

🏌️ Golf

+

6-Card Golf Card Game

@@ -105,7 +104,7 @@ What happens when you draw from deck and discard
@@ -219,6 +218,7 @@
+
@@ -292,7 +292,6 @@
-
@@ -399,8 +398,8 @@ TOTAL: 0 + 8 + 16 = 24 points
-

Suspense Mode (Optional Flip Near Endgame)

-

Optional flip activates when any player is close to finishing.

+

Endgame Mode (Flip When Close to Finishing)

+

Flip activates when any player has only 1 hidden card remaining.

How it works:

-

Q: In Suspense mode, when exactly can I flip?

+

Q: In Endgame mode, when exactly can I flip?

A: The optional flip activates the moment ANY player (including you) has 1 or fewer face-down cards remaining. From that point until the round ends, whenever you discard from the deck, you'll get the option to flip or skip.

-

Q: Why would I NOT flip in Suspense mode?

+

Q: Why would I NOT flip in Endgame mode?

A: Maybe you have a hidden card you hope is good, and you don't want to reveal a potential disaster. Or maybe you want to keep your opponents guessing about your score. It's a strategic choice!

diff --git a/client/style.css b/client/style.css index 0bcbefb..34567d3 100644 --- a/client/style.css +++ b/client/style.css @@ -44,6 +44,115 @@ body { text-align: center; } +/* Golf title - golf ball with dimples and shine */ +.golf-title { + font-size: 1.3em; + font-weight: 800; + letter-spacing: 0.02em; + /* Shiny gradient like a golf ball surface */ + background: + /* Dimple pattern - diagonal grid */ + radial-gradient(circle at 3px 3px, rgba(0,0,0,0.18) 2px, transparent 2px), + /* Shiny highlight gradient - whiter */ + linear-gradient( + 135deg, + #ffffff 0%, + #ffffff 25%, + #f5f5f2 50%, + #ffffff 75%, + #f0f0ed 100% + ); + background-size: 10px 10px, 100% 100%; + background-position: 0 0, 0 0; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: + 2px 2px 4px rgba(0, 0, 0, 0.2), + -1px -1px 0 rgba(255, 255, 255, 0.4); + filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15)); +} + +/* Golfer swing animation */ +.golfer-swing { + display: inline-block; + transform: scaleX(-1); + animation: golf-swing 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; + animation-delay: 0.3s; +} + +@keyframes golf-swing { + 0% { + transform: scaleX(-1) translateX(0) rotate(0deg); + } + /* Wind up - pull back leg */ + 30% { + transform: scaleX(-1) translateX(-8px) rotate(-15deg); + } + /* Hold briefly */ + 40% { + transform: scaleX(-1) translateX(-8px) rotate(-15deg); + } + /* KICK! */ + 55% { + transform: scaleX(-1) translateX(8px) rotate(20deg); + } + /* Follow through */ + 80% { + transform: scaleX(-1) translateX(4px) rotate(12deg); + } + /* Final pose - freeze */ + 100% { + transform: scaleX(-1) translateX(3px) rotate(10deg); + } +} + +/* Kicked golf ball - parabolic trajectory */ +.kicked-ball { + display: inline-block; + font-size: 0.2em; + position: relative; + opacity: 0; + animation: ball-kicked 0.7s linear forwards; + animation-delay: 0.72s; +} + +/* Trajectory: y = 0.0124x² - 1.42x (parabola with peak at x=57) */ +@keyframes ball-kicked { + 0% { + transform: translate(-12px, 8px) scale(1); + opacity: 1; + } + 15% { + transform: translate(8px, -16px) scale(1); + opacity: 1; + } + 30% { + transform: translate(28px, -31px) scale(1); + opacity: 1; + } + 45% { + transform: translate(48px, -38px) scale(0.95); + opacity: 1; + } + 55% { + transform: translate(63px, -38px) scale(0.9); + opacity: 1; + } + 70% { + transform: translate(83px, -27px) scale(0.85); + opacity: 0.9; + } + 85% { + transform: translate(103px, -6px) scale(0.75); + opacity: 0.6; + } + 100% { + transform: translate(118px, 25px) scale(0.65); + opacity: 0; + } +} + #lobby-screen .form-group { text-align: left; } @@ -510,6 +619,9 @@ input::placeholder { .header-col-center { justify-content: center; + display: flex; + align-items: center; + gap: 10px; } .header-col-right { @@ -563,7 +675,7 @@ input::placeholder { .active-rules-bar .rules-list { display: flex; gap: 5px; - flex-wrap: wrap; + flex-wrap: nowrap; } .active-rules-bar .rule-tag { @@ -580,6 +692,18 @@ input::placeholder { color: rgba(255, 255, 255, 0.7); } +.active-rules-bar .rule-tag.rule-more { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); + cursor: help; + border: 1px dashed rgba(255, 255, 255, 0.3); +} + +.active-rules-bar .rule-tag.rule-more:hover { + background: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); +} + /* Card Styles */ .card { width: clamp(65px, 5.5vw, 100px); @@ -661,6 +785,16 @@ input::placeholder { box-shadow: 0 0 0 3px #f4a460; } +/* Disable hover effects when not player's turn */ +.not-my-turn .card { + cursor: default; +} + +.not-my-turn .card:hover { + transform: none; + box-shadow: none; +} + .card.selected, .card.clickable.selected { box-shadow: 0 0 0 4px #fff, 0 0 12px 4px #f4a460; @@ -1060,9 +1194,13 @@ input::placeholder { /* Round winner highlight */ .opponent-area.round-winner h4, .player-area.round-winner h4 { - background: rgba(200, 255, 50, 0.6); - box-shadow: 0 0 8px rgba(200, 255, 50, 0.5); - color: #0a2a10; + background: rgba(180, 255, 80, 0.85); + color: #1a1a1a; +} + +.winner-crown { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); + margin-right: 3px; } /* Status message in header */ @@ -1081,6 +1219,31 @@ input::placeholder { color: #1a472a; } +/* Final turn badge - separate indicator */ +.final-turn-badge { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + color: #fff; + padding: 6px 14px; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.05em; + animation: pulse-subtle 2s ease-in-out infinite; +} + +.final-turn-badge.hidden { + display: none; +} + +@keyframes pulse-subtle { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(220, 38, 38, 0); + } +} + @keyframes toastIn { from { opacity: 0; } to { opacity: 1; } @@ -1670,6 +1833,16 @@ input::placeholder { transform: scale(1.02); } +/* Disable hover effects when not player's turn */ +.not-my-turn .real-card { + cursor: default; +} + +.not-my-turn .real-card:hover { + transform: none; +} +} + .real-card.selected { box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460; transform: scale(1.06); @@ -2159,14 +2332,26 @@ input::placeholder { #rules-screen { max-width: 800px; margin: 0 auto; - padding: 20px; + padding: 10px 20px; + width: auto; + margin-left: auto; + margin-right: auto; +} + +#rules-screen.active { + display: block; +} + +#rules-screen h1 { + margin-top: 0; } .rules-container { background: rgba(0, 0, 0, 0.3); border-radius: 12px; - padding: 25px 35px; + padding: 20px 35px; border: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 0; } .rules-container h1 { diff --git a/server/RULES.md b/server/RULES.md index 37f7305..985c75a 100644 --- a/server/RULES.md +++ b/server/RULES.md @@ -272,13 +272,13 @@ The `flip_mode` setting controls what happens when you draw from the deck and ch |-------|------|----------| | `never` | **Standard** | No flip when discarding - your turn ends immediately. This is the classic rule. | | `always` | **Speed Golf** | Must flip one face-down card when discarding. Accelerates the game by revealing more information each turn. | -| `endgame` | **Suspense** | May *optionally* flip if any player has ≤1 face-down card. Creates tension near the end of rounds. | +| `endgame` | **Endgame** | Flip after discard if any player has 1 hidden card remaining. | **Standard (never):** When you draw from the deck and choose not to use the card, simply discard it and your turn ends. **Speed Golf (always):** When you discard from the deck, you must also flip one of your face-down cards. This accelerates the game by revealing more information each turn, leading to faster rounds. -**Suspense (endgame):** When any player has only 1 (or 0) face-down cards remaining, discarding from the deck gives you the *option* to flip a card. This creates tension near the end of rounds - do you reveal more to improve your position, or keep your cards hidden? +**Endgame:** When any player has only 1 (or 0) face-down cards remaining, discarding from the deck triggers a flip. This accelerates the endgame by revealing more information as rounds approach their conclusion. | Implementation | File | |----------------|------| diff --git a/server/game.py b/server/game.py index d27f625..ae36900 100644 --- a/server/game.py +++ b/server/game.py @@ -1066,7 +1066,7 @@ class Game: if self.options.flip_mode == FlipMode.ALWAYS.value: active_rules.append("Speed Golf") elif self.options.flip_mode == FlipMode.ENDGAME.value: - active_rules.append("Suspense") + active_rules.append("Endgame Flip") if self.options.knock_penalty: active_rules.append("Knock Penalty") if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye: diff --git a/server/games.db b/server/games.db index acef4c7..8a2a544 100644 Binary files a/server/games.db and b/server/games.db differ