This commit is contained in:
2026-03-16 17:53:59 +01:00
parent 65b719334f
commit 5e7a6808ab
11 changed files with 807 additions and 60 deletions

View File

@@ -95,6 +95,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q24862": CardType.artwork, # film "Q24862": CardType.artwork, # film
"Q11032": CardType.artwork, # newspaper "Q11032": CardType.artwork, # newspaper
"Q25379": CardType.artwork, # play "Q25379": CardType.artwork, # play
"Q41298": CardType.artwork, # magazine
"Q482994": CardType.artwork, # album "Q482994": CardType.artwork, # album
"Q134556": CardType.artwork, # single "Q134556": CardType.artwork, # single
"Q169930": CardType.artwork, # EP "Q169930": CardType.artwork, # EP
@@ -130,6 +131,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q3624078": CardType.location, # sovereign state "Q3624078": CardType.location, # sovereign state
"Q1093829": CardType.location, # city in the United States "Q1093829": CardType.location, # city in the United States
"Q7930989": CardType.location, # city/town "Q7930989": CardType.location, # city/town
"Q3146899": CardType.location, # diocese of the catholic church
"Q35145263": CardType.location, # natural geographic object "Q35145263": CardType.location, # natural geographic object
"Q16521": CardType.life_form, # taxon "Q16521": CardType.life_form, # taxon
@@ -155,6 +157,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q5419137": CardType.group, # veterans' organization "Q5419137": CardType.group, # veterans' organization
"Q12973014": CardType.group, # sports team "Q12973014": CardType.group, # sports team
"Q11446438": CardType.group, # female idol group "Q11446438": CardType.group, # female idol group
"Q10517054": CardType.group, # handball team
"Q135408445": CardType.group, # men's national association football team "Q135408445": CardType.group, # men's national association football team
"Q7187": CardType.science_thing, # gene "Q7187": CardType.science_thing, # gene
@@ -164,6 +167,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
"Q11276": CardType.science_thing, # globular cluster "Q11276": CardType.science_thing, # globular cluster
"Q898273": CardType.science_thing, # protein domain "Q898273": CardType.science_thing, # protein domain
"Q168845": CardType.science_thing, # star cluster "Q168845": CardType.science_thing, # star cluster
"Q1341811": CardType.science_thing, # astronomical maser
"Q1840368": CardType.science_thing, # cloud type "Q1840368": CardType.science_thing, # cloud type
"Q113145171": CardType.science_thing, # type of chemical entity "Q113145171": CardType.science_thing, # type of chemical entity

View File

@@ -1,10 +1,12 @@
import logging import logging
import asyncio import asyncio
from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from card import _get_cards_async from card import _get_cards_async
from models import Card as CardModel from models import Card as CardModel
from models import User as UserModel
from database import SessionLocal from database import SessionLocal
logger = logging.getLogger("app") logger = logging.getLogger("app")
@@ -59,3 +61,27 @@ async def fill_card_pool():
finally: finally:
pool_filling = False pool_filling = False
db.close() db.close()
BOOSTER_MAX = 5
BOOSTER_COOLDOWN_HOURS = 5
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
if user.boosters_countdown is None:
user.boosters = 5
db.commit()
return (user.boosters, user.boosters_countdown)
now = datetime.now()
countdown = user.boosters_countdown
while user.boosters < BOOSTER_MAX:
next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS)
if now >= next_tick:
user.boosters += 1
countdown = next_tick
else:
break
user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None
db.commit()
return (user.boosters, user.boosters_countdown)

View File

@@ -37,6 +37,16 @@ loggers:
handlers: handlers:
- default - default
propagate: no propagate: no
passlib.utils.compat:
level: INFO
handlers:
- default
propagate: no
python_multipart.multipart:
level: INFO
handlers:
- default
propagate: no
app: app:
level: INFO level: INFO
handlers: handlers:

View File

