import React, { useEffect, useMemo, useRef, useState } from "react"; /** * Spider Solitaire – one-file React app * - 1/2/4-Suit modes * - Drag & drop (mouse + touch) * - Stock deals, undo, hint, restart * - Auto-collect completed same-suit runs (K..A) * - Simple scoring, timer, move counter * - LocalStorage persistence * * Styling uses Tailwind (available in this environment). No external assets. */ // --- Types --------------------------------------------------------------- const SUITS = ["♠", "♥", "♦", "♣"]; // display only; color via CSS const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]; // A low for building down function rankValue(rank) { return RANKS.indexOf(rank) + 1; // A=1 ... K=13 } function cardId(card, idx) { return `${card.suit}${card.rank}-${card.uid ?? idx}`; } // --- Utilities ----------------------------------------------------------- function makePRNG(seed) { // xorshift32 – deterministic for undo/restore let x = seed || Date.now() >>> 0; return () => { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; return (x >>> 0) / 4294967296; }; } function shuffle(array, rand) { const a = array.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor((rand ? rand() : Math.random()) * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function deepClone(state) { return JSON.parse(JSON.stringify(state)); } // --- Deck building for Spider ------------------------------------------- function buildSpiderDeck(mode = 1) { // Spider uses TWO standard decks -> 104 cards total // For 1-suit: all cards same suit // For 2-suit: evenly split between two suits (♠, ♥) // For 4-suit: full 4-suit distribution across two decks const deck = []; const uidGen = (() => { let u = 0; return () => u++; })(); const suitSet = mode === 1 ? ["♠"] : mode === 2 ? ["♠", "♥"] : SUITS; if (mode === 4) { // Two full decks for (let d = 0; d < 2; d++) { for (const s of SUITS) { for (const r of RANKS) { deck.push({ suit: s, rank: r, faceUp: false, uid: uidGen() }); } } } } else if (mode === 2) { // Two decks worth of ranks, split across two suits equally // Each rank appears 8 times total; we split 4x ♥ and 4x ♠ for (let d = 0; d < 2; d++) { for (const r of RANKS) { for (let i = 0; i < 2; i++) deck.push({ suit: "♠", rank: r, faceUp: false, uid: uidGen() }); for (let i = 0; i < 2; i++) deck.push({ suit: "♥", rank: r, faceUp: false, uid: uidGen() }); for (let i = 0; i < 2; i++) deck.push({ suit: "♠", rank: r, faceUp: false, uid: uidGen() }); for (let i = 0; i < 2; i++) deck.push({ suit: "♥", rank: r, faceUp: false, uid: uidGen() }); } } // Trim in case of overfill (safety) – but counts line up to 104 while (deck.length > 104) deck.pop(); } else { // 1-suit: all spades for (let d = 0; d < 2; d++) { for (const r of RANKS) { for (let i = 0; i < 4; i++) deck.push({ suit: "♠", rank: r, faceUp: false, uid: uidGen() }); } } } return deck; } // --- Game logic helpers -------------------------------------------------- function canPlaceOn(targetCard, movingCard) { if (!targetCard) return true; // empty column ok return rankValue(targetCard.rank) === rankValue(movingCard.rank) + 1; // descending regardless of suit } function isSameSuitDescendingSequence(cards) { if (cards.length === 0) return false; for (let i = 0; i < cards.length - 1; i++) { const a = cards[i], b = cards[i + 1]; if (a.suit !== b.suit) return false; if (rankValue(a.rank) !== rankValue(b.rank) + 1) return false; } return true; } function findTopFaceUpSequence(col, index) { // returns slice starting at index; only if from index to end is descending same-suit (for group move) const seq = col.slice(index); return isSameSuitDescendingSequence(seq) ? seq : null; } function canDealStock(columns) { // Official rule: cannot deal if any column is empty return columns.every(c => c.length > 0); } function tryCollectRun(column) { // If a complete K..A same-suit run at the tail, remove and return it if (column.length < 13) return { collected: false, column }; const tail = column.slice(-13); if (!isSameSuitDescendingSequence(tail)) return { collected: false, column }; if (tail[0].rank !== "K" || tail[12].rank !== "A") return { collected: false, column }; // remove and return const remaining = column.slice(0, column.length - 13); // flip new top if needed if (remaining.length && !remaining[remaining.length - 1].faceUp) remaining[remaining.length - 1].faceUp = true; return { collected: true, column: remaining, run: tail }; } // --- Persistence --------------------------------------------------------- const STORAGE_KEY = "spider-solitaire-state-v1"; function saveState(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } // --- React App ----------------------------------------------------------- export default function SpiderSolitaireApp() { const [mode, setMode] = useState(1); // 1,2,4 suits const [columns, setColumns] = useState(Array.from({ length: 10 }, () => [])); const [stock, setStock] = useState([]); // array of 5 packets (each 10 cards) const [foundations, setFoundations] = useState([]); // completed runs const [selected, setSelected] = useState(null); // { colIdx, startIdx, cards } const [drag, setDrag] = useState(null); // { x, y, w, h } const [seed, setSeed] = useState(() => Date.now() >>> 0); const [moves, setMoves] = useState(0); const [score, setScore] = useState(500); const [seconds, setSeconds] = useState(0); const [history, setHistory] = useState([]); // stack of previous states for undo const timerRef = useRef(null); // Load from storage once useEffect(() => { const saved = loadState(); if (saved) { setMode(saved.mode); setColumns(saved.columns); setStock(saved.stock); setFoundations(saved.foundations); setSeed(saved.seed); setMoves(saved.moves); setScore(saved.score); setSeconds(saved.seconds ?? 0); return; } newGame(1); // eslint-disable-next-line }, []); // Persist on change useEffect(() => { const st = { mode, columns, stock, foundations, seed, moves, score, seconds }; saveState(st); }, [mode, columns, stock, foundations, seed, moves, score, seconds]); // Timer useEffect(() => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000); return () => clearInterval(timerRef.current); }, []); function pushHistory() { setHistory(h => [...h, deepClone({ mode, columns, stock, foundations, seed, moves, score, seconds })]); } function undo() { setHistory(h => { if (h.length === 0) return h; const prev = h[h.length - 1]; setMode(prev.mode); setColumns(prev.columns); setStock(prev.stock); setFoundations(prev.foundations); setSeed(prev.seed); setMoves(prev.moves); setScore(prev.score); setSeconds(prev.seconds); return h.slice(0, -1); }); } function newGame(newMode = mode) { const newSeed = (Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0; const rand = makePRNG(newSeed); const rawDeck = shuffle(buildSpiderDeck(newMode), rand); // Deal: 10 columns; first 4 get 6 cards, next 6 get 5 cards (total 54) const cols = Array.from({ length: 10 }, () => []); let idx = 0; for (let c = 0; c < 10; c++) { const count = c < 4 ? 6 : 5; for (let k = 0; k < count; k++) { cols[c].push({ ...rawDeck[idx++] }); } // Flip top cols[c][cols[c].length - 1].faceUp = true; } // Remaining 50 cards -> 5 stock packets of 10 const rest = rawDeck.slice(idx); const packets = []; for (let p = 0; p < 5; p++) packets.push(rest.slice(p * 10, p * 10 + 10)); setMode(newMode); setColumns(cols); setStock(packets); setFoundations([]); setMoves(0); setScore(500); setSeconds(0); setSeed(newSeed); setHistory([]); setSelected(null); setDrag(null); } function afterColumnChange(cols, moved = true) { // Try collecting runs on all columns let updated = cols.map(c => c.slice()); let collectedAnything = false; for (let i = 0; i < updated.length; i++) { let res = tryCollectRun(updated[i]); if (res.collected) { updated[i] = res.column; setFoundations(f => [...f, res.run]); collectedAnything = true; } } if (moved) { setMoves(m => m + 1); setScore(s => s - 1 + (collectedAnything ? 100 : 0)); } setColumns(updated); } function onCardMouseDown(colIdx, cardIdx, e) { e.preventDefault(); const col = columns[colIdx]; const card = col[cardIdx]; if (!card.faceUp) return; // can't move facedown // Determine moving group (same-suit descending from cardIdx) const group = findTopFaceUpSequence(col, cardIdx) || [card]; setSelected({ colIdx, startIdx: cardIdx, cards: group }); } function onDropOnColumn(targetColIdx) { if (!selected) return; const { colIdx, startIdx, cards } = selected; const cols = columns.map(c => c.slice()); const moving = cols[colIdx].slice(startIdx); // If group wasn't valid same-suit sequence, restrict to single card const movingGroup = isSameSuitDescendingSequence(moving) ? moving : [moving[0]]; const srcBefore = cols[colIdx].slice(0, cols[colIdx].length - movingGroup.length); const targetTop = cols[targetColIdx][cols[targetColIdx].length - 1]; if (!canPlaceOn(targetTop, movingGroup[0])) { setSelected(null); return; // illegal } pushHistory(); // Move cols[colIdx] = srcBefore; if (cols[colIdx].length && !cols[colIdx][cols[colIdx].length - 1].faceUp) cols[colIdx][cols[colIdx].length - 1].faceUp = true; cols[targetColIdx] = cols[targetColIdx].concat(movingGroup); afterColumnChange(cols, true); setSelected(null); } function dealFromStock() { if (stock.length === 0) return; if (!canDealStock(columns)) { alert("Du kannst erst geben, wenn keine Spalte leer ist."); return; } pushHistory(); const next = stock[0]; const rest = stock.slice(1); const cols = columns.map(c => c.slice()); for (let i = 0; i < 10; i++) { const card = next[i]; cols[i].push({ ...card, faceUp: true }); } setStock(rest); afterColumnChange(cols, false); // don't count as a move } function anyMovesLeft() { // Rough heuristic: any legal single move or group move exists? for (let i = 0; i < 10; i++) { const col = columns[i]; for (let j = 0; j < col.length; j++) { const card = col[j]; if (!card.faceUp) continue; const moving = col.slice(j); const groupOK = isSameSuitDescendingSequence(moving); const first = groupOK ? moving[0] : card; for (let t = 0; t < 10; t++) { if (t === i) continue; const targetTop = columns[t][columns[t].length - 1]; if (canPlaceOn(targetTop, first)) return { from: i, to: t, index: j }; } } } return null; } function hint() { const h = anyMovesLeft(); if (!h) { alert("Kein offensichtlicher Zug – vielleicht erst aufdecken/geben?"); return; } // Soft highlight via selected state for 900ms const col = columns[h.from]; const moving = col.slice(h.index); const groupOK = isSameSuitDescendingSequence(moving); const cards = groupOK ? moving : [moving[0]]; setSelected({ colIdx: h.from, startIdx: h.index, cards, hintTo: h.to }); setTimeout(() => setSelected(null), 900); } const won = useMemo(() => foundations.length === 8, [foundations.length]); // --- Rendering helpers ------------------------------------------------- function CardView({ card, lifted }) { const isRed = card.suit === "♥" || card.suit === "♦"; const hidden = !card.faceUp; return (
{hidden ? (
) : (
{card.rank}
{card.suit}
{card.rank}
)}
); } function ColumnView({ colIdx }) { const col = columns[colIdx]; const handleDrop = (e) => { e.preventDefault(); onDropOnColumn(colIdx); }; const handleDragOver = (e) => { e.preventDefault(); }; const spacing = 26; // px overlap return (
{/* empty holder */} {col.length === 0 && (
)}
{col.map((card, i) => { const isSelected = selected && selected.colIdx === colIdx && i >= selected.startIdx; const style = { top: i * spacing }; return (
{ if (!card.faceUp) { e.preventDefault(); return; } onCardMouseDown(colIdx, i, e); }} onMouseDown={(e) => onCardMouseDown(colIdx, i, e)} onTouchStart={(e) => onCardMouseDown(colIdx, i, e)} >
); })}
); } // When user releases drag outside columns, cancel selection useEffect(() => { const up = () => setSelected(null); window.addEventListener("mouseup", up); window.addEventListener("touchend", up); return () => { window.removeEventListener("mouseup", up); window.removeEventListener("touchend", up); }; }, []); // Keyboard helpers: U=undo, H=hint, D=deal useEffect(() => { const onKey = (e) => { if (e.key === "u" || e.key === "U") undo(); if (e.key === "h" || e.key === "H") hint(); if (e.key === "d" || e.key === "D") dealFromStock(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [columns, stock]); const timeStr = useMemo(() => { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, "0")}`; }, [seconds]); return (
{/* Header */}

