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

@@ -5,67 +5,530 @@
let cards = $state([]);
let loading = $state(false);
let boosters = $state(null);
let countdown = $state(null);
let countdownDisplay = $state('');
let countdownInterval = null;
onMount(() => {
if (!localStorage.getItem('token')) goto('/auth');
let phase = $state('idle');
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() {
if (loading || boosters === 0 || phase !== 'idle') return;
loading = true;
try {
const res = await fetch('http://localhost:8000/pack/10', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
if (res.status === 401) { goto('/auth'); return; }
cards = await res.json();
} catch (e) {
console.error(e);
} finally {
loading = false;
const rect = getPackRect();
overlayPackStyle = `position:fixed; top:${rect.top}px; left:${rect.left}px; width:${rect.width}px; height:${rect.height}px;`;
phase = 'darkening';
await delay(600);
phase = 'ripping';
await delay(900);
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>
<main>
<button onclick={openPack} disabled={loading}>
{loading ? "Opening..." : "Open Pack"}
</button>
<div class="pack">
{#each cards as card}
<Card {card} />
{/each}
<div class="top">
<h1 class="pack-count">
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
</h1>
{#if boosters !== null && boosters < 5 && countdownDisplay}
<p class="countdown">{countdownDisplay} until next pack</p>
{/if}
</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>
<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 {
min-height: 100vh;
background: #887859;
height: 100vh;
overflow: hidden;
background: #0d0a04;
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;
flex-wrap: wrap;
gap: 16px;
flex-direction: column;
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;
}
.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>