5 Commits

Author SHA1 Message Date
adlee-was-taken
4664aae8aa Bump version to 2.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:31:39 -05:00
adlee-was-taken
a5d108f4f2 Add animation system documentation and project context
- client/ANIMATIONS.md: Full documentation of the CardAnimations API, timing config, CSS rules, and common patterns
- CLAUDE.md: Project context for AI assistants with architecture overview and development guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:14:04 -05:00
adlee-was-taken
df422907b0 Speed up animations and reduce CPU turn delays
- Reduce move animation durations by 40% for snappier card movement
- Widen and slow down turn indicator shake for better visibility
- Cut CPU turn delays significantly:
  - Pre-turn pause: 0.6s → 0.25s
  - Initial look: 0.6-0.9s → 0.3-0.5s
  - Post-draw settle: 0.9s → 0.5s
  - Post-draw consider: 0.6-0.9s → 0.3-0.6s
  - Post-action pause: 0.6-0.9s → 0.3-0.5s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:06:17 -05:00
adlee-was-taken
bc1b1b7725 Migrate animation system to unified anime.js framework
- Replace CSS transitions with anime.js for all card animations
- Create card-animations.js as single source for all animation logic
- Remove draw-animations.js (merged into card-animations.js)
- Strip CSS transitions from card elements to prevent conflicts
- Fix held card appearing before draw animation completes
- Make opponent/CPU animations match local player behavior
- Add subtle shake effect for turn indicator (replaces brightness pulse)
- Speed up flip animations by 30% for snappier feel
- Remove unnecessary pulse effects after draws/swaps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:57:53 -05:00
adlee-was-taken
7b64b8c17c Timing and animation changes for a more natural feeling game with CPU opps.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:22:54 -05:00
12 changed files with 2692 additions and 464 deletions

134
CLAUDE.md Normal file
View File

@@ -0,0 +1,134 @@
# Golf Card Game - Project Context
A real-time multiplayer 6-card Golf card game with CPU opponents and smooth anime.js animations.
## Quick Start
```bash
# Install dependencies
pip install -r server/requirements.txt
# Run the server
python server/main.py
# Visit http://localhost:5000
```
## Architecture
```
golfgame/
├── server/ # Python FastAPI backend
│ ├── main.py # HTTP routes, WebSocket handling
│ ├── game.py # Game logic, state machine
│ └── ai.py # CPU opponent AI with timing/personality
├── client/ # Vanilla JS frontend
│ ├── app.js # Main game controller
│ ├── card-animations.js # Unified anime.js animation system
│ ├── card-manager.js # DOM management for cards
│ ├── animation-queue.js # Animation sequencing
│ ├── timing-config.js # Centralized timing configuration
│ ├── state-differ.js # Diff game state for animations
│ ├── style.css # Styles (NO card transitions)
│ └── ANIMATIONS.md # Animation system documentation
└── docs/v3/ # Feature planning documents
```
## Key Technical Decisions
### Animation System
**All card animations use anime.js.** No CSS transitions on card elements.
- See `client/ANIMATIONS.md` for full documentation
- `CardAnimations` class in `card-animations.js` handles everything
- Timing configured in `timing-config.js`
### State Management
- Server is source of truth
- Client receives full game state on each update
- `state-differ.js` computes diffs to trigger appropriate animations
- `isDrawAnimating` flag prevents UI updates during animations
### CPU Players
- AI logic in `server/ai.py`
- Configurable timing delays for natural feel
- Multiple personality types affect decision-making
## Common Development Tasks
### Adjusting Animation Speed
Edit `timing-config.js` - all timings are centralized there.
### Adding New Animations
1. Add method to `CardAnimations` class in `card-animations.js`
2. Use anime.js, not CSS transitions
3. Track in `activeAnimations` Map for cancellation support
4. Add timing config to `timing-config.js` if needed
### Debugging Animations
```javascript
// Check what's animating
console.log(window.cardAnimations.activeAnimations);
// Force cleanup
window.cardAnimations.cancelAll();
// Check timing config
console.log(window.TIMING);
```
### Testing CPU Behavior
Adjust delays in `server/ai.py` `CPU_TIMING` dict.
## Important Patterns
### No CSS Transitions on Cards
Cards animate via anime.js only. The following should NOT have `transition`:
- `.card`, `.card-inner`
- `.real-card`, `.swap-card`
- `.held-card-floating`
### Animation Overlays
Complex animations create temporary overlay elements:
1. Create `.draw-anim-card` positioned over source
2. Hide original card
3. Animate overlay
4. Remove overlay, reveal updated card
### Fire-and-Forget for Opponents
Opponent animations don't block - no callbacks needed:
```javascript
cardAnimations.animateOpponentFlip(cardElement, cardData);
```
## Dependencies
### Server
- FastAPI
- uvicorn
- websockets
### Client
- anime.js (animations)
- No other frameworks
## Game Rules Reference
- 6 cards per player in 2x3 grid
- Lower score wins
- Matching columns cancel out (0 points)
- Jokers are -2 points
- Kings are 0 points
- Game ends when a player flips all cards

261
client/ANIMATIONS.md Normal file
View File

