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

@@ -12,6 +12,7 @@
{/if}
<svelte:head>
<title>WikiTCG</title>
<link rel="icon" href={favicon} />
</svelte:head>

View File

@@ -30,7 +30,7 @@
let sortAsc = $state(true);
let costMin = $state(1);
let costMax = $state(12);
let costMax = $state(11);
let filtered = $derived.by(() => {
let result = allCards.filter(c =>
@@ -236,14 +236,14 @@
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 12; }}>Reset</button>
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin}
<input type="range" min="1" max="11" bind:value={costMin}
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax}
<input type="range" min="1" max="11" bind:value={costMax}
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>

View File

@@ -75,6 +75,7 @@
<tr>
<th>Name</th>
<th>Cards</th>
<th>Cost</th>
<th>Type</th>
<th>Played</th>
<th>W / L</th>
@@ -87,8 +88,9 @@
{@const wr = winRate(deck)}
<tr>
<td class="deck-name">{deck.name}</td>
<td class="deck-count" class:incomplete={deck.card_count < 20}>
{deck.card_count}/20
<td class="deck-count">{deck.card_count}</td>
<td class="deck-cost" class:over-budget={deck.total_cost > 50}>
{deck.total_cost}/50
</td>
<td class="deck-type">
<DeckTypeBadge deckType={deck.deck_type} />
@@ -100,14 +102,14 @@
<span class="separator"> / </span>
<span class="losses">{deck.losses}</span>
{:else}
<span class="no-data"></span>
<span class="no-data">-</span>
{/if}
</td>
<td class="deck-stat">
{#if wr !== null}
<span class:good-wr={wr >= 50} class:bad-wr={wr < 50}>{wr}%</span>
{:else}
<span class="no-data"></span>
<span class="no-data">-</span>
{/if}
</td>
<td class="deck-actions">
@@ -144,7 +146,7 @@
<p class="popup-body">
Are you sure you want to delete <strong>{deleteConfirm.name}</strong>?
{#if deleteConfirm.times_played > 0}
This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'} — its stats will be preserved in your profile history.
This deck has been played {deleteConfirm.times_played} time{deleteConfirm.times_played === 1 ? '' : 's'}. Its stats will be preserved in your profile history.
{/if}
</p>
<div class="popup-actions">
@@ -240,6 +242,14 @@
}
.deck-count {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
color: rgba(240, 180, 80, 0.6);
width: 60px;
}
.deck-cost {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
@@ -247,7 +257,7 @@
width: 60px;
}
.deck-count.incomplete { color: #c85050; }
.deck-cost.over-budget { color: #c85050; }
.deck-type { width: 90px; }

View File

@@ -26,7 +26,7 @@
let selectedRarities = $state(new Set(RARITIES));
let selectedTypes = $state(new Set(TYPES));
let costMin = $state(1);
let costMax = $state(12);
let costMax = $state(11);
let filtersOpen = $state(false);
function label(str) {
@@ -74,12 +74,17 @@
function toggleAllRarities() { selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES); }
function toggleAllTypes() { selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES); }
const selectedCost = $derived(
allCards.filter(c => selectedIds.has(c.id)).reduce((sum, c) => sum + c.cost, 0)
);
function toggleCard(id) {
const s = new Set(selectedIds);
if (s.has(id)) {
s.delete(id);
} else {
if (s.size >= 20) return;
const card = allCards.find(c => c.id === id);
if (card && selectedCost + card.cost > 50) return;
s.add(id);
}
selectedIds = s;
@@ -151,8 +156,8 @@
{/if}
<div class="header-right">
<span class="card-counter" class:full={selectedIds.size === 20} class:empty={selectedIds.size === 0}>
{selectedIds.size}/20
<span class="card-counter" class:full={selectedCost === 50} class:over={selectedCost > 50} class:empty={selectedIds.size === 0}>
{selectedIds.size} cards · {selectedCost}/50
</span>
<button class="done-btn" onclick={save} disabled={saving}>
{saving ? 'Saving...' : 'Done'}
@@ -171,7 +176,7 @@
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
{filtersOpen ? 'Hide filters' : 'Filter'}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 12}
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 11}
<span class="filter-dot"></span>
{/if}
</button>
@@ -212,13 +217,13 @@
<div class="filter-group">
<div class="filter-group-header">
<span class="filter-group-label">Cost</span>
<button class="select-all" onclick={() => { costMin = 1; costMax = 12; }}>Reset</button>
<button class="select-all" onclick={() => { costMin = 1; costMax = 11; }}>Reset</button>
</div>
<div class="cost-range">
<span class="range-label">Min: {costMin}</span>
<input type="range" min="1" max="12" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<input type="range" min="1" max="11" bind:value={costMin} oninput={() => { if (costMin > costMax) costMax = costMin; }} />
<span class="range-label">Max: {costMax}</span>
<input type="range" min="1" max="12" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
<input type="range" min="1" max="11" bind:value={costMax} oninput={() => { if (costMax < costMin) costMin = costMax; }} />
</div>
</div>
</div>
@@ -235,7 +240,7 @@
<button
class="card-wrap"
class:selected={selectedIds.has(card.id)}
class:disabled={!selectedIds.has(card.id) && selectedIds.size >= 20}
class:disabled={!selectedIds.has(card.id) && selectedCost + card.cost > 50}
onclick={() => toggleCard(card.id)}
>
<Card {card} noHover={true} />
@@ -317,11 +322,12 @@
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 700;
color: #c85050;
color: rgba(240, 180, 80, 0.7);
transition: color 0.2s;
}
.card-counter.full { color: #6aaa6a; }
.card-counter.full { color: #6aaa6a; }
.card-counter.over { color: #c85050; }
.card-counter.empty { color: rgba(240, 180, 80, 0.3); }
.done-btn {

View File

@@ -18,7 +18,7 @@
const annotations = [
{ number: 1, label: "Name", description: "The name of the Wikipedia article this card was generated from." },
{ number: 2, label: "Type", description: "The category of the subject Person, Location, Artwork, etc." },
{ number: 2, label: "Type", description: "The category of the subject. Person, Location, Artwork, etc." },
{ number: 3, label: "Rarity badge", description: "Rarity is determined by the article's WikiRank quality score. From lowest to highest: Common, Uncommon, Rare, Super Rare, Epic, and Legendary." },
{ number: 4, label: "Wikipedia link", description: "Opens the Wikipedia article this card was generated from." },
{ number: 5, label: "Cost bubbles", description: "How much energy it costs to play this card. Derived from the card's attack and defense stats." },
@@ -29,14 +29,14 @@
// Annotation positions as percentage of card width/height
const markerPositions = [
{ number: 1, x: 15, y: 3 }, // name top center
{ number: 2, x: 75, y: 3 }, // type badge top right
{ number: 3, x: 14, y: 20 }, // rarity badge top left of image
{ number: 4, x: 85, y: 20 }, // wiki link top right of image
{ number: 5, x: 15, y: 53 }, // cost bubbles bottom left of image
{ number: 6, x: 50, y: 73 }, // text middle
{ number: 7, x: 15, y: 88 }, // attack bottom left
{ number: 8, x: 85, y: 88 }, // defense bottom right
{ number: 1, x: 15, y: 3 }, // name. top center
{ number: 2, x: 75, y: 3 }, // type badge. top right
{ number: 3, x: 14, y: 20 }, // rarity badge. top left of image
{ number: 4, x: 85, y: 20 }, // wiki link. top right of image
{ number: 5, x: 15, y: 53 }, // cost bubbles. bottom left of image
{ number: 6, x: 50, y: 73 }, // text. middle
{ number: 7, x: 15, y: 88 }, // attack. bottom left
{ number: 8, x: 85, y: 88 }, // defense. bottom right
];
</script>
@@ -102,6 +102,22 @@
</div>
</section>
<section class="section">
<h2 class="section-title">Building a Deck</h2>
<div class="rules-grid">
<div class="rule-card">
<div class="rule-icon"></div>
<h3 class="rule-title">Cost Limit</h3>
<p class="rule-body">Your deck's total cost, the sum of all card costs, must be 50 or less. You can't queue for a game with an empty deck or one that exceeds the limit.</p>
</div>
<div class="rule-card">
<div class="rule-icon">🃏</div>
<h3 class="rule-title">No Card Limit</h3>
<p class="rule-body">There is no minimum or maximum number of cards. On the extreme ends, you can have just 4 11-cost cards, or 50 1-cost cards.</p>
</div>
</div>
</section>
<section class="section">
<h2 class="section-title">Taking a Turn</h2>
<div class="rules-grid">
@@ -123,7 +139,7 @@
<div class="rule-card">
<div class="rule-icon">🗡</div>
<h3 class="rule-title">Sacrificing</h3>
<p class="rule-body">Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover its energy cost. Use this to afford expensive cards.</p>
<p class="rule-body">Click the dagger icon to enter sacrifice mode, then click one of your cards to remove it from play and recover 1 energy. Use this to afford expensive cards.</p>
</div>
</div>
</section>

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;

View File

@@ -60,7 +60,7 @@
<div class="stat-card">
<span class="stat-label">Win Rate</span>
<span class="stat-value" class:good-wr={profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
{profile.win_rate !== null ? `${profile.win_rate}%` : ''}
{profile.win_rate !== null ? `${profile.win_rate}%` : '-'}
</span>
</div>
</div>