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.processing = false;
this.animationInProgress = false; this.animationInProgress = false;
// Timing configuration (ms) // Timing configuration (ms) - use centralized TIMING config
// Rhythm: action → settle → action → breathe const T = window.TIMING || {};
this.timing = { this.timing = {
flipDuration: 540, // Must match CSS .card-inner transition (0.54s) flipDuration: T.card?.flip || 540,
moveDuration: 270, moveDuration: T.card?.move || 270,
pauseAfterFlip: 144, // Brief settle after flip before move cardLift: T.card?.lift || 100,
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle pauseAfterFlip: T.pause?.afterFlip || 144,
pauseBeforeNewCard: 150, // Anticipation before new card moves in pauseAfterDiscard: T.pause?.afterDiscard || 550,
pauseAfterSwapComplete: 400, // Breathing room after swap completes pauseBeforeNewCard: T.pause?.beforeNewCard || 150,
pauseBetweenAnimations: 90 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 // Animate the flip
this.playSound('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 // Remove flipped to trigger animation to front
inner.classList.remove('flipped'); inner.classList.remove('flipped');
@@ -136,11 +143,10 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate a card swap (hand card to discard, drawn card to hand) // Animate a card swap - smooth continuous motion
async animateSwap(movement) { async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement; const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position); const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
@@ -149,67 +155,54 @@ class AnimationQueue {
return; return;
} }
// Create a temporary card element for the animation // Create animation cards
const animCard = this.createAnimCard(); const handCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard); this.cardManager.cardLayer.appendChild(handCard);
this.setCardPosition(handCard, slotRect);
// Position at slot const handInner = handCard.querySelector('.card-inner');
this.setCardPosition(animCard, slotRect); const handFront = handCard.querySelector('.card-face-front');
// Start face down (showing back) const heldCard = this.createAnimCard();
const inner = animCard.querySelector('.card-inner'); this.cardManager.cardLayer.appendChild(heldCard);
const front = animCard.querySelector('.card-face-front'); this.setCardPosition(heldCard, holdingRect || discardRect);
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it const heldInner = heldCard.querySelector('.card-inner');
this.setCardFront(front, oldCard); 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) { if (!oldCard.face_up) {
this.playSound('flip'); this.playSound('flip');
inner.classList.remove('flipped'); handInner.classList.remove('flipped');
await this.delay(this.timing.flipDuration); 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'); this.playSound('card');
animCard.classList.add('moving'); handCard.classList.remove('fade-out');
this.setCardPosition(animCard, discardRect); heldCard.classList.remove('fade-out');
await this.delay(this.timing.moveDuration); handCard.classList.add('fade-in');
animCard.classList.remove('moving'); heldCard.classList.add('fade-in');
await this.delay(150);
// Let discard land and pulse settle // Clean up
await this.delay(this.timing.pauseAfterDiscard); handCard.remove();
heldCard.remove();
// 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();
} }
// Create a temporary animation card element // Create a temporary animation card element
@@ -337,22 +330,47 @@ class AnimationQueue {
animCard.remove(); animCard.remove();
} }
// Animate drawing from discard // Animate drawing from discard - show card lifting and moving to holding position
async animateDrawDiscard(movement) { async animateDrawDiscard(movement) {
const { playerId } = movement; const { card } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const discardRect = this.getLocationRect('discard'); const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding'); const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return; if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state // Create animation card at discard position (face UP - visible card)
this.playSound('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); 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 // 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> <h3>Game Settings</h3>
<div class="basic-settings-row"> <div class="basic-settings-row">
<div class="form-group"> <div class="form-group">
<label for="num-decks">Decks</label> <label>Decks</label>
<select id="num-decks"> <div class="stepper-control">
<option value="1">1</option> <button type="button" id="decks-minus" class="stepper-btn"></button>
<option value="2">2</option> <span id="num-decks-display" class="stepper-value">1</span>
<option value="3">3</option> <input type="hidden" id="num-decks" value="1">
</select> <button type="button" id="decks-plus" class="stepper-btn">+</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="num-rounds">Holes</label> <label for="num-rounds">Holes</label>
@@ -94,13 +95,36 @@
<option value="1">1</option> <option value="1">1</option>
</select> </select>
</div> </div>
<div class="form-group"> <div id="deck-colors-group" class="form-group">
<label for="initial-flips">Cards Revealed</label> <label for="deck-color-preset">Card Backs</label>
<select id="initial-flips"> <div class="deck-color-selector">
<option value="2" selected>2 cards</option> <select id="deck-color-preset">
<option value="1">1 card</option> <optgroup label="Themes">
<option value="0">None</option> <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> </select>
<div id="deck-color-preview" class="deck-color-preview">
<div class="preview-card deck-red"></div>
</div>
</div>
</div> </div>
</div> </div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p> <p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
@@ -281,9 +305,7 @@
</div> </div>
<span class="held-label">Holding</span> <span class="held-label">Holding</span>
</div> </div>
<div id="deck" class="card card-back"> <div id="deck" class="card card-back"></div>
<span>?</span>
</div>
<div class="discard-stack"> <div class="discard-stack">
<div id="discard" class="card"> <div id="discard" class="card">
<span id="discard-content"></span> <span id="discard-content"></span>
@@ -312,14 +334,14 @@
<div id="swap-card-from-hand" class="swap-card"> <div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
<!-- Drawn card being held (animates to hand) --> <!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden"> <div id="held-card" class="swap-card hidden">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -335,11 +357,12 @@
<!-- Right panel: Scores --> <!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel"> <div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden"> <div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button> <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> <button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
<hr class="scores-divider">
</div> </div>
<h4>Scores</h4>
<table id="score-table"> <table id="score-table">
<thead> <thead>
<tr> <tr>
@@ -805,6 +828,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div> </div>
</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="card-manager.js"></script>
<script src="state-differ.js"></script> <script src="state-differ.js"></script>
<script src="animation-queue.js"></script> <script src="animation-queue.js"></script>

View File

@@ -249,6 +249,87 @@ body {
padding: 8px 4px; 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 - below players list */
.cpu-controls-section { .cpu-controls-section {
background: rgba(0,0,0,0.2); background: rgba(0,0,0,0.2);
@@ -778,7 +859,7 @@ input::placeholder {
} }
.card-back { .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-color: #c41e3a;
background-image: background-image:
linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%), 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); 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 { .card-front {
background: #fff; background: #fff;
border: 2px solid #ddd; border: 2px solid #ddd;
@@ -829,6 +923,13 @@ input::placeholder {
color: #9b59b6; 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 { .card.clickable {
cursor: pointer; cursor: pointer;
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5); box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
@@ -990,69 +1091,84 @@ input::placeholder {
align-items: flex-start; align-items: flex-start;
} }
/* Gentle pulse when it's your turn to draw */ /* Gentle pulse when it's your turn to draw - handled by anime.js */
.deck-area.your-turn-to-draw { /* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
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);
}
}
/* Held card slot - hidden, using floating card over discard instead */ /* 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 { .held-card-slot {
display: none !important; 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 { .held-card-floating {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 100; z-index: 100;
transform: scale(1.2) translateY(-12px); transform: scale(1.15);
transform-origin: center bottom;
border: 3px solid #f4a460 !important; 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; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none; pointer-events: none;
transition: opacity 0.15s ease-out, transform 0.15s ease-out; /* No transition - anime.js handles animations */
} }
.held-card-floating.hidden { .held-card-floating.hidden {
opacity: 0; opacity: 0;
transform: scale(1) translateY(0);
pointer-events: none; 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) */ /* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping { .held-card-floating.dropping {
transform: scale(1) translateY(0);
border-color: transparent !important; border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !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 */ /* Swoop animation for deck → immediate discard */
.held-card-floating.swooping { .held-card-floating.swooping {
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1), /* transition removed - anime.js handles animations */
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;
} }
.held-card-floating.swooping.landed { .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; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
} }
@@ -1080,11 +1196,10 @@ input::placeholder {
transform: scale(1.05); 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 { #discard.picked-up {
opacity: 0.5; opacity: 0.5;
filter: grayscale(40%); filter: grayscale(40%);
transform: scale(0.95);
} }
.discard-stack { .discard-stack {
@@ -1092,13 +1207,40 @@ input::placeholder {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
position: relative;
} }
.discard-stack .btn { .discard-stack .btn {
white-space: nowrap; 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, #deck.disabled,
#discard.disabled { #discard.disabled {
opacity: 0.5; opacity: 0.5;
@@ -1112,79 +1254,87 @@ input::placeholder {
box-shadow: none; 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, #deck.draw-pulse,
#discard.draw-pulse { #discard.draw-pulse {
animation: draw-highlight 0.45s ease-out; position: relative;
z-index: 100; 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% { 0% {
transform: scale(1); opacity: 1;
outline: 0px solid rgba(255, 220, 100, 0); transform: scale(0.9);
border-width: 4px;
} }
15% { 30% {
transform: scale(1.08); opacity: 1;
outline: 3px solid rgba(255, 220, 100, 1); transform: scale(1.1);
outline-offset: 2px; border-width: 6px;
}
40% {
transform: scale(1.04);
outline: 3px solid rgba(255, 200, 80, 0.7);
outline-offset: 4px;
} }
100% { 100% {
transform: scale(1); opacity: 0;
outline: 3px solid rgba(255, 200, 80, 0); transform: scale(1.2);
outline-offset: 8px; 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 { .card-flip-in {
animation: cardFlipIn 0.56s ease-out; animation: cardFlipIn 0.25s ease-out;
} }
@keyframes cardFlipIn { @keyframes cardFlipIn {
0% { from { opacity: 0.5; }
transform: scale(1.4) translateY(-20px); to { opacity: 1; }
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);
}
} }
/* Discard pile pulse when card lands */ /* Discard pile pulse when card lands - handled by anime.js pulseDiscard() */
#discard.discard-land { /* The .discard-land class is kept for backwards compatibility */
animation: discardLand 0.46s ease-out;
/* 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% { 0% {
transform: scale(1); opacity: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
40% { 50% {
transform: scale(1.18); opacity: 0.6;
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
} }
100% { 100% {
transform: scale(1); opacity: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
} }
@@ -1197,7 +1347,6 @@ input::placeholder {
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
perspective: 1000px;
} }
.swap-animation.hidden { .swap-animation.hidden {
@@ -1208,7 +1357,7 @@ input::placeholder {
position: absolute; position: absolute;
width: 70px; width: 70px;
height: 98px; height: 98px;
perspective: 1000px; perspective: 800px;
} }
.swap-card.hidden { .swap-card.hidden {
@@ -1219,8 +1368,9 @@ input::placeholder {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 8px;
transform-style: preserve-3d; 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 { .swap-card.flipping .swap-card-inner {
@@ -1232,13 +1382,13 @@ input::placeholder {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
backface-visibility: hidden;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4); box-shadow: 0 4px 15px rgba(0,0,0,0.4);
backface-visibility: hidden;
} }
.swap-card-back { .swap-card-back {
@@ -1247,18 +1397,32 @@ input::placeholder {
font-size: 2rem; 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 { .swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%); background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
transform: rotateY(180deg); border: 2px solid #ddd;
font-size: 2rem; font-size: clamp(1.8rem, 2.2vw, 2.8rem);
flex-direction: column; flex-direction: column;
color: #2c3e50; color: #333;
line-height: 1.1; line-height: 1.1;
font-weight: bold; font-weight: bold;
transform: rotateY(180deg);
} }
.swap-card-front.red { .swap-card-front.red {
color: #e74c3c; color: #c0392b;
} }
.swap-card-front.black { .swap-card-front.black {
@@ -1270,61 +1434,81 @@ input::placeholder {
} }
.swap-card-front .joker-icon { .swap-card-front .joker-icon {
font-size: 1.6em; font-size: 1.5em;
line-height: 1; line-height: 1;
} }
.swap-card-front .joker-label { .swap-card-front .joker-label {
font-size: 0.45em; font-size: 0.4em;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em;
} }
/* Movement animation */ .swap-card-front.unknown {
.swap-card.flipping { color: #7f8c8d;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8)); }
.swap-card-front .unknown-icon {
font-size: 2em;
opacity: 0.6;
} }
.swap-card.moving { .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; /* transition removed - anime.js handles animations */
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
} }
/* Card in hand fading during swap */ /* Card in hand fading during swap */
.card.swap-out { .card.swap-out {
opacity: 0; opacity: 0;
transition: opacity 0.1s; /* transition removed - anime.js handles animations */
} }
/* Discard fading during swap */ /* Discard fading during swap */
#discard.swap-to-hand { #discard.swap-to-hand {
opacity: 0; opacity: 0;
transition: opacity 0.2s; /* transition removed - anime.js handles animations */
} }
/* Subtle swap pulse for face-to-face swaps (no flip needed) */ /* Subtle swap pulse for face-to-face swaps - handled by anime.js pulseSwap() */
.card.swap-pulse { /* Keeping the class for backwards compatibility */
animation: swapPulse 0.4s ease-out;
/* 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 { .card.fade-in,
0% { .held-card-floating.fade-in,
transform: scale(1); .anim-card.fade-in {
filter: brightness(1); 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% { 50% {
transform: scale(1.08); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 35px rgba(244, 164, 96, 1),
filter: brightness(1.15); 0 0 50px rgba(244, 164, 96, 0.5);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
100% {
transform: scale(1);
filter: brightness(1);
} }
} }
@@ -1435,6 +1619,12 @@ input::placeholder {
color: #2d3436; 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 - separate indicator */
.final-turn-badge { .final-turn-badge {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
@@ -1631,10 +1821,17 @@ input::placeholder {
} }
.game-buttons { .game-buttons {
margin-bottom: 8px; margin-bottom: 10px;
display: flex; display: flex;
flex-direction: column; 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 { .game-buttons .btn {
@@ -1938,14 +2135,14 @@ input::placeholder {
display: none; display: none;
} }
/* Real Card - persistent card element with 3D structure */ /* Real Card - persistent card element with 3D flip */
.real-card { .real-card {
position: fixed; position: fixed;
border-radius: 6px; border-radius: 6px;
perspective: 1000px;
z-index: 501; z-index: 501;
cursor: pointer; 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 { .real-card:hover {
@@ -1956,8 +2153,9 @@ input::placeholder {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 6px;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: transform 0.54s ease-in-out; /* transition removed - anime.js handles all flip animations */
} }
.real-card .card-inner.flipped { .real-card .card-inner.flipped {
@@ -1968,7 +2166,6 @@ input::placeholder {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
backface-visibility: hidden;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1976,6 +2173,7 @@ input::placeholder {
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backface-visibility: hidden;
} }
/* Card Front */ /* Card Front */
@@ -1984,7 +2182,7 @@ input::placeholder {
border: 2px solid #ddd; border: 2px solid #ddd;
color: #333; color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem); font-size: clamp(1.8rem, 2.2vw, 2.8rem);
line-height: 1.1; line-height: 0.95;
} }
.real-card .card-face-front.red { .real-card .card-face-front.red {
@@ -2031,11 +2229,7 @@ input::placeholder {
.real-card.moving, .real-card.moving,
.real-card.anim-card.moving { .real-card.anim-card.moving {
z-index: 600; z-index: 600;
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1), /* transition removed - anime.js handles animations */
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);
} }
/* Animation card - temporary cards used for animations */ /* Animation card - temporary cards used for animations */
@@ -2045,14 +2239,13 @@ input::placeholder {
} }
.real-card.anim-card .card-inner { .real-card.anim-card .card-inner {
transition: transform 0.54s ease-in-out; /* transition removed - anime.js handles all flip animations */
} }
.real-card.holding { .real-card.holding {
z-index: 550; z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6), box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4); 0 4px 15px rgba(0, 0, 0, 0.4);
transform: scale(1.08);
} }
.real-card.clickable { .real-card.clickable {
@@ -2062,7 +2255,6 @@ input::placeholder {
.real-card.clickable:hover { .real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460, box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3); 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
} }
/* Disable hover effects when not player's turn */ /* Disable hover effects when not player's turn */
@@ -2077,7 +2269,6 @@ input::placeholder {
.real-card.selected { .real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460; box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
transform: scale(1.06);
z-index: 520; 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] [project]
name = "golfgame" name = "golfgame"
version = "0.1.0" version = "2.0.1"
description = "6-Card Golf card game with AI opponents" description = "6-Card Golf card game with AI opponents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -27,6 +27,12 @@ dependencies = [
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
# V2: Event sourcing infrastructure # V2: Event sourcing infrastructure
"asyncpg>=0.29.0", "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] [project.optional-dependencies]

View File

@@ -33,6 +33,40 @@ def ai_log(message: str):
ai_logger.debug(message) 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 # Alias for backwards compatibility - use the centralized function from game.py
def get_ai_card_value(card: Card, options: GameOptions) -> int: def get_ai_card_value(card: Card, options: GameOptions) -> int:
"""Get card value with house rules applied for AI decisions. """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: def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
"""Calculate CPU 'thinking time' based on how obvious the discard decision is. """Calculate CPU 'thinking time' based on how obvious the discard decision is.
Easy decisions (obviously good or bad cards) = quick (400-600ms) Easy decisions (obviously good or bad cards) = quick
Hard decisions (medium value cards) = slower (900-1100ms) Hard decisions (medium value cards) = slower
Returns time in seconds. Returns time in seconds. Uses THINKING_TIME constants.
""" """
if not card: if not card:
# No discard available - quick decision to draw from deck # 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) value = get_card_value(card, options)
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1) # Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
if value <= 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) # Obviously bad cards (easy pass): 10, J, Q (value 10)
if 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 # Medium cards require more thought: 3-9
# 5, 6, 7 are the hardest decisions (middle of the range) # 5, 6, 7 are the hardest decisions (middle of the range)
if value in (5, 6, 7): 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 # 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: 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 # Pair hunters might hold medium cards hoping for matches
if best_pos is not None and not player.cards[best_pos].face_up: 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 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) pair_viability = get_pair_viability(drawn_card.rank, game)
phase = get_game_phase(game) phase = get_game_phase(game)
pressure = get_end_game_pressure(player, 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 logger = get_logger() if game_id else None
# Brief initial delay before CPU "looks at" the discard pile # 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 # "Thinking" delay based on how obvious the discard decision is
# Easy decisions (good/bad cards) are quick, medium cards take longer # 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 # Adjust for personality - chaotic players have more variance
if profile.unpredictability > 0.2: 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" 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})") 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() await broadcast_callback()
# Brief pause after draw to let the flash animation register visually # Brief pause after draw to let the flash animation register visually
await asyncio.sleep(0.08) await asyncio.sleep(CPU_TIMING["post_draw_settle"])
await asyncio.sleep(0.35 + random.uniform(0, 0.35)) # 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 # Decide whether to swap or discard
swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game) swap_pos = GolfAI.choose_swap_or_discard(drawn, cpu_player, profile, game)
@@ -1535,3 +1586,7 @@ async def process_cpu_turn(
) )
await broadcast_callback() 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( app = FastAPI(
title="Golf Card Game", title="Golf Card Game",
debug=config.DEBUG, debug=config.DEBUG,
version="0.1.0", version="2.0.1",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -645,6 +645,16 @@ async def websocket_endpoint(websocket: WebSocket):
num_decks = data.get("decks", 1) num_decks = data.get("decks", 1)
num_rounds = data.get("rounds", 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 # Build game options
options = GameOptions( options = GameOptions(
# Standard options # Standard options
@@ -669,6 +679,8 @@ async def websocket_endpoint(websocket: WebSocket):
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False), negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False), one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False), knock_early=data.get("knock_early", False),
# Multi-deck card back colors
deck_colors=deck_colors,
) )
# Validate settings # 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: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now
await asyncio.sleep(0.25)
# Run CPU turn # Run CPU turn
async def broadcast_cb(): async def broadcast_cb():
await broadcast_game_state(room) await broadcast_game_state(room)
@@ -1191,6 +1206,10 @@ if os.path.exists(client_path):
async def serve_animation_queue(): async def serve_animation_queue():
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript") 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") @app.get("/leaderboard.js")
async def serve_leaderboard_js(): async def serve_leaderboard_js():
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript") 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(): async def serve_replay_js():
return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript") 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 # Serve replay page for share links
@app.get("/replay/{share_code}") @app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str): async def serve_replay_page(share_code: str):