@@ -0,0 +1,261 @@
# Card Animation System
This document describes the unified animation system for the Golf card game client.
## Architecture
**All card animations use anime.js.** There are no CSS transitions on card elements.
| What | How |
|------|-----|
| Card movements | anime.js |
| Card flips | anime.js |
| Swap animations | anime.js |
| Pulse/glow effects | anime.js |
| Hover states | CSS `:hover` only |
| Show/hide | CSS `.hidden` class only |
### Why anime.js?
- Consistent timing and easing across all animations
- Coordinated multi-element sequences via timelines
- Proper animation cancellation via `activeAnimations` tracking
- No conflicts between CSS and JS animation systems
---
## Core Files
| File | Purpose |
|------|---------|
| `card-animations.js` | Unified `CardAnimations` class - all animation logic |
| `timing-config.js` | Centralized timing/easing configuration |
| `style.css` | Static styles only (no transitions on cards) |
---
## CardAnimations Class API
Global instance available at `window.cardAnimations`.
### Draw Animations
```javascript
// Draw from deck - lift, move to hold area, flip to reveal
cardAnimations.animateDrawDeck(cardData, onComplete)
// Draw from discard - quick grab, no flip
cardAnimations.animateDrawDiscard(cardData, onComplete)
// For opponent draw-then-discard - deck to discard with flip
cardAnimations.animateDeckToDiscard(card, onComplete)
```
### Flip Animations
```javascript
// Generic flip animation on any card element
cardAnimations.animateFlip(element, cardData, onComplete)
// Initial flip at game start (local player)
cardAnimations.animateInitialFlip(cardElement, cardData, onComplete)
// Opponent card flip (fire-and-forget)
cardAnimations.animateOpponentFlip(cardElement, cardData, rotation)
```
### Swap Animations
```javascript
// Player swaps drawn card with hand card
cardAnimations.animateSwap(position, oldCard, newCard, handCardElement, onComplete)
// Opponent swap (fire-and-forget)
cardAnimations.animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation, wasFaceUp)
```
### Discard Animations
```javascript
// Animate held card swooping to discard pile
cardAnimations.animateDiscard(heldCardElement, targetCard, onComplete)
```
### Ambient Effects (Looping)
```javascript
// "Your turn to draw" shake effect
cardAnimations.startTurnPulse(element)
cardAnimations.stopTurnPulse(element)
// CPU thinking glow
cardAnimations.startCpuThinking(element)
cardAnimations.stopCpuThinking(element)
// Initial flip phase - clickable cards glow
cardAnimations.startInitialFlipPulse(element)
cardAnimations.stopInitialFlipPulse(element)
cardAnimations.stopAllInitialFlipPulses()
```
### One-Shot Effects
```javascript
// Pulse when card lands on discard
cardAnimations.pulseDiscard()
// Pulse effect on face-up swap
cardAnimations.pulseSwap(element)
// Pop-in when element appears (use sparingly)
cardAnimations.popIn(element)
// Gold ring expanding effect before draw
cardAnimations.startDrawPulse(element)
```
### Utility Methods
```javascript
// Check if animation is in progress
cardAnimations.isBusy()
// Cancel all running animations
cardAnimations.cancel()
cardAnimations.cancelAll()
// Clean up animation elements
cardAnimations.cleanup()
```
---
## Animation Overlay Pattern
For complex animations (flips, swaps), the system:
1. Creates a temporary overlay element (`.draw-anim-card`)
2. Positions it exactly over the source card
3. Hides the original card (`opacity: 0` or `.swap-out`)
4. Animates the overlay
5. Removes overlay and reveals updated original card
This ensures smooth animations without modifying the DOM structure of game cards.
---
## Timing Configuration
All timing values are in `timing-config.js` and exposed as `window.TIMING`.
### Key Durations
| Animation | Duration | Notes |
|-----------|----------|-------|
| Flip | 245ms | 3D rotateY animation |
| Deck lift | 63ms | Before moving to hold |
| Deck move | 105ms | To hold position |
| Discard lift | 25ms | Quick grab |
| Discard move | 76ms | To hold position |
| Swap pulse | 400ms | Scale + brightness |
| Turn shake | 400ms | Every 3 seconds |
### Easing Functions
```javascript
window.TIMING.anime.easing = {
flip: 'easeInOutQuad', // Smooth acceleration/deceleration
move: 'easeOutCubic', // Fast start, gentle settle
lift: 'easeOutQuad', // Quick lift
pulse: 'easeInOutSine', // Smooth oscillation
}
```
---
## CSS Rules
### What CSS Does
- Static card appearance (colors, borders, sizing)
- Layout and positioning
- Hover states (`:hover` scale/shadow)
- Show/hide via `.hidden` class
### What CSS Does NOT Do
- No `transition` on any card element
- No `@keyframes` for card animations
- No `.flipped`, `.moving`, `.flipping` transition triggers
### Important Classes
| Class | Purpose |
|-------|---------|
| `.draw-anim-card` | Temporary overlay during animation |
| `.draw-anim-inner` | 3D flip container |
| `.swap-out` | Hides original during swap animation |
| `.hidden` | Opacity 0, no display change |
| `.draw-pulse` | Gold ring expanding effect |
---
## Common Patterns
### Preventing Premature UI Updates
The `isDrawAnimating` flag in `app.js` prevents the held card from appearing before the draw animation completes:
```javascript
// In renderGame()
if (!this.isDrawAnimating && /* other conditions */) {
// Show held card
}
```
### Animation Sequencing
Use anime.js timelines for coordinated sequences:
```javascript
const timeline = anime.timeline({
easing: 'easeOutQuad',
complete: () => { /* cleanup */ }
});
timeline.add({ targets: el, translateY: -15, duration: 100 });
timeline.add({ targets: el, left: x, top: y, duration: 200 });
timeline.add({ targets: inner, rotateY: 0, duration: 245 });
```
### Fire-and-Forget Animations
For opponent/CPU animations that don't block game flow:
```javascript
// No onComplete callback needed
cardAnimations.animateOpponentFlip(cardElement, cardData);
```
---
## Debugging
### Check Active Animations
```javascript
console.log(window.cardAnimations.activeAnimations);
```
### Force Cleanup
```javascript
window.cardAnimations.cancelAll();
```
### Animation Not Working?
1. Check that anime.js is loaded before card-animations.js
2. Verify element exists and is visible
3. Check for CSS transitions that might conflict
4. Look for errors in console

View File

@@ -11,16 +11,23 @@ class AnimationQueue {
this.processing = false;
this.animationInProgress = false;
// Timing configuration (ms)
// Rhythm: action → settle → action → breathe
// Timing configuration (ms) - use centralized TIMING config
const T = window.TIMING || {};
this.timing = {
flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
moveDuration: 270,
pauseAfterFlip: 144, // Brief settle after flip before move
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
pauseBeforeNewCard: 150, // Anticipation before new card moves in
pauseAfterSwapComplete: 400, // Breathing room after swap completes
pauseBetweenAnimations: 90
flipDuration: T.card?.flip || 540,
moveDuration: T.card?.move || 270,
cardLift: T.card?.lift || 100,
pauseAfterFlip: T.pause?.afterFlip || 144,
pauseAfterDiscard: T.pause?.afterDiscard || 550,
pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
pauseAfterSwapComplete: T.pause?.afterSwapComplete || 400,
pauseBetweenAnimations: T.pause?.betweenAnimations || 90,
pauseBeforeFlip: T.pause?.beforeFlip || 50,
// Beat timing
beatBase: T.beat?.base || 1000,
beatVariance: T.beat?.variance || 200,
fadeOut: T.beat?.fadeOut || 300,
fadeIn: T.beat?.fadeIn || 300,
};
}
@@ -124,7 +131,7 @@ class AnimationQueue {
// Animate the flip
this.playSound('flip');
await this.delay(50); // Brief pause before flip
await this.delay(this.timing.pauseBeforeFlip);
// Remove flipped to trigger animation to front
inner.classList.remove('flipped');
@@ -136,11 +143,10 @@ class AnimationQueue {
animCard.remove();
}
// Animate a card swap (hand card to discard, drawn card to hand)
// Animate a card swap - smooth continuous motion
async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
@@ -149,67 +155,54 @@ class AnimationQueue {
return;
}
// Create a temporary card element for the animation
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
// Create animation cards
const handCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(handCard);
this.setCardPosition(handCard, slotRect);
// Position at slot
this.setCardPosition(animCard, slotRect);
const handInner = handCard.querySelector('.card-inner');
const handFront = handCard.querySelector('.card-face-front');
// Start face down (showing back)
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
inner.classList.add('flipped');
const heldCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(heldCard);
this.setCardPosition(heldCard, holdingRect || discardRect);
// Step 1: If card was face down, flip to reveal it
this.setCardFront(front, oldCard);
const heldInner = heldCard.querySelector('.card-inner');
const heldFront = heldCard.querySelector('.card-face-front');
// Set up initial state
this.setCardFront(handFront, oldCard);
if (!oldCard.face_up) {
handInner.classList.add('flipped');
}
this.setCardFront(heldFront, newCard);
heldInner.classList.remove('flipped');
// Step 1: If face-down, flip to reveal
if (!oldCard.face_up) {
this.playSound('flip');
inner.classList.remove('flipped');
handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
} else {
// Already face up, just show it immediately
inner.classList.remove('flipped');
}
// Step 2: Move card to discard pile
// Step 2: Quick crossfade swap
handCard.classList.add('fade-out');
heldCard.classList.add('fade-out');
await this.delay(150);
this.setCardPosition(handCard, discardRect);
this.setCardPosition(heldCard, slotRect);
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
handCard.classList.remove('fade-out');
heldCard.classList.remove('fade-out');
handCard.classList.add('fade-in');
heldCard.classList.add('fade-in');
await this.delay(150);
// Let discard land and pulse settle
await this.delay(this.timing.pauseAfterDiscard);
// Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(newAnimCard);
// New card starts at holding/discard position
this.setCardPosition(newAnimCard, holdingRect || discardRect);
const newInner = newAnimCard.querySelector('.card-inner');
const newFront = newAnimCard.querySelector('.card-face-front');
// Show new card (it's face up from the drawn card)
this.setCardFront(newFront, newCard);
newInner.classList.remove('flipped');
// Brief anticipation before new card moves
await this.delay(this.timing.pauseBeforeNewCard);
// Step 4: Move new card to the hand slot
this.playSound('card');
newAnimCard.classList.add('moving');
this.setCardPosition(newAnimCard, slotRect);
await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving');
// Breathing room after swap completes
await this.delay(this.timing.pauseAfterSwapComplete);
animCard.remove();
newAnimCard.remove();
// Clean up
handCard.remove();
heldCard.remove();
}
// Create a temporary animation card element
@@ -337,22 +330,47 @@ class AnimationQueue {
animCard.remove();
}
// Animate drawing from discard
// Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) {
const { playerId } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const { card } = movement;
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state
this.playSound('card');
// Create animation card at discard position (face UP - visible card)
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, discardRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Show the card face (discard is always visible)
if (card) {
this.setCardFront(front, card);
}
inner.classList.remove('flipped'); // Face up
// Lift effect before moving - card rises slightly
animCard.style.transform = 'translateY(-8px) scale(1.05)';
animCard.style.transition = `transform ${this.timing.cardLift}ms ease-out`;
await this.delay(this.timing.cardLift);
// Move to holding position
this.playSound('card');
animCard.classList.add('moving');
animCard.style.transform = '';
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Brief settle before state updates
await this.delay(this.timing.pauseBeforeNewCard);
// Clean up - renderGame will show the holding card state
animCard.remove();
}
// Check if animations are currently playing