@@ -2,6 +2,7 @@ import asyncio
import logging import logging
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
@@ -9,11 +10,10 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from database import SessionLocal, get_db from database import get_db
from database_functions import fill_card_pool from database_functions import fill_card_pool, check_boosters, BOOSTER_MAX
from models import Card as CardModel from models import Card as CardModel
from models import User as UserModel from models import User as UserModel
from card import _get_cards_async
from auth import hash_password, verify_password, create_access_token, decode_access_token from auth import hash_password, verify_password, create_access_token, decode_access_token
logger = logging.getLogger("app") logger = logging.getLogger("app")
@@ -49,16 +49,6 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.get("/pack/{size}")
async def open_pack(size: int = 10, user: UserModel = Depends(get_current_user)):
cards = await _get_cards_async(size)
return [
{**card._asdict(),
"card_type": card.card_type.name,
"card_rarity": card.card_rarity.name}
for card in cards
]
@app.post("/register") @app.post("/register")
def register(req: RegisterRequest, db: Session = Depends(get_db)): def register(req: RegisterRequest, db: Session = Depends(get_db)):
if db.query(UserModel).filter(UserModel.username == req.username).first(): if db.query(UserModel).filter(UserModel.username == req.username).first():
@@ -82,3 +72,43 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
raise HTTPException(status_code=400, detail="Invalid username or password") raise HTTPException(status_code=400, detail="Invalid username or password")
token = create_access_token(str(user.id)) token = create_access_token(str(user.id))
return {"access_token": token, "token_type": "bearer"} return {"access_token": token, "token_type": "bearer"}
@app.get("/boosters")
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]:
return check_boosters(user, db)
@app.post("/open_pack")
async def open_pack(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
check_boosters(user, db)
if user.boosters == 0:
raise HTTPException(status_code=400, detail="No booster packs available")
cards = (
db.query(CardModel)
.filter(CardModel.user_id == None)
.limit(5)
.all()
)
if len(cards) < 5:
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
for card in cards:
card.user_id = user.id
# was_full = user.boosters == BOOSTER_MAX
# user.boosters -= 1
# if was_full:
# user.boosters_countdown = datetime.now()
db.commit()
asyncio.create_task(fill_card_pool())
return [
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
"card_rarity": card.card_rarity,
"card_type": card.card_type}
for card in cards
]

View File

@@ -13,6 +13,8 @@ class User(Base):
email: Mapped[str] = mapped_column(String, unique=True, nullable=False) email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String, nullable=False) password_hash: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cards: Mapped[list["Card"]] = relationship(back_populates="user") cards: Mapped[list["Card"]] = relationship(back_populates="user")
decks: Mapped[list["Deck"]] = relationship(back_populates="user") decks: Mapped[list["Deck"]] = relationship(back_populates="user")

View File

@@ -0,0 +1,8 @@
fastapi==0.135.1
httpx==0.28.1
passlib==1.7.4
pydantic==2.12.5
python_jose==3.5.0
SQLAlchemy==2.0.48
python-multipart
bcrypt==4.3.0

View File

@@ -7,3 +7,8 @@
body { body {
background: #0d0a04; background: #0d0a04;
} }
html, body {
height: 100%;
overflow: hidden;
}

View File

