Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.
This commit is contained in:
378
client/animation-queue.js
Normal file
378
client/animation-queue.js
Normal 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;
|
||||
}
|
||||
1023
client/app.js
1023
client/app.js
File diff suppressed because it is too large
Load Diff
259
client/card-manager.js
Normal file
259
client/card-manager.js
Normal 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;
|
||||
}
|
||||
@@ -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
164
client/state-differ.js
Normal 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;
|
||||
}
|
||||
484
client/style.css
484
client/style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user