Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4664aae8aa | ||
|
|
a5d108f4f2 | ||
|
|
df422907b0 | ||
|
|
bc1b1b7725 | ||
|
|
7b64b8c17c |
134
CLAUDE.md
Normal file
134
CLAUDE.md
Normal 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
261
client/ANIMATIONS.md
Normal 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
|
||||||
@@ -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
8
client/anime.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
937
client/app.js
937
client/app.js
File diff suppressed because it is too large
Load Diff
876
client/card-animations.js
Normal file
876
client/card-animations.js
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
469
client/style.css
469
client/style.css
@@ -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);
|
/* Pulse animation for clickable cards during initial flip phase */
|
||||||
filter: brightness(0.85);
|
/* 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
91
client/timing-config.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
79
server/ai.py
79
server/ai.py
@@ -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]))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user