1194 lines
32 KiB
Svelte
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> |