Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.

This commit is contained in:
Aaron D. Lee
2026-01-25 17:37:01 -05:00
parent d9073f862c
commit f80bab3b4b
35 changed files with 5772 additions and 403 deletions

378
client/animation-queue.js Normal file
View File

@@ -0,0 +1,378 @@
// AnimationQueue - Sequences card animations properly
// Ensures animations play in order without overlap
class AnimationQueue {
constructor(cardManager, getSlotRect, getLocationRect, playSound) {
this.cardManager = cardManager;
this.getSlotRect = getSlotRect; // Function to get slot position
this.getLocationRect = getLocationRect; // Function to get deck/discard position
this.playSound = playSound || (() => {}); // Sound callback
this.queue = [];
this.processing = false;
this.animationInProgress = false;
// Timing configuration (ms)
this.timing = {
flipDuration: 400,
moveDuration: 300,
pauseAfterMove: 200,
pauseAfterFlip: 100,
pauseBetweenAnimations: 100
};
}
// Add movements to the queue and start processing
async enqueue(movements, onComplete) {
if (!movements || movements.length === 0) {
if (onComplete) onComplete();
return;
}
// Add completion callback to last movement
const movementsWithCallback = movements.map((m, i) => ({
...m,
onComplete: i === movements.length - 1 ? onComplete : null
}));
this.queue.push(...movementsWithCallback);
if (!this.processing) {
await this.processQueue();
}
}
// Process queued animations one at a time
async processQueue() {
if (this.processing) return;
this.processing = true;
this.animationInProgress = true;
while (this.queue.length > 0) {
const movement = this.queue.shift();
try {
await this.animate(movement);
} catch (e) {
console.error('Animation error:', e);
}
// Callback after last movement
if (movement.onComplete) {
movement.onComplete();
}
// Pause between animations
if (this.queue.length > 0) {
await this.delay(this.timing.pauseBetweenAnimations);
}
}
this.processing = false;
this.animationInProgress = false;
}
// Route to appropriate animation
async animate(movement) {
switch (movement.type) {
case 'flip':
await this.animateFlip(movement);
break;
case 'swap':
await this.animateSwap(movement);
break;
case 'discard':
await this.animateDiscard(movement);
break;
case 'draw-deck':
await this.animateDrawDeck(movement);
break;
case 'draw-discard':
await this.animateDrawDiscard(movement);
break;
}
}
// Animate a card flip
async animateFlip(movement) {
const { playerId, position, faceUp, card } = movement;
// Get slot position
const slotRect = this.getSlotRect(playerId, position);
if (!slotRect || slotRect.width === 0 || slotRect.height === 0) {
return;
}
// Create animation card at slot position
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, slotRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Set up what we're flipping to (front face)
this.setCardFront(front, card);
// Start face down (flipped = showing back)
inner.classList.add('flipped');
// Force a reflow to ensure the initial state is applied
animCard.offsetHeight;
// Animate the flip
this.playSound('flip');
await this.delay(50); // Brief pause before flip
// Remove flipped to trigger animation to front
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
// Clean up
animCard.remove();
}
// Animate a card swap (hand card to discard, drawn card to hand)
async animateSwap(movement) {
const { playerId, position, oldCard, newCard } = movement;
// Get positions
const slotRect = this.getSlotRect(playerId, position);
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!slotRect || !discardRect || slotRect.width === 0) {
return;
}
// Create a temporary card element for the animation
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
// Position at slot
this.setCardPosition(animCard, slotRect);
// Start face down (showing back)
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it
if (!oldCard.face_up) {
// Set up the front with the old card content (what we're discarding)
this.setCardFront(front, oldCard);
this.playSound('flip');
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
} else {
// Already face up, just show it
this.setCardFront(front, oldCard);
inner.classList.remove('flipped');
}
await this.delay(100);
// Step 2: Move card to discard pile
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// Pause to show the card landing on discard
await this.delay(this.timing.pauseAfterMove + 200);
// 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');
// 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');
// Clean up animation cards
await this.delay(this.timing.pauseAfterMove);
animCard.remove();
newAnimCard.remove();
}
// Create a temporary animation card element
createAnimCard() {
const card = document.createElement('div');
card.className = 'real-card anim-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
return card;
}
// Set card position
setCardPosition(card, 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`;
}
// Set card front content
setCardFront(frontEl, cardData) {
frontEl.className = 'card-face card-face-front';
if (!cardData) return;
if (cardData.rank === '★') {
frontEl.classList.add('joker');
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
frontEl.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
frontEl.classList.add(isRed ? 'red' : 'black');
const suitSymbol = this.getSuitSymbol(cardData.suit);
frontEl.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
}
}
getSuitSymbol(suit) {
const symbols = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
return symbols[suit] || '';
}
// Animate discarding a card (from hand to discard pile) - called for other players
async animateDiscard(movement) {
const { card, fromPlayerId, fromPosition } = movement;
// If no specific position, animate from opponent's area
const discardRect = this.getLocationRect('discard');
if (!discardRect) return;
let startRect;
if (fromPosition !== null && fromPosition !== undefined) {
startRect = this.getSlotRect(fromPlayerId, fromPosition);
}
// Fallback: use discard position offset upward
if (!startRect) {
startRect = {
left: discardRect.left,
top: discardRect.top - 80,
width: discardRect.width,
height: discardRect.height
};
}
// Create animation card
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, startRect);
const inner = animCard.querySelector('.card-inner');
const front = animCard.querySelector('.card-face-front');
// Show the card that was discarded
this.setCardFront(front, card);
inner.classList.remove('flipped');
// Move to discard
this.playSound('card');
animCard.classList.add('moving');
this.setCardPosition(animCard, discardRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
await this.delay(this.timing.pauseAfterMove);
// Clean up
animCard.remove();
}
// Animate drawing from deck
async animateDrawDeck(movement) {
const { playerId } = movement;
const deckRect = this.getLocationRect('deck');
const holdingRect = this.getLocationRect('holding');
if (!deckRect || !holdingRect) return;
// Create animation card at deck position (face down)
const animCard = this.createAnimCard();
this.cardManager.cardLayer.appendChild(animCard);
this.setCardPosition(animCard, deckRect);
const inner = animCard.querySelector('.card-inner');
inner.classList.add('flipped'); // Show back
// Move to holding position
this.playSound('card');
await this.delay(50);
animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
// The card stays face down until the player decides what to do
// (the actual card reveal happens when server sends card_drawn)
await this.delay(this.timing.pauseAfterMove);
// Clean up - renderGame will show the holding card state
animCard.remove();
}
// Animate drawing from discard
async animateDrawDiscard(movement) {
const { playerId } = movement;
// Discard to holding is mostly visual feedback
// The card "lifts" slightly
const discardRect = this.getLocationRect('discard');
const holdingRect = this.getLocationRect('holding');
if (!discardRect || !holdingRect) return;
// Just play sound - visual handled by CSS :holding state
this.playSound('card');
await this.delay(this.timing.moveDuration);
}
// Check if animations are currently playing
isAnimating() {
return this.animationInProgress;
}
// Clear the queue (for interruption)
clear() {
this.queue = [];
}
// Utility delay
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = AnimationQueue;
}

File diff suppressed because it is too large Load Diff

259
client/card-manager.js Normal file
View File

@@ -0,0 +1,259 @@
// CardManager - Manages persistent card DOM elements
// Cards are REAL elements that exist in ONE place and move between locations
class CardManager {
constructor(cardLayer) {
this.cardLayer = cardLayer;
// Map of "playerId-position" -> card element
this.handCards = new Map();
// Special cards
this.deckCard = null;
this.discardCard = null;
this.holdingCard = null;
}
// Initialize cards for a game state
initializeCards(gameState, playerId, getSlotRect, getDeckRect, getDiscardRect) {
this.clear();
// Create cards for each player's hand
for (const player of gameState.players) {
for (let i = 0; i < 6; i++) {
const card = player.cards[i];
const slotKey = `${player.id}-${i}`;
const cardEl = this.createCardElement(card);
// Position at slot (will be updated later if rect not ready)
const rect = getSlotRect(player.id, i);
if (rect && rect.width > 0) {
this.positionCard(cardEl, rect);
} else {
// Start invisible, will be positioned by updateAllPositions
cardEl.style.opacity = '0';
}
this.handCards.set(slotKey, {
element: cardEl,
cardData: card,
playerId: player.id,
position: i
});
this.cardLayer.appendChild(cardEl);
}
}
}
// Create a card DOM element with 3D flip structure
createCardElement(cardData) {
const card = document.createElement('div');
card.className = 'real-card';
card.innerHTML = `
<div class="card-inner">
<div class="card-face card-face-front"></div>
<div class="card-face card-face-back"><span>?</span></div>
</div>
`;
this.updateCardAppearance(card, cardData);
return card;
}
// Update card visual state (face up/down, content)
updateCardAppearance(cardEl, cardData) {
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
// Reset front classes
front.className = 'card-face card-face-front';
if (!cardData || !cardData.face_up || !cardData.rank) {
// Face down or no data
inner.classList.add('flipped');
front.innerHTML = '';
} else {
// Face up with data
inner.classList.remove('flipped');
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 = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
}
}
}
getSuitSymbol(suit) {
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
}
// Position a card at a rect
positionCard(cardEl, rect, animate = false) {
if (animate) {
cardEl.classList.add('moving');
}
cardEl.style.left = `${rect.left}px`;
cardEl.style.top = `${rect.top}px`;
cardEl.style.width = `${rect.width}px`;
cardEl.style.height = `${rect.height}px`;
if (animate) {
setTimeout(() => cardEl.classList.remove('moving'), 350);
}
}
// Get a hand card by player and position
getHandCard(playerId, position) {
return this.handCards.get(`${playerId}-${position}`);
}
// Update all card positions to match current slot positions
// Returns number of cards successfully positioned
updateAllPositions(getSlotRect) {
let positioned = 0;
for (const [key, cardInfo] of this.handCards) {
const rect = getSlotRect(cardInfo.playerId, cardInfo.position);
if (rect && rect.width > 0) {
this.positionCard(cardInfo.element, rect, false);
// Restore visibility if it was hidden
cardInfo.element.style.opacity = '1';
positioned++;
}
}
return positioned;
}
// Animate a card flip
async flipCard(playerId, position, newCardData, duration = 400) {
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const inner = cardInfo.element.querySelector('.card-inner');
const front = cardInfo.element.querySelector('.card-face-front');
// Set up the front content before flip
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
// Animate flip
inner.classList.remove('flipped');
await this.delay(duration);
cardInfo.cardData = newCardData;
}
// Animate a swap: hand card goes to discard, new card comes to hand
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
const cardInfo = this.getHandCard(playerId, position);
if (!cardInfo) return;
const slotRect = getSlotRect(playerId, position);
const discardRect = getDiscardRect();
if (!slotRect || !discardRect) return;
if (!oldCardData || !oldCardData.rank) {
// Can't animate without card data - just update appearance
this.updateCardAppearance(cardInfo.element, newCardData);
cardInfo.cardData = newCardData;
return;
}
const cardEl = cardInfo.element;
const inner = cardEl.querySelector('.card-inner');
const front = cardEl.querySelector('.card-face-front');
// Step 1: If face down, flip to reveal the old card
if (!oldCardData.face_up) {
// Set front to show old card
front.className = 'card-face card-face-front';
if (oldCardData.rank === '★') {
front.classList.add('joker');
const icon = oldCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${oldCardData.rank}<br>${this.getSuitSymbol(oldCardData.suit)}`;
}
inner.classList.remove('flipped');
await this.delay(400);
}
// Step 2: Move card to discard
cardEl.classList.add('moving');
this.positionCard(cardEl, discardRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
// Pause to show the discarded card
await this.delay(250);
// Step 3: Update card to show new card and move back to hand
front.className = 'card-face card-face-front';
if (newCardData.rank === '★') {
front.classList.add('joker');
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
} else {
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
front.classList.add(isRed ? 'red' : 'black');
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
}
if (!newCardData.face_up) {
inner.classList.add('flipped');
}
cardEl.classList.add('moving');
this.positionCard(cardEl, slotRect);
await this.delay(duration + 50);
cardEl.classList.remove('moving');
cardInfo.cardData = newCardData;
}
// Set holding state for a card (drawn card highlight)
setHolding(playerId, position, isHolding) {
const cardInfo = this.getHandCard(playerId, position);
if (cardInfo) {
cardInfo.element.classList.toggle('holding', isHolding);
}
}
// Clear all cards
clear() {
for (const [key, cardInfo] of this.handCards) {
cardInfo.element.remove();
}
this.handCards.clear();
if (this.holdingCard) {
this.holdingCard.remove();
this.holdingCard = null;
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = CardManager;
}

View File

@@ -200,19 +200,24 @@
<!-- Game Screen -->
<div id="game-screen" class="screen">
<!-- Card layer for persistent card elements -->
<div id="card-layer"></div>
<div class="game-layout">
<div class="game-main">
<div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
<div class="game-header-center">
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="header-status">
<div id="status-message" class="status-message"></div>
</div>
</div>
<div class="turn-info" id="turn-info">Your turn</div>
<div class="score-info">Showing: <span id="your-score">0</span></div>
<div class="header-buttons">
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div>
</div>
@@ -225,22 +230,30 @@
<div id="deck" class="card card-back">
<span>?</span>
</div>
<div id="discard" class="card">
<span id="discard-content"></span>
<div class="discard-stack">
<div id="discard" class="card">
<span id="discard-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
</div>
</div>
<div id="drawn-card-area" class="hidden">
<div id="drawn-card" class="card"></div>
<button id="discard-btn" class="btn btn-small">Discard</button>
</div>
</div>
<div class="player-section">
<div id="flip-prompt" class="flip-prompt hidden"></div>
<div class="player-area">
<h4 id="player-header">You<span id="your-score" class="player-showing">0</span></h4>
<div id="player-cards" class="card-grid"></div>
</div>
<div id="toast" class="toast hidden"></div>
</div>
<!-- Legacy swap animation overlay (kept for rollback) -->
<div id="swap-animation" class="swap-animation hidden">
<div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
</div>
</div>
</div>
</div>
</div>
@@ -287,6 +300,9 @@
</div>
</div>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>
<script src="app.js"></script>
</body>
</html>

164
client/state-differ.js Normal file
View File

@@ -0,0 +1,164 @@
// StateDiffer - Detects what changed between game states
// Generates movement instructions for the animation queue
class StateDiffer {
constructor() {
this.previousState = null;
}
// Compare old and new state, return array of movements
diff(oldState, newState) {
const movements = [];
if (!oldState || !newState) {
return movements;
}
// Check for initial flip phase - still animate initial flips
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
// Initial flip just completed - detect which cards were flipped
for (const newPlayer of newState.players) {
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
if (oldPlayer) {
for (let i = 0; i < 6; i++) {
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
movements.push({
type: 'flip',
playerId: newPlayer.id,
position: i,
faceUp: true,
card: newPlayer.cards[i]
});
}
}
}
}
return movements;
}
// Still in initial flip selection - no animations
if (newState.waiting_for_initial_flip) {
return movements;
}
// Check for turn change - the previous player just acted
const previousPlayerId = oldState.current_player_id;
const currentPlayerId = newState.current_player_id;
const turnChanged = previousPlayerId !== currentPlayerId;
// Detect if a swap happened (discard changed AND a hand position changed)
const newTop = newState.discard_top;
const oldTop = oldState.discard_top;
const discardChanged = newTop && (!oldTop ||
oldTop.rank !== newTop.rank ||
oldTop.suit !== newTop.suit);
// Find hand changes for the player who just played
if (turnChanged && previousPlayerId) {
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
if (oldPlayer && newPlayer) {
// First pass: detect swaps (card identity changed)
const swappedPositions = new Set();
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
// Card identity changed = swap happened at this position
if (this.cardIdentityChanged(oldCard, newCard)) {
swappedPositions.add(i);
// Use discard_top for the revealed card (more reliable for opponents)
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
movements.push({
type: 'swap',
playerId: previousPlayerId,
position: i,
oldCard: revealedCard,
newCard: newCard
});
break; // Only one swap per turn
}
}
// Second pass: detect flips (card went from face_down to face_up, not a swap)
for (let i = 0; i < 6; i++) {
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
if (this.cardWasFlipped(oldCard, newCard)) {
movements.push({
type: 'flip',
playerId: previousPlayerId,
position: i,
faceUp: true,
card: newCard
});
}
}
}
}
// Detect drawing (current player just drew)
if (newState.has_drawn_card && !oldState.has_drawn_card) {
// Discard pile decreased = drew from discard
const drewFromDiscard = !newState.discard_top ||
(oldState.discard_top &&
(!newState.discard_top ||
oldState.discard_top.rank !== newState.discard_top.rank ||
oldState.discard_top.suit !== newState.discard_top.suit));
movements.push({
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
playerId: currentPlayerId
});
}
return movements;
}
// Check if the card identity (rank+suit) changed between old and new
// Returns true if definitely different cards, false if same or unknown
cardIdentityChanged(oldCard, newCard) {
// If both have rank/suit data, compare directly
if (oldCard.rank && newCard.rank) {
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
}
// Can't determine - assume same card (flip, not swap)
return false;
}
// Check if a card was just flipped (same card, now face up)
cardWasFlipped(oldCard, newCard) {
return !oldCard.face_up && newCard.face_up;
}
// Get a summary of movements for debugging
summarize(movements) {
return movements.map(m => {
switch (m.type) {
case 'flip':
return `Flip: Player ${m.playerId} position ${m.position}`;
case 'swap':
return `Swap: Player ${m.playerId} position ${m.position}`;
case 'discard':
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
case 'draw-deck':
return `Draw from deck: Player ${m.playerId}`;
case 'draw-discard':
return `Draw from discard: Player ${m.playerId}`;
default:
return `Unknown: ${m.type}`;
}
}).join('\n');
}
}
// Export for use in app.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = StateDiffer;
}