Spider Solitaire

{/* Stats */}
Zeit
{timeStr}
Züge
{moves}
Punkte
{score}
Vervollständigt
{foundations.length} / 8
{/* Board */}
{Array.from({ length: 10 }).map((_, i) => ( ))}
{/* Foundations */}
Abgelegte Stapel
{foundations.map((run, i) => (
{run[0].suit}
))} {foundations.length === 0 &&
Noch keine vollständigen Suiten (K bis A)
}
{/* Win banner */} {won && (
🎉 Glückwunsch! Du hast gewonnen. – Punkte: {score}, Züge: {moves}, Zeit: {timeStr}
)} {/* Help */}
Regeln & Steuerung
  • Baue absteigend (K → A). Du darfst Karten oder Gruppen auf eine Karte legen, die genau um 1 höher ist.
  • Nur gleichfarbige, absteigende Sequenzen können als Gruppe verschoben werden.
  • Vollständige Suiten K..A in einer Farbe werden automatisch abgelegt (8 Stapel für zwei Decks).
  • Geben (Stock) ist nur erlaubt, wenn keine Spalte leer ist. Tastenkürzel: D.
  • Rückgängig: U, Tipp: H.
  • Modus bestimmt die Anzahl der Farben: 1 (leicht), 2 (mittel), 4 (schwer).
  • Das Spiel speichert automatisch deinen Fortschritt im Browser.
); }