Files
wiki-tcg/frontend/src/routes/play/+page.svelte
2026-03-26 00:51:25 +01:00

1194 lines
32 KiB
Svelte

<script>
import { API_URL, WS_URL } from '$lib/api.js';
import { apiFetch } from '$lib/api.js';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import Card from '$lib/Card.svelte';
import DeckTypeBadge from '$lib/DeckTypeBadge.svelte';
const token = () => localStorage.getItem('token');
let queueWs = null;
let gameWs = null;
let phase = $state('idle');
let error = $state('');
let decks = $state([]);
let selectedDeckId = $state('');
let selectedDeck = $derived(decks.find(d => d.id === selectedDeckId));
let gameId = $state('');
let gameState = $state(null);
let myId = $state('');
let viewingBoard = $state(false);
let showDifficultyModal = $state(false);
let selectedDifficulty = $state(5);
const difficultyLabel = $derived(
selectedDifficulty <= 2 ? 'Throws the game' :
selectedDifficulty === 3 ? 'Fully random' :
selectedDifficulty <= 5 ? 'Beginner' :
selectedDifficulty <= 7 ? 'Intermediate' :
selectedDifficulty <= 9 ? 'Advanced' :
'Expert'
);
let selectedHandIndex = $state(null);
let combatAnimating = $state(false);
let lunging = $state(new Set());
let lungingDown = $state(new Set());
let shaking = $state(new Set());
let me = $derived(gameState?.you);
let opp = $derived(gameState?.opponent);
let isMyTurn = $derived(gameState?.active_player_id === myId);
let gameOver = $derived(!!gameState?.result);
let sacrificeMode = $state(false);
let displayedDefense = $state({});
let destroying = $state(new Set());
let destroyed = $state(new Set());
let displayedLife = $state({});
const TURN_TIME_LIMIT = 120; // seconds
const TIMER_WARNING = 30; // show timer when this many seconds remain
let turnStartedAt = $state(null);
let secondsRemaining = $state(TURN_TIME_LIMIT);
let timerInterval = null
$effect(() => {
if (!gameState?.turn_started_at) return;
turnStartedAt = new Date(gameState.turn_started_at);
clearInterval(timerInterval);
timerInterval = setInterval(async () => {
const elapsed = (Date.now() - turnStartedAt) / 1000;
secondsRemaining = Math.max(0, TURN_TIME_LIMIT - elapsed);
if (secondsRemaining <= 0 && !isMyTurn && gameState && !gameState.result) {
clearInterval(timerInterval);
await claimTimeoutWin();
}
}, 500);
});
onDestroy(() => {
clearInterval(timerInterval);
});
async function claimTimeoutWin() {
const res = await apiFetch(`${API_URL}/game/${gameId}/claim-timeout-win`, {
method: 'POST'
});
if (!res.ok) {
// Server rejected the claim. Game may have ended another way
const err = await res.json();
console.warn('Timeout claim rejected:', err.detail);
}
}
$effect(() => {
if (!gameState || combatAnimating) return;
displayedLife = {
[gameState.you.user_id]: Math.max(0, gameState.you.life),
[gameState.opponent.user_id]: Math.max(0, gameState.opponent.life),
};
});
// Keep displayedDefense in sync with board state when not animating
$effect(() => {
if (!gameState || combatAnimating) return;
const all = [
...(gameState.you.board.filter(Boolean) || []),
...(gameState.opponent.board.filter(Boolean) || []),
];
const next = {};
for (const card of all) next[card.instance_id] = card.defense;
displayedDefense = next;
});
// Reset sacrifice mode when turn changes
$effect(() => {
if (!isMyTurn) sacrificeMode = false;
});
onMount(async () => {
if (!token()) { goto('/auth'); return; }
const res = await apiFetch(`${API_URL}/decks`);
decks = await res.json();
if (decks.length > 0) selectedDeckId = decks[0].id;
});
onDestroy(() => {
queueWs?.close();
gameWs?.close();
});
function joinQueue() {
if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return;
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
queueWs.onopen = () => queueWs.send(token());
queueWs.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'game_start') {
gameId = msg.game_id;
queueWs.close();
connectToGame();
} else if (msg.type === 'error') {
error = msg.message;
phase = 'idle';
}
};
queueWs.onerror = () => { error = 'Connection failed'; phase = 'idle'; };
}
function connectToGame() {
gameWs = new WebSocket(`${WS_URL}/ws/game/${gameId}`);
gameWs.onopen = () => gameWs.send(token());
gameWs.onmessage = async (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') {
const newState = msg.state;
if (
gameState &&
newState.last_combat_events?.length > 0 &&
JSON.stringify(newState.last_combat_events) !== JSON.stringify(gameState.last_combat_events)
) {
await animateCombat(newState);
}
destroyed = new Set();
gameState = newState;
if (!myId) myId = newState.you.user_id;
phase = newState.result ? 'ended' : 'playing';
} else if (msg.type === 'sacrifice_animation') {
const id = msg.instance_id;
destroying = new Set([...destroying, id]);
await delay(600);
destroying = new Set([...destroying].filter(i => i !== id));
destroyed = new Set([...destroyed, id]);
} else if (msg.type === 'error') {
error = msg.message;
setTimeout(() => error = '', 3000);
}
};
gameWs.onerror = () => { error = 'Connection lost'; };
}
async function animateCombat(newState) {
combatAnimating = true;
// The attacker is whoever was active when end_turn was called.
// After end_turn resolves, active_player_id switches. So we look
// at who is NOT the current active player to find the attacker,
// unless the game just ended (result is set), in which case
// active_player_id hasn't switched yet.
const attackerId = newState.result
? newState.active_player_id
: newState.player_order.find(id => id !== newState.active_player_id);
const attackerIsMe = attackerId === myId;
for (const event of newState.last_combat_events) {
await delay(100);
const attackerBoard = attackerIsMe ? gameState.you.board : gameState.opponent.board;
const defenderBoard = attackerIsMe ? gameState.opponent.board : gameState.you.board;
const attacker = attackerBoard[event.attacker_slot];
const defender = event.defender_slot !== null ? defenderBoard[event.defender_slot] : null;
if (attacker) {
if (attackerIsMe) {
lunging = new Set([...lunging, attacker.instance_id]);
} else {
lungingDown = new Set([...lungingDown, attacker.instance_id]);
}
if (defender) shaking = new Set([...shaking, defender.instance_id]);
await delay(220);
if (defender) {
const newDefense = Math.max(0, (displayedDefense[defender.instance_id] ?? defender.defense) - attacker.attack);
displayedDefense = { ...displayedDefense, [defender.instance_id]: newDefense };
} else {
// Direct life damage
const defenderId = attackerIsMe ? gameState.opponent.user_id : gameState.you.user_id;
const newLife = Math.max(0, (displayedLife[defenderId] ?? 0) - attacker.attack);
displayedLife = { ...displayedLife, [defenderId]: newLife };
}
await delay(200);
lunging = new Set([...lunging].filter(id => id !== attacker.instance_id));
lungingDown = new Set([...lungingDown].filter(id => id !== attacker.instance_id));
if (defender) shaking = new Set([...shaking].filter(id => id !== defender.instance_id));
if (defender && (displayedDefense[defender.instance_id] ?? defender.defense) <= 0) {
destroying = new Set([...destroying, defender.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== defender.instance_id));
destroyed = new Set([...destroyed, defender.instance_id]);
}
await delay(80);
}
}
combatAnimating = false;
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function send(msg) { gameWs?.send(JSON.stringify(msg)); }
function selectHandCard(index) {
if (!isMyTurn || combatAnimating) return;
selectedHandIndex = selectedHandIndex === index ? null : index;
}
function clickSlot(slot) {
if (!isMyTurn || combatAnimating || selectedHandIndex === null) return;
send({ type: 'play_card', hand_index: selectedHandIndex, slot });
selectedHandIndex = null;
}
async function sacrifice(slot) {
if (!isMyTurn || combatAnimating) return;
const card = me.board[slot];
if (!card) return;
destroying = new Set([...destroying, card.instance_id]);
await delay(600);
destroying = new Set([...destroying].filter(id => id !== card.instance_id));
destroyed = new Set([...destroyed, card.instance_id]);
send({ type: 'sacrifice', slot });
sacrificeMode = false;
}
function endTurn() {
if (!isMyTurn || combatAnimating) return;
selectedHandIndex = null;
send({ type: 'end_turn' });
}
function handleHandCardMouseMove(e, node) {
const rect = node.getBoundingClientRect();
const cy = rect.top + rect.height / 2;
const dy = (e.clientY - cy) / (rect.height / 2);
const ty = -dy * 80;
node.style.setProperty('--peek-y', `${ty}px`);
}
function handleHandCardMouseLeave(node) {
node.style.setProperty('--peek-y', '0px');
}
async function joinSolo() {
if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return;
showDifficultyModal = false;
error = '';
phase = 'queuing';
const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}&difficulty=${selectedDifficulty}`, {
method: 'POST'
});
if (!res.ok) {
const err = await res.json();
error = err.detail;
phase = 'idle';
return;
}
const data = await res.json();
gameId = data.game_id;
connectToGame();
}
</script>
<main>
{#if phase === 'idle'}
<div class="lobby">
<h1 class="lobby-title">Find a Match</h1>
<a href="/how-to-play" class="how-to-play-link">How to Play</a>
{#if decks.length === 0}
<p class="lobby-hint">You need a deck to play. <a href="/decks">Build one first.</a></p>
{:else}
<div class="deck-select">
<label class="deck-label" for="deck">Choose your deck</label>
<select id="deck" bind:value={selectedDeckId}>
{#each decks as deck}
<option value={deck.id}>{deck.name} ({deck.card_count} cards, cost {deck.total_cost})</option>
{/each}
</select>
</div>
<p class="error">{selectedDeck && (selectedDeck.total_cost === 0 || selectedDeck.total_cost > 50) ? `Deck cost must be between 1 and 50 (current: ${selectedDeck.total_cost})` : ''}</p>
<div class="lobby-buttons">
<button class="play-btn" onclick={joinQueue} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
Find Opponent
</button>
<button class="play-btn solo-btn" onclick={() => showDifficultyModal = true} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
vs. Computer
</button>
</div>
{/if}
</div>
{:else if phase === 'queuing'}
<div class="lobby">
<div class="spinner"></div>
<p class="lobby-hint">Waiting for an opponent...</p>
<button class="cancel-btn" onclick={() => { queueWs?.close(); phase = 'idle'; }}>Cancel</button>
</div>
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
<div class="game">
<div class="sidebar left-sidebar">
<div class="sidebar-section top-section">
<div class="sidebar-name opp-name">{opp.username}</div>
<DeckTypeBadge deckType={opp.deck_type} />
<div class="sidebar-life">{displayedLife[opp.user_id] ?? opp.life}</div>
<div class="sidebar-deck">Deck: {opp.deck_size}</div>
<div class="sidebar-hand">Hand: {opp.hand_size}</div>
</div>
<div class="sidebar-section bottom-section">
<div class="sidebar-name you-name">You</div>
<div class="sidebar-life">{displayedLife[me.user_id] ?? me.life}</div>
<div class="sidebar-energy">
<div class="cost-bubble-display"></div>
<span class="energy-count">{me.energy}/{me.energy_cap}</span>
{#if isMyTurn && !combatAnimating}
<button
class="sacrifice-mode-btn"
class:active={sacrificeMode}
onclick={() => sacrificeMode = !sacrificeMode}
title="Sacrifice a card"
>🗡</button>
{/if}
</div>
<div class="sidebar-deck">Deck: {me.deck_size}</div>
</div>
</div>
<div class="field">
<div class="board opponent-board">
{#each opp.board as card, slot}
<div class="slot">
{#if card && !destroyed.has(card.instance_id)}
<div class="board-card-wrap"
class:lunging={lunging.has(card.instance_id)}
class:lunge-down={lungingDown.has(card.instance_id)}
class:shaking={shaking.has(card.instance_id)}
class:destroying={destroying.has(card.instance_id)}
>
<div class="board-card">
<Card {card} noHover={true} defenseOverride={displayedDefense[card.instance_id] ?? card.defense} />
</div>
</div>
{:else}
<div class="empty-slot"></div>
{/if}
</div>
{/each}
</div>
<div class="divider">
<span class="turn-indicator" class:my-turn={isMyTurn}>
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
</span>
{#if secondsRemaining <= TIMER_WARNING}
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
{Math.ceil(secondsRemaining)}s
</span>
{/if}
</div>
<div class="board my-board">
{#each me.board as card, slot}
<div
class="slot"
class:targetable={selectedHandIndex !== null && card === null}
onclick={() => {
if (sacrificeMode && card) { sacrifice(slot); }
else if (card === null) { clickSlot(slot); }
}}
>
{#if card && !destroyed.has(card.instance_id)}
<div class="board-card-wrap"
class:lunging={lunging.has(card.instance_id)}
class:lunge-down={lungingDown.has(card.instance_id)}
class:shaking={shaking.has(card.instance_id)}
class:destroying={destroying.has(card.instance_id)}
>
<div class="board-card">
<Card {card} noHover={true} defenseOverride={displayedDefense[card.instance_id] ?? card.defense} />
</div>
{#if sacrificeMode}
<div class="sacrifice-overlay">🗡</div>
{/if}
</div>
{:else}
<div class="empty-slot" class:highlight={selectedHandIndex !== null}>
{#if selectedHandIndex !== null}
<span class="slot-hint">Play here</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
<div class="sidebar right-sidebar">
{#if phase === 'ended'}
<button class="end-turn-btn" onclick={() => viewingBoard = false}>Go Back</button>
{:else if isMyTurn && !combatAnimating}
<button class="end-turn-btn" onclick={endTurn}>End Turn</button>
{/if}
</div>
</div>
<div class="hand" class:inactive={!isMyTurn}>
{#each me.hand as card, i}
<button
class="hand-card"
class:selected={selectedHandIndex === i}
class:unaffordable={card.cost > me.energy || !isMyTurn}
onclick={() => selectHandCard(i)}
disabled={!isMyTurn || card.cost > me.energy}
onmousemove={(e) => handleHandCardMouseMove(e, e.currentTarget)}
onmouseleave={(e) => handleHandCardMouseLeave(e.currentTarget)}
>
<Card {card} noHover={true} />
</button>
{/each}
</div>
{#if error}
<div class="toast">{error}</div>
{/if}
{:else if phase === 'ended' && gameState?.result}
<div class="lobby">
<h1 class="lobby-title" class:win={gameState.result.winner_id === myId} class:lose={gameState.result.winner_id !== myId}>
{gameState.result.winner_id === myId ? 'Victory' : 'Defeat'}
</h1>
<p class="lobby-hint">{gameState.result.reason}</p>
<div class="lobby-buttons">
<button class="play-btn" onclick={() => viewingBoard = true}>View Board</button>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go Back</button>
</div>
</div>
{/if}
{#if showDifficultyModal}
<div class="modal-backdrop" onclick={() => showDifficultyModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 class="modal-title">Choose Difficulty</h2>
<div class="difficulty-display">
<span class="difficulty-number">{selectedDifficulty}</span>
<span class="difficulty-label">{difficultyLabel}</span>
</div>
<input
class="difficulty-slider"
type="range"
min="1"
max="10"
bind:value={selectedDifficulty}
/>
<div class="difficulty-ticks">
<span>1</span><span>10</span>
</div>
<div class="modal-buttons">
<button class="cancel-btn" onclick={() => showDifficultyModal = false}>Cancel</button>
<button class="play-btn" onclick={joinSolo}>Start Game</button>
</div>
</div>
</div>
{/if}
</main>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
main {
height: 100vh;
overflow: hidden;
background: #0d0a04;
display: flex;
flex-direction: column;
}
/* ── Lobby ── */
.lobby {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.lobby-title {
font-family: 'Cinzel', serif;
font-size: 32px;
font-weight: 900;
color: #f0d080;
margin: 0;
letter-spacing: 0.1em;
}
.lobby-title.win { color: #6aaa6a; }
.lobby-title.lose { color: #c85050; }
.how-to-play-link {
font-family: 'Crimson Text', serif;
font-size: 14px;
font-style: italic;
color: rgba(240, 180, 80, 0.4);
text-decoration: underline;
transition: color 0.15s;
margin-top: -1rem;
padding-bottom: 20px;
}
.how-to-play-link:hover { color: rgba(240, 180, 80, 0.7); }
.lobby-hint {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.6);
margin: 0;
text-align: center;
}
.lobby-hint a { color: #f0d080; }
.final-life {
font-family: 'Cinzel', serif;
font-size: 13px;
color: rgba(240, 180, 80, 0.5);
}
.deck-label {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.5);
display: block;
margin-bottom: 0.5rem;
}
.deck-select { display: flex; flex-direction: column; }
select {
font-family: 'Crimson Text', serif;
font-size: 15px;
color: #f0d080;
background: #1a1008;
border: 1.5px solid #6b4c1e;
border-radius: 6px;
padding: 8px 12px;
outline: none;
cursor: pointer;
min-width: 240px;
}
.play-btn, .cancel-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 10px 32px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.play-btn {
background: #c8861a;
color: #fff8e0;
border: none;
}
.play-btn:hover:not(:disabled) { background: #e09820; }
.play-btn:disabled {
background: #6b4c1e;
color: rgba(255, 248, 224, 0.4);
cursor: not-allowed;
}
.lobby-buttons {
display: flex;
gap: 1rem;
}
.solo-btn {
background: #2a3d20;
border: 1px solid #5a8a40;
color: #a8d880;
}
.solo-btn:hover:not(:disabled) {
background: #3a5a2a;
}
.solo-btn:disabled {
background: #1a2510;
color: rgba(168, 216, 128, 0.3);
cursor: not-allowed;
border-color: #3a5a2a;
}
.cancel-btn {
background: none;
color: rgba(240, 180, 80, 0.6);
border: 1px solid rgba(107, 76, 30, 0.4);
}
.cancel-btn:hover { border-color: #c8861a; color: #f0d080; }
.error {
font-family: 'Crimson Text', serif;
font-size: 14px;
color: #c85050;
margin: 0;
height: 1.4em;
margin-top: -1rem;
margin-bottom: -1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(200, 134, 26, 0.2);
border-top-color: #c8861a;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Game layout ── */
.game {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0 0.5rem;
}
/* ── Sidebars ── */
.left-sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 200px;
flex-shrink: 0;
padding: 0.5rem;
}
.right-sidebar {
width: 100px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem 0.5rem;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: flex-start;
}
.sidebar-section :global(.type-badge) {
align-self: flex-start;
}
.bottom-section {
padding-bottom: 0.5rem;
}
.sidebar-name {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.opp-name { color: rgba(200, 80, 80, 0.8); }
.you-name { color: #c8861a; }
.sidebar-life {
font-family: 'Cinzel', serif;
font-size: 30px;
font-weight: 700;
color: #f0d080;
}
.sidebar-deck, .sidebar-hand {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.45);
}
.sidebar-energy {
display: flex;
align-items: center;
gap: 4px;
}
.cost-bubble-display {
width: 22px;
height: 22px;
border-radius: 50%;
background: #6ea0ec;
border: 2.5px solid #000;
display: flex;
align-items: center;
justify-content: center;
color: #08152c;
font-size: 13px;
font-weight: 700;
font-family: 'Cinzel', serif;
flex-shrink: 0;
}
.energy-count {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
}
.sacrifice-mode-btn {
background: none;
color: rgba(107, 76, 30, 1);
border: 2px solid rgba(107, 76, 30, 1);
border-radius: 15px;
font-size: 16px;
cursor: pointer;
padding: 6px 5.5px;
line-height: 1;
transition: background 0.15s, border-color 0.15s;
margin-left: 4px;
}
.sacrifice-mode-btn:hover {
border-color: #c8861a;
background: rgba(200, 134, 26, 0.1);
}
.sacrifice-mode-btn.active {
background: rgba(180, 40, 40, 0.3);
border-color: #c84040;
}
.end-turn-btn {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #c8861a;
border: none;
border-radius: 4px;
color: #fff8e0;
padding: 10px 8px;
cursor: pointer;
transition: background 0.15s;
margin-top: auto;
}
.end-turn-btn:hover { background: #e09820; }
.sacrifice-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
background: rgba(180, 40, 40, 0.35);
border-radius: 10px;
cursor: pointer;
z-index: 10;
transition: background 0.15s;
}
.sacrifice-overlay:hover {
background: rgba(220, 60, 60, 0.5);
}
/* ── Field ── */
.field {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.25rem;
min-height: 0;
}
.board {
flex: 1;
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
min-height: 0;
}
.slot {
/* card aspect ratio 300:460 */
flex: 0 0 calc(400px * 0.55 * 300 / 400);
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Board cards ── */
.board-card-wrap {
position: relative;
width: calc(300px * 0.55);
height: calc(400px * 0.55);
flex-shrink: 0;
transition: transform 0.15s, filter 0.15s;
}
.my-board .board-card-wrap:hover {
transform: scale(1.5);
z-index: 25;
}
.opponent-board .board-card-wrap:hover {
transform: scale(1.5) translateY(20px);
z-index: 25;
}
.board-card-wrap.destroying {
animation: crumble 0.6s ease-in forwards;
z-index: 20;
pointer-events: none;
}
@keyframes crumble {
0% {
transform: scale(1) rotate(0deg);
opacity: 1;
filter: brightness(1);
}
15% {
transform: scale(1.08) rotate(-3deg);
filter: brightness(2) saturate(0);
}
30% {
transform: scale(0.95) rotate(5deg);
filter: brightness(1.5) saturate(0);
}
50% {
transform: scale(0.85) rotate(-4deg) translateY(8px);
opacity: 0.7;
filter: brightness(0.8) saturate(0);
}
75% {
transform: scale(0.6) rotate(8deg) translateY(20px);
opacity: 0.3;
filter: brightness(0.4) saturate(0);
}
100% {
transform: scale(0.2) rotate(15deg) translateY(40px);
opacity: 0;
filter: brightness(0) saturate(0);
}
}
.board-card {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.board-card :global(.card) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 300px !important;
transform: scale(0.55) !important;
transform-origin: top left !important;
}
.empty-slot {
width: calc(300px * 0.55);
height: calc(400px * 0.55);
border: 1.5px dashed rgba(107, 76, 30, 0.25);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
z-index: 1;
}
.empty-slot.highlight {
border-color: #c8861a;
background: rgba(200, 134, 26, 0.08);
cursor: pointer;
}
.slot-hint {
font-family: 'Cinzel', serif;
font-size: 9px;
color: rgba(200, 134, 26, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
pointer-events: none;
}
/* ── Combat animations ── */
.board-card-wrap.lunging {
animation: lunge-up 0.4s cubic-bezier(0.2, 0, 0.8, 1) forwards;
z-index: 20;
}
.board-card-wrap.lunge-down {
animation: lunge-down 0.4s cubic-bezier(0.2, 0, 0.8, 1) forwards;
z-index: 20;
}
@keyframes lunge-up {
0% { transform: translateY(0); }
45% { transform: translateY(-70px); }
100% { transform: translateY(0); }
}
@keyframes lunge-down {
0% { transform: translateY(0); }
45% { transform: translateY(70px); }
100% { transform: translateY(0); }
}
.board-card-wrap.shaking {
animation: shake 0.4s ease forwards;
}
@keyframes shake {
0% { transform: translateX(0); }
20% { transform: translateX(-10px); }
40% { transform: translateX(10px); }
60% { transform: translateX(-7px); }
80% { transform: translateX(7px); }
100% { transform: translateX(0); }
}
/* ── Divider ── */
.divider {
height: 24px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
border-top: 1px solid rgba(107, 76, 30, 0.2);
border-bottom: 1px solid rgba(107, 76, 30, 0.2);
}
.turn-timer {
font-family: 'Cinzel', serif;
font-size: 11px;
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
transition: color 0.3s;
}
.turn-timer.urgent {
color: #c85050;
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.turn-indicator {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.3);
transition: color 0.3s;
}
.turn-indicator.my-turn { color: #c8861a; }
/* ── Hand ── */
.hand {
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 8px;
justify-content: center;
padding: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
border-top: 1px solid rgba(107, 76, 30, 0.3);
background: rgba(0,0,0,0.3);
height: calc(400px * 0.9 + 1rem);
}
.hand.inactive { opacity: 0.6; }
.hand-card {
all: unset;
cursor: pointer;
flex-shrink: 0;
/* wrapper is the scaled size so layout is correct */
width: calc(300px * 0.65);
height: calc(400px * 0.65);
position: relative;
transition: transform 0.15s, filter 0.15s;
--peek-x: 0px;
--peek-y: 0px;
}
.hand-card :global(.card) {
position: absolute !important;
top: 20px !important;
left: 0 !important;
width: 300px !important;
transform: scale(0.65) !important;
transform-origin: top left !important;
transition: transform 0.15s ease !important;
}
.hand-card:hover:not(:disabled) :global(.card) {
transform: scale(1.1) translate(-50px, calc(var(--peek-y) - 80px)) !important;
transform-origin: top left !important;
z-index: 50 !important;
}
.hand-card.selected {
/* transform: translateY(-16px); */
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
z-index: 25 !important;
}
.hand-card.selected :global(.card) {
/* transform: scale(1.) translate(-30px, calc(var(--peek-y) - 80px)) !important; */
/* transform-origin: top left !important; */
z-index: 50 !important;
filter: drop-shadow(0 0 8px rgba(200, 134, 26, 0.9));
}
.hand-card.unaffordable { filter: grayscale(0.7) brightness(0.55); }
.hand-card:disabled { cursor: default; }
/* ── Difficulty modal ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #110d04;
border: 1.5px solid #6b4c1e;
border-radius: 10px;
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
min-width: 300px;
}
.modal-title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-weight: 700;
color: #f0d080;
margin: 0;
letter-spacing: 0.08em;
}
.difficulty-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.difficulty-number {
font-family: 'Cinzel', serif;
font-size: 48px;
font-weight: 900;
color: #f0d080;
line-height: 1;
}
.difficulty-label {
font-family: 'Crimson Text', serif;
font-size: 16px;
font-style: italic;
color: rgba(240, 180, 80, 0.6);
}
.difficulty-slider {
width: 100%;
accent-color: #c8861a;
cursor: pointer;
}
.difficulty-ticks {
width: 100%;
display: flex;
justify-content: space-between;
font-family: 'Cinzel', serif;
font-size: 10px;
color: rgba(240, 180, 80, 0.4);
margin-top: -0.75rem;
}
.modal-buttons {
display: flex;
gap: 0.75rem;
margin-top: 0.25rem;
}
/* ── Toast ── */
.toast {
position: fixed;
bottom: 220px;
left: 50%;
transform: translateX(-50%);
background: rgba(180, 40, 40, 0.9);
color: #fff;
font-family: 'Cinzel', serif;
font-size: 12px;
padding: 8px 20px;
border-radius: 6px;
pointer-events: none;
z-index: 100;
}
</style>