@@ -146,11 +146,11 @@
.card.foil.legendary::before { .card.foil.legendary::before {
background: repeating-linear-gradient( background: repeating-linear-gradient(
105deg, 105deg,
rgba(255,215,0,0.30) 0%, rgba(255,215,0,0.35) 0%,
rgba(255,180,0,0.15) 15%, rgba(255,180,0,0.15) 15%,
rgba(255,255,180,0.35) 30%, rgba(255,255,180,0.40) 30%,
rgba(255,200,0,0.15) 45%, rgba(255,200,0,0.15) 45%,
rgba(255,215,0,0.30) 60% rgba(255,215,0,0.35) 60%
); );
animation-duration: 1.8s; animation-duration: 1.8s;
background-size: 300% 300%; background-size: 300% 300%;

View File

@@ -0,0 +1,193 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
function logout() {
localStorage.removeItem('token');
goto('/auth');
close();
}
let menuOpen = $state(false);
const links = [
{ href: '/', label: 'Booster Packs' },
{ href: '/cards', label: 'Cards' },
{ href: '/decks', label: 'Decks' },
{ href: '/play', label: 'Play' },
];
function close() { menuOpen = false; }
</script>
<header>
<a href="/" class="logo" onclick={close}>WikiTCG</a>
<nav class="desktop">
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
{/each}
<button class="logout" onclick={logout}>Log out</button>
</nav>
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
<span class:open={menuOpen}></span>
<span class:open={menuOpen}></span>
<span class:open={menuOpen}></span>
</button>
</header>
{#if menuOpen}
<div class="mobile-backdrop" onclick={close}></div>
<nav class="mobile" class:open={menuOpen}>
{#each links as link}
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
{/each}
<button class="logout mobile-logout" onclick={logout}>Log out</button>
</nav>
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 56px;
background: #1a1008;
border-bottom: 2px solid #6b4c1e;
}
.logo {
font-family: 'Cinzel', serif;
font-size: 18px;
font-weight: 700;
color: #f0d080;
text-decoration: none;
letter-spacing: 0.05em;
}
nav.desktop {
display: flex;
gap: 2rem;
}
nav.desktop a {
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.8);
text-decoration: none;
transition: color 0.15s;
padding: 4px 0;
border-bottom: 1.5px solid transparent;
}
nav.desktop a:hover,
nav.desktop a.active {
color: #f0d080;
border-bottom-color: #f0d080;
}
.hamburger {
display: none;
flex-direction: column;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
width: auto;
}
.hamburger span {
display: block;
width: 22px;
height: 2px;
background: #f0d080;
border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
.hamburger span:nth-child(1).open { transform: translateY(7px) rotate(45deg); }
.hamburger span:nth-child(2).open { opacity: 0; }
.hamburger span:nth-child(3).open { transform: translateY(-7px) rotate(-45deg); }
.mobile-backdrop {
position: fixed;
inset: 56px 0 0 0;
z-index: 99;
background: rgba(0,0,0,0.5);
}
nav.mobile {
position: fixed;
top: 56px;
right: 0;
bottom: 0;
width: 240px;
z-index: 100;
background: #1a1008;
border-left: 2px solid #6b4c1e;
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.25rem;
}
nav.mobile a {
font-family: 'Cinzel', serif;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240, 180, 80, 0.6);
text-decoration: none;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
transition: color 0.15s;
}
nav.mobile a:hover,
nav.mobile a.active {
color: #f0d080;
}
.logout {
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(200, 80, 80, 0.7);
background: none;
border: none;
border-bottom: 1.5px solid transparent;
cursor: pointer;
padding: 4px 0;
width: auto;
transition: color 0.15s, border-color 0.15s;
}
.logout:hover {
color: #c84040;
border-bottom-color: #c84040;
}
.mobile-logout {
font-size: 13px;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
text-align: left;
}
@media (max-width: 640px) {
nav.desktop { display: none; }
.hamburger { display: flex; }
}
</style>

View File

@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import Header from "$lib/header.svelte";
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
let { children } = $props(); let { children } = $props();
</script> </script>
{#if page.url.pathname !== '/auth'}
<Header />
{/if}
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>

View File

@@ -5,67 +5,530 @@
let cards = $state([]); let cards = $state([]);
let loading = $state(false); let loading = $state(false);
let boosters = $state(null);
let countdown = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
onMount(() => { let phase = $state('idle');
if (!localStorage.getItem('token')) goto('/auth'); let flippedCards = $state([]);
let fanVisible = $state(false);
let packRef = $state(null);
let overlayPackRef = $state(null);
onMount(async () => {
if (!localStorage.getItem('token')) { goto('/auth'); return; }
await fetchBoosters();
return () => clearInterval(countdownInterval);
}); });
async function fetchBoosters() {
const res = await fetch('http://localhost:8000/boosters', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
if (res.status === 401) { goto('/auth'); return; }
const [count, countdownTs] = await res.json();
boosters = count;
countdown = countdownTs ? new Date(countdownTs) : null;
startCountdown();
}
function startCountdown() {
clearInterval(countdownInterval);
if (!countdown || boosters >= 5) return;
countdownInterval = setInterval(() => {
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
const diff = nextTick - Date.now();
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}, 1000);
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
// Get the screen position of the idle pack so the overlay pack starts there
function getPackRect() {
if (!packRef) return { top: window.innerHeight / 2, left: window.innerWidth / 2 };
const rect = packRef.getBoundingClientRect();
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
}
let overlayPackStyle = $state('');
async function openPack() { async function openPack() {
if (loading || boosters === 0 || phase !== 'idle') return;
loading = true; loading = true;
try {
const res = await fetch('http://localhost:8000/pack/10', { const rect = getPackRect();
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } overlayPackStyle = `position:fixed; top:${rect.top}px; left:${rect.left}px; width:${rect.width}px; height:${rect.height}px;`;
});
if (res.status === 401) { goto('/auth'); return; } phase = 'darkening';
cards = await res.json(); await delay(600);
} catch (e) {
console.error(e); phase = 'ripping';
} finally { await delay(900);
loading = false;
phase = 'dropping';
// Fetch while pack is sliding away
const fetchPromise = fetch('http://localhost:8000/open_pack', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
await delay(700);
const res = await fetchPromise;
if (!res.ok) { phase = 'idle'; loading = false; return; }
cards = await res.json();
flippedCards = new Array(cards.length).fill(false);
boosters -= 1;
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
phase = 'fanning';
await delay(50);
fanVisible = true;
await delay(800);
phase = 'flipping';
const isMobile = window.innerWidth <= 640;
const indices = isMobile
? [...Array(cards.length).keys()] // 0,1,2,3,4 top to bottom
: [...Array(cards.length).keys()].reverse(); // 4,3,2,1,0 right to left
for (let i of indices) {
await delay(350);
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
} }
phase = 'done';
loading = false;
}
function close() {
phase = 'idle';
cards = [];
flippedCards = [];
fanVisible = false;
}
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
// Compute fan positions for each card
function fanStyle(i, total) {
const isMobile = window.innerWidth <= 640;
if (isMobile) {
return `--tx: 0px; --ty: 0px;`;
}
// Cards fan out horizontally from center
const spread = Math.min(320, (window.innerWidth - 100) / total);
const offset = (i - (total - 1) / 2) * spread;
return `--tx: ${offset}px; --ty: 0px;`;
} }
</script> </script>
<main> <main>
<button onclick={openPack} disabled={loading}> <div class="top">
{loading ? "Opening..." : "Open Pack"} <h1 class="pack-count">
</button> {#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
</h1>
<div class="pack"> {#if boosters !== null && boosters < 5 && countdownDisplay}
{#each cards as card} <p class="countdown">{countdownDisplay} until next pack</p>
<Card {card} /> {/if}
{/each}
</div> </div>
<!-- Idle pack -->
{#if boosters !== null && boosters > 0 && phase === 'idle'}
<div class="pack-wrap" bind:this={packRef}>
<button class="pack-btn" onclick={openPack}>
<div class="booster-pack">
<div class="pack-shine"></div>
<div class="pack-stripe top-stripe"></div>
<div class="pack-stripe bottom-stripe"></div>
<div class="pack-logo">
<img src="https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg" alt="W" />
</div>
</div>
</button>
</div>
{/if}
{#if phase !== 'idle'}
<div class="overlay" class:visible={phase !== 'idle'}>
{#if phase === 'darkening' || phase === 'ripping' || phase === 'dropping'}
<div class="overlay-pack-wrap" style={overlayPackStyle} class:dropping={phase === 'dropping'}>
<div class="pack-top-flap" class:rip={phase === 'ripping' || phase === 'dropping'}></div>
<div class="booster-pack overlay-pack" class:no-top={phase === 'ripping' || phase === 'dropping'}>
<div class="pack-shine"></div>
<div class="pack-stripe bottom-stripe"></div>
<div class="pack-logo">
<img src="https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg" alt="W" />
</div>
</div>
</div>
{/if}
{#if phase === 'fanning' || phase === 'flipping' || phase === 'done'}
<div class="fan-wrap">
{#each cards as card, i}
{@const flipped = flippedCards[i]}
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
<div
class="fan-card"
class:fan-visible={fanVisible}
class:foil-reveal={flipped && foil}
style="--i: {i};"
>
<div class="card-flipper" class:flipped>
<div class="card-face back"><div class="card-back-face"></div></div>
<div class="card-face front"><Card {card} /></div>
</div>
</div>
{/each}
</div>
{#if phase === 'done'}
<button class="close-btn" onclick={close}>Close</button>
{/if}
{/if}
</div>
{/if}
</main> </main>
<style> <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 { main {
min-height: 100vh; height: 100vh;
background: #887859; overflow: hidden;
background: #0d0a04;
padding: 2rem; padding: 2rem;
}
button {
display: block;
margin: 0 auto 2rem;
padding: 10px 28px;
font-family: 'Cinzel', serif;
font-size: 14px;
background: #2e1c05;
color: #f0d080;
border: 1px solid #6b4c1e;
border-radius: 6px;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #3d2507;
}
.pack {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 16px; align-items: center;
}
.top {
text-align: center;
margin-bottom: 3rem;
}
.pack-count {
font-family: 'Cinzel', serif;
font-size: clamp(16px, 3vw, 26px);
font-weight: 900;
color: #f0d080;
letter-spacing: 0.1em;
margin: 0 0 0.4rem;
}
.countdown {
font-family: 'Crimson Text', serif;
font-size: 15px;
font-style: italic;
color: rgba(240, 180, 80, 0.6);
margin: 0;
}
.pack-wrap {
display: flex;
justify-content: center; justify-content: center;
} }
.pack-btn {
all: unset;
cursor: pointer;
display: block;
}
/* ── Booster pack ── */
.booster-pack {
position: relative;
width: 300px;
height: 460px;
background: #dfdbcf;
background-image: linear-gradient(
to right,
transparent,
rgba(255,255,255,0.35) 5%,
transparent 8%,
transparent 92%,
rgba(0,0,0,0.12) 95%,
transparent
);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 0 1.5px rgba(0,0,0,0.3);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.pack-btn:hover .booster-pack {
transform: translateY(-6px) scale(1.02);
box-shadow: 0 16px 48px rgba(0,0,0,0.6), 0 0 0 1.5px rgba(0,0,0,0.3);
}
.pack-shine {
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.08) 0%,
rgba(255,255,255,0.08) 1px,
transparent 1px,
transparent 8px
);
pointer-events: none;
}
.pack-stripe {
position: absolute;
left: 0;
width: 100%;
height: 9%;
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.25),
rgba(0,0,0,0.15) 2%,
rgba(0,0,0,0.08) 3%,
transparent 3%,
transparent 4%
);
box-shadow: 0 0 12px 8px rgba(255,255,255,0.15);
}
.top-stripe { top: 0; }
.bottom-stripe { bottom: 0; }
.pack-logo {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pack-logo img {
width: 100px;
opacity: 0.8;
filter: grayscale(1);
}
/* no-top clips the pack body below the tear line */
.booster-pack.no-top {
clip-path: inset(60px 0 0 0 round 0 0 10px 10px);
}
/* ── Overlay ── */
.overlay {
position: fixed;
overflow: hidden;
inset: 0;
z-index: 200;
background: rgba(0,0,0,0);
transition: background 0.6s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.overlay.visible {
background: rgba(0,0,0,0.88);
}
/* ── Overlay pack ── */
.overlay-pack-wrap {
z-index: 210;
}
.overlay-pack {
box-shadow:
0 0 60px 20px rgba(240,200,80,0.2),
0 8px 32px rgba(0,0,0,0.6);
transition: none;
}
/* Top flap */
.pack-top-flap {
position: absolute;
top: 0;
left: 0;
width: 300px;
height: 60px;
background: #dfdbcf;
background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%);
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.18) 0%,
rgba(255,255,255,0.18) 1px,
transparent 1px,
transparent 8px
);
border-radius: 10px 10px 0 0;
transform-origin: top left;
z-index: 220;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s;
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.3);
}
.pack-top-flap.rip {
transform: rotate(-40deg) translateX(-20px) translateY(-40px);
opacity: 0;
}
/* Pack slides straight down */
.overlay-pack-wrap.dropping {
animation: pack-drop 0.7s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes pack-drop {
0% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(110vh); opacity: 0; }
}
/* ── Card back ── */
.card-back-face {
width: 300px;
height: 412px;
background: linear-gradient(135deg, #d3d0c4 0%, #dfdbcf 100%);
border-radius: 14px;
border: 10px solid rgba(255,255,255,1);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
}
.card-back-face::after {
content: '';
width: 90px;
height: 90px;
background: url('https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg') center/contain no-repeat;
opacity: 0.8;
filter: grayscale(1);
}
/* Cards fly in from the bottom */
.fan-card {
opacity: 0;
transform: translateY(80vh);
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
transition-delay: calc(var(--i) * 0.1s);
}
.fan-card.fan-visible {
opacity: 1;
transform: translateY(0);
}
.fan-wrap {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
z-index: 220;
flex-wrap: wrap;
}
/* ── Card flip ── */
.card-flipper {
width: 300px;
height: 412px;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-flipper.flipped {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.card-face.back {
display: flex;
align-items: center;
justify-content: center;
}
.card-face.front {
transform: rotateY(180deg);
display: flex;
align-items: center;
justify-content: center;
}
.fan-card.foil-reveal {
animation: foil-flash 1.4s ease forwards;
}
@keyframes foil-flash {
0% { filter: brightness(1); }
40% { filter: brightness(2.8) saturate(2.5); }
100% { filter: brightness(1); }
}
/* ── Close ── */
.close-btn {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 10px 32px;
background: rgba(60,30,5,0.85);
color: #f0d080;
border: 1.5px solid #c8861a;
border-radius: 6px;
font-family: 'Cinzel', serif;
font-size: 13px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.08em;
transition: background 0.15s;
z-index: 230;
}
.close-btn:hover {
background: rgba(100,60,10,0.9);
}
/* ── Mobile ── */
@media (max-width: 640px) {
.fan-wrap {
flex-direction: column;
flex-wrap: nowrap;
overflow-y: auto;
overflow-x: hidden;
max-height: calc(100vh - 120px);
overscroll-behavior: contain;
padding: 1rem;
justify-content: flex-start;
}
.card-flipper {
width: 280px;
height: 430px;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-flipper.flipped {
transform: rotateX(180deg);
}
.card-face.front {
transform: rotateX(180deg);
}
}
</style> </style>