8
client/anime.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

876
client/card-animations.js Normal file
View File

@@ -0,0 +1,876 @@
// CardAnimations - Unified anime.js-based animation system
// Replaces draw-animations.js and handles ALL card animations
class CardAnimations {
constructor() {
this.activeAnimations = new Map();
this.isAnimating = false;
this.cleanupTimeout = null;
}
// === UTILITY METHODS ===
getDeckRect() {
const deck = document.getElementById('deck');
return deck ? deck.getBoundingClientRect() : null;
}
getDiscardRect() {
const discard = document.getElementById('discard');
return discard ? discard.getBoundingClientRect() : null;
}
getHoldingRect() {
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) return null;
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
const cardWidth = deckRect.width;
const cardHeight = deckRect.height;
const overlapOffset = cardHeight * 0.35;
return {
left: centerX - cardWidth / 2,
top: deckRect.top - overlapOffset,
width: cardWidth,
height: cardHeight
};
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
isRedSuit(suit) {
return suit === 'hearts' || suit === 'diamonds';
}
playSound(type) {
if (window.game && typeof window.game.playSound === 'function') {
window.game.playSound(type);
}
}
getEasing(type) {
const easings = window.TIMING?.anime?.easing || {};
return easings[type] || 'easeOutQuad';
}
// Create animated card element with 3D flip structure
createAnimCard(rect, showBack = false, deckColor = null) {
const card = document.createElement('div');
card.className = 'draw-anim-card';
card.innerHTML = `
<div class="draw-anim-inner">
<div class="draw-anim-front card card-front"></div>
<div class="draw-anim-back card card-back"></div>
</div>
`;
document.body.appendChild(card);
// Apply deck color to back
if (deckColor) {
const back = card.querySelector('.draw-anim-back');
back.classList.add(`back-${deckColor}`);
}
if (showBack) {
card.querySelector('.draw-anim-inner').style.transform = 'rotateY(180deg)';
}
if (rect) {
card.style.left = rect.left + 'px';
card.style.top = rect.top + 'px';
card.style.width = rect.width + 'px';
card.style.height = rect.height + 'px';
}
return card;
}
setCardContent(card, cardData) {
const front = card.querySelector('.draw-anim-front');
if (!front) return;
front.className = 'draw-anim-front card card-front';
if (!cardData) return;
if (cardData.rank === '★') {
front.classList.add('joker');
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = this.isRedSuit(cardData.suit);
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
getDeckColor() {
if (window.game?.gameState?.deck_colors) {
const deckId = window.game.gameState.deck_top_deck_id || 0;
return window.game.gameState.deck_colors[deckId] || window.game.gameState.deck_colors[0];
}
return null;
}
cleanup() {
document.querySelectorAll('.draw-anim-card').forEach(el => el.remove());
this.isAnimating = false;
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}
}
cancelAll() {
// Cancel all tracked anime.js animations
for (const [id, anim] of this.activeAnimations) {
if (anim && typeof anim.pause === 'function') {
anim.pause();
}
}
this.activeAnimations.clear();
this.cleanup();
}
// === DRAW ANIMATIONS ===
// Draw from deck with suspenseful reveal
animateDrawDeck(cardData, onComplete) {
this.cleanup();
const deckRect = this.getDeckRect();
const holdingRect = this.getHoldingRect();
if (!deckRect || !holdingRect) {
if (onComplete) onComplete();
return;
}
this.isAnimating = true;
// Pulse the deck before drawing
this.startDrawPulse(document.getElementById('deck'));
// Delay card animation to let pulse be visible
setTimeout(() => {
this._animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete);
}, 250);
}
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
const inner = animCard.querySelector('.draw-anim-inner');
if (cardData) {
this.setCardContent(animCard, cardData);
}
this.playSound('card');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
this.cleanup();
if (onComplete) onComplete();
}, 1500);
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.cleanup();
if (onComplete) onComplete();
}
});
// Lift off deck with slight wobble
timeline.add({
targets: animCard,
translateY: -15,
rotate: [-2, 0],
duration: 63,
easing: this.getEasing('lift')
});
// Move to holding position
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
duration: 105,
easing: this.getEasing('move')
});
// Suspense pause
timeline.add({ duration: 200 });
// Flip to reveal
if (cardData) {
timeline.add({
targets: inner,
rotateY: 0,
duration: 245,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip')
});
}
// Brief pause to see card
timeline.add({ duration: 150 });
this.activeAnimations.set('drawDeck', timeline);
} catch (e) {
console.error('Draw animation error:', e);
this.cleanup();
if (onComplete) onComplete();
}
}
// Draw from discard (quick decisive grab, no flip)
animateDrawDiscard(cardData, onComplete) {
this.cleanup();
const discardRect = this.getDiscardRect();
const holdingRect = this.getHoldingRect();
if (!discardRect || !holdingRect) {
if (onComplete) onComplete();
return;
}
this.isAnimating = true;
// Pulse discard pile
this.startDrawPulse(document.getElementById('discard'));
setTimeout(() => {
this._animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete);
}, 200);
}
_animateDrawDiscardCard(cardData, discardRect, holdingRect, onComplete) {
const animCard = this.createAnimCard(discardRect, false);
this.setCardContent(animCard, cardData);
this.playSound('card');
// Failsafe cleanup
this.cleanupTimeout = setTimeout(() => {
this.cleanup();
if (onComplete) onComplete();
}, 600);
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.cleanup();
if (onComplete) onComplete();
}
});
// Quick decisive lift
timeline.add({
targets: animCard,
translateY: -12,
scale: 1.05,
duration: 25
});
// Direct move to holding
timeline.add({
targets: animCard,
left: holdingRect.left,
top: holdingRect.top,
translateY: 0,
scale: 1,
duration: 76
});
// Minimal pause
timeline.add({ duration: 80 });
this.activeAnimations.set('drawDiscard', timeline);
} catch (e) {
console.error('Draw animation error:', e);
this.cleanup();
if (onComplete) onComplete();
}
}
// === FLIP ANIMATIONS ===
// Animate flipping a card element
animateFlip(element, cardData, onComplete) {
if (!element) {
if (onComplete) onComplete();
return;
}
const inner = element.querySelector('.card-inner');
if (!inner) {
if (onComplete) onComplete();
return;
}
const duration = 245; // 30% faster flip
try {
const anim = anime({
targets: inner,
rotateY: [180, 0],
duration: duration,
easing: this.getEasing('flip'),
begin: () => {
this.playSound('flip');
inner.classList.remove('flipped');
},
complete: () => {
if (onComplete) onComplete();
}
});
this.activeAnimations.set(`flip-${Date.now()}`, anim);
} catch (e) {
console.error('Flip animation error:', e);
inner.classList.remove('flipped');
if (onComplete) onComplete();
}
}
// Animate initial flip at game start - smooth flip only, no lift
animateInitialFlip(cardElement, cardData, onComplete) {
if (!cardElement) {
if (onComplete) onComplete();
return;
}
const rect = cardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
// Create overlay card for flip animation
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData);
// Hide original card during animation
cardElement.style.opacity = '0';
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
try {
// Simple smooth flip - no lift/settle
anime({
targets: inner,
rotateY: 0,
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
complete: () => {
animCard.remove();
cardElement.style.opacity = '1';
if (onComplete) onComplete();
}
});
this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} });
} catch (e) {
console.error('Initial flip animation error:', e);
animCard.remove();
cardElement.style.opacity = '1';
if (onComplete) onComplete();
}
}
// Fire-and-forget flip for opponent cards
animateOpponentFlip(cardElement, cardData, rotation = 0) {
if (!cardElement) return;
const rect = cardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout
if (rotation) {
animCard.style.transform = `rotate(${rotation}deg)`;
}
cardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const duration = 245; // 30% faster flip
try {
anime({
targets: inner,
rotateY: 0,
duration: duration,
easing: this.getEasing('flip'),
begin: () => this.playSound('flip'),
complete: () => {
animCard.remove();
cardElement.classList.remove('swap-out');
}
});
} catch (e) {
console.error('Opponent flip animation error:', e);
animCard.remove();
cardElement.classList.remove('swap-out');
}
}
// === SWAP ANIMATIONS ===
// Animate player swapping drawn card with hand card
animateSwap(position, oldCard, newCard, handCardElement, onComplete) {
if (!handCardElement) {
if (onComplete) onComplete();
return;
}
const isAlreadyFaceUp = oldCard?.face_up;
if (isAlreadyFaceUp) {
// Face-up swap: subtle pulse, no flip needed
this._animateFaceUpSwap(handCardElement, onComplete);
} else {
// Face-down swap: flip reveal then swap
this._animateFaceDownSwap(position, oldCard, handCardElement, onComplete);
}
}
_animateFaceUpSwap(handCardElement, onComplete) {
this.playSound('card');
// Apply swap pulse via anime.js
try {
const timeline = anime.timeline({
easing: 'easeOutQuad',
complete: () => {
if (onComplete) onComplete();
}
});
timeline.add({
targets: handCardElement,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
this.activeAnimations.set(`swapPulse-${Date.now()}`, timeline);
} catch (e) {
console.error('Face-up swap animation error:', e);
if (onComplete) onComplete();
}
}
_animateFaceDownSwap(position, oldCard, handCardElement, onComplete) {
const rect = handCardElement.getBoundingClientRect();
const discardRect = this.getDiscardRect();
const deckColor = this.getDeckColor();
// Create animated card at hand position
const animCard = this.createAnimCard(rect, true, deckColor);
// Set content to show what's being revealed (the OLD card going to discard)
if (oldCard) {
this.setCardContent(animCard, oldCard);
}
handCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
try {
const timeline = anime.timeline({
easing: this.getEasing('flip'),
complete: () => {
animCard.remove();
handCardElement.classList.remove('swap-out');
if (onComplete) onComplete();
}
});
// Flip to reveal old card
timeline.add({
targets: inner,
rotateY: 0,
duration: flipDuration,
begin: () => this.playSound('flip')
});
// Brief pause to see the card
timeline.add({ duration: 100 });
this.activeAnimations.set(`swap-${Date.now()}`, timeline);
} catch (e) {
console.error('Face-down swap animation error:', e);
animCard.remove();
handCardElement.classList.remove('swap-out');
if (onComplete) onComplete();
}
}
// Fire-and-forget opponent swap animation
animateOpponentSwap(playerId, position, discardCard, sourceCardElement, rotation = 0, wasFaceUp = false) {
if (wasFaceUp && sourceCardElement) {
// Face-to-face swap: just pulse
this.pulseSwap(sourceCardElement);
return;
}
if (!sourceCardElement) return;
const rect = sourceCardElement.getBoundingClientRect();
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
this.setCardContent(animCard, discardCard);
if (rotation) {
animCard.style.transform = `rotate(${rotation}deg)`;
}
sourceCardElement.classList.add('swap-out');
const inner = animCard.querySelector('.draw-anim-inner');
const flipDuration = 245; // 30% faster flip
try {
anime.timeline({
easing: this.getEasing('flip'),
complete: () => {
animCard.remove();
this.pulseDiscard();
}
})
.add({
targets: inner,
rotateY: 0,
duration: flipDuration,
begin: () => this.playSound('flip')
});
} catch (e) {
console.error('Opponent swap animation error:', e);
animCard.remove();
}
}
// === DISCARD ANIMATIONS ===
// Animate held card swooping to discard pile
animateDiscard(heldCardElement, targetCard, onComplete) {
if (!heldCardElement) {
if (onComplete) onComplete();
return;
}
const discardRect = this.getDiscardRect();
if (!discardRect) {
if (onComplete) onComplete();
return;
}
this.playSound('card');
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
this.pulseDiscard();
if (onComplete) onComplete();
}
});
timeline.add({
targets: heldCardElement,
left: discardRect.left,
top: discardRect.top,
width: discardRect.width,
height: discardRect.height,
scale: 1,
duration: 350,
easing: 'cubicBezier(0.25, 0.1, 0.25, 1)'
});
this.activeAnimations.set(`discard-${Date.now()}`, timeline);
} catch (e) {
console.error('Discard animation error:', e);
if (onComplete) onComplete();
}
}
// Animate deck draw then immediate discard (for draw-discard by other players)
animateDeckToDiscard(card, onComplete) {
const deckRect = this.getDeckRect();
const discardRect = this.getDiscardRect();
if (!deckRect || !discardRect) {
if (onComplete) onComplete();
return;
}
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(deckRect, true, deckColor);
this.setCardContent(animCard, card);
const inner = animCard.querySelector('.draw-anim-inner');
const moveDuration = window.TIMING?.card?.move || 270;
try {
const timeline = anime.timeline({
easing: this.getEasing('move'),
complete: () => {
animCard.remove();
this.pulseDiscard();
if (onComplete) onComplete();
}
});
// Small delay
timeline.add({ duration: 50 });
// Move to discard while flipping
timeline.add({
targets: animCard,
left: discardRect.left,
top: discardRect.top,
duration: moveDuration,
begin: () => this.playSound('card')
});
timeline.add({
targets: inner,
rotateY: 0,
duration: moveDuration * 0.8,
easing: this.getEasing('flip')
}, `-=${moveDuration * 0.6}`);
this.activeAnimations.set(`deckToDiscard-${Date.now()}`, timeline);
} catch (e) {
console.error('Deck to discard animation error:', e);
animCard.remove();
if (onComplete) onComplete();
}
}
// === AMBIENT EFFECTS (looping) ===
// Your turn to draw - quick rattlesnake shake every few seconds
startTurnPulse(element) {
if (!element) return;
const id = 'turnPulse';
this.stopTurnPulse(element);
// Quick shake animation
const doShake = () => {
if (!this.activeAnimations.has(id)) return;
anime({
targets: element,
translateX: [0, -8, 8, -6, 4, 0],
duration: 400,
easing: 'easeInOutQuad'
});
};
// Do initial shake, then repeat every 3 seconds
doShake();
const interval = setInterval(doShake, 3000);
this.activeAnimations.set(id, { interval });
}
stopTurnPulse(element) {
const id = 'turnPulse';
const existing = this.activeAnimations.get(id);
if (existing) {
if (existing.interval) clearInterval(existing.interval);
if (existing.pause) existing.pause();
this.activeAnimations.delete(id);
}
if (element) {
anime.remove(element);
element.style.transform = '';
}
}
// CPU thinking - glow on discard pile
startCpuThinking(element) {
if (!element) return;
const id = 'cpuThinking';
this.stopCpuThinking(element);
const config = window.TIMING?.anime?.loop?.cpuThinking || { duration: 1500 };
try {
const anim = anime({
targets: element,
boxShadow: [
'0 4px 12px rgba(0,0,0,0.3)',
'0 4px 12px rgba(0,0,0,0.3), 0 0 18px rgba(59, 130, 246, 0.5)',
'0 4px 12px rgba(0,0,0,0.3)'
],
duration: config.duration,
easing: 'easeInOutSine',
loop: true
});
this.activeAnimations.set(id, anim);
} catch (e) {
console.error('CPU thinking animation error:', e);
}
}
stopCpuThinking(element) {
const id = 'cpuThinking';
const existing = this.activeAnimations.get(id);
if (existing) {
existing.pause();
this.activeAnimations.delete(id);
}
if (element) {
anime.remove(element);
element.style.boxShadow = '';
}
}
// Initial flip phase - clickable cards glow
startInitialFlipPulse(element) {
if (!element) return;
const id = `initialFlipPulse-${element.dataset.position || Date.now()}`;
const config = window.TIMING?.anime?.loop?.initialFlipGlow || { duration: 1500 };
try {
const anim = anime({
targets: element,
boxShadow: [
'0 0 0 2px rgba(244, 164, 96, 0.5)',
'0 0 0 4px rgba(244, 164, 96, 0.8), 0 0 15px rgba(244, 164, 96, 0.4)',
'0 0 0 2px rgba(244, 164, 96, 0.5)'
],
duration: config.duration,
easing: 'easeInOutSine',
loop: true
});
this.activeAnimations.set(id, anim);
} catch (e) {
console.error('Initial flip pulse animation error:', e);
}
}
stopInitialFlipPulse(element) {
if (!element) return;
const id = `initialFlipPulse-${element.dataset.position || ''}`;
// Try to find and stop any matching animation
for (const [key, anim] of this.activeAnimations) {
if (key.startsWith('initialFlipPulse')) {
anim.pause();
this.activeAnimations.delete(key);
}
}
anime.remove(element);
element.style.boxShadow = '';
}
stopAllInitialFlipPulses() {
for (const [key, anim] of this.activeAnimations) {
if (key.startsWith('initialFlipPulse')) {
anim.pause();
this.activeAnimations.delete(key);
}
}
}
// === ONE-SHOT EFFECTS ===
// Pulse when card lands on discard
pulseDiscard() {
const discard = document.getElementById('discard');
if (!discard) return;
const duration = window.TIMING?.feedback?.discardLand || 375;
try {
anime({
targets: discard,
scale: [1, 1.08, 1],
duration: duration,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Discard pulse error:', e);
}
}
// Pulse effect on swap
pulseSwap(element) {
if (!element) return;
this.playSound('card');
try {
anime({
targets: element,
scale: [1, 0.92, 1.08, 1],
filter: ['brightness(1)', 'brightness(0.85)', 'brightness(1.15)', 'brightness(1)'],
duration: 400,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Swap pulse error:', e);
}
}
// Pop-in effect when card appears
popIn(element) {
if (!element) return;
try {
anime({
targets: element,
scale: [0.5, 1.25, 1.15],
opacity: [0, 1, 1],
duration: 300,
easing: 'easeOutQuad'
});
} catch (e) {
console.error('Pop-in error:', e);
}
}
// Draw pulse effect (gold ring expanding)
startDrawPulse(element) {
if (!element) return;
element.classList.add('draw-pulse');
setTimeout(() => {
element.classList.remove('draw-pulse');
}, 450);
}
// === HELPER METHODS ===
isBusy() {
return this.isAnimating;
}
cancel() {
this.cancelAll();
}
}
// Create global instance
window.cardAnimations = new CardAnimations();
// Backwards compatibility - point drawAnimations to the new system
window.drawAnimations = window.cardAnimations;

View File

@@ -78,12 +78,13 @@
<h3>Game Settings</h3>
<div class="basic-settings-row">
<div class="form-group">
<label for="num-decks">Decks</label>
<select id="num-decks">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<label>Decks</label>
<div class="stepper-control">
<button type="button" id="decks-minus" class="stepper-btn"></button>
<span id="num-decks-display" class="stepper-value">1</span>
<input type="hidden" id="num-decks" value="1">
<button type="button" id="decks-plus" class="stepper-btn">+</button>
</div>
</div>
<div class="form-group">
<label for="num-rounds">Holes</label>
@@ -94,13 +95,36 @@
<option value="1">1</option>
</select>
</div>
<div class="form-group">
<label for="initial-flips">Cards Revealed</label>
<select id="initial-flips">
<option value="2" selected>2 cards</option>
<option value="1">1 card</option>
<option value="0">None</option>
<div id="deck-colors-group" class="form-group">
<label for="deck-color-preset">Card Backs</label>
<div class="deck-color-selector">
<select id="deck-color-preset">
<optgroup label="Themes">
<option value="classic" selected>Classic</option>
<option value="ninja">Ninja Turtles</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="berry">Berry</option>
<option value="neon">Neon</option>
<option value="royal">Royal</option>
<option value="earth">Earth</option>
</optgroup>
<optgroup label="Single Color">
<option value="all-red">All Red</option>
<option value="all-blue">All Blue</option>
<option value="all-green">All Green</option>
<option value="all-gold">All Gold</option>
<option value="all-purple">All Purple</option>
<option value="all-teal">All Teal</option>
<option value="all-pink">All Pink</option>
<option value="all-slate">All Slate</option>
</optgroup>
</select>
<div id="deck-color-preview" class="deck-color-preview">
<div class="preview-card deck-red"></div>
</div>
</div>
</div>
</div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
@@ -281,9 +305,7 @@
</div>
<span class="held-label">Holding</span>
</div>
<div id="deck" class="card card-back">
<span>?</span>
</div>
<div id="deck" class="card card-back"></div>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
@@ -312,14 +334,14 @@
<div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
<div class="swap-card-back"></div>
</div>
</div>
<!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
<div class="swap-card-back"></div>
</div>
</div>
</div>
@@ -335,11 +357,12 @@
<!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
<hr class="scores-divider">
</div>
<h4>Scores</h4>
<table id="score-table">
<thead>
<tr>
@@ -805,6 +828,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div>
</div>
<script src="anime.min.js"></script>
<script src="timing-config.js"></script>
<script src="card-animations.js"></script>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>

View File

@@ -249,6 +249,87 @@ body {
padding: 8px 4px;
}
/* Stepper Control */
.stepper-control {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
padding: 4px 8px;
}
.stepper-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: #4a5568;
color: white;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.stepper-btn:hover {
background: #5a6578;
}
.stepper-btn:active {
background: #3a4558;
}
.stepper-value {
min-width: 24px;
text-align: center;
font-weight: bold;
font-size: 1.1rem;
}
/* Deck Color Selector */
.deck-color-selector {
display: flex;
align-items: center;
gap: 10px;
}
.deck-color-selector select {
flex: 1;
}
.deck-color-preview {
display: flex;
gap: 3px;
padding: 4px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.preview-card {
width: 16px;
height: 22px;
border-radius: 2px;
border: 1px solid rgba(255,255,255,0.2);
}
/* Deck color classes for preview cards */
.deck-red { background: linear-gradient(135deg, #c41e3a 0%, #922b21 100%); }
.deck-blue { background: linear-gradient(135deg, #2e5cb8 0%, #1a3a7a 100%); }
.deck-green { background: linear-gradient(135deg, #228b22 0%, #145214 100%); }
.deck-gold { background: linear-gradient(135deg, #daa520 0%, #b8860b 100%); }
.deck-purple { background: linear-gradient(135deg, #6a0dad 0%, #4b0082 100%); }
.deck-teal { background: linear-gradient(135deg, #008b8b 0%, #005f5f 100%); }
.deck-pink { background: linear-gradient(135deg, #db7093 0%, #c04f77 100%); }
.deck-slate { background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%); }
.deck-orange { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); }
.deck-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.deck-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
/* CPU Controls Section - below players list */
.cpu-controls-section {
background: rgba(0,0,0,0.2);
@@ -778,7 +859,7 @@ input::placeholder {
}
.card-back {
/* Bee-style diamond grid pattern - red with white crosshatch */
/* Bee-style diamond grid pattern - default red with white crosshatch */
background-color: #c41e3a;
background-image:
linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
@@ -793,6 +874,19 @@ input::placeholder {
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
/* Card back color variants */
.card-back.back-red { background-color: #c41e3a; border-color: #8b1528; }
.card-back.back-blue { background-color: #2e5cb8; border-color: #1a3a7a; }
.card-back.back-green { background-color: #228b22; border-color: #145214; }
.card-back.back-gold { background-color: #daa520; border-color: #b8860b; }
.card-back.back-purple { background-color: #6a0dad; border-color: #4b0082; }
.card-back.back-teal { background-color: #008b8b; border-color: #005f5f; }
.card-back.back-pink { background-color: #db7093; border-color: #c04f77; }
.card-back.back-slate { background-color: #4a5568; border-color: #2d3748; }
.card-back.back-orange { background-color: #e67e22; border-color: #d35400; }
.card-back.back-cyan { background-color: #00bcd4; border-color: #0097a7; }
.card-back.back-brown { background-color: #8b4513; border-color: #5d2f0d; }
.card-front {
background: #fff;
border: 2px solid #ddd;
@@ -829,6 +923,13 @@ input::placeholder {
color: #9b59b6;
}
/* Unknown card placeholder (locally flipped, server hasn't confirmed yet) */
.card-front .unknown-card {
font-size: 1.8em;
color: #7f8c8d;
opacity: 0.6;
}
.card.clickable {
cursor: pointer;
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
@@ -990,69 +1091,84 @@ input::placeholder {
align-items: flex-start;
}
/* Gentle pulse when it's your turn to draw */
.deck-area.your-turn-to-draw {
animation: deckAreaPulse 2s ease-in-out infinite;
}
@keyframes deckAreaPulse {
0%, 100% {
filter: brightness(1);
transform: scale(1);
}
50% {
filter: brightness(1.08);
transform: scale(1.02);
}
}
/* Gentle pulse when it's your turn to draw - handled by anime.js */
/* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
/* Held card slot - hidden, using floating card over discard instead */
/* Draw animation card (Anime.js powered) */
.draw-anim-card {
position: fixed;
z-index: 200;
perspective: 800px;
pointer-events: none;
}
.draw-anim-inner {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
}
.draw-anim-front,
.draw-anim-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
}
.draw-anim-front {
transform: rotateY(0deg);
}
.draw-anim-back {
transform: rotateY(180deg);
}
.held-card-slot {
display: none !important;
}
/* Held card floating over discard pile (larger, closer to viewer) */
/* Held card floating above and between deck and discard (larger, closer to viewer) */
.held-card-floating {
position: absolute;
position: fixed;
top: 0;
left: 0;
z-index: 100;
transform: scale(1.2) translateY(-12px);
transform: scale(1.15);
transform-origin: center bottom;
border: 3px solid #f4a460 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none;
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
/* No transition - anime.js handles animations */
}
.held-card-floating.hidden {
opacity: 0;
transform: scale(1) translateY(0);
pointer-events: none;
}
/* Pop-in animation - now handled by anime.js popIn() */
/* Keeping class for backwards compatibility */
.held-card-floating.pop-in {
/* Animation handled by JS */
}
/* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping {
transform: scale(1) translateY(0);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
/* transition removed - anime.js handles animations */
}
/* Swoop animation for deck → immediate discard */
.held-card-floating.swooping {
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s ease-out,
border-color 0.35s ease-out,
box-shadow 0.35s ease-out;
transform: scale(1.15) rotate(-8deg);
border-color: rgba(244, 164, 96, 0.8) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
/* transition removed - anime.js handles animations */
}
.held-card-floating.swooping.landed {
transform: scale(1) rotate(0deg);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
@@ -1080,11 +1196,10 @@ input::placeholder {
transform: scale(1.05);
}
/* Picked-up state - showing card underneath after drawing from discard */
/* Picked-up state - dimmed when someone is holding a card */
#discard.picked-up {
opacity: 0.5;
filter: grayscale(40%);
transform: scale(0.95);
}
.discard-stack {
@@ -1092,13 +1207,40 @@ input::placeholder {
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
}
.discard-stack .btn {
white-space: nowrap;
}
/* Discard button as a tab attached to right side of held card */
#discard-btn {
position: fixed;
z-index: 101;
writing-mode: vertical-rl;
text-orientation: mixed;
padding: 16px 8px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
border-radius: 0 8px 8px 0;
background: linear-gradient(90deg, #e8914d 0%, #f4a460 100%);
color: #1a472a;
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3);
border: none;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.15s;
}
#discard-btn:hover {
transform: scale(1.05);
box-shadow: 3px 3px 12px rgba(0, 0, 0, 0.4);
}
#discard-btn:active {
transform: scale(0.98);
}
#deck.disabled,
#discard.disabled {
opacity: 0.5;
@@ -1112,79 +1254,87 @@ input::placeholder {
box-shadow: none;
}
/* Highlight flash when opponent draws from a pile */
/* Highlight flash when drawing from a pile - uses ::after for guaranteed visibility */
#deck.draw-pulse,
#discard.draw-pulse {
animation: draw-highlight 0.45s ease-out;
z-index: 100;
position: relative;
z-index: 250;
}
@keyframes draw-highlight {
#deck.draw-pulse::after,
#discard.draw-pulse::after {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
border: 4px solid gold;
border-radius: 10px;
animation: draw-highlight-ring 0.4s ease-out forwards;
pointer-events: none;
z-index: 999;
}
@keyframes draw-highlight-ring {
0% {
transform: scale(1);
outline: 0px solid rgba(255, 220, 100, 0);
opacity: 1;
transform: scale(0.9);
border-width: 4px;
}
15% {
transform: scale(1.08);
outline: 3px solid rgba(255, 220, 100, 1);
outline-offset: 2px;
}
40% {
transform: scale(1.04);
outline: 3px solid rgba(255, 200, 80, 0.7);
outline-offset: 4px;
30% {
opacity: 1;
transform: scale(1.1);
border-width: 6px;
}
100% {
transform: scale(1);
outline: 3px solid rgba(255, 200, 80, 0);
outline-offset: 8px;
opacity: 0;
transform: scale(1.2);
border-width: 2px;
}
}
/* Card flip animation for discard pile */
/* Deck "dealing" effect when drawing from deck */
#deck.dealing {
animation: deck-deal 0.15s ease-out;
}
@keyframes deck-deal {
0% { transform: scale(1); }
30% { transform: scale(0.97) translateY(2px); }
100% { transform: scale(1); }
}
/* Card appearing on discard pile */
.card-flip-in {
animation: cardFlipIn 0.56s ease-out;
animation: cardFlipIn 0.25s ease-out;
}
@keyframes cardFlipIn {
0% {
transform: scale(1.4) translateY(-20px);
opacity: 0;
box-shadow: 0 0 40px rgba(244, 164, 96, 1);
}
30% {
transform: scale(1.25) translateY(-10px);
opacity: 1;
box-shadow: 0 0 35px rgba(244, 164, 96, 0.9);
}
70% {
transform: scale(1.1) translateY(0);
box-shadow: 0 0 20px rgba(244, 164, 96, 0.5);
}
100% {
transform: scale(1) translateY(0);
opacity: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
from { opacity: 0.5; }
to { opacity: 1; }
}
/* Discard pile pulse when card lands */
#discard.discard-land {
animation: discardLand 0.46s ease-out;
/* Discard pile pulse when card lands - handled by anime.js pulseDiscard() */
/* The .discard-land class is kept for backwards compatibility */
/* CPU considering discard pile - handled by anime.js startCpuThinking() */
/* The .cpu-considering class is still used as a flag, but animation is via JS */
/* Discard pickup animation - simple dim */
#discard.discard-pickup {
animation: discardPickup 0.25s ease-out;
}
@keyframes discardLand {
@keyframes discardPickup {
0% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
opacity: 1;
}
40% {
transform: scale(1.18);
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
50% {
opacity: 0.6;
}
100% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
opacity: 1;
}
}
@@ -1197,7 +1347,6 @@ input::placeholder {
height: 100%;
pointer-events: none;
z-index: 1000;
perspective: 1000px;
}
.swap-animation.hidden {
@@ -1208,7 +1357,7 @@ input::placeholder {
position: absolute;
width: 70px;
height: 98px;
perspective: 1000px;
perspective: 800px;
}
.swap-card.hidden {
@@ -1219,8 +1368,9 @@ input::placeholder {
position: relative;
width: 100%;
height: 100%;
border-radius: 8px;
transform-style: preserve-3d;
transition: transform 0.54s ease-in-out;
/* transition removed - anime.js handles all flip animations */
}
.swap-card.flipping .swap-card-inner {
@@ -1232,13 +1382,13 @@ input::placeholder {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
backface-visibility: hidden;
}
.swap-card-back {
@@ -1247,18 +1397,32 @@ input::placeholder {
font-size: 2rem;
}
/* Swap card back color variants */
.swap-card-back.back-red { background: linear-gradient(135deg, #c41e3a 0%, #922b21 100%); }
.swap-card-back.back-blue { background: linear-gradient(135deg, #2e5cb8 0%, #1a3a7a 100%); }
.swap-card-back.back-green { background: linear-gradient(135deg, #228b22 0%, #145214 100%); }
.swap-card-back.back-gold { background: linear-gradient(135deg, #daa520 0%, #b8860b 100%); }
.swap-card-back.back-purple { background: linear-gradient(135deg, #6a0dad 0%, #4b0082 100%); }
.swap-card-back.back-teal { background: linear-gradient(135deg, #008b8b 0%, #005f5f 100%); }
.swap-card-back.back-pink { background: linear-gradient(135deg, #db7093 0%, #c04f77 100%); }
.swap-card-back.back-slate { background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%); }
.swap-card-back.back-orange { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); }
.swap-card-back.back-cyan { background: linear-gradient(135deg, #00bcd4 0%, #0097a7 100%); }
.swap-card-back.back-brown { background: linear-gradient(135deg, #8b4513 0%, #5d2f0d 100%); }
.swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
transform: rotateY(180deg);
font-size: 2rem;
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 2px solid #ddd;
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
flex-direction: column;
color: #2c3e50;
color: #333;
line-height: 1.1;
font-weight: bold;
transform: rotateY(180deg);
}
.swap-card-front.red {
color: #e74c3c;
color: #c0392b;
}
.swap-card-front.black {
@@ -1270,61 +1434,81 @@ input::placeholder {
}
.swap-card-front .joker-icon {
font-size: 1.6em;
font-size: 1.5em;
line-height: 1;
}
.swap-card-front .joker-label {
font-size: 0.45em;
font-size: 0.4em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Movement animation */
.swap-card.flipping {
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
.swap-card-front.unknown {
color: #7f8c8d;
}
.swap-card-front .unknown-icon {
font-size: 2em;
opacity: 0.6;
}
.swap-card.moving {
transition: top 0.45s cubic-bezier(0.4, 0, 0.2, 1), left 0.45s cubic-bezier(0.4, 0, 0.2, 1), transform 0.45s ease-out;
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
/* transition removed - anime.js handles animations */
}
/* Card in hand fading during swap */
.card.swap-out {
opacity: 0;
transition: opacity 0.1s;
/* transition removed - anime.js handles animations */
}
/* Discard fading during swap */
#discard.swap-to-hand {
opacity: 0;
transition: opacity 0.2s;
/* transition removed - anime.js handles animations */
}
/* Subtle swap pulse for face-to-face swaps (no flip needed) */
.card.swap-pulse {
animation: swapPulse 0.4s ease-out;
/* Subtle swap pulse for face-to-face swaps - handled by anime.js pulseSwap() */
/* Keeping the class for backwards compatibility */
/* Fade transitions for swap animation */
.card.fade-out,
.held-card-floating.fade-out,
.anim-card.fade-out {
opacity: 0;
/* transition removed - anime.js handles animations */
}
@keyframes swapPulse {
0% {
transform: scale(1);
filter: brightness(1);
.card.fade-in,
.held-card-floating.fade-in,
.anim-card.fade-in {
opacity: 1;
/* transition removed - anime.js handles animations */
}
20% {
transform: scale(0.92);
filter: brightness(0.85);
/* Pulse animation for clickable cards during initial flip phase */
/* Now handled by anime.js startInitialFlipPulse() for consistency */
/* Keeping the class as a hook but animation is via JS */
.card.clickable.initial-flip-pulse {
/* Fallback static glow if JS doesn't start animation */
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
}
/* Held card pulse glow for local player's turn */
/* Keeping CSS animation for this as it's a simple looping effect */
.held-card-floating.your-turn-pulse {
animation: heldCardPulse 1.5s ease-in-out infinite;
}
@keyframes heldCardPulse {
0%, 100% {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7);
}
50% {
transform: scale(1.08);
filter: brightness(1.15);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
100% {
transform: scale(1);
filter: brightness(1);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
0 0 50px rgba(244, 164, 96, 0.5);
}
}
@@ -1435,6 +1619,12 @@ input::placeholder {
color: #2d3436;
}
/* CPU action status - subtle blue to indicate CPU is doing something */
.status-message.cpu-action {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
color: #fff;
}
/* Final turn badge - separate indicator */
.final-turn-badge {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
@@ -1631,10 +1821,17 @@ input::placeholder {
}
.game-buttons {
margin-bottom: 8px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 5px;
gap: 8px;
}
.game-buttons .scores-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.2);
margin: 4px 0 0 0;
width: 100%;
}
.game-buttons .btn {
@@ -1938,14 +2135,14 @@ input::placeholder {
display: none;
}
/* Real Card - persistent card element with 3D structure */
/* Real Card - persistent card element with 3D flip */
.real-card {
position: fixed;
border-radius: 6px;
perspective: 1000px;
z-index: 501;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
transition: box-shadow 0.3s ease-out, opacity 0.3s ease-out;
perspective: 800px;
}
.real-card:hover {
@@ -1956,8 +2153,9 @@ input::placeholder {
position: relative;
width: 100%;
height: 100%;
border-radius: 6px;
transform-style: preserve-3d;
transition: transform 0.54s ease-in-out;
/* transition removed - anime.js handles all flip animations */
}
.real-card .card-inner.flipped {
@@ -1968,7 +2166,6 @@ input::placeholder {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 6px;
display: flex;
flex-direction: column;
@@ -1976,6 +2173,7 @@ input::placeholder {
justify-content: center;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backface-visibility: hidden;
}
/* Card Front */
@@ -1984,7 +2182,7 @@ input::placeholder {
border: 2px solid #ddd;
color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
line-height: 1.1;
line-height: 0.95;
}
.real-card .card-face-front.red {
@@ -2031,11 +2229,7 @@ input::placeholder {
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.27s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg);
/* transition removed - anime.js handles animations */
}
/* Animation card - temporary cards used for animations */
@@ -2045,14 +2239,13 @@ input::placeholder {
}
.real-card.anim-card .card-inner {
transition: transform 0.54s ease-in-out;
/* transition removed - anime.js handles all flip animations */
}
.real-card.holding {
z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4);
transform: scale(1.08);
}
.real-card.clickable {
@@ -2062,7 +2255,6 @@ input::placeholder {
.real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
}
/* Disable hover effects when not player's turn */
@@ -2077,7 +2269,6 @@ input::placeholder {
.real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
transform: scale(1.06);
z-index: 520;
}

91
client/timing-config.js Normal file
View File

@@ -0,0 +1,91 @@
// Centralized timing configuration for all animations and pauses
// Edit these values to tune the feel of card animations and CPU gameplay
const TIMING = {
// Card animations (milliseconds) - smooth, unhurried
card: {
flip: 400, // Card flip duration (must match CSS transition)
move: 400, // Card movement - slower = smoother
lift: 0, // No lift pause
moving: 400, // Card moving class duration
},
// Pauses - minimal, let animations flow
pause: {
afterFlip: 0, // No pause - flow into next action
afterDiscard: 100, // Brief settle
beforeNewCard: 0, // No pause
afterSwapComplete: 100, // Brief settle
betweenAnimations: 0, // No gaps - continuous flow
beforeFlip: 0, // No pause
},
// Beat timing for animation phases (~1.2 sec with variance)
beat: {
base: 1200, // Base beat duration (longer to see results)
variance: 200, // +/- variance for natural feel
fadeOut: 300, // Fade out duration
fadeIn: 300, // Fade in duration
},
// UI feedback durations (milliseconds)
feedback: {
drawPulse: 375, // Draw pile highlight duration (25% slower for clear sequencing)
discardLand: 375, // Discard land effect duration (25% slower)
cardFlipIn: 300, // Card flip-in effect duration
statusMessage: 2000, // Toast/status message duration
copyConfirm: 2000, // Copy button confirmation duration
discardPickup: 250, // Discard pickup animation duration
},
// CSS animation timing (for reference - actual values in style.css)
css: {
cpuConsidering: 1500, // CPU considering pulse cycle
},
// Anime.js animation configuration
anime: {
easing: {
flip: 'easeInOutQuad',
move: 'easeOutCubic',
lift: 'easeOutQuad',
pulse: 'easeInOutSine',
},
loop: {
turnPulse: { duration: 2000 },
cpuThinking: { duration: 1500 },
initialFlipGlow: { duration: 1500 },
}
},
// Card manager specific
cardManager: {
flipDuration: 400, // Card flip animation
moveDuration: 400, // Card move animation
},
// Player swap animation steps - smooth continuous motion
playerSwap: {
flipToReveal: 400, // Initial flip to show card
pauseAfterReveal: 50, // Tiny beat to register the card
moveToDiscard: 400, // Move old card to discard
pulseBeforeSwap: 0, // No pulse - just flow
completePause: 50, // Tiny settle
},
};
// Helper to get beat duration with variance
function getBeatDuration() {
const base = TIMING.beat.base;
const variance = TIMING.beat.variance;
return base + (Math.random() * variance * 2 - variance);
}
// Export for module systems, also attach to window for direct use
if (typeof module !== 'undefined' && module.exports) {
module.exports = TIMING;
}
if (typeof window !== 'undefined') {
window.TIMING = TIMING;
window.getBeatDuration = getBeatDuration;
}

View File

@@ -1,6 +1,6 @@
[project]
name = "golfgame"
version = "0.1.0"
version = "2.0.1"
description = "6-Card Golf card game with AI opponents"
readme = "README.md"
requires-python = ">=3.11"
@@ -27,6 +27,12 @@ dependencies = [
"python-dotenv>=1.0.0",
# V2: Event sourcing infrastructure
"asyncpg>=0.29.0",
"redis>=5.0.0",
# V2: Authentication
"bcrypt>=4.1.0",
"resend>=2.0.0",
# V2: Production monitoring (optional but recommended)
"sentry-sdk[fastapi]>=1.40.0",
]
[project.optional-dependencies]

View File

@@ -33,6 +33,40 @@ def ai_log(message: str):
ai_logger.debug(message)
# =============================================================================
# CPU Turn Timing Configuration (seconds)
# =============================================================================
# Centralized timing constants for all CPU turn delays.
# Adjust these values to tune the "feel" of CPU gameplay.
CPU_TIMING = {
# Delay before CPU "looks at" the discard pile
"initial_look": (0.3, 0.5),
# Brief pause after draw broadcast - let draw animation complete
"post_draw_settle": 0.5,
# Consideration time after drawing (before swap/discard decision)
"post_draw_consider": (0.3, 0.6),
# Variance multiplier range for chaotic personality players
"thinking_multiplier_chaotic": (0.6, 1.4),
# Pause after swap/discard to let animation complete and show result
"post_action_pause": (0.3, 0.5),
}
# Thinking time ranges by card difficulty (seconds)
THINKING_TIME = {
# Obviously good cards (Jokers, Kings, 2s, Aces) - easy take
"easy_good": (0.15, 0.3),
# Obviously bad cards (10s, Jacks, Queens) - easy pass
"easy_bad": (0.15, 0.3),
# Medium difficulty (3, 4, 8, 9)
"medium": (0.15, 0.3),
# Hardest decisions (5, 6, 7 - middle of range)
"hard": (0.15, 0.3),
# No discard available - quick decision
"no_card": (0.15, 0.3),
}
# Alias for backwards compatibility - use the centralized function from game.py
def get_ai_card_value(card: Card, options: GameOptions) -> int:
"""Get card value with house rules applied for AI decisions.
@@ -50,32 +84,37 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
Easy decisions (obviously good or bad cards) = quick (400-600ms)
Hard decisions (medium value cards) = slower (900-1100ms)
Easy decisions (obviously good or bad cards) = quick
Hard decisions (medium value cards) = slower
Returns time in seconds.
Returns time in seconds. Uses THINKING_TIME constants.
"""
if not card:
# No discard available - quick decision to draw from deck
return random.uniform(0.4, 0.5)
t = THINKING_TIME["no_card"]
return random.uniform(t[0], t[1])
value = get_card_value(card, options)
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
if value <= 1:
return random.uniform(0.4, 0.6)
t = THINKING_TIME["easy_good"]
return random.uniform(t[0], t[1])
# Obviously bad cards (easy pass): 10, J, Q (value 10)
if value >= 10:
return random.uniform(0.4, 0.6)
t = THINKING_TIME["easy_bad"]
return random.uniform(t[0], t[1])
# Medium cards require more thought: 3-9
# 5, 6, 7 are the hardest decisions (middle of the range)
if value in (5, 6, 7):
return random.uniform(0.9, 1.1)
t = THINKING_TIME["hard"]
return random.uniform(t[0], t[1])
# 3, 4, 8, 9 - moderate difficulty
return random.uniform(0.6, 0.85)
t = THINKING_TIME["medium"]
return random.uniform(t[0], t[1])
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
@@ -1017,6 +1056,14 @@ class GolfAI:
# Pair hunters might hold medium cards hoping for matches
if best_pos is not None and not player.cards[best_pos].face_up:
if drawn_value >= 5: # Only hold out for medium/high cards
# DON'T hold if placing at best_pos would actually CREATE a pair right now!
partner_pos = get_column_partner_position(best_pos)
partner_card = player.cards[partner_pos]
would_make_pair = partner_card.face_up and partner_card.rank == drawn_card.rank
if would_make_pair:
ai_log(f" Skip hold-for-pair: placing at {best_pos} creates pair with {partner_card.rank.value}")
else:
pair_viability = get_pair_viability(drawn_card.rank, game)
phase = get_game_phase(game)
pressure = get_end_game_pressure(player, game)
@@ -1316,7 +1363,8 @@ async def process_cpu_turn(
logger = get_logger() if game_id else None
# Brief initial delay before CPU "looks at" the discard pile
await asyncio.sleep(random.uniform(0.08, 0.15))
initial_look = CPU_TIMING["initial_look"]
await asyncio.sleep(random.uniform(initial_look[0], initial_look[1]))
# "Thinking" delay based on how obvious the discard decision is
# Easy decisions (good/bad cards) are quick, medium cards take longer
@@ -1325,7 +1373,8 @@ async def process_cpu_turn(
# Adjust for personality - chaotic players have more variance
if profile.unpredictability > 0.2:
thinking_time *= random.uniform(0.6, 1.4)
chaos_mult = CPU_TIMING["thinking_multiplier_chaotic"]
thinking_time *= random.uniform(chaos_mult[0], chaos_mult[1])
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
@@ -1397,8 +1446,10 @@ async def process_cpu_turn(
await broadcast_callback()
# Brief pause after draw to let the flash animation register visually
await asyncio.sleep(0.08)
await asyncio.sleep(0.35 + random.uniform(0, 0.35))
await asyncio.sleep(CPU_TIMING["post_draw_settle"])
# Consideration time before swap/discard decision
consider = CPU_TIMING["post_draw_consider"]
await asyncio.sleep(consider[0] + random.uniform(0, consider[1] - consider[0]))
# Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
@@ -1535,3 +1586,7 @@ async def process_cpu_turn(
)
await broadcast_callback()
# Pause to let client animation complete and show result before next turn
post_action = CPU_TIMING["post_action_pause"]
await asyncio.sleep(random.uniform(post_action[0], post_action[1]))

View File

@@ -251,7 +251,7 @@ async def _close_all_websockets():
app = FastAPI(
title="Golf Card Game",
debug=config.DEBUG,
version="0.1.0",
version="2.0.1",
lifespan=lifespan,
)
@@ -645,6 +645,16 @@ async def websocket_endpoint(websocket: WebSocket):
num_decks = data.get("decks", 1)
num_rounds = data.get("rounds", 1)
# Parse deck colors (validate against allowed colors)
allowed_colors = {
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
"green", "pink", "cyan", "brown", "slate"
}
raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"])
deck_colors = [c for c in raw_deck_colors if c in allowed_colors]
if not deck_colors:
deck_colors = ["red", "blue", "gold"]
# Build game options
options = GameOptions(
# Standard options
@@ -669,6 +679,8 @@ async def websocket_endpoint(websocket: WebSocket):
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False),
# Multi-deck card back colors
deck_colors=deck_colors,
)
# Validate settings
@@ -1132,6 +1144,9 @@ 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)
# Run CPU turn
async def broadcast_cb():
await broadcast_game_state(room)
@@ -1191,6 +1206,10 @@ if os.path.exists(client_path):
async def serve_animation_queue():
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
@app.get("/timing-config.js")
async def serve_timing_config():
return FileResponse(os.path.join(client_path, "timing-config.js"), media_type="application/javascript")
@app.get("/leaderboard.js")
async def serve_leaderboard_js():
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript")
@@ -1216,6 +1235,14 @@ if os.path.exists(client_path):
async def serve_replay_js():
return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript")
@app.get("/card-animations.js")
async def serve_card_animations_js():
return FileResponse(os.path.join(client_path, "card-animations.js"), media_type="application/javascript")
@app.get("/anime.min.js")
async def serve_anime_js():
return FileResponse(os.path.join(client_path, "anime.min.js"), media_type="application/javascript")
# Serve replay page for share links
@app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str):