View File

@@ -462,13 +462,11 @@ input::placeholder {
/* Game Screen */
.game-header {
display: grid;
grid-template-columns: auto 1fr auto auto auto;
display: flex;
align-items: center;
gap: 15px;
padding: 10px 25px;
justify-content: space-between;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
border-radius: 0;
font-size: 0.9rem;
width: 100vw;
margin-left: calc(-50vw + 50%);
@@ -480,20 +478,22 @@ input::placeholder {
white-space: nowrap;
}
.game-header-center {
display: flex;
align-items: center;
gap: 40px;
}
.game-header .turn-info {
font-weight: 600;
color: #f4a460;
white-space: nowrap;
}
.game-header .score-info {
white-space: nowrap;
}
.game-header .header-buttons {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
#leave-game-btn {
@@ -602,8 +602,24 @@ input::placeholder {
}
.card-front.joker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.card-front.joker .joker-icon {
font-size: 1.6em;
line-height: 1;
}
.card-front.joker .joker-label {
font-size: 0.45em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9b59b6;
font-size: 1.1rem;
}
.card.clickable {
@@ -762,6 +778,25 @@ input::placeholder {
border: 2px solid #ddd;
}
/* Holding state - when player has drawn a card */
#discard.holding {
background: #fff;
border: 3px solid #f4a460;
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
transform: scale(1.05);
}
.discard-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.discard-stack .btn {
white-space: nowrap;
}
#deck.disabled,
#discard.disabled {
opacity: 0.5;
@@ -777,45 +812,140 @@ input::placeholder {
/* Card flip animation for discard pile */
.card-flip-in {
animation: cardFlipIn 0.4s ease-out;
animation: cardFlipIn 0.5s ease-out;
}
@keyframes cardFlipIn {
0% {
transform: scale(1.3) rotateY(90deg);
opacity: 0.5;
box-shadow: 0 0 30px rgba(244, 164, 96, 0.8);
transform: scale(1.4) translateY(-20px);
opacity: 0;
box-shadow: 0 0 40px rgba(244, 164, 96, 1);
}
50% {
transform: scale(1.15) rotateY(0deg);
30% {
transform: scale(1.25) translateY(-10px);
opacity: 1;
box-shadow: 0 0 25px rgba(244, 164, 96, 0.6);
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) rotateY(0deg);
transform: scale(1) translateY(0);
opacity: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
#drawn-card-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(0,0,0,0.25);
/* Swap animation overlay */
.swap-animation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
perspective: 1000px;
}
.swap-animation.hidden {
display: none;
}
.swap-card {
position: absolute;
width: 70px;
height: 98px;
perspective: 1000px;
}
.swap-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
}
.swap-card.flipping .swap-card-inner {
transform: rotateY(180deg);
}
.swap-card-front,
.swap-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
}
#drawn-card-area .card {
width: clamp(80px, 7vw, 120px);
height: clamp(112px, 9.8vw, 168px);
font-size: clamp(2.4rem, 3.2vw, 4rem);
.swap-card-back {
background: linear-gradient(135deg, #c0392b 0%, #922b21 100%);
color: rgba(255,255,255,0.4);
font-size: 2rem;
}
#drawn-card-area .btn {
white-space: nowrap;
.swap-card-front {
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
transform: rotateY(180deg);
font-size: 2rem;
flex-direction: column;
color: #2c3e50;
line-height: 1.1;
font-weight: bold;
}
.swap-card-front.red {
color: #e74c3c;
}
.swap-card-front.black {
color: #2c3e50;
}
.swap-card-front.joker {
color: #9b59b6;
}
.swap-card-front .joker-icon {
font-size: 1.6em;
line-height: 1;
}
.swap-card-front .joker-label {
font-size: 0.45em;
font-weight: 700;
text-transform: uppercase;
}
/* Movement animation */
.swap-card.flipping {
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
}
.swap-card.moving {
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out;
transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
}
/* Card in hand fading during swap */
.card.swap-out {
opacity: 0;
transition: opacity 0.1s;
}
/* Discard fading during swap */
#discard.swap-to-hand {
opacity: 0;
transition: opacity 0.2s;
}
/* Player Area */
@@ -856,7 +986,8 @@ input::placeholder {
align-items: center;
}
.opponent-showing {
.opponent-showing,
.player-showing {
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.25);
@@ -866,6 +997,18 @@ input::placeholder {
margin-left: 8px;
}
/* Player area header - matches opponent style */
.player-area h4 {
font-size: clamp(0.8rem, 1vw, 1.1rem);
margin: 0 0 8px 0;
padding: clamp(4px, 0.4vw, 8px) clamp(10px, 1vw, 16px);
background: rgba(244, 164, 96, 0.6);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.opponent-area .card-grid {
display: grid;
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
@@ -885,25 +1028,27 @@ input::placeholder {
}
/* Toast Notification */
.toast {
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 8px 20px;
border-radius: 6px;
font-size: 0.85rem;
/* Header status area */
.header-status {
display: flex;
align-items: center;
justify-content: center;
min-width: 200px;
}
.status-message {
padding: 6px 16px;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
text-align: center;
margin-top: 8px;
animation: toastIn 0.3s ease;
white-space: nowrap;
color: #fff;
}
.toast.hidden {
display: none;
}
.toast.your-turn {
.status-message.your-turn {
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
color: #1a472a;
font-weight: 600;
}
@keyframes toastIn {
@@ -915,12 +1060,12 @@ input::placeholder {
.flip-prompt {
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
color: #1a472a;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.9rem;
padding: 6px 16px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
margin-bottom: 8px;
white-space: nowrap;
}
.flip-prompt.hidden {
@@ -955,44 +1100,40 @@ input::placeholder {
/* Side Panels - positioned in bottom corners */
.side-panel {
position: fixed;
bottom: 20px;
bottom: 15px;
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
border-radius: 16px;
padding: 18px 20px;
width: 263px;
border-radius: 10px;
padding: 10px 12px;
width: 200px;
z-index: 100;
backdrop-filter: blur(10px);
border: 1px solid rgba(244, 164, 96, 0.25);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.5),
0 0 40px rgba(244, 164, 96, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.side-panel.left-panel {
left: 20px;
left: 15px;
}
.side-panel.right-panel {
right: 20px;
right: 15px;
}
.side-panel > h4 {
font-size: 1rem;
font-size: 0.7rem;
text-align: center;
margin-bottom: 14px;
margin-bottom: 8px;
color: #f4a460;
text-transform: uppercase;
letter-spacing: 0.2em;
letter-spacing: 0.15em;
font-weight: 700;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
padding-bottom: 12px;
padding-bottom: 6px;
}
/* Standings list - two sections, top 4 each */
.standings-section {
margin-bottom: 10px;
margin-bottom: 6px;
}
.standings-section:last-child {
@@ -1000,27 +1141,27 @@ input::placeholder {
}
.standings-title {
font-size: 0.7rem;
font-size: 0.6rem;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
padding-bottom: 3px;
margin-bottom: 3px;
padding-bottom: 2px;
margin-bottom: 2px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.standings-list .rank-row {
display: grid;
grid-template-columns: 22px 1fr 36px;
gap: 4px;
font-size: 0.8rem;
padding: 2px 0;
grid-template-columns: 18px 1fr 30px;
gap: 3px;
font-size: 0.7rem;
padding: 1px 0;
align-items: center;
}
.standings-list .rank-pos {
text-align: center;
font-size: 0.75rem;
font-size: 0.65rem;
}
.standings-list .rank-name {
@@ -1031,7 +1172,7 @@ input::placeholder {
.standings-list .rank-val {
text-align: right;
font-size: 0.75rem;
font-size: 0.65rem;
color: rgba(255,255,255,0.7);
}
@@ -1047,12 +1188,12 @@ input::placeholder {
.side-panel table {
width: 100%;
border-collapse: collapse;
font-size: 1rem;
font-size: 0.75rem;
}
.side-panel th,
.side-panel td {
padding: 8px 6px;
padding: 4px 3px;
text-align: center;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
@@ -1060,9 +1201,9 @@ input::placeholder {
.side-panel th {
font-weight: 600;
background: rgba(0,0,0,0.25);
font-size: 0.85rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.6);
}
@@ -1081,15 +1222,15 @@ input::placeholder {
}
.game-buttons {
margin-top: 12px;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 5px;
}
.game-buttons .btn {
font-size: 0.8rem;
padding: 10px 12px;
font-size: 0.7rem;
padding: 6px 8px;
width: 100%;
}
@@ -1347,6 +1488,181 @@ input::placeholder {
.suit-clubs::after { content: "♣"; }
.suit-spades::after { content: "♠"; }
/* ============================================
New Card System - Persistent Card Elements
============================================ */
/* Card Layer - container for all persistent cards */
#card-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 500;
perspective: 1000px;
}
#card-layer .real-card {
pointer-events: auto;
}
/* Card Slot - only used when USE_NEW_CARD_SYSTEM is true */
.card-slot {
display: none;
}
/* Real Card - persistent card element with 3D structure */
.real-card {
position: fixed;
border-radius: 6px;
perspective: 1000px;
z-index: 501;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
}
.real-card:hover {
z-index: 510;
}
.real-card .card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
}
.real-card .card-inner.flipped {
transform: rotateY(180deg);
}
.real-card .card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* Card Front */
.real-card .card-face-front {
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 2px solid #ddd;
color: #333;
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
line-height: 1.1;
}
.real-card .card-face-front.red {
color: #c0392b;
}
.real-card .card-face-front.black {
color: #2c3e50;
}
.real-card .card-face-front.joker {
color: #9b59b6;
}
.real-card .card-face-front .joker-icon {
font-size: 1.5em;
line-height: 1;
}
.real-card .card-face-front .joker-label {
font-size: 0.4em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Card Back */
.real-card .card-face-back {
background-color: #c41e3a;
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%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.25) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.25) 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
border: 3px solid #8b1528;
color: rgba(255, 255, 255, 0.5);
font-size: clamp(1.8rem, 2.5vw, 3rem);
transform: rotateY(180deg);
}
/* Card States */
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s 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 */
.real-card.anim-card {
z-index: 700;
pointer-events: none;
}
.real-card.anim-card .card-inner {
transition: transform 0.4s ease-in-out;
}
.real-card.holding {
z-index: 550;
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4);
transform: scale(1.08);
}
.real-card.clickable {
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
}
.real-card.clickable:hover {
box-shadow: 0 0 0 3px #f4a460,
0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.02);
}
.real-card.selected {
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
transform: scale(1.06);
z-index: 520;
}
.real-card.drawing {
z-index: 590;
}
/* Deck card styling in new system */
#deck.new-system {
cursor: pointer;
position: relative;
}
/* Discard styling for new system */
#discard.new-system.holding {
box-shadow: none;
background: rgba(255, 255, 255, 0.1);
border: 2px dashed rgba(244, 164, 96, 0.5);
}
/* Modal */
.modal {
position: fixed;