This commit is contained in:
2026-03-19 22:34:02 +01:00
parent d1a39620a7
commit fa05447895
18 changed files with 796 additions and 369 deletions

View File

@@ -21,6 +21,19 @@
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());
@@ -69,7 +82,7 @@
method: 'POST'
});
if (!res.ok) {
// Server rejected the claim — game may have ended another way
// Server rejected the claim. Game may have ended another way
const err = await res.json();
console.warn('Timeout claim rejected:', err.detail);
}
@@ -78,8 +91,8 @@
$effect(() => {
if (!gameState || combatAnimating) return;
displayedLife = {
[gameState.you.user_id]: gameState.you.life,
[gameState.opponent.user_id]: gameState.opponent.life,
[gameState.you.user_id]: Math.max(0, gameState.you.life),
[gameState.opponent.user_id]: Math.max(0, gameState.opponent.life),
};
});
@@ -113,7 +126,7 @@
});
function joinQueue() {
if (!selectedDeckId || selectedDeck?.card_count < 20) return;
if (!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50) return;
error = '';
phase = 'queuing';
queueWs = new WebSocket(`${WS_URL}/ws/queue?deck_id=${selectedDeckId}`);
@@ -168,7 +181,7 @@
combatAnimating = true;
// The attacker is whoever was active when end_turn was called.
// After end_turn resolves, active_player_id switches — so we look
// 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.
@@ -267,10 +280,11 @@
}
async function joinSolo() {
if (!selectedDeckId || selectedDeck?.card_count < 20) return;
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=5`, {
const res = await apiFetch(`${API_URL}/game/solo?deck_id=${selectedDeckId}&difficulty=${selectedDifficulty}`, {
method: 'POST'
});
if (!res.ok) {
@@ -297,16 +311,16 @@
<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}/20)</option>
<option value={deck.id}>{deck.name} ({deck.card_count} cards, cost {deck.total_cost})</option>
{/each}
</select>
</div>
<p class="error">{selectedDeck && selectedDeck.card_count < 20 ? `Deck must have 20 cards (${selectedDeck.card_count}/20)` : ''}</p>
<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?.card_count < 20}>
<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={joinSolo} disabled={!selectedDeckId || selectedDeck?.card_count < 20}>
<button class="play-btn solo-btn" onclick={() => showDifficultyModal = true} disabled={!selectedDeckId || selectedDeck?.total_cost === 0 || selectedDeck?.total_cost > 50}>
vs. Computer
</button>
</div>
@@ -320,7 +334,7 @@
<button class="cancel-btn" onclick={() => { queueWs?.close(); phase = 'idle'; }}>Cancel</button>
</div>
{:else if phase === 'playing' && gameState}
{:else if (phase === 'playing' || (phase === 'ended' && viewingBoard)) && gameState}
<div class="game">
<div class="sidebar left-sidebar">
@@ -374,7 +388,7 @@
<div class="divider">
<span class="turn-indicator" class:my-turn={isMyTurn}>
{isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
{phase === 'ended' ? 'Game Ended' : isMyTurn ? 'Your turn' : `${opp.username}'s turn`}
</span>
{#if secondsRemaining <= TIMER_WARNING}
<span class="turn-timer" class:urgent={secondsRemaining <= 10}>
@@ -420,7 +434,9 @@
</div>
<div class="sidebar right-sidebar">
{#if isMyTurn && !combatAnimating}
{#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>
@@ -453,7 +469,36 @@
{gameState.result.winner_id === myId ? 'Victory' : 'Defeat'}
</h1>
<p class="lobby-hint">{gameState.result.reason}</p>
<button class="play-btn" onclick={() => { gameState = null; phase = 'idle'; }}>Go back</button>
<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}
@@ -1040,6 +1085,82 @@